A Flutter app that uses the "The Movie DB" api to fetch popular people and their info (their movies, images, ..etc). (API version 3 is used)
- Running the App
- Previews
- App Architecture & Folder Structure
- Http Caching
- Infinite Scroll Functionality
- Testing with Riverpod
An api key from The Movie DB is required to run the app. Then you can run the app by adding the following run arguments:
--dart-define=TMDB_API_KEY=<YOUR_API_KEY>
(Paginated list with Riverpod providers, more information below 👇🏼)
The code of the app implements clean architecture to separate the UI, domain and data layers with a feature-first approach for folder structure.
lib
├── core
│ ├── configs
│ ├── exceptions
│ ├── models
│ ├── services
│ │ ├── http
│ │ └── storage
│ └── widgets
├── features
│ ├── media
│ │ ├── enums
│ │ ├── models
│ │ ├── providers
│ │ ├── repositories
│ │ └── views
│ │ ├── pages
│ │ └── widgets
│ ├── people
│ │ ├── enums
│ │ ├── models
│ │ ├── providers
│ │ ├── repositories
│ │ └── views
│ │ ├── pages
│ │ └── widgets
│ └── tmdb-configs
│ ├── enums
│ ├── models
│ ├── providers
│ └── repositories
├── main.dart
└── movies_app.dart
main.dart
file has services initialization code and wraps the rootMoviesApp
with aProviderScope
movies_app.dart
has the rootMaterialApp
and fetches the TMDB configs necessary to generate links for the images of the TMDB API endpoints inside the app- The
core
folder contains code shared across featuresconfigs
contain general styles (colors, themes & text styles)services
abstract app-level services with their implementationshttp
service is implemented withDio
and uses aCacheInterceptor
to achieve caching by using theStorageService
(more information about caching below 👇🏼)storage
service is implemented withHive
- Service locator pattern and Riverpod are used to abstract services when used in other layers.
For example:
final storageServiceProvider = Provider<StorageService>(
(_) => HiveStorageService(),
);
// Usage:
// ref.watch(storageServiceProvider)
- The
features
folder: the repository pattern is used to decouple logic required to access data sources from the domain layer. For example, thePeopleRepository
abstracts and centralizes the various functionality required to accessPeople
from the TMDB API.
abstract class PeopleRepository {
String get path;
String get apiKey;
Future<Person> getPersonDetails(
int personId, {
bool forceRefresh = false,
required TMDBImageConfigs imageConfigs,
});
//...
}
The repository implementation with the HttpService
:
class HttpPeopleRepository implements PeopleRepository {
final HttpService httpService;
HttpPeopleRepository(this.httpService);
@override
String get path => '/person';
@override
String get apiKey => Configs.tmdbAPIKey;
@override
Future<Person> getPersonDetails(
int personId, {
bool forceRefresh = false,
required TMDBImageConfigs imageConfigs,
}) async {
final responseData = await httpService.get(
'$path/$personId',
forceRefresh: forceRefresh,
queryParameters: {
'api_key': apiKey,
},
);
return Person.fromJson(responseData).populateImages(imageConfigs);
}
//...
}
Using Riverpod Provider
to access this implementation:
final peopleRepositoryProvider = Provider<PeopleRepository>(
(ref) {
final httpService = ref.watch(httpServiceProvider);
return HttpPeopleRepository(httpService);
},
);
And finally accessing the repository implementation from the UI layer using a Riverpod FutureProvider
:
final personDetailsProvider = FutureProvider.family<Person, int>(
(ref, personId) async {
final peopleRepository = ref.watch(peopleRepositoryProvider);
final tmdbConfigs = await ref.watch(tmdbConfigsProvider.future);
return await peopleRepository.getPersonDetails(
personId,
imageConfigs: tmdbConfigs.images,
);
},
);
Notice how the abstract HttpService
is accessed from the repository implementation and then the abstract PeopleRepository
is accessed from the UI and how each of these layers acheive separation and scalability by providing the ability to switch implementation and make changes and/or test each layer seaparately. (More about testing 👇🏼)
To achieve caching http requests and the ability to show content to the user even when an error or loss of connectivity happens, a CacheInterceptor
was created and added to Dio
's interceptor in the DioHttpService
class. A Dio Interceptor
has the following methods:
class CacheInterceptor implements Interceptor {
final StorageService storageService;
CacheInterceptor(this.storageService);
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
// TODO: implement onError
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// TODO: implement onRequest
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// TODO: implement onResponse
}
}
By depending on our StorageService
we were able to cache a reposnse when it doesn't exist in storage and when its age
duration has not passed, and return that cache in case of error in the onError
method.
Infinite scrolling was achieved by utilizing Riverpod's providers and the ListView's itemBuilder
param whithout needing the complication of listening to scrolling events. The itemBuilder runs on each item build when it comes into view, if the data of this item is available it displays it, if it's not, the next page is fetched. Here is the code with explanation in the comments:
/// The FutureProvider that does the fetching of the paginated list of people
final paginatedPopularPeopleProvider =
FutureProvider.family<PaginatedResponse<Person>, int>(
(ref, int pageIndex) async {
final peopleRepository = ref.watch(peopleRepositoryProvider);
// The API request:
return await peopleRepository.getPopularPeople(page: pageIndex + 1);
},
);
/// The provider that has the value of the total count of the list items
///
/// The [PaginatedResponse] class contains information about the total number of
/// pages and the total results in all pages along with a list of the provided type
///
/// An example response from the API for any page looks like this:
/// {
/// "page": 1,
/// "results": [], // list of 20 items
/// "total_pages": 500,
/// "total_results": 10000 // Value taken by this provider
/// }
final popularPeopleCountProvider = Provider<AsyncValue<int>>((ref) {
return ref.watch(paginatedPopularPeopleProvider(0)).whenData(
(PaginatedResponse<Person> pageData) => pageData.totalResults,
);
});
/// The provider that provides the Person data for each list item
///
/// Initially it throws an UnimplementedError because we won't use it before overriding it
final currentPopularPersonProvider = Provider<AsyncValue<Person>>((ref) {
throw UnimplementedError();
});
/// The provider that provides the Person data for each list item
///
/// Initially it throws an UnimplementedError because we won't use it before overriding it
final currentPopularPersonProvider = Provider<AsyncValue<Person>>((ref) {
throw UnimplementedError();
});
class PopularPeopleList extends ConsumerWidget {
const PopularPeopleList({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final popularPeopleCount = ref.watch(popularPeopleCountProvider);
// The ListView's count is from the popularPeopleCountProvider which
// by watching it here, causes the first fetch with a page index of 0
return popularPeopleCount.when(
loading: () => const CircularProgressIndicator(),
data: (int count) {
return ListView.builder(
itemCount: count,
itemBuilder: (context, index) {
// At this point the paginatedPopularPeopleProvider stores the values of the
// list items of at least the first page
//
// (index ~/ 20): Performing a truncating division of the list item index by the number of
// items per page gives us the value of the current page that we then access using the
// family modifier of the paginatedPopularPeopleProvider provider
// This way calling 21 ~/ 20 = 1 will fetch the second page,
// and 41 ~/ 20 = 2 will fetch the 3rd page, and so on.
final AsyncValue<Person> currentPopularPersonFromIndex = ref
.watch(paginatedPopularPeopleProvider(index ~/ 20))
.whenData((pageData) => pageData.results[index % 20]);
return ProviderScope(
overrides: [
// Override the Unimplemented provider
currentPopularPersonProvider
.overrideWithValue(currentPopularPersonFromIndex)
],
child: const PopularPersonListItem(),
);
},
);
},
// Handle error
error: (_, __) => const Icon(Icons.error),
);
}
}
class PopularPersonListItem extends ConsumerWidget {
const PopularPersonListItem({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// Here we don't need to do anything but listen to the currentPopularPersonProvider's
// AsyncValue that was overridden in the ListView's builder
final AsyncValue<Person> personAsync =
ref.watch(currentPopularPersonProvider);
return Container(
child: personAsync.when(
data: (Person person) => Container(/* ... */), // List item content
loading: () => const CircularProgressIndicator(), // Handle loading
error: (_, __) => const Icon(Icons.error), // Handle Error
),
);
}
}
The test
folder mirrors the lib
folder in addition to some test utilities. And more tests will be added.
http_mock_adapter
is used to test the DioHttpService
and mock http requests.
hive_test
is used to test the HiveStorageService
and mock storage methods.
mocktail
is used to mock dependecies.
Testing with Riverpod is hassle-free and simple. You can test your providers separately from Flutter, and you can test how they behave in your widgets with widget testing. You can find helpful information about this in the official docs. But let's see examples from this repo to have a look at both methods for different kinds of Riverpod providers.
Simply we can read our providers with a ProviderContainer
and we should make sure to dispose it and not share it between tests. The ProviderContainer
takes an overrides
param which you can provide your mocks to.
This is the simplest provider and it's the easiest to test:
final foo = Provider<String>((_) => 'bar');
void main() {
test('foo is a bar', () {
final providerContainer = ProviderContainer();
addTearDown(providerContainer.dispose);
expect(providerContainer.read(foo), equals('bar'));
});
}
In this app, I am making sure my abstract services and repository providers return the correct implementations by doing these simple tests:
// service provider
final storageServiceProvider = Provider<StorageService>((_) => HiveStorageService());
// test
void main() {
test('serviceProvider returns HiveStorageService', () {
final providerContainer = ProviderContainer();
addTearDown(providerContainer.dispose);
expect(
providerContainer.read(storageServiceProvider),
isA<HiveStorageService>(),
);
});
}
Let's take our tmdbConfigsProvider
as an example:
final tmdbConfigsProvider = FutureProvider<TMDBConfigs>((ref) async {
final tmdbConfigsRepository = ref.watch(tmdbConfigsRepositoryProvider);
return await tmdbConfigsRepository.get();
});
And here is how we can test it separately from Flutter:
// Mocks
class MockTMDBConfigsRepository extends Mock implements TMDBConfigsRepository {}
class Listener<T> extends Mock {
void call(T? previous, T value);
}
// Test
void main() {
final TMDBConfigsRepository mockTMDBConfigsRepository =
MockTMDBConfigsRepository();
test('fetches TMDB configs', () async {
when(() => mockTMDBConfigsRepository.get(forceRefresh: false))
.thenAnswer((_) async => DummyConfigs.tmdbConfigs);
final tmdbConfigsListener = Listener<AsyncValue<TMDBConfigs>>();
final providerContainer = ProviderContainer(
overrides: [
// Replace the TMDB Configs repository with the Mock Repository
tmdbConfigsRepositoryProvider
.overrideWithValue(mockTMDBConfigsRepository),
],
);
addTearDown(providerContainer.dispose);
providerContainer.listen<AsyncValue<TMDBConfigs>>(
tmdbConfigsProvider,
tmdbConfigsListener,
fireImmediately: true,
);
// Perform first reading, expects loading state
final firstReading = providerContainer.read(tmdbConfigsProvider);
expect(firstReading, const AsyncValue<TMDBConfigs>.loading());
// Listener was fired from `null` to loading AsyncValue
verify(() => tmdbConfigsListener(
null,
const AsyncValue<TMDBConfigs>.loading(),
)).called(1);
// Perform second reading, by waiting for the request, expects fetched data
final secondReading = await providerContainer.read(tmdbConfigsProvider.future);
expect(secondReading, DummyConfigs.tmdbConfigs);
// Listener was fired from loading to fetched values
verify(() => tmdbConfigsListener(
const AsyncValue<TMDBConfigs>.loading(),
const AsyncValue<TMDBConfigs>.data(DummyConfigs.tmdbConfigs),
)).called(1);
// No further listener events fired
verifyNoMoreInteractions(tmdbConfigsListener);
});
}
We can simply wrap our pumped widget in our widget test with a ProviderScope
and provide it with the mocks using the overrides
param.
Let's see how we can test the same tmdbConfigsProvider
to see how if it behaves as we want in our root MoviesApp
widget. Basically it should render the AppLoader
widget while loading, the ErrorView
widget in case of error, and finally the PopularPeoplePage
widget when the request completes successfully.
void main() {
final TMDBConfigsRepository mockTMDBConfigsRepository =
MockTMDBConfigsRepository();
testWidgets('renders ErrorView for request error',
(WidgetTester tester) async {
when(() => mockTMDBConfigsRepository.get(forceRefresh: false))
.thenThrow('An Error Occurred!');
await tester.pumpWidget(
ProviderScope(
overrides: [
// Replace the TMDB Configs repository with the Mock Repository
tmdbConfigsRepositoryProvider
.overrideWithValue(mockTMDBConfigsRepository),
],
child: const MoviesApp(),
),
);
// Initially loading
expect(find.byType(AppLoader), findsOneWidget);
// Re-render to make sure fetching is finished
await tester.pumpAndSettle();
// Shows error view
expect(find.byType(ErrorView), findsOneWidget);
});
testWidgets(
'renders PopularPeoplePage widget on request success',
(WidgetTester tester) async {
when(() => mockTMDBConfigsRepository.get(forceRefresh: false))
.thenAnswer((_) async => DummyConfigs.tmdbConfigs);
await tester.pumpWidget(
ProviderScope(
overrides: [
// Replace the TMDB Configs repository with the Mock Repository
tmdbConfigsRepositoryProvider
.overrideWithValue(mockTMDBConfigsRepository),
],
child: const MoviesApp(),
),
);
// Initially loading
expect(find.byType(AppLoader), findsOneWidget);
// Re-render to make sure fetching is finished
await tester.pumpAndSettle();
expect(find.byType(PopularPeoplePage), findsOneWidget);
},
);
}
To explore the test coverage, run tests with --coverage argument
flutter test --coverage
Then generate coverage files
genhtml coverage/lcov.info -o coverage/html
Then open the html files:
open coverage/html/index.html