From 8da02f2021f0e4b6ec9878bb438c111222a1e5ff Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 12 Apr 2024 09:18:31 +0100 Subject: [PATCH] More pagination tweaks: debouncing, error widget --- .../movies/data/movies_repository.dart | 16 ++--- .../movies/data/movies_repository.g.dart | 32 ++++----- .../movies/movies_search_bar.dart | 18 +---- .../movies/movies_search_query_notifier.dart | 45 ++++++++++++ ...rt => movies_search_query_notifier.g.dart} | 6 +- .../movies/movies_search_screen.dart | 70 +++++++++++++++---- 6 files changed, 128 insertions(+), 59 deletions(-) create mode 100644 lib/src/features/movies/presentation/movies/movies_search_query_notifier.dart rename lib/src/features/movies/presentation/movies/{movies_search_bar.g.dart => movies_search_query_notifier.g.dart} (85%) diff --git a/lib/src/features/movies/data/movies_repository.dart b/lib/src/features/movies/data/movies_repository.dart index f42d34c..478db46 100644 --- a/lib/src/features/movies/data/movies_repository.dart +++ b/lib/src/features/movies/data/movies_repository.dart @@ -10,7 +10,7 @@ import 'package:tmdb_movie_app_riverpod/src/utils/dio_provider.dart'; part 'movies_repository.g.dart'; /// Metadata used when fetching movies with the paginated search API. -typedef MoviesPagination = ({String query, int page}); +typedef MoviesQueryData = ({String query, int page}); class MoviesRepository { const MoviesRepository({required this.client, required this.apiKey}); @@ -18,7 +18,7 @@ class MoviesRepository { final String apiKey; Future searchMovies( - {required MoviesPagination pagination, CancelToken? cancelToken}) async { + {required MoviesQueryData queryData, CancelToken? cancelToken}) async { final url = Uri( scheme: 'https', host: 'api.themoviedb.org', @@ -26,8 +26,8 @@ class MoviesRepository { queryParameters: { 'api_key': apiKey, 'include_adult': 'false', - 'page': '${pagination.page}', - 'query': pagination.query, + 'page': '${queryData.page}', + 'query': queryData.query, }, ).toString(); final response = await client.get(url, cancelToken: cancelToken); @@ -90,7 +90,7 @@ Future movie( @riverpod Future fetchMovies( FetchMoviesRef ref, { - required MoviesPagination pagination, + required MoviesQueryData queryData, }) async { final moviesRepo = ref.watch(moviesRepositoryProvider); // See this for how the timeout is implemented: @@ -120,10 +120,10 @@ Future fetchMovies( ref.onResume(() { timer?.cancel(); }); - if (pagination.query.isEmpty) { + if (queryData.query.isEmpty) { // use non-search endpoint return moviesRepo.nowPlayingMovies( - page: pagination.page, + page: queryData.page, cancelToken: cancelToken, ); } else { @@ -133,7 +133,7 @@ Future fetchMovies( if (cancelToken.isCancelled) throw AbortedException(); // use search endpoint return moviesRepo.searchMovies( - pagination: pagination, + queryData: queryData, cancelToken: cancelToken, ); } diff --git a/lib/src/features/movies/data/movies_repository.g.dart b/lib/src/features/movies/data/movies_repository.g.dart index 07bf340..cf2d737 100644 --- a/lib/src/features/movies/data/movies_repository.g.dart +++ b/lib/src/features/movies/data/movies_repository.g.dart @@ -181,7 +181,7 @@ class _MovieProviderElement extends AutoDisposeFutureProviderElement int get movieId => (origin as MovieProvider).movieId; } -String _$fetchMoviesHash() => r'e522dc1c22ffbf0c195b263646b5cb400ca1cd6a'; +String _$fetchMoviesHash() => r'd42ea205e64124a9a9de14afbfdc6f47106334cc'; /// Provider to fetch paginated movies data /// @@ -202,10 +202,10 @@ class FetchMoviesFamily extends Family> { /// /// Copied from [fetchMovies]. FetchMoviesProvider call({ - required ({int page, String query}) pagination, + required ({int page, String query}) queryData, }) { return FetchMoviesProvider( - pagination: pagination, + queryData: queryData, ); } @@ -214,7 +214,7 @@ class FetchMoviesFamily extends Family> { covariant FetchMoviesProvider provider, ) { return call( - pagination: provider.pagination, + queryData: provider.queryData, ); } @@ -242,11 +242,11 @@ class FetchMoviesProvider /// /// Copied from [fetchMovies]. FetchMoviesProvider({ - required ({int page, String query}) pagination, + required ({int page, String query}) queryData, }) : this._internal( (ref) => fetchMovies( ref as FetchMoviesRef, - pagination: pagination, + queryData: queryData, ), from: fetchMoviesProvider, name: r'fetchMoviesProvider', @@ -257,7 +257,7 @@ class FetchMoviesProvider dependencies: FetchMoviesFamily._dependencies, allTransitiveDependencies: FetchMoviesFamily._allTransitiveDependencies, - pagination: pagination, + queryData: queryData, ); FetchMoviesProvider._internal( @@ -267,10 +267,10 @@ class FetchMoviesProvider required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.pagination, + required this.queryData, }) : super.internal(); - final ({int page, String query}) pagination; + final ({int page, String query}) queryData; @override Override overrideWith( @@ -285,7 +285,7 @@ class FetchMoviesProvider dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - pagination: pagination, + queryData: queryData, ), ); } @@ -297,21 +297,21 @@ class FetchMoviesProvider @override bool operator ==(Object other) { - return other is FetchMoviesProvider && other.pagination == pagination; + return other is FetchMoviesProvider && other.queryData == queryData; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, pagination.hashCode); + hash = _SystemHash.combine(hash, queryData.hashCode); return _SystemHash.finish(hash); } } mixin FetchMoviesRef on AutoDisposeFutureProviderRef { - /// The parameter `pagination` of this provider. - ({int page, String query}) get pagination; + /// The parameter `queryData` of this provider. + ({int page, String query}) get queryData; } class _FetchMoviesProviderElement @@ -320,8 +320,8 @@ class _FetchMoviesProviderElement _FetchMoviesProviderElement(super.provider); @override - ({int page, String query}) get pagination => - (origin as FetchMoviesProvider).pagination; + ({int page, String query}) get queryData => + (origin as FetchMoviesProvider).queryData; } // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/src/features/movies/presentation/movies/movies_search_bar.dart b/lib/src/features/movies/presentation/movies/movies_search_bar.dart index 68342d5..3d0b927 100644 --- a/lib/src/features/movies/presentation/movies/movies_search_bar.dart +++ b/lib/src/features/movies/presentation/movies/movies_search_bar.dart @@ -1,22 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'movies_search_bar.g.dart'; - -/// Notifier that can be watched to obtain the current search query. -@riverpod -class MoviesSearchQueryNotifier extends _$MoviesSearchQueryNotifier { - @override - String build() { - // by default, return an empty query - return ''; - } - - void setQuery(String query) { - state = query; - } -} +import 'package:tmdb_movie_app_riverpod/src/features/movies/presentation/movies/movies_search_query_notifier.dart'; class MoviesSearchBar extends ConsumerStatefulWidget { const MoviesSearchBar({super.key}); diff --git a/lib/src/features/movies/presentation/movies/movies_search_query_notifier.dart b/lib/src/features/movies/presentation/movies/movies_search_query_notifier.dart new file mode 100644 index 0000000..b8c2099 --- /dev/null +++ b/lib/src/features/movies/presentation/movies/movies_search_query_notifier.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'movies_search_query_notifier.g.dart'; + +/// A notifier class to keep track of the search query (with debouncing) +@riverpod +class MoviesSearchQueryNotifier extends _$MoviesSearchQueryNotifier { + /// Used to debounce the input queries + final _searchQueryController = StreamController.broadcast(); + Timer? _debounceTimer; + late final StreamSubscription _subscription; + + @override + String build() { + // Listen to the stream of input queries + _subscription = _searchQueryController.stream.listen((query) { + // Cancel existing timer if there is one + _debounceTimer?.cancel(); + // Set a new timer to debounce the query + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + _updateState(query); + }); + }); + + // don't forget to close the StreamController and cancel the subscriptions on dispose + ref.onDispose(() { + _searchQueryController.close(); + _subscription.cancel(); + _debounceTimer?.cancel(); + }); + + // by default, return an empty query + return ''; + } + + void _updateState(String query) { + // only update the state once the query has been debounced + state = query; + } + + void setQuery(String query) { + _searchQueryController.sink.add(query); + } +} diff --git a/lib/src/features/movies/presentation/movies/movies_search_bar.g.dart b/lib/src/features/movies/presentation/movies/movies_search_query_notifier.g.dart similarity index 85% rename from lib/src/features/movies/presentation/movies/movies_search_bar.g.dart rename to lib/src/features/movies/presentation/movies/movies_search_query_notifier.g.dart index 4611ab8..be16f87 100644 --- a/lib/src/features/movies/presentation/movies/movies_search_bar.g.dart +++ b/lib/src/features/movies/presentation/movies/movies_search_query_notifier.g.dart @@ -1,15 +1,15 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'movies_search_bar.dart'; +part of 'movies_search_query_notifier.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** String _$moviesSearchQueryNotifierHash() => - r'a134a9096c00cf385f45da7a6a16589c76c017ad'; + r'e5feb3e8f1c81a63519db4e67c615c169b380710'; -/// Notifier that can be watched to obtain the current search query. +/// A notifier class to keep track of the search query (with debouncing) /// /// Copied from [MoviesSearchQueryNotifier]. @ProviderFor(MoviesSearchQueryNotifier) diff --git a/lib/src/features/movies/presentation/movies/movies_search_screen.dart b/lib/src/features/movies/presentation/movies/movies_search_screen.dart index 574936c..9566e59 100644 --- a/lib/src/features/movies/presentation/movies/movies_search_screen.dart +++ b/lib/src/features/movies/presentation/movies/movies_search_screen.dart @@ -5,6 +5,7 @@ import 'package:tmdb_movie_app_riverpod/src/features/movies/data/movies_reposito import 'package:tmdb_movie_app_riverpod/src/features/movies/presentation/movies/movie_list_tile.dart'; import 'package:tmdb_movie_app_riverpod/src/features/movies/presentation/movies/movie_list_tile_shimmer.dart'; import 'package:tmdb_movie_app_riverpod/src/features/movies/presentation/movies/movies_search_bar.dart'; +import 'package:tmdb_movie_app_riverpod/src/features/movies/presentation/movies/movies_search_query_notifier.dart'; import 'package:tmdb_movie_app_riverpod/src/routing/app_router.dart'; class MoviesSearchScreen extends ConsumerWidget { @@ -17,13 +18,11 @@ class MoviesSearchScreen extends ConsumerWidget { final query = ref.watch(moviesSearchQueryNotifierProvider); // * get the first page so we can retrieve the total number of results final responseAsync = ref.watch( - fetchMoviesProvider(pagination: (page: 1, query: query)), + fetchMoviesProvider(queryData: (page: 1, query: query)), ); final totalResults = responseAsync.valueOrNull?.totalResults; return Scaffold( - appBar: AppBar( - title: const Text('TMDB Movies'), - ), + appBar: AppBar(title: const Text('TMDB Movies')), body: Column( children: [ const MoviesSearchBar(), @@ -34,9 +33,8 @@ class MoviesSearchScreen extends ConsumerWidget { ref.invalidate(fetchMoviesProvider); // keep showing the progress indicator until the first page is fetched return ref.read( - fetchMoviesProvider( - pagination: (page: 1, query: query), - ).future, + fetchMoviesProvider(queryData: (page: 1, query: query)) + .future, ); }, child: ListView.builder( @@ -51,16 +49,15 @@ class MoviesSearchScreen extends ConsumerWidget { // Note that ref.watch is called for up to pageSize items // with the same page and query arguments (but this is ok since data is cached) final responseAsync = ref.watch( - fetchMoviesProvider(pagination: (page: page, query: query)), + fetchMoviesProvider(queryData: (page: page, query: query)), ); return responseAsync.when( - // * Only show error on the first item of the page - error: (err, stack) => indexInPage == 0 - ? Padding( - padding: const EdgeInsets.all(16.0), - child: Text(err.toString()), - ) - : const SizedBox.shrink(), + error: (err, stack) => MovieListTileError( + query: query, + page: page, + indexInPage: indexInPage, + error: err.toString(), + ), loading: () => const MovieListTileShimmer(), data: (response) { //log('index: $index, page: $page, indexInPage: $indexInPage, len: ${response.results.length}'); @@ -89,3 +86,46 @@ class MoviesSearchScreen extends ConsumerWidget { ); } } + +class MovieListTileError extends ConsumerWidget { + const MovieListTileError({ + super.key, + required this.query, + required this.page, + required this.indexInPage, + required this.error, + }); + final String query; + final int page; + final int indexInPage; + final String error; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // * Only show error on the first item of the page + return indexInPage == 0 + ? Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(error), + ElevatedButton( + onPressed: () { + // dispose all the pages previously fetched. Next read will refresh them + ref.invalidate(fetchMoviesProvider( + queryData: (page: page, query: query))); + // keep showing the progress indicator until the first page is fetched + return ref.read( + fetchMoviesProvider(queryData: (page: page, query: query)) + .future, + ); + }, + child: const Text('Retry'), + ), + ], + ), + ) + : const SizedBox.shrink(); + } +}