mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 09:09:29 +01:00
636 lines
17 KiB
Dart
636 lines
17 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|