Skip to content

Commit

Permalink
More pagination tweaks: debouncing, error widget
Browse files Browse the repository at this point in the history
  • Loading branch information
bizz84 committed Apr 12, 2024
1 parent 1af87cf commit 8da02f2
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 59 deletions.
16 changes: 8 additions & 8 deletions lib/src/features/movies/data/movies_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,24 @@ 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});
final Dio client;
final String apiKey;

Future<TMDBMoviesResponse> searchMovies(
{required MoviesPagination pagination, CancelToken? cancelToken}) async {
{required MoviesQueryData queryData, CancelToken? cancelToken}) async {
final url = Uri(
scheme: 'https',
host: 'api.themoviedb.org',
path: '3/search/movie',
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);
Expand Down Expand Up @@ -90,7 +90,7 @@ Future<TMDBMovie> movie(
@riverpod
Future<TMDBMoviesResponse> fetchMovies(
FetchMoviesRef ref, {
required MoviesPagination pagination,
required MoviesQueryData queryData,
}) async {
final moviesRepo = ref.watch(moviesRepositoryProvider);
// See this for how the timeout is implemented:
Expand Down Expand Up @@ -120,10 +120,10 @@ Future<TMDBMoviesResponse> 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 {
Expand All @@ -133,7 +133,7 @@ Future<TMDBMoviesResponse> fetchMovies(
if (cancelToken.isCancelled) throw AbortedException();
// use search endpoint
return moviesRepo.searchMovies(
pagination: pagination,
queryData: queryData,
cancelToken: cancelToken,
);
}
Expand Down
32 changes: 16 additions & 16 deletions lib/src/features/movies/data/movies_repository.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>.broadcast();
Timer? _debounceTimer;
late final StreamSubscription<String> _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);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
Expand All @@ -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(
Expand All @@ -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}');
Expand Down Expand Up @@ -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();
}
}

0 comments on commit 8da02f2

Please sign in to comment.