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

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
@ -11,7 +12,9 @@ import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/screens/shared/chips/category_chip.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/map_bool_helper.dart';
import 'package:fladder/util/position_provider.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/shared/button_group.dart';
class LibraryFilterChips extends ConsumerStatefulWidget {
const LibraryFilterChips({super.key});
@ -25,133 +28,137 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
Widget build(BuildContext context) {
final uniqueKey = widget.key ?? UniqueKey();
final libraryProvider = ref.watch(librarySearchProvider(uniqueKey).notifier);
final groupBy = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.groupBy));
final favourites = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.favourites));
final recursive = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.recursive));
final hideEmpty = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.hideEmptyShows));
final groupBy = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.filters.groupBy));
final favourites = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.filters.favourites));
final recursive = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.filters.recursive));
final hideEmpty = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.filters.hideEmptyShows));
final librarySearchResults = ref.watch(librarySearchProvider(uniqueKey));
final chips = [
if (librarySearchResults.folderOverwrite.isEmpty)
CategoryChip(
label: Text(context.localized.library(2)),
items: librarySearchResults.views.sortByKey((value) => value.name),
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.filters.types.length)),
items: librarySearchResults.filters.types.sortByKey((value) => value.label(context)),
activeIcon: IconsaxPlusBold.filter_tick,
labelBuilder: (item) => Row(
children: [
Icon(item.icon),
const SizedBox(width: 12),
Text(item.label(context)),
],
),
onSave: (value) => libraryProvider.setTypes(value),
onClear: () => libraryProvider.setTypes(librarySearchResults.filters.types.setAll(false)),
),
ExpressiveButton(
isSelected: favourites,
icon: favourites ? const Icon(IconsaxPlusBold.heart) : null,
label: Text(context.localized.favorites),
onPressed: () {
libraryProvider.toggleFavourite();
context.refreshData();
},
),
ExpressiveButton(
isSelected: recursive,
icon: recursive ? const Icon(IconsaxPlusBold.tick_circle) : null,
label: Text(context.localized.recursive),
onPressed: () {
libraryProvider.toggleRecursive();
context.refreshData();
},
),
if (librarySearchResults.filters.genres.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.genre(librarySearchResults.filters.genres.length)),
activeIcon: IconsaxPlusBold.hierarchy_2,
items: librarySearchResults.filters.genres,
labelBuilder: (item) => Text(item),
onSave: (value) => libraryProvider.setGenres(value),
onCancel: () => libraryProvider.setGenres(librarySearchResults.filters.genres),
onClear: () => libraryProvider.setGenres(librarySearchResults.filters.genres.setAll(false)),
),
if (librarySearchResults.filters.studios.isNotEmpty)
CategoryChip<Studio>(
label: Text(context.localized.studio(librarySearchResults.filters.studios.length)),
activeIcon: IconsaxPlusBold.airdrop,
items: librarySearchResults.filters.studios,
labelBuilder: (item) => Text(item.name),
onSave: (value) => libraryProvider.setStudios(value),
onCancel: () => libraryProvider.setStudios(librarySearchResults.filters.studios),
onClear: () => libraryProvider.setStudios(librarySearchResults.filters.studios.setAll(false)),
),
if (librarySearchResults.filters.tags.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.label(librarySearchResults.filters.tags.length)),
activeIcon: Icons.label_rounded,
items: librarySearchResults.filters.tags,
labelBuilder: (item) => Text(item),
onSave: (value) => libraryProvider.setTags(value),
onCancel: () => libraryProvider.setTags(librarySearchResults.filters.tags),
onClear: () => libraryProvider.setTags(librarySearchResults.filters.tags.setAll(false)),
),
ExpressiveButton(
isSelected: groupBy != GroupBy.none,
icon: groupBy != GroupBy.none ? const Icon(IconsaxPlusBold.bag_tick) : null,
label: Text(context.localized.group),
onPressed: () {
_openGroupDialogue(context, ref, libraryProvider, uniqueKey);
},
),
CategoryChip<ItemFilter>(
label: Text(context.localized.filter(librarySearchResults.filters.itemFilters.length)),
items: librarySearchResults.filters.itemFilters,
labelBuilder: (item) => Text(item.label(context)),
onSave: (value) => libraryProvider.setFilters(value),
onClear: () => libraryProvider.setFilters(librarySearchResults.filters.itemFilters.setAll(false)),
),
if (librarySearchResults.filters.types[FladderItemType.series] == true)
ExpressiveButton(
isSelected: !hideEmpty,
icon: !hideEmpty ? const Icon(IconsaxPlusBold.ghost) : null,
label: Text(!hideEmpty ? context.localized.hideEmpty : context.localized.showEmpty),
onPressed: libraryProvider.toggleEmptyShows,
),
if (librarySearchResults.filters.officialRatings.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.rating(librarySearchResults.filters.officialRatings.length)),
activeIcon: Icons.star_rate_rounded,
items: librarySearchResults.filters.officialRatings,
labelBuilder: (item) => Text(item),
onSave: (value) => libraryProvider.setRatings(value),
onCancel: () => libraryProvider.setRatings(librarySearchResults.filters.officialRatings),
onClear: () => libraryProvider.setRatings(librarySearchResults.filters.officialRatings.setAll(false)),
),
if (librarySearchResults.filters.years.isNotEmpty)
CategoryChip<int>(
label: Text(context.localized.year(librarySearchResults.filters.years.length)),
items: librarySearchResults.filters.years,
labelBuilder: (item) => Text(item.toString()),
onSave: (value) => libraryProvider.setYears(value),
onCancel: () => libraryProvider.setYears(librarySearchResults.filters.years),
onClear: () => libraryProvider.setYears(librarySearchResults.filters.years.setAll(false)),
),
];
return Row(
spacing: 8,
children: [
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(
favourites ? IconsaxPlusBold.heart : IconsaxPlusLinear.heart,
color: Theme.of(context).colorScheme.onSurface,
),
selected: favourites,
showCheckmark: false,
onSelected: (_) {
libraryProvider.toggleFavourite();
context.refreshData();
},
),
FilterChip(
label: Text(context.localized.recursive),
selected: recursive,
onSelected: (_) {
libraryProvider.toggleRecursive();
context.refreshData();
},
),
if (librarySearchResults.genres.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.genre(librarySearchResults.genres.length)),
activeIcon: IconsaxPlusBold.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: IconsaxPlusBold.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: groupBy != GroupBy.none,
onSelected: (_) {
_openGroupDialogue(context, ref, libraryProvider, uniqueKey);
},
),
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(
hideEmpty ? Icons.visibility_off_rounded : Icons.visibility_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
selected: hideEmpty,
showCheckmark: false,
label: Text(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)),
),
],
spacing: 4,
children: chips.mapIndexed(
(index, element) {
final position = index == 0
? PositionContext.first
: (index == chips.length - 1 ? PositionContext.last : PositionContext.middle);
return PositionProvider(position: position, child: element);
},
).toList(),
);
}
@ -164,7 +171,7 @@ class _LibraryFilterChipsState extends ConsumerState<LibraryFilterChips> {
showDialog(
context: context,
builder: (context) {
final groupBy = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.groupBy));
final groupBy = ref.watch(librarySearchProvider(uniqueKey).select((v) => v.filters.groupBy));
return AlertDialog(
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.65,

View file

@ -3,41 +3,40 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/library_search/library_search_model.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
Future<void> showSavedFilters(
BuildContext context,
LibrarySearchModel model,
LibrarySearchNotifier provider,
Key providerKey,
) {
return showDialog(
context: context,
builder: (context) => LibrarySavedFiltersDialogue(
searchModel: model,
provider: provider,
providerKey: providerKey,
),
);
}
class LibrarySavedFiltersDialogue extends ConsumerWidget {
final LibrarySearchModel searchModel;
final LibrarySearchNotifier provider;
final Key providerKey;
const LibrarySavedFiltersDialogue({
required this.searchModel,
required this.provider,
super.key,
required this.providerKey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
final provider = ref.watch(librarySearchProvider(providerKey).notifier);
final currentFilters = ref.watch(librarySearchProvider(providerKey).select((value) => value.filters));
final filters = ref.watch(provider.filterProvider);
final filterProvider = ref.watch(provider.filterProvider.notifier);
final anyFilterSelected = filters.any((element) => element.filter == currentFilters);
return Dialog(
child: Padding(
padding: const EdgeInsets.all(16),
@ -57,68 +56,75 @@ class LibrarySavedFiltersDialogue extends ConsumerWidget {
children: [
...filters.map(
(filter) {
final isCurrentFilter = filter.filter == currentFilters;
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Card(
child: InkWell(
onTap: () => provider.loadModel(filter),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(child: Text(filter.name)),
IconButton.filledTonal(
tooltip: context.localized.defaultFilterForLibrary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
filter.isFavourite
? Colors.yellowAccent.shade700.withValues(alpha: 0.5)
: null,
),
),
onPressed: () =>
filterProvider.saveFilter(filter.copyWith(isFavourite: !filter.isFavourite)),
icon: Icon(
color: filter.isFavourite ? Colors.yellowAccent : null,
filter.isFavourite ? IconsaxPlusBold.star_1 : IconsaxPlusLinear.star,
),
),
IconButton.filledTonal(
tooltip: context.localized.updateFilterForLibrary,
onPressed: () => provider.updateFilter(filter),
icon: const Icon(IconsaxPlusBold.refresh),
),
IconButton.filledTonal(
tooltip: context.localized.delete,
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.removeFilterForLibrary(filter.name),
context.localized.deleteFilterConfirmation,
(context) {
filterProvider.removeFilter(filter);
Navigator.of(context).pop();
},
context.localized.delete,
(context) {
Navigator.of(context).pop();
},
context.localized.cancel,
);
},
style: ButtonStyle(
backgroundColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
iconColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
foregroundColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
),
icon: const Icon(IconsaxPlusLinear.trash),
),
].addInBetween(const SizedBox(width: 8)),
color: isCurrentFilter
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.75)
: null,
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(spacing: 8, children: [
Expanded(
child: OutlinedTextField(
fillColor: Colors.transparent,
controller: TextEditingController(text: filter.name),
onSubmitted: (value) => provider.updateFilter(filter.copyWith(name: value)),
),
),
),
IconButton.filledTonal(
onPressed: isCurrentFilter ? null : () => provider.loadModel(filter.filter),
icon: const Icon(IconsaxPlusBold.filter_add),
),
IconButton.filledTonal(
tooltip: context.localized.defaultFilterForLibrary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
filter.isFavourite ? Colors.yellowAccent.shade700.withValues(alpha: 0.5) : null,
),
),
onPressed: () =>
filterProvider.saveFilter(filter.copyWith(isFavourite: !filter.isFavourite)),
icon: Icon(
color: filter.isFavourite ? Colors.yellowAccent : null,
filter.isFavourite ? IconsaxPlusBold.star_1 : IconsaxPlusLinear.star_1,
),
),
IconButton.filledTonal(
tooltip: context.localized.updateFilterForLibrary,
onPressed:
isCurrentFilter || anyFilterSelected ? null : () => provider.updateFilter(filter),
icon: const Icon(IconsaxPlusBold.refresh),
),
IconButton.filledTonal(
tooltip: context.localized.delete,
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.removeFilterForLibrary(filter.name),
context.localized.deleteFilterConfirmation,
(context) {
filterProvider.removeFilter(filter);
Navigator.of(context).pop();
},
context.localized.delete,
(context) {
Navigator.of(context).pop();
},
context.localized.cancel,
);
},
style: ButtonStyle(
backgroundColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
iconColor: WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
foregroundColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
),
icon: const Icon(IconsaxPlusLinear.trash),
),
]),
),
),
);
@ -129,31 +135,42 @@ class LibrarySavedFiltersDialogue extends ConsumerWidget {
),
const Divider(),
],
if (filters.length < 10)
if (filters.length < 10 && !anyFilterSelected)
StatefulBuilder(builder: (context, setState) {
return Row(
children: [
Flexible(
child: OutlinedTextField(
controller: controller,
label: context.localized.name,
onChanged: (value) => setState(() {}),
onSubmitted: (value) => provider.saveFiltersNew(value),
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
spacing: 8,
children: [
Expanded(
child: OutlinedTextField(
controller: controller,
label: context.localized.name,
onChanged: (value) => setState(() {}),
onSubmitted: (value) => provider.saveFiltersNew(value),
),
),
FilledButton(
onPressed: controller.text.isEmpty
? null
: () {
provider.saveFiltersNew(controller.text);
},
child: Row(
spacing: 8,
children: [Text(context.localized.save), const Icon(IconsaxPlusLinear.save_2)],
),
)
],
),
),
const SizedBox(width: 6),
FilledButton.tonal(
onPressed: controller.text.isEmpty
? null
: () {
provider.saveFiltersNew(controller.text);
},
child: const Icon(IconsaxPlusLinear.save_2),
),
],
),
);
})
else
else if (filters.length >= 10)
Text(context.localized.libraryFiltersLimitReached),
ElevatedButton(
onPressed: () {

View file

@ -14,6 +14,7 @@ import 'package:sliver_tools/sliver_tools.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_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';
@ -76,7 +77,7 @@ class LibraryViews extends ConsumerWidget {
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
final decimal = posterSize - posterSize.toInt();
final sortingOptions = ref.watch(librarySearchProvider(key!).select((value) => value.sortingOption));
final sortingOptions = ref.watch(librarySearchProvider(key!).select((value) => value.filters.sortingOption));
List<ItemAction> otherActions(ItemBaseModel item) {
return [

View file

@ -66,7 +66,7 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: FladderTheme.largeShape.borderRadius,
borderRadius: FladderTheme.smallShape.borderRadius,
),
shadowColor: Colors.transparent,
child: TypeAheadField<ItemBaseModel>(
@ -83,7 +83,7 @@ class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
decorationBuilder: (context, child) => DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: FladderTheme.largeShape.borderRadius,
borderRadius: FladderTheme.smallShape.borderRadius,
),
child: child,
),