mirror of
https://github.com/Dr-Blank/Vaani.git
synced 2025-12-28 22:09:31 +00:00
feat: implement library view with Authors, Genres, and Series browsing
This commit implements a comprehensive library browsing feature: - Add LibraryBrowserProvider with providers for authors, genres, and series data - Create LibraryAuthorsPage with grid view of authors including images and book counts - Create LibraryGenresPage with list view of all genres - Create LibrarySeriesPage with list view of series and book counts - Update LibraryBrowserPage navigation to route to the new views - Add routes for /browser/authors, /browser/genres, and /browser/series - Replace "Not Implemented" toasts with functional navigation The implementation uses the Audiobookshelf API via shelfsdk to fetch: - Authors list with metadata (getAuthors) - Genres from library filter data (getFilterData) - Series with pagination support (getSeries) All views follow Material Design 3 patterns and include proper loading/error states.
This commit is contained in:
parent
07aea41c6e
commit
53027bf74c
7 changed files with 485 additions and 5 deletions
79
lib/api/library_browser_provider.dart
Normal file
79
lib/api/library_browser_provider.dart
Normal file
|
|
@ -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<List<Author>> 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<List<String>> 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<GetLibrarysSeriesResponse?> 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;
|
||||
}
|
||||
163
lib/features/library_browser/view/library_authors_page.dart
Normal file
163
lib/features/library_browser/view/library_authors_page.dart
Normal file
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
96
lib/features/library_browser/view/library_genres_page.dart
Normal file
96
lib/features/library_browser/view/library_genres_page.dart
Normal file
|
|
@ -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<String>.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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/features/library_browser/view/library_series_page.dart
Normal file
105
lib/features/library_browser/view/library_series_page.dart
Normal file
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue