search for books

This commit is contained in:
Dr-Blank 2024-06-05 12:08:44 -04:00
parent a1e238fc25
commit d372a6b096
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
12 changed files with 963 additions and 88 deletions

View file

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_controller.g.dart';
/// The controller for the search bar.
@Riverpod(keepAlive: true)
class GlobalSearchController extends _$GlobalSearchController {
@override
Raw<SearchController> build() {
final controller = SearchController();
// dispose the controller when the provider is disposed
ref.onDispose(controller.dispose);
return controller;
}
}

View file

@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$globalSearchControllerHash() =>
r'd854ace6f2e00a10fc33aba63051375f82ad1b10';
/// The controller for the search bar.
///
/// Copied from [GlobalSearchController].
@ProviderFor(GlobalSearchController)
final globalSearchControllerProvider =
NotifierProvider<GlobalSearchController, Raw<SearchController>>.internal(
GlobalSearchController.new,
name: r'globalSearchControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$globalSearchControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$GlobalSearchController = Notifier<Raw<SearchController>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,23 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/settings/api_settings_provider.dart';
part 'search_result_provider.g.dart';
/// The provider for the search result.
@riverpod
FutureOr<LibrarySearchResponse?> searchResult(
SearchResultRef ref,
String query, {
int limit = 25,
}) async {
final api = ref.watch(authenticatedApiProvider);
final apiSettings = ref.watch(apiSettingsProvider);
return await api.libraries.search(
libraryId: apiSettings.activeLibraryId!,
query: query,
limit: limit,
);
}

View file

@ -0,0 +1,189 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_result_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$searchResultHash() => r'9baa643cce24f3a5e022f42202e423373939ef95';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// The provider for the search result.
///
/// Copied from [searchResult].
@ProviderFor(searchResult)
const searchResultProvider = SearchResultFamily();
/// The provider for the search result.
///
/// Copied from [searchResult].
class SearchResultFamily extends Family<AsyncValue<LibrarySearchResponse?>> {
/// The provider for the search result.
///
/// Copied from [searchResult].
const SearchResultFamily();
/// The provider for the search result.
///
/// Copied from [searchResult].
SearchResultProvider call(
String query, {
int limit = 25,
}) {
return SearchResultProvider(
query,
limit: limit,
);
}
@override
SearchResultProvider getProviderOverride(
covariant SearchResultProvider provider,
) {
return call(
provider.query,
limit: provider.limit,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'searchResultProvider';
}
/// The provider for the search result.
///
/// Copied from [searchResult].
class SearchResultProvider
extends AutoDisposeFutureProvider<LibrarySearchResponse?> {
/// The provider for the search result.
///
/// Copied from [searchResult].
SearchResultProvider(
String query, {
int limit = 25,
}) : this._internal(
(ref) => searchResult(
ref as SearchResultRef,
query,
limit: limit,
),
from: searchResultProvider,
name: r'searchResultProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$searchResultHash,
dependencies: SearchResultFamily._dependencies,
allTransitiveDependencies:
SearchResultFamily._allTransitiveDependencies,
query: query,
limit: limit,
);
SearchResultProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.query,
required this.limit,
}) : super.internal();
final String query;
final int limit;
@override
Override overrideWith(
FutureOr<LibrarySearchResponse?> Function(SearchResultRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SearchResultProvider._internal(
(ref) => create(ref as SearchResultRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
query: query,
limit: limit,
),
);
}
@override
AutoDisposeFutureProviderElement<LibrarySearchResponse?> createElement() {
return _SearchResultProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SearchResultProvider &&
other.query == query &&
other.limit == limit;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, query.hashCode);
hash = _SystemHash.combine(hash, limit.hashCode);
return _SystemHash.finish(hash);
}
}
mixin SearchResultRef on AutoDisposeFutureProviderRef<LibrarySearchResponse?> {
/// The parameter `query` of this provider.
String get query;
/// The parameter `limit` of this provider.
int get limit;
}
class _SearchResultProviderElement
extends AutoDisposeFutureProviderElement<LibrarySearchResponse?>
with SearchResultRef {
_SearchResultProviderElement(super.provider);
@override
String get query => (origin as SearchResultProvider).query;
@override
int get limit => (origin as SearchResultProvider).limit;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,401 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/api/api_provider.dart';
import 'package:whispering_pages/api/image_provider.dart';
import 'package:whispering_pages/api/library_item_provider.dart';
import 'package:whispering_pages/constants/hero_tag_conventions.dart';
import 'package:whispering_pages/features/explore/providers/search_controller.dart';
import 'package:whispering_pages/features/explore/view/search_result_page.dart';
import 'package:whispering_pages/router/router.dart';
import 'package:whispering_pages/settings/api_settings_provider.dart';
import 'package:whispering_pages/settings/app_settings_provider.dart';
import 'package:whispering_pages/shared/widgets/shelves/book_shelf.dart';
const Duration debounceDuration = Duration(milliseconds: 500);
class ExplorePage extends HookConsumerWidget {
const ExplorePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// hooks for the dark mode
final settings = ref.watch(appSettingsProvider);
final api = ref.watch(authenticatedApiProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Explore'),
backgroundColor: Colors.transparent,
),
body: const MySearchBar(),
);
}
}
class MySearchBar extends HookConsumerWidget {
const MySearchBar({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = ref.watch(globalSearchControllerProvider);
final searchBarFocusNode = useFocusNode();
final api = ref.watch(authenticatedApiProvider);
final settings = ref.watch(apiSettingsProvider);
// The query currently being searched for. If null, there is no pending
// request.
String? currentQuery;
// The most recent suggestions received from the API.
final lastOptions = useState(<Widget>[]);
// Calls the "remote" API to search with the given query. Returns null when
// the call has been made obsolete.
Future<LibrarySearchResponse?> search(String query) async {
currentQuery = query;
// In a real application, there should be some error handling here.
final options = await api.libraries
.search(libraryId: settings.activeLibraryId!, query: query, limit: 3);
// If another search happened after this one, throw away these options.
if (currentQuery != query) {
return null;
}
currentQuery = null;
return options;
}
final debouncedSearch = _debounce(search);
return SearchAnchor(
searchController: searchController,
textInputAction: TextInputAction.search,
dividerColor: Colors.transparent,
builder: (context, controller) {
return SearchBar(
controller: controller,
focusNode: searchBarFocusNode,
// "What's your next page-turner?"
// "Looking for a good read? Type it in!"
// "Your next adventure is a search away..."
// "Bookworms unite! What's your pick today?"
// "Let's find your next literary love..."
// "Type in your next escape..."
// "What's on your reading list today?"
// "Seek and you shall find... your next book!"
// "Let's uncover your next favorite book..."
// "Ready to dive into a new story?"
hintText: 'Seek and you shall discover...',
// opacity: 0.5 for the hint text
hintStyle: WidgetStatePropertyAll(
Theme.of(context).textTheme.bodyMedium!.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
),
),
textInputAction: TextInputAction.search,
onTapOutside: (_) {
searchBarFocusNode.unfocus();
},
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 16.0),
),
onTap: () {
controller.openView();
},
onChanged: (_) {
controller.openView();
},
leading: const Icon(Icons.search),
);
},
viewOnSubmitted: (value) {
context.pushNamed(
Routes.search.name,
queryParameters: {
'q': value,
},
);
},
suggestionsBuilder: (context, controller) async {
// check if the search controller is empty
if (controller.text.isEmpty) {
// TODO: show recent searches
return <Widget>[];
}
final options = await debouncedSearch(controller.text);
// debugPrint('options: $options');
if (options == null) {
// TODO: show loading indicator or failure message
return <Widget>[const ListTile(title: Text('Loading...'))];
}
// see if BookLibrarySearchResponse or PodcastLibrarySearchResponse
if (options is BookLibrarySearchResponse) {
lastOptions.value = buildBookSearchResult(options, context);
} else if (options is PodcastLibrarySearchResponse) {
lastOptions.value = options.podcast
.map(
(result) => ListTile(
title: Text(result.libraryItem.id),
subtitle: Text(result.libraryItem.libraryId),
),
)
.toList();
}
return lastOptions.value;
},
viewBuilder: (suggestions) {
// return a container such that on tap other than the suggestions, the view is closed
return GestureDetector(
onTap: () {
searchController.closeView(searchController.text);
searchBarFocusNode.unfocus();
},
child: ListView.builder(
itemCount: suggestions.length,
itemBuilder: (context, index) {
return suggestions.toList()[index];
},
),
);
},
);
}
}
List<Widget> buildBookSearchResult(
BookLibrarySearchResponse options,
BuildContext context,
) {
// build sections for the search results
// 1. Books
// 2. Authors
// 3. Series
// 4. Tags
// 5. Narrators
// each section will have a title and a list of items
// only show the section if there are items in it
final sections = <Widget>[];
if (options.book.isNotEmpty) {
sections.add(
SearchResultMiniSection(
// title: 'Books',
category: SearchResultCategory.books,
options: options.book.map(
(result) {
// convert result to a book object
final book =
BookExpanded.fromJson(result.libraryItem.media.toJson());
final metadata = BookMetadataExpanded.fromJson(
book.metadata.toJson(),
);
return BookSearchResultMini(book: book, metadata: metadata);
},
),
),
);
}
if (options.authors.isNotEmpty) {
sections.add(
SearchResultMiniSection(
// title: 'Authors',
category: SearchResultCategory.authors,
options: options.authors.map(
(result) {
return ListTile(title: Text(result.name));
},
),
),
);
}
return sections;
}
/// A mini version of the book that is displayed in the search results.
class BookSearchResultMini extends HookConsumerWidget {
const BookSearchResultMini({
super.key,
required this.book,
required this.metadata,
});
final BookExpanded book;
final BookMetadataExpanded metadata;
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(libraryItemProvider(book.libraryItemId)).valueOrNull;
final image = item == null
? const AsyncValue.loading()
: ref.watch(coverImageProvider(item));
return ListTile(
leading: SizedBox(
width: 50,
height: 50,
child: Hero(
tag: HeroTagPrefixes.bookCover + book.libraryItemId,
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: image.when(
data: (bytes) => Image.memory(
bytes,
fit: BoxFit.cover,
),
loading: () => const BookCoverSkeleton(),
error: (error, _) => const Icon(Icons.error),
),
),
),
),
title: Text(book.metadata.title ?? ''),
subtitle: Text(
maxLines: 1,
overflow: TextOverflow.ellipsis,
metadata.authors
.map(
(author) => author.name,
)
.join(', '),
),
onTap: () {
// navigate to the book details page
context.pushNamed(
Routes.libraryItem.name,
pathParameters: {
Routes.libraryItem.pathParamName!: book.libraryItemId,
},
);
},
trailing: IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
// TODO: show a menu with options for the book
},
),
);
}
}
class SearchResultMiniSection extends HookConsumerWidget {
const SearchResultMiniSection({
super.key,
required this.category,
required this.options,
this.onTap,
});
// final String title;
final SearchResultCategory category;
final Iterable<Widget> options;
/// A callback that is called when the section is tapped.
/// typically used to navigate to a search page for the section
final VoidCallback? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = ref.watch(globalSearchControllerProvider);
openSearch() {
final query = searchController.text;
context.pushNamed(
Routes.search.name,
queryParameters: {
'q': query,
'category': category.toString().split('.').last,
},
);
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// title for the section
// Heading and then a hr line
Row(
children: [
Text(
category.toString().split('.').last,
style: Theme.of(context).textTheme.headlineSmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.arrow_forward_ios),
onPressed: onTap ?? openSearch,
),
],
),
// const Divider(
// height: 1,
// thickness: BorderSide.strokeAlignCenter,
// ),
...options,
],
),
);
}
}
typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
/// Returns a new function that is a debounced version of the given function.
///
/// This means that the original function will be called only after no calls
/// have been made for the given Duration.
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {
_DebounceTimer? debounceTimer;
return (T parameter) async {
if (debounceTimer != null && !debounceTimer!.isCompleted) {
debounceTimer!.cancel();
}
debounceTimer = _DebounceTimer();
try {
await debounceTimer!.future;
} catch (error) {
if (error is _CancelException) {
return null;
}
rethrow;
}
return function(parameter);
};
}
// A wrapper around Timer used for debouncing.
class _DebounceTimer {
_DebounceTimer() {
_timer = Timer(debounceDuration, _onComplete);
}
late final Timer _timer;
final Completer<void> _completer = Completer<void>();
void _onComplete() {
_completer.complete();
}
Future<void> get future => _completer.future;
bool get isCompleted => _completer.isCompleted;
void cancel() {
_timer.cancel();
_completer.completeError(const _CancelException());
}
}
// An exception indicating that the timer was canceled.
class _CancelException implements Exception {
const _CancelException();
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:whispering_pages/features/explore/providers/search_result_provider.dart';
import 'package:whispering_pages/features/explore/view/explore_page.dart';
enum SearchResultCategory {
books,
authors,
series,
tags,
narrators,
}
class SearchResultPage extends HookConsumerWidget {
const SearchResultPage({
super.key,
required this.query,
this.category,
Object? extra,
});
/// The search query.
final String query;
/// The category of the search result, if not provided, the search result will be displayed in all categories.
final SearchResultCategory? category;
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchResultProvider(query));
return Scaffold(
appBar: AppBar(
title: Text(
category != null
? '${category.toString().split('.').last} in "$query"'
: 'Search result for $query',
),
),
body: results.when(
data: (options) {
if (options == null) {
return Container(
child: const Text('No data found'),
);
}
if (options is BookLibrarySearchResponse) {
if (category == null) {
return Container();
}
return switch (category!) {
SearchResultCategory.books => ListView.builder(
itemCount: options.book.length,
itemBuilder: (context, index) {
final book = BookExpanded.fromJson(
options.book[index].libraryItem.media.toJson(),
);
final metadata = BookMetadataExpanded.fromJson(
book.metadata.toJson(),
);
return BookSearchResultMini(
book: book,
metadata: metadata,
);
},
),
SearchResultCategory.authors => Container(),
SearchResultCategory.series => Container(),
SearchResultCategory.tags => Container(),
SearchResultCategory.narrators => Container(),
};
}
return null;
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Text('Error: $error'),
),
),
);
}
}