From 43712643a2b3513e722e94012b3575829bf9f11b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 16:43:21 +0000 Subject: [PATCH] feat: implement filtered book navigation and improve author sorting - Sort authors by surname (last word in name) instead of first name - Create FilteredLibraryItemsPage to display books filtered by author/genre/series - Add navigation from authors/genres/series pages to filtered book lists - Use AuthorFilter, GenreFilter, and SeriesFilter from shelfsdk - Add libraryFiltered route with filter parameter support Users can now: - Browse authors sorted by surname - Tap on an author to see all their books - Tap on a genre to see all books in that genre - Tap on a series to see all books in that series --- .../view/filtered_library_items_page.dart | 208 ++++++++++++++++++ .../view/library_authors_page.dart | 23 +- .../view/library_genres_page.dart | 16 +- .../view/library_series_page.dart | 16 +- lib/router/constants.dart | 6 + lib/router/router.dart | 26 +++ 6 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 lib/features/library_browser/view/filtered_library_items_page.dart diff --git a/lib/features/library_browser/view/filtered_library_items_page.dart b/lib/features/library_browser/view/filtered_library_items_page.dart new file mode 100644 index 0000000..9296e38 --- /dev/null +++ b/lib/features/library_browser/view/filtered_library_items_page.dart @@ -0,0 +1,208 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/api/library_provider.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/api_settings_provider.dart'; + +/// Page that displays library items filtered by author, genre, or series +class FilteredLibraryItemsPage extends HookConsumerWidget { + const FilteredLibraryItemsPage({ + super.key, + required this.filter, + required this.title, + }); + + final Filter filter; + final String title; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final api = ref.watch(authenticatedApiProvider); + final currentLibraryAsync = ref.watch(currentLibraryProvider); + + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: currentLibraryAsync.when( + data: (library) { + if (library == null) { + return const Center( + child: Text('No library selected'), + ); + } + + return FutureBuilder( + future: api.libraries.getItems( + libraryId: library.id, + parameters: GetLibrarysItemsReqParams( + filter: filter, + sort: 'media.metadata.title', + limit: 100, + ), + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error: ${snapshot.error}'), + ], + ), + ); + } + + final response = snapshot.data; + if (response == null || response.results.isEmpty) { + return const Center( + child: Text('No items found'), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: response.results.length, + itemBuilder: (context, index) { + final item = response.results[index]; + return LibraryItemListTile(item: item); + }, + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('Error: $error'), + ), + ), + ); + } +} + +class LibraryItemListTile extends ConsumerWidget { + const LibraryItemListTile({ + super.key, + required this.item, + }); + + final LibraryItem item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final apiSettings = ref.watch(apiSettingsProvider); + final media = item.media; + + // Extract book info + String title = ''; + String? subtitle; + String? authorName; + + if (media is MediaBook) { + final metadata = media.metadata; + if (metadata is BookMetadata) { + title = metadata.title ?? 'Unknown'; + subtitle = metadata.subtitle; + authorName = metadata.authorName; + } else if (metadata is BookMetadataMinified) { + title = metadata.title ?? 'Unknown'; + authorName = metadata.authorName; + } else if (metadata is BookMetadataExpanded) { + title = metadata.title ?? 'Unknown'; + subtitle = metadata.subtitle; + final authors = metadata.authors; + if (authors != null && authors.isNotEmpty) { + authorName = authors.map((a) => a.name).join(', '); + } + } + } + + final imageUrl = apiSettings.activeServer != null + ? '${apiSettings.activeServer!.serverUrl}/api/items/${item.id}/cover' + : null; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: imageUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: imageUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + httpHeaders: { + if (apiSettings.activeUser?.authToken != null) + 'Authorization': + 'Bearer ${apiSettings.activeUser!.authToken}', + }, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.book, size: 24), + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.book, size: 24), + ), + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.book, size: 24), + ), + title: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (authorName != null) Text(authorName), + if (subtitle != null) + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // Navigate to item detail page + context.goNamed( + Routes.you.name, + pathParameters: {'libraryItemId': item.id}, + ); + }, + ), + ); + } +} diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart index 7c42ac8..52a9feb 100644 --- a/lib/features/library_browser/view/library_authors_page.dart +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -1,9 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/api_provider.dart'; import 'package:vaani/api/library_browser_provider.dart'; +import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; class LibraryAuthorsPage extends HookConsumerWidget { @@ -25,9 +27,13 @@ class LibraryAuthorsPage extends HookConsumerWidget { ); } - // Sort authors alphabetically by name + // Sort authors alphabetically by surname (last word in name) final sortedAuthors = List.from(authors) - ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + ..sort((a, b) { + final aSurname = a.name.split(' ').last.toLowerCase(); + final bSurname = b.name.split(' ').last.toLowerCase(); + return aSurname.compareTo(bSurname); + }); return GridView.builder( padding: const EdgeInsets.all(16), @@ -98,12 +104,13 @@ class AuthorCard extends HookConsumerWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - // TODO: Navigate to author detail page - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Tapped on ${author.name}'), - duration: const Duration(seconds: 1), - ), + // Navigate to filtered items page with author filter + context.goNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': AuthorFilter(author.id), + 'title': author.name, + }, ); }, child: Column( diff --git a/lib/features/library_browser/view/library_genres_page.dart b/lib/features/library_browser/view/library_genres_page.dart index 04056ac..3fbf985 100644 --- a/lib/features/library_browser/view/library_genres_page.dart +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/library_browser_provider.dart'; +import 'package:vaani/router/router.dart'; class LibraryGenresPage extends HookConsumerWidget { const LibraryGenresPage({super.key}); @@ -82,12 +85,13 @@ class GenreListTile extends StatelessWidget { ), trailing: const Icon(Icons.chevron_right), onTap: () { - // TODO: Navigate to books in this genre - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Tapped on $genre'), - duration: const Duration(seconds: 1), - ), + // Navigate to filtered items page with genre filter + context.goNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': GenreFilter(genre), + 'title': genre, + }, ); }, ), diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart index 33248c5..5f5bf8e 100644 --- a/lib/features/library_browser/view/library_series_page.dart +++ b/lib/features/library_browser/view/library_series_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/library_browser_provider.dart'; +import 'package:vaani/router/router.dart'; class LibrarySeriesPage extends HookConsumerWidget { const LibrarySeriesPage({super.key}); @@ -82,12 +85,13 @@ class SeriesListTile extends StatelessWidget { : null, trailing: const Icon(Icons.chevron_right), onTap: () { - // TODO: Navigate to series detail page - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Tapped on ${series.name}'), - duration: const Duration(seconds: 1), - ), + // Navigate to filtered items page with series filter + context.goNamed( + Routes.libraryFiltered.name, + extra: { + 'filter': SeriesFilter(series.id), + 'title': series.name, + }, ); }, ), diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 2cb2bdd..41951ff 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -101,6 +101,12 @@ class Routes { parentRoute: libraryBrowser, ); + static const libraryFiltered = _SimpleRoute( + pathName: 'filtered', + name: 'libraryFiltered', + parentRoute: libraryBrowser, + ); + // you page for the user static const you = _SimpleRoute( pathName: 'you', diff --git a/lib/router/router.dart b/lib/router/router.dart index b90301c..dbea576 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -4,6 +4,7 @@ import 'package:vaani/features/downloads/view/downloads_page.dart'; import 'package:vaani/features/explore/view/explore_page.dart'; import 'package:vaani/features/explore/view/search_result_page.dart'; import 'package:vaani/features/item_viewer/view/library_item_page.dart'; +import 'package:vaani/features/library_browser/view/filtered_library_items_page.dart'; import 'package:vaani/features/library_browser/view/library_authors_page.dart'; import 'package:vaani/features/library_browser/view/library_browser_page.dart'; import 'package:vaani/features/library_browser/view/library_genres_page.dart'; @@ -140,6 +141,31 @@ class MyAppRouter { name: Routes.librarySeries.name, pageBuilder: defaultPageBuilder(const LibrarySeriesPage()), ), + GoRoute( + path: Routes.libraryFiltered.pathName, + name: Routes.libraryFiltered.name, + pageBuilder: (context, state) { + final extra = state.extra as Map?; + final filter = extra?['filter'] as Filter?; + final title = extra?['title'] as String? ?? 'Filtered Items'; + if (filter == null) { + return MaterialPage( + child: Scaffold( + appBar: AppBar(title: const Text('Error')), + body: const Center( + child: Text('Invalid filter'), + ), + ), + ); + } + return MaterialPage( + child: FilteredLibraryItemsPage( + filter: filter, + title: title, + ), + ); + }, + ), ], ), ],