fix: discover pages now correctly using opds pagination (#179)
Mirror to Codeberg / Mirror to Codeberg (push) Canceled after 0s
Mirror to Codeberg / Mirror to Codeberg (push) Canceled after 0s
This commit is contained in:
@@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart';
|
||||
|
||||
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart';
|
||||
|
||||
class DiscoverDetailsBloc
|
||||
@@ -14,6 +16,8 @@ class DiscoverDetailsBloc
|
||||
on<LoadBooks>(_onLoadBooks);
|
||||
on<LoadCategories>(_onLoadCategories);
|
||||
on<LoadBooksFromPath>(_onLoadBooksFromPath);
|
||||
on<LoadMoreDiscoverBooks>(_onLoadMoreDiscoverBooks);
|
||||
on<LoadMoreDiscoverCategories>(_onLoadMoreDiscoverCategories);
|
||||
}
|
||||
|
||||
Future<void> _onLoadBooks(
|
||||
@@ -125,4 +129,76 @@ class DiscoverDetailsBloc
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadMoreDiscoverBooks(
|
||||
LoadMoreDiscoverBooks event,
|
||||
Emitter<DiscoverDetailsState> emit,
|
||||
) async {
|
||||
final currentFeed = state.bookFeed;
|
||||
final nextPageUrl = currentFeed?.nextPageUrl;
|
||||
|
||||
if (state.isLoadingMore || currentFeed == null || nextPageUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isLoadingMore: true));
|
||||
|
||||
try {
|
||||
final page = await repository.loadBooksFromPath(nextPageUrl);
|
||||
|
||||
final existingIds = currentFeed.books.map((b) => b.id).toSet();
|
||||
final newBooks = page.books.where((b) => !existingIds.contains(b.id));
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
bookFeed: DiscoverFeedModel(
|
||||
books: [...currentFeed.books, ...newBooks],
|
||||
nextPageUrl: _nextOrStop(nextPageUrl, page.nextPageUrl),
|
||||
),
|
||||
isLoadingMore: false,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoadingMore: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadMoreDiscoverCategories(
|
||||
LoadMoreDiscoverCategories event,
|
||||
Emitter<DiscoverDetailsState> emit,
|
||||
) async {
|
||||
final currentFeed = state.categoryFeed;
|
||||
final nextPageUrl = currentFeed?.nextPageUrl;
|
||||
|
||||
if (state.isLoadingMore || currentFeed == null || nextPageUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isLoadingMore: true));
|
||||
|
||||
try {
|
||||
final page = await repository.loadCategoriesFromPath(nextPageUrl);
|
||||
|
||||
final existingIds = currentFeed.categories.map((c) => c.id).toSet();
|
||||
final newCategories = page.categories.where(
|
||||
(c) => !existingIds.contains(c.id),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
categoryFeed: CategoryFeed(
|
||||
categories: [...currentFeed.categories, ...newCategories],
|
||||
nextPageUrl: _nextOrStop(nextPageUrl, page.nextPageUrl),
|
||||
),
|
||||
isLoadingMore: false,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoadingMore: false));
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards against servers that keep pointing at the page we just loaded
|
||||
String? _nextOrStop(String loadedUrl, String? nextUrl) =>
|
||||
nextUrl == loadedUrl ? null : nextUrl;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ class LoadBooksFromPath extends DiscoverDetailsEvent {
|
||||
List<Object?> get props => [fullPath];
|
||||
}
|
||||
|
||||
class LoadMoreDiscoverBooks extends DiscoverDetailsEvent {
|
||||
const LoadMoreDiscoverBooks();
|
||||
}
|
||||
|
||||
class LoadMoreDiscoverCategories extends DiscoverDetailsEvent {
|
||||
const LoadMoreDiscoverCategories();
|
||||
}
|
||||
|
||||
class NavigateToBook extends DiscoverDetailsEvent {
|
||||
final DiscoverDetailsModel book;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class DiscoverDetailsState extends Equatable {
|
||||
final BookViewModel? bookDetails;
|
||||
final String? loadingBookId;
|
||||
final bool isNotFound;
|
||||
final bool isLoadingMore;
|
||||
|
||||
const DiscoverDetailsState({
|
||||
this.status = DiscoverDetailsStatus.initial,
|
||||
@@ -27,8 +28,12 @@ class DiscoverDetailsState extends Equatable {
|
||||
this.bookDetails,
|
||||
this.loadingBookId,
|
||||
this.isNotFound = false,
|
||||
this.isLoadingMore = false,
|
||||
});
|
||||
|
||||
bool get hasMoreBooks => bookFeed?.nextPageUrl != null;
|
||||
bool get hasMoreCategories => categoryFeed?.nextPageUrl != null;
|
||||
|
||||
DiscoverDetailsState copyWith({
|
||||
DiscoverDetailsStatus? status,
|
||||
DiscoverFeedModel? bookFeed,
|
||||
@@ -39,6 +44,7 @@ class DiscoverDetailsState extends Equatable {
|
||||
BookViewModel? bookDetails,
|
||||
String? loadingBookId,
|
||||
bool? isNotFound,
|
||||
bool? isLoadingMore,
|
||||
}) {
|
||||
return DiscoverDetailsState(
|
||||
status: status ?? this.status,
|
||||
@@ -50,6 +56,7 @@ class DiscoverDetailsState extends Equatable {
|
||||
bookDetails: bookDetails ?? this.bookDetails,
|
||||
loadingBookId: loadingBookId ?? this.loadingBookId,
|
||||
isNotFound: isNotFound ?? this.isNotFound,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,5 +71,6 @@ class DiscoverDetailsState extends Equatable {
|
||||
bookDetails,
|
||||
loadingBookId,
|
||||
isNotFound,
|
||||
isLoadingMore,
|
||||
];
|
||||
}
|
||||
|
||||
+50
-17
@@ -50,7 +50,7 @@ class DiscoverDetailsRemoteDatasource {
|
||||
|
||||
return DiscoverFeedModel(
|
||||
books: books,
|
||||
nextPageUrl: jsonData['nextPageUrl'],
|
||||
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e('Error loading books: $e');
|
||||
@@ -116,7 +116,7 @@ class DiscoverDetailsRemoteDatasource {
|
||||
|
||||
return CategoryFeed(
|
||||
categories: categories,
|
||||
nextPageUrl: jsonData['nextPageUrl'],
|
||||
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to load categories: $e');
|
||||
@@ -126,15 +126,7 @@ class DiscoverDetailsRemoteDatasource {
|
||||
Future<DiscoverFeedModel> loadBooksFromPath(String fullPath) async {
|
||||
logger.d('Loading books from path: $fullPath');
|
||||
try {
|
||||
String endpoint = fullPath;
|
||||
final baseUrl = apiService.getBaseUrl();
|
||||
|
||||
if (endpoint.startsWith(baseUrl)) {
|
||||
endpoint = endpoint.substring(baseUrl.length);
|
||||
} else if (baseUrl.endsWith('/api/v1/opds') &&
|
||||
endpoint.startsWith('/api/v1/opds')) {
|
||||
endpoint = endpoint.replaceFirst('/api/v1/opds', '');
|
||||
}
|
||||
final endpoint = _toEndpoint(fullPath);
|
||||
|
||||
final jsonData = await apiService.getXmlAsJson(
|
||||
endpoint: endpoint,
|
||||
@@ -159,13 +151,9 @@ class DiscoverDetailsRemoteDatasource {
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final book in books) {
|
||||
logger.d(book.coverUrl);
|
||||
}
|
||||
|
||||
return DiscoverFeedModel(
|
||||
books: books,
|
||||
nextPageUrl: jsonData['nextPageUrl'],
|
||||
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e('Error loading books from path: $e');
|
||||
@@ -173,6 +161,20 @@ class DiscoverDetailsRemoteDatasource {
|
||||
}
|
||||
}
|
||||
|
||||
Future<CategoryFeed> loadCategoriesFromPath(String fullPath) async {
|
||||
try {
|
||||
final jsonData = await apiService.getXmlAsJson(
|
||||
endpoint: _toEndpoint(fullPath),
|
||||
authMethod: AuthMethod.auto,
|
||||
);
|
||||
|
||||
return _parseCategoryFeed(jsonData);
|
||||
} catch (e) {
|
||||
logger.e('Error loading categories from path: $e');
|
||||
throw Exception('Failed to load categories from path: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<CategoryFeed> loadCategoriesgeneric(String path) async {
|
||||
try {
|
||||
final jsonData = await apiService.getXmlAsJson(
|
||||
@@ -202,7 +204,38 @@ class DiscoverDetailsRemoteDatasource {
|
||||
|
||||
categories.sort((a, b) => a.title.compareTo(b.title));
|
||||
|
||||
return CategoryFeed(categories: categories);
|
||||
return CategoryFeed(
|
||||
categories: categories,
|
||||
nextPageUrl: _parseNextPageUrl(jsonData['feed']['link']),
|
||||
);
|
||||
}
|
||||
|
||||
String _toEndpoint(String fullPath) {
|
||||
String endpoint = fullPath;
|
||||
final baseUrl = apiService.getBaseUrl();
|
||||
|
||||
if (endpoint.startsWith(baseUrl)) {
|
||||
endpoint = endpoint.substring(baseUrl.length);
|
||||
} else if (baseUrl.endsWith('/api/v1/opds') &&
|
||||
endpoint.startsWith('/api/v1/opds')) {
|
||||
endpoint = endpoint.replaceFirst('/api/v1/opds', '');
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
String? _parseNextPageUrl(dynamic links) {
|
||||
if (links == null) return null;
|
||||
final linkList = links is List ? links : [links];
|
||||
|
||||
for (final link in linkList) {
|
||||
if (link is! Map) continue;
|
||||
final rel = (link['_rel'] ?? link['rel'])?.toString();
|
||||
if (rel != 'next') continue;
|
||||
final href = (link['_href'] ?? link['href'])?.toString();
|
||||
if (href != null && href.isNotEmpty) return href;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getBookListPath(DiscoverType type, String? subPath) {
|
||||
|
||||
@@ -44,4 +44,13 @@ class DiscoverDetailsRepository {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CategoryFeed> loadCategoriesFromPath(String fullPath) async {
|
||||
try {
|
||||
final categories = await dataSource.loadCategoriesFromPath(fullPath);
|
||||
return categories;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,13 +162,18 @@ class DiscoverDetailsPage extends StatelessWidget {
|
||||
if (state.isShowingBooks &&
|
||||
state.bookFeed != null &&
|
||||
state.bookFeed!.books.isNotEmpty) {
|
||||
return _buildBookGrid(context, state.bookFeed!);
|
||||
return _buildBookGrid(context, state, state.bookFeed!, localizations);
|
||||
}
|
||||
|
||||
if (state.isShowingCategories &&
|
||||
state.categoryFeed != null &&
|
||||
state.categoryFeed!.categories.isNotEmpty) {
|
||||
return _buildCategoryList(context, state.categoryFeed!);
|
||||
return _buildCategoryList(
|
||||
context,
|
||||
state,
|
||||
state.categoryFeed!,
|
||||
localizations,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildEmptyState(context, localizations);
|
||||
@@ -221,52 +226,127 @@ class DiscoverDetailsPage extends StatelessWidget {
|
||||
|
||||
bool get _isSeriesView => fullPath != null && fullPath!.contains('/series/');
|
||||
|
||||
Widget _buildBookGrid(BuildContext context, DiscoverFeedModel feed) {
|
||||
return BlocBuilder<DiscoverDetailsBloc, DiscoverDetailsState>(
|
||||
builder: (context, state) {
|
||||
final viewState = context.watch<BookViewBloc>().state;
|
||||
final seriesView = _isSeriesView;
|
||||
Widget _buildBookGrid(
|
||||
BuildContext context,
|
||||
DiscoverDetailsState state,
|
||||
DiscoverFeedModel feed,
|
||||
AppLocalizations localizations,
|
||||
) {
|
||||
final viewState = context.watch<BookViewBloc>().state;
|
||||
final seriesView = _isSeriesView;
|
||||
|
||||
if (viewState.isListView) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: feed.books.length,
|
||||
itemBuilder:
|
||||
(context, index) => _buildBookListTile(
|
||||
context,
|
||||
feed.books[index],
|
||||
state,
|
||||
seriesNumber: seriesView ? index + 1 : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: viewState.columnCount,
|
||||
childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9,
|
||||
crossAxisSpacing: 16.0,
|
||||
mainAxisSpacing: 16.0,
|
||||
return _buildPaginatedScrollView(
|
||||
context: context,
|
||||
state: state,
|
||||
localizations: localizations,
|
||||
hasMore: state.hasMoreBooks,
|
||||
onLoadMore:
|
||||
() => context.read<DiscoverDetailsBloc>().add(
|
||||
const LoadMoreDiscoverBooks(),
|
||||
),
|
||||
itemCount: feed.books.length,
|
||||
itemBuilder: (context, index) {
|
||||
final book = feed.books[index];
|
||||
return BookCard(
|
||||
bookId: book.id,
|
||||
coverUrl: book.coverUrl,
|
||||
title: book.title,
|
||||
authors: book.authors,
|
||||
isLoading: state.loadingBookId == book.id,
|
||||
onTap: () => _openBook(context, book),
|
||||
topLeftBadge: seriesView ? '${index + 1}' : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
sliver: SliverPadding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
sliver:
|
||||
viewState.isListView
|
||||
? SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildBookListTile(
|
||||
context,
|
||||
feed.books[index],
|
||||
state,
|
||||
seriesNumber: seriesView ? index + 1 : null,
|
||||
),
|
||||
childCount: feed.books.length,
|
||||
),
|
||||
)
|
||||
: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: viewState.columnCount,
|
||||
childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9,
|
||||
crossAxisSpacing: 16.0,
|
||||
mainAxisSpacing: 16.0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final book = feed.books[index];
|
||||
return BookCard(
|
||||
bookId: book.id,
|
||||
coverUrl: book.coverUrl,
|
||||
title: book.title,
|
||||
authors: book.authors,
|
||||
isLoading: state.loadingBookId == book.id,
|
||||
onTap: () => _openBook(context, book),
|
||||
topLeftBadge: seriesView ? '${index + 1}' : null,
|
||||
);
|
||||
}, childCount: feed.books.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Scrollable feed that pulls the next page in as the end comes into reach
|
||||
Widget _buildPaginatedScrollView({
|
||||
required BuildContext context,
|
||||
required DiscoverDetailsState state,
|
||||
required AppLocalizations localizations,
|
||||
required bool hasMore,
|
||||
required VoidCallback onLoadMore,
|
||||
required Widget sliver,
|
||||
}) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (hasMore &&
|
||||
!state.isLoadingMore &&
|
||||
notification.metrics.pixels >=
|
||||
notification.metrics.maxScrollExtent - 600) {
|
||||
onLoadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
sliver,
|
||||
SliverToBoxAdapter(
|
||||
child: _buildPaginationFooter(
|
||||
context,
|
||||
state,
|
||||
localizations,
|
||||
hasMore: hasMore,
|
||||
onLoadMore: onLoadMore,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaginationFooter(
|
||||
BuildContext context,
|
||||
DiscoverDetailsState state,
|
||||
AppLocalizations localizations, {
|
||||
required bool hasMore,
|
||||
required VoidCallback onLoadMore,
|
||||
}) {
|
||||
if (state.isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (hasMore) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onLoadMore,
|
||||
icon: const Icon(Icons.expand_more_rounded),
|
||||
label: Text(localizations.loadMore),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox(height: 16);
|
||||
}
|
||||
|
||||
Widget _buildBookListTile(
|
||||
BuildContext context,
|
||||
DiscoverDetailsModel book,
|
||||
@@ -396,17 +476,31 @@ class DiscoverDetailsPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryList(BuildContext context, CategoryFeed feed) {
|
||||
return ListView.builder(
|
||||
itemCount: feed.categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = feed.categories[index];
|
||||
return CategoryListItem(
|
||||
category: category,
|
||||
type: categoryType ?? CategoryType.category,
|
||||
onTap: () => _navigateToCategoryOrBooks(context, category),
|
||||
);
|
||||
},
|
||||
Widget _buildCategoryList(
|
||||
BuildContext context,
|
||||
DiscoverDetailsState state,
|
||||
CategoryFeed feed,
|
||||
AppLocalizations localizations,
|
||||
) {
|
||||
return _buildPaginatedScrollView(
|
||||
context: context,
|
||||
state: state,
|
||||
localizations: localizations,
|
||||
hasMore: state.hasMoreCategories,
|
||||
onLoadMore:
|
||||
() => context.read<DiscoverDetailsBloc>().add(
|
||||
const LoadMoreDiscoverCategories(),
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final category = feed.categories[index];
|
||||
return CategoryListItem(
|
||||
category: category,
|
||||
type: categoryType ?? CategoryType.category,
|
||||
onTap: () => _navigateToCategoryOrBooks(context, category),
|
||||
);
|
||||
}, childCount: feed.categories.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,4 +98,32 @@ void main() {
|
||||
expect(feed, isNotNull);
|
||||
expect(feed.categories, isA<List>());
|
||||
});
|
||||
|
||||
test('loadBooks() exposes the next link and that page loads', () async {
|
||||
await setUpDataSource();
|
||||
|
||||
final first = await dataSource.loadBooks(DiscoverType.newlyAdded);
|
||||
if (first.nextPageUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final second = await dataSource.loadBooksFromPath(first.nextPageUrl!);
|
||||
final firstIds = first.books.map((b) => b.id).toSet();
|
||||
|
||||
expect(second.books, isNotEmpty);
|
||||
expect(second.books.any((b) => !firstIds.contains(b.id)), isTrue);
|
||||
});
|
||||
|
||||
test('loadCategoriesFromPath() follows a category next link', () async {
|
||||
await setUpDataSource();
|
||||
|
||||
final first = await dataSource.loadCategories(CategoryType.author);
|
||||
if (first.nextPageUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final second = await dataSource.loadCategoriesFromPath(first.nextPageUrl!);
|
||||
|
||||
expect(second.categories, isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_bloc.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/datasources/discover_details_remote_datasource.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
|
||||
import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart';
|
||||
|
||||
class _FakeRepository implements DiscoverDetailsRepository {
|
||||
final Map<String, DiscoverFeedModel> bookPages;
|
||||
final Map<String, CategoryFeed> categoryPages;
|
||||
final List<String> requestedPaths = [];
|
||||
|
||||
_FakeRepository({
|
||||
this.bookPages = const {},
|
||||
this.categoryPages = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
DiscoverDetailsRemoteDatasource get dataSource => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<DiscoverFeedModel> loadBooksFromPath(String fullPath) async {
|
||||
requestedPaths.add(fullPath);
|
||||
final page = bookPages[fullPath];
|
||||
if (page == null) throw Exception('404 for $fullPath');
|
||||
return page;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CategoryFeed> loadCategoriesFromPath(String fullPath) async {
|
||||
requestedPaths.add(fullPath);
|
||||
final page = categoryPages[fullPath];
|
||||
if (page == null) throw Exception('404 for $fullPath');
|
||||
return page;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiscoverFeedModel> loadBooks(
|
||||
DiscoverType type, {
|
||||
String? subPath,
|
||||
}) async => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<CategoryFeed> loadCategories(
|
||||
CategoryType type, {
|
||||
String? subPath,
|
||||
}) async => categoryPages['first'] ?? const CategoryFeed(categories: []);
|
||||
}
|
||||
|
||||
DiscoverDetailsModel book(String id) =>
|
||||
DiscoverDetailsModel(id: id, uuid: 'uuid-$id', title: id, authors: 'A');
|
||||
|
||||
Future<DiscoverDetailsState> loaded(DiscoverDetailsBloc bloc) =>
|
||||
bloc.stream.firstWhere((s) => s.status == DiscoverDetailsStatus.loaded);
|
||||
|
||||
Future<DiscoverDetailsState> settled(DiscoverDetailsBloc bloc) =>
|
||||
bloc.stream.firstWhere((s) => !s.isLoadingMore);
|
||||
|
||||
void main() {
|
||||
test('load more appends the next page and carries its next link', () async {
|
||||
final repository = _FakeRepository(
|
||||
bookPages: {
|
||||
'/opds/series/1': DiscoverFeedModel(
|
||||
books: [book('1'), book('2')],
|
||||
nextPageUrl: '/opds/series/1?offset=2',
|
||||
),
|
||||
'/opds/series/1?offset=2': DiscoverFeedModel(
|
||||
books: [book('3')],
|
||||
nextPageUrl: '/opds/series/1?offset=4',
|
||||
),
|
||||
},
|
||||
);
|
||||
final bloc = DiscoverDetailsBloc(repository: repository);
|
||||
|
||||
bloc.add(const LoadBooksFromPath('/opds/series/1'));
|
||||
final first = await loaded(bloc);
|
||||
expect(first.bookFeed!.books, hasLength(2));
|
||||
expect(first.hasMoreBooks, isTrue);
|
||||
|
||||
bloc.add(const LoadMoreDiscoverBooks());
|
||||
final second = await settled(bloc);
|
||||
|
||||
expect(second.bookFeed!.books.map((b) => b.id), ['1', '2', '3']);
|
||||
expect(second.bookFeed!.nextPageUrl, '/opds/series/1?offset=4');
|
||||
expect(second.status, DiscoverDetailsStatus.loaded);
|
||||
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('load more drops books that are already in the feed', () async {
|
||||
final repository = _FakeRepository(
|
||||
bookPages: {
|
||||
'/opds/new': DiscoverFeedModel(
|
||||
books: [book('1'), book('2')],
|
||||
nextPageUrl: '/opds/new?offset=2',
|
||||
),
|
||||
'/opds/new?offset=2': DiscoverFeedModel(books: [book('2'), book('3')]),
|
||||
},
|
||||
);
|
||||
final bloc = DiscoverDetailsBloc(repository: repository);
|
||||
|
||||
bloc.add(const LoadBooksFromPath('/opds/new'));
|
||||
await loaded(bloc);
|
||||
|
||||
bloc.add(const LoadMoreDiscoverBooks());
|
||||
final state = await settled(bloc);
|
||||
|
||||
expect(state.bookFeed!.books.map((b) => b.id), ['1', '2', '3']);
|
||||
expect(state.hasMoreBooks, isFalse);
|
||||
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('a next link pointing at the page just loaded ends pagination', () async {
|
||||
final repository = _FakeRepository(
|
||||
bookPages: {
|
||||
'/opds/new': DiscoverFeedModel(
|
||||
books: [book('1')],
|
||||
nextPageUrl: '/opds/new?offset=1',
|
||||
),
|
||||
'/opds/new?offset=1': DiscoverFeedModel(
|
||||
books: [book('2')],
|
||||
nextPageUrl: '/opds/new?offset=1',
|
||||
),
|
||||
},
|
||||
);
|
||||
final bloc = DiscoverDetailsBloc(repository: repository);
|
||||
|
||||
bloc.add(const LoadBooksFromPath('/opds/new'));
|
||||
await loaded(bloc);
|
||||
|
||||
bloc.add(const LoadMoreDiscoverBooks());
|
||||
final state = await settled(bloc);
|
||||
|
||||
expect(state.bookFeed!.books, hasLength(2));
|
||||
expect(state.hasMoreBooks, isFalse);
|
||||
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('load more without a next link does not hit the repository', () async {
|
||||
final repository = _FakeRepository(
|
||||
bookPages: {'/opds/new': DiscoverFeedModel(books: [book('1')])},
|
||||
);
|
||||
final bloc = DiscoverDetailsBloc(repository: repository);
|
||||
|
||||
bloc.add(const LoadBooksFromPath('/opds/new'));
|
||||
await loaded(bloc);
|
||||
|
||||
bloc.add(const LoadMoreDiscoverBooks());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(repository.requestedPaths, ['/opds/new']);
|
||||
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('a failing next page keeps the books already shown', () async {
|
||||
final repository = _FakeRepository(
|
||||
bookPages: {
|
||||
'/opds/new': DiscoverFeedModel(
|
||||
books: [book('1')],
|
||||
nextPageUrl: '/opds/new?offset=1',
|
||||
),
|
||||
},
|
||||
);
|
||||
final bloc = DiscoverDetailsBloc(repository: repository);
|
||||
|
||||
bloc.add(const LoadBooksFromPath('/opds/new'));
|
||||
await loaded(bloc);
|
||||
|
||||
bloc.add(const LoadMoreDiscoverBooks());
|
||||
final state = await settled(bloc);
|
||||
|
||||
expect(state.bookFeed!.books, hasLength(1));
|
||||
expect(state.status, DiscoverDetailsStatus.loaded);
|
||||
|
||||
await bloc.close();
|
||||
});
|
||||
|
||||
test('categories paginate the same way as books', () async {
|
||||
final repository = _FakeRepository(
|
||||
categoryPages: {
|
||||
'first': const CategoryFeed(
|
||||
categories: [CategoryModel(id: '/opds/series/1', title: 'A Series')],
|
||||
nextPageUrl: '/opds/series/letter/A?offset=1',
|
||||
),
|
||||
'/opds/series/letter/A?offset=1': const CategoryFeed(
|
||||
categories: [CategoryModel(id: '/opds/series/2', title: 'B Series')],
|
||||
),
|
||||
},
|
||||
);
|
||||
final bloc = DiscoverDetailsBloc(repository: repository);
|
||||
|
||||
bloc.add(const LoadCategories(CategoryType.series, subPath: 'letter/A'));
|
||||
await loaded(bloc);
|
||||
|
||||
bloc.add(const LoadMoreDiscoverCategories());
|
||||
final state = await settled(bloc);
|
||||
|
||||
expect(state.categoryFeed!.categories.map((c) => c.title), [
|
||||
'A Series',
|
||||
'B Series',
|
||||
]);
|
||||
expect(state.hasMoreCategories, isFalse);
|
||||
|
||||
await bloc.close();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user