import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../database/database.dart'; import '../../models/query.dart'; import '../app_router.dart'; import '../context_menus.dart'; part 'library_page.g.dart'; @Riverpod(keepAlive: true) TabObserver libraryTabObserver(LibraryTabObserverRef ref) { return TabObserver(); } @Riverpod(keepAlive: true) Stream libraryTabPath(LibraryTabPathRef ref) async* { final observer = ref.watch(libraryTabObserverProvider); await for (var tab in observer.path) { yield tab; } } @Riverpod(keepAlive: true) class LastLibraryStateService extends _$LastLibraryStateService { @override Future build() async { final db = ref.watch(databaseProvider); final tab = await ref.watch(libraryTabPathProvider.future); await db.saveLastLibraryState(LastLibraryStateData( id: 1, tab: tab, albumsList: ref.watch(libraryListQueryProvider(0)).query, artistsList: ref.watch(libraryListQueryProvider(1)).query, playlistsList: ref.watch(libraryListQueryProvider(2)).query, songsList: ref.watch(libraryListQueryProvider(3)).query, )); } } @Riverpod(keepAlive: true) class LibraryLists extends _$LibraryLists { @override IList build() { return const IListConst([ /// Albums LibraryListQuery( options: ListQueryOptions( sortColumns: IListConst([ 'albums.name', 'albums.created', 'albums.album_artist', 'albums.year', ]), filterColumns: IListConst([ 'albums.starred', 'albums.album_artist', 'albums.year', 'albums.genre', ]), ), query: ListQuery( page: Pagination(limit: 60), sort: SortBy(column: 'albums.name'), ), ), /// Artists LibraryListQuery( options: ListQueryOptions( sortColumns: IListConst([ 'artists.name', 'artists.album_count', ]), filterColumns: IListConst([ 'artists.starred', ]), ), query: ListQuery( page: Pagination(limit: 30), sort: SortBy(column: 'artists.name'), ), ), /// Playlists LibraryListQuery( options: ListQueryOptions( sortColumns: IListConst([ 'playlists.name', 'playlists.created', 'playlists.changed', ]), filterColumns: IListConst([ 'playlists.owner', ]), ), query: ListQuery( page: Pagination(limit: 30), sort: SortBy(column: 'playlists.name'), ), ), /// Songs LibraryListQuery( options: ListQueryOptions( sortColumns: IListConst([ 'songs.album', 'songs.artist', 'songs.created', 'songs.title', 'songs.year', ]), filterColumns: IListConst([ 'songs.starred', 'songs.artist', 'songs.album', 'songs.year', 'songs.genre', ]), ), query: ListQuery( page: Pagination(limit: 30), sort: SortBy(column: 'songs.album'), ), ), ]); } Future init() async { final db = ref.read(databaseProvider); final last = await db.getLastLibraryState().getSingleOrNull(); if (last == null) { return; } state = state .replace(0, state[0].copyWith(query: last.albumsList)) .replace(1, state[1].copyWith(query: last.artistsList)) .replace(2, state[2].copyWith(query: last.playlistsList)) .replace(3, state[3].copyWith(query: last.songsList)); } void setSortColumn(int index, String column) { state = state.replace( index, state[index].copyWith.query.sort!(column: column), ); } void toggleDirection(int index) { final toggled = state[index].query.sort?.dir == SortDirection.asc ? SortDirection.desc : SortDirection.asc; state = state.replace( index, state[index].copyWith.query.sort!(dir: toggled), ); } void setFilter(int index, FilterWith filter) { state = state.replace( index, state[index].copyWith.query( filters: state[index].query.filters.updateById( [filter], (e) => e.column, ), ), ); } void removeFilter(int index, String column) { state = state.replace( index, state[index].copyWith.query( filters: state[index] .query .filters .removeWhere((f) => f.column == column)), ); } void clearFilters(int index) { state = state.replace(index, state[index].copyWith.query(filters: IList())); } } @Riverpod(keepAlive: true) LibraryListQuery libraryListQuery(LibraryListQueryRef ref, int index) { return ref.watch(libraryListsProvider.select((value) => value[index])); } class LibraryTabsPage extends HookConsumerWidget { const LibraryTabsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final observer = ref.watch(libraryTabObserverProvider); return AutoTabsRouter.tabBar( inheritNavigatorObservers: false, navigatorObservers: () => [observer], routes: const [ LibraryAlbumsRoute(), LibraryArtistsRoute(), LibraryPlaylistsRoute(), LibrarySongsRoute(), ], builder: (context, child, tabController) { return Scaffold( body: child, floatingActionButton: const _LibraryFilterFab(), ); }, ); } } class _LibraryFilterFab extends HookConsumerWidget { const _LibraryFilterFab(); @override Widget build(BuildContext context, WidgetRef ref) { final tabsRouter = AutoTabsRouter.of(context); final activeIndex = useListenableSelector(tabsRouter, () => tabsRouter.activeIndex); final tabHasFilters = ref.watch(libraryListQueryProvider(activeIndex) .select((value) => value.query.filters.isNotEmpty)); List dot = []; if (tabHasFilters) { dot.addAll([ PositionedDirectional( top: 3, end: 0, child: Icon( Icons.circle, color: Theme.of(context).colorScheme.primaryContainer, size: 11, ), ), const PositionedDirectional( top: 5, end: 1, child: Icon( Icons.circle, size: 7, ), ), ]); } return FloatingActionButton( heroTag: null, onPressed: () async { showContextMenu( context: context, ref: ref, builder: (context) => BottomSheetMenu( child: LibraryMenu( tabsRouter: tabsRouter, ), ), ); }, tooltip: 'List', child: Stack( children: [ const Icon( Icons.sort_rounded, size: 28, ), ...dot, ], ), ); } } class LibraryMenu extends HookConsumerWidget { final TabsRouter tabsRouter; const LibraryMenu({ super.key, required this.tabsRouter, }); @override Widget build(BuildContext context, WidgetRef ref) { return CustomScrollView( slivers: [ SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), sliver: SliverToBoxAdapter( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( children: [ FilterChip( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(100), ), onSelected: (value) {}, selected: true, label: const Icon( Icons.grid_on, size: 20, ), ), const SizedBox(width: 6), FilterChip( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(100), ), onSelected: (value) {}, label: const Icon( Icons.grid_view_outlined, size: 20, ), ), const SizedBox(width: 6), FilterChip( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(100), ), onSelected: (value) {}, label: const Icon( Icons.format_list_bulleted, size: 20, ), ), ], ), _FilterToggleButton(tabsRouter: tabsRouter), ], ), ), ), const SliverPadding(padding: EdgeInsets.only(top: 8)), ListSortFilterOptions(index: tabsRouter.activeIndex), const SliverPadding(padding: EdgeInsets.only(top: 16)), ], ); } } class _FilterToggleButton extends HookConsumerWidget { final TabsRouter tabsRouter; const _FilterToggleButton({ required this.tabsRouter, }); @override Widget build(BuildContext context, WidgetRef ref) { final tabHasFilters = ref.watch( libraryListQueryProvider(tabsRouter.activeIndex) .select((value) => value.query.filters.isNotEmpty)); return FilledButton( onPressed: tabHasFilters ? () { ref .read(libraryListsProvider.notifier) .clearFilters(tabsRouter.activeIndex); } : null, child: const Icon(Icons.filter_list_off_rounded), ); } } class ListSortFilterOptions extends HookConsumerWidget { final int index; const ListSortFilterOptions({ super.key, required this.index, }); void Function()? _filterOnEdit( String column, BuildContext context, WidgetRef ref, ) { final type = column.split('.').last; switch (type) { case 'year': return () { // TODO: year filter dialog // showDialog( // context: context, // builder: (context) { // return Dialog( // child: Text('adsf'), // ); // }, // ); }; case 'genre': case 'album_artist': case 'owner': case 'album': case 'artist': // TODO: other filter dialogs return () {}; default: return null; } } void Function(bool? value)? _filterOnChanged(String column, WidgetRef ref) { final type = column.split('.').last; switch (type) { case 'starred': return (value) { if (value!) { ref.read(libraryListsProvider.notifier).setFilter( index, FilterWith.isNull(column: column, invert: true), ); } else { ref.read(libraryListsProvider.notifier).removeFilter(index, column); } }; case 'year': // TODO: add/remove filter return null; case 'genre': case 'album_artist': case 'owner': case 'album': case 'artist': // TODO: add/remove filter return null; default: return null; } } @override Widget build(BuildContext context, WidgetRef ref) { final list = ref.watch(libraryListQueryProvider(index)); return SliverList( delegate: SliverChildListDelegate.fixed([ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( 'Sort by', style: Theme.of(context).textTheme.titleLarge, ), ), const SizedBox(height: 8), for (var column in list.options.sortColumns) SortOptionTile( column: column, value: list.query.sort!.copyWith(column: column), groupValue: list.query.sort!, onColumnChanged: (column) { if (column != null) { ref .read(libraryListsProvider.notifier) .setSortColumn(index, column); } }, onDirectionToggle: () => ref.read(libraryListsProvider.notifier).toggleDirection(index), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( 'Filter', style: Theme.of(context).textTheme.titleLarge, ), ), for (var column in list.options.filterColumns) FilterOptionTile( column: column, state: list.query.filters.singleWhereOrNull( (e) => e.column == column, ), onEdit: _filterOnEdit(column, context, ref), onChanged: _filterOnChanged(column, ref), ) ]), ); } } class SortOptionTile extends HookConsumerWidget { final String column; final SortBy value; final SortBy groupValue; final void Function(String? value) onColumnChanged; final void Function() onDirectionToggle; const SortOptionTile({ super.key, required this.column, required this.value, required this.groupValue, required this.onColumnChanged, required this.onDirectionToggle, }); String _sortTitle(AppLocalizations l, String type) { type = type.split('.').last; switch (type) { case 'name': return l.resourcesSortByName; case 'album_artist': return l.resourcesSortByArtist; case 'created': return l.resourcesSortByAdded; case 'year': return l.resourcesSortByYear; case 'album_count': return l.resourcesSortByAlbumCount; case 'changed': return l.resourcesSortByUpdated; case 'album': return l.resourcesSortByAlbum; case 'artist': return l.resourcesSortByArtist; case 'title': return l.resourcesSortByTitle; default: return ''; } } @override Widget build(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); return RadioListTile( value: value.column, groupValue: groupValue.column, onChanged: onColumnChanged, selected: value.column == groupValue.column, title: Text(_sortTitle(l, column)), secondary: value.column == groupValue.column ? IconButton( icon: Icon( value.dir == SortDirection.desc ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, ), onPressed: onDirectionToggle, ) : null, ); } } class FilterOptionTile extends HookConsumerWidget { final String column; final FilterWith? state; final void Function(bool? value)? onChanged; final void Function()? onEdit; const FilterOptionTile({ super.key, required this.column, required this.state, required this.onChanged, this.onEdit, }); String _filterTitle(AppLocalizations l, String type) { type = type.split('.').last; switch (type) { case 'starred': return l.resourcesFilterStarred; case 'year': return l.resourcesFilterYear; case 'genre': return l.resourcesFilterGenre; case 'album_artist': return l.resourcesFilterArtist; case 'owner': return l.resourcesFilterOwner; case 'album': return l.resourcesFilterAlbum; case 'artist': return l.resourcesFilterArtist; default: return ''; } } @override Widget build(BuildContext context, WidgetRef ref) { final l = AppLocalizations.of(context); return CheckboxListTile( value: state == null ? false : state!.map( equals: (value) => value.invert ? null : true, greaterThan: (value) => true, isNull: (_) => true, betweenInt: (_) => true, isIn: (value) => value.invert ? null : true, ), tristate: state?.map( equals: (value) => true, greaterThan: (value) => false, isNull: (_) => false, betweenInt: (_) => false, isIn: (_) => true, ) ?? false, title: Text(_filterTitle(l, column)), secondary: onEdit == null ? null : IconButton( icon: const Icon(Icons.edit_rounded), onPressed: onEdit, ), controlAffinity: ListTileControlAffinity.leading, onChanged: onChanged, ); } }