diff --git a/lib/api/library_browser_provider.dart b/lib/api/library_browser_provider.dart new file mode 100644 index 0000000..08fb524 --- /dev/null +++ b/lib/api/library_browser_provider.dart @@ -0,0 +1,79 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart' show Ref; +import 'package:logging/logging.dart' show Logger; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart' + show Author, GetLibrarysSeriesResponse, LibraryFilterData; +import 'package:vaani/api/api_provider.dart' show authenticatedApiProvider; +import 'package:vaani/api/library_provider.dart' show currentLibraryProvider; + +part 'library_browser_provider.g.dart'; + +final _logger = Logger('LibraryBrowserProvider'); + +/// Provider for fetching all authors in the current library +@riverpod +Future> libraryAuthors(Ref ref) async { + final api = ref.watch(authenticatedApiProvider); + final currentLibrary = await ref.watch(currentLibraryProvider.future); + + if (currentLibrary == null) { + _logger.warning('No current library found'); + return []; + } + + final authors = await api.libraries.getAuthors(libraryId: currentLibrary.id); + + if (authors == null) { + _logger.warning('Failed to fetch authors for library ${currentLibrary.id}'); + return []; + } + + _logger.fine('Fetched ${authors.length} authors'); + return authors; +} + +/// Provider for fetching all genres in the current library +@riverpod +Future> libraryGenres(Ref ref) async { + final api = ref.watch(authenticatedApiProvider); + final currentLibrary = await ref.watch(currentLibraryProvider.future); + + if (currentLibrary == null) { + _logger.warning('No current library found'); + return []; + } + + final filterData = await api.libraries.getFilterData(libraryId: currentLibrary.id); + + if (filterData == null) { + _logger.warning('Failed to fetch filter data for library ${currentLibrary.id}'); + return []; + } + + _logger.fine('Fetched ${filterData.genres.length} genres'); + return filterData.genres; +} + +/// Provider for fetching all series in the current library +@riverpod +Future librarySeries(Ref ref, {int page = 0, int limit = 50}) async { + final api = ref.watch(authenticatedApiProvider); + final currentLibrary = await ref.watch(currentLibraryProvider.future); + + if (currentLibrary == null) { + _logger.warning('No current library found'); + return null; + } + + final series = await api.libraries.getSeries( + libraryId: currentLibrary.id, + ); + + if (series == null) { + _logger.warning('Failed to fetch series for library ${currentLibrary.id}'); + return null; + } + + _logger.fine('Fetched ${series.results.length} series (${series.total} total)'); + return series; +} diff --git a/lib/features/library_browser/view/library_authors_page.dart b/lib/features/library_browser/view/library_authors_page.dart new file mode 100644 index 0000000..59db1a4 --- /dev/null +++ b/lib/features/library_browser/view/library_authors_page.dart @@ -0,0 +1,163 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.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/settings/api_settings_provider.dart'; + +class LibraryAuthorsPage extends HookConsumerWidget { + const LibraryAuthorsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authorsAsync = ref.watch(libraryAuthorsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Authors'), + ), + body: authorsAsync.when( + data: (authors) { + if (authors.isEmpty) { + return const Center( + child: Text('No authors found'), + ); + } + + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.75, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: authors.length, + itemBuilder: (context, index) { + final author = authors[index]; + return AuthorCard(author: author); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error loading authors: $error'), + ], + ), + ), + ), + ); + } +} + +class AuthorCard extends HookConsumerWidget { + const AuthorCard({ + super.key, + required this.author, + }); + + final Author author; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final apiSettings = ref.watch(apiSettingsProvider); + final api = ref.watch(authenticatedApiProvider); + + // Determine the author variant and extract relevant data + final String? imagePath = author.map( + (base) => base.imagePath, + minified: (minified) => null, + expanded: (expanded) => expanded.imagePath, + ); + + final int? numBooks = author.map( + (base) => base.libraryItems?.length, + minified: (minified) => null, + expanded: (expanded) => expanded.numBooks, + ); + + // Build the image URL if imagePath is available + String? imageUrl; + if (imagePath != null && apiSettings.activeServer != null) { + imageUrl = '${apiSettings.activeServer!.url}/api/authors/${author.id}/image'; + } + + return Card( + 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), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl, + httpHeaders: { + 'Authorization': 'Bearer ${apiSettings.activeUser?.token}', + }, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.person, size: 48), + ), + ) + : Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.person, size: 48), + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + author.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (numBooks != null) ...[ + const SizedBox(height: 4), + Text( + '$numBooks ${numBooks == 1 ? 'book' : 'books'}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/library_browser/view/library_browser_page.dart b/lib/features/library_browser/view/library_browser_page.dart index 4327b17..1490295 100644 --- a/lib/features/library_browser/view/library_browser_page.dart +++ b/lib/features/library_browser/view/library_browser_page.dart @@ -6,8 +6,6 @@ import 'package:vaani/features/you/view/widgets/library_switch_chip.dart' show showLibrarySwitcher; import 'package:vaani/router/router.dart' show Routes; import 'package:vaani/shared/icons/abs_icons.dart' show AbsIcons; -import 'package:vaani/shared/widgets/not_implemented.dart' - show showNotImplementedToast; class LibraryBrowserPage extends HookConsumerWidget { const LibraryBrowserPage({super.key}); @@ -48,7 +46,7 @@ class LibraryBrowserPage extends HookConsumerWidget { leading: const Icon(Icons.person), trailing: const Icon(Icons.chevron_right), onTap: () { - showNotImplementedToast(context); + GoRouter.of(context).pushNamed(Routes.libraryAuthors.name); }, ), ListTile( @@ -56,7 +54,7 @@ class LibraryBrowserPage extends HookConsumerWidget { leading: const Icon(Icons.category), trailing: const Icon(Icons.chevron_right), onTap: () { - showNotImplementedToast(context); + GoRouter.of(context).pushNamed(Routes.libraryGenres.name); }, ), ListTile( @@ -64,7 +62,7 @@ class LibraryBrowserPage extends HookConsumerWidget { leading: const Icon(Icons.list), trailing: const Icon(Icons.chevron_right), onTap: () { - showNotImplementedToast(context); + GoRouter.of(context).pushNamed(Routes.librarySeries.name); }, ), // Downloads diff --git a/lib/features/library_browser/view/library_genres_page.dart b/lib/features/library_browser/view/library_genres_page.dart new file mode 100644 index 0000000..04056ac --- /dev/null +++ b/lib/features/library_browser/view/library_genres_page.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:vaani/api/library_browser_provider.dart'; + +class LibraryGenresPage extends HookConsumerWidget { + const LibraryGenresPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final genresAsync = ref.watch(libraryGenresProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Genres'), + ), + body: genresAsync.when( + data: (genres) { + if (genres.isEmpty) { + return const Center( + child: Text('No genres found'), + ); + } + + // Sort genres alphabetically + final sortedGenres = List.from(genres)..sort(); + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: sortedGenres.length, + itemBuilder: (context, index) { + final genre = sortedGenres[index]; + return GenreListTile(genre: genre); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error loading genres: $error'), + ], + ), + ), + ), + ); + } +} + +class GenreListTile extends StatelessWidget { + const GenreListTile({ + super.key, + required this.genre, + }); + + final String genre; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.category, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: Text( + genre, + style: Theme.of(context).textTheme.titleMedium, + ), + 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), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/library_browser/view/library_series_page.dart b/lib/features/library_browser/view/library_series_page.dart new file mode 100644 index 0000000..a967b9b --- /dev/null +++ b/lib/features/library_browser/view/library_series_page.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/library_browser_provider.dart'; + +class LibrarySeriesPage extends HookConsumerWidget { + const LibrarySeriesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final seriesAsync = ref.watch(librarySeriesProvider()); + + return Scaffold( + appBar: AppBar( + title: const Text('Series'), + ), + body: seriesAsync.when( + data: (seriesResponse) { + if (seriesResponse == null || seriesResponse.results.isEmpty) { + return const Center( + child: Text('No series found'), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: seriesResponse.results.length, + itemBuilder: (context, index) { + final series = seriesResponse.results[index]; + return SeriesListTile(series: series); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Error loading series: $error'), + ], + ), + ), + ), + ); + } +} + +class SeriesListTile extends StatelessWidget { + const SeriesListTile({ + super.key, + required this.series, + }); + + final Series series; + + @override + Widget build(BuildContext context) { + // Extract series data based on variant + final String seriesName = series.name; + final int? numBooks = series.maybeMap( + numBooks: (s) => s.numBooks, + books: (s) => s.books.length, + orElse: () => null, + ); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.list, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + title: Text( + seriesName, + style: Theme.of(context).textTheme.titleMedium, + ), + subtitle: numBooks != null + ? Text('$numBooks ${numBooks == 1 ? 'book' : 'books'}') + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () { + // TODO: Navigate to series detail page + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Tapped on $seriesName'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ); + } +} diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 79c4556..2cb2bdd 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -82,6 +82,25 @@ class Routes { // parentRoute: library, ); + // library browser sub-routes + static const libraryAuthors = _SimpleRoute( + pathName: 'authors', + name: 'libraryAuthors', + parentRoute: libraryBrowser, + ); + + static const libraryGenres = _SimpleRoute( + pathName: 'genres', + name: 'libraryGenres', + parentRoute: libraryBrowser, + ); + + static const librarySeries = _SimpleRoute( + pathName: 'series', + name: 'librarySeries', + 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 eda348e..b90301c 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -4,7 +4,10 @@ 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/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'; +import 'package:vaani/features/library_browser/view/library_series_page.dart'; import 'package:vaani/features/logging/view/logs_page.dart'; import 'package:vaani/features/onboarding/view/callback_page.dart'; import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'; @@ -121,6 +124,23 @@ class MyAppRouter { path: Routes.libraryBrowser.localPath, name: Routes.libraryBrowser.name, pageBuilder: defaultPageBuilder(const LibraryBrowserPage()), + routes: [ + GoRoute( + path: Routes.libraryAuthors.pathName, + name: Routes.libraryAuthors.name, + pageBuilder: defaultPageBuilder(const LibraryAuthorsPage()), + ), + GoRoute( + path: Routes.libraryGenres.pathName, + name: Routes.libraryGenres.name, + pageBuilder: defaultPageBuilder(const LibraryGenresPage()), + ), + GoRoute( + path: Routes.librarySeries.pathName, + name: Routes.librarySeries.name, + pageBuilder: defaultPageBuilder(const LibrarySeriesPage()), + ), + ], ), ], ),