mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
v2
This commit is contained in:
@@ -0,0 +1,635 @@
|
||||
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<String> 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<void> 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<LibraryListQuery> 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<void> 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<Widget> 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<String?>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user