In the previous article we explored the Riverpod package, learned about the different providers available, learned how to read a provider, and also the available modifiers that can be used with a provider. In this tutorial we will be creating a movie app using most of the concepts learned in the previous article. Without further ado, let’s get started.
To complete this tutorial, you will need to:
Download and install Android Studio or Visual Studio Code
Download and install Flutter.
Set up your editor as described here.
Get The Movie Database API key here.
Once you have your environment set up for Flutter you can run the following command to create a new application:
$ flutter create movie_app
Open the pubspec.yaml for the newly created project and add the Riverpod package and also add dio package like this:
1
2
3
4
5
6
7
8
9
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^ 0.14 .0 + 3
dio: ^ 4.0 .0
Run the command flutter pub get
in your terminal to include the package in your project. The Dio package will be used to make the network calls to TMDB API to get the list of movies that will be displayed on the app.
In this tutorial we will build a movie app to fetch popular, latest, now playing, top-rated and upcoming movies as shown in the image above. We will use the Riverpod library to manage the state of the app. When a user clicks on any of the movie types we will make an API call using the Dio package and display the movies under that category. The title text will be dynamic depending on the movie category selected, and there will be a details page that gives full information about the selected movie.
If you are only interested in the code skip to the end and find the GitHub link.
We will have a class to keep our environment variables such as the base url, the image base url, and also the movie database API key.
1
2
3
4
5
6
7
8
9
10
11
12
class EnvironmentConfig {
static
const BASE_URL = String.fromEnvironment('BASE_URL',
defaultValue: "https://api.themoviedb.org/3/");
static
const IMAGE_BASE_URL = String.fromEnvironment('IMAGE_BASE_URL',
defaultValue: "https://image.tmdb.org/t/p/w185");
static
const API_KEY = String.fromEnvironment('API_KEY',
//Todo put your api key here
defaultValue: "<<YOUR API KEY HERE>>");
}
We will create a dart class to represent the movie object from TMDB API.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class Movie {
final bool adult;
final String backdropPath;
final List < int > genreIds;
final int id;
final String originalLanguage;
final String originalTitle;
final String overview;
final num popularity;
final String posterPath;
final String releaseDate;
final String title;
final bool video;
final num voteAverage;
final num voteCount;
Movie({
this.adult,
this.backdropPath,
this.genreIds,
this.id,
this.originalLanguage,
this.originalTitle,
this.overview,
this.popularity,
this.posterPath,
this.releaseDate,
this.title,
this.video,
this.voteAverage,
this.voteCount
});
factory Movie.fromJson(Map < String, dynamic > json) {
return Movie(
adult: json['adult'],
backdropPath: json['backdrop_path'],
genreIds: json['genre_ids'].cast < int > (),
id: json['id'],
originalLanguage: json['original_language'],
originalTitle: json['original_title'],
overview: json['overview'],
popularity: json['popularity'],
posterPath: json['poster_path'],
releaseDate: json['release_date'],
title: json['title'],
video: json['video'],
voteAverage: json['vote_average'],
voteCount: json['vote_count']);
}
}
class MovieResponse {
int page;
List < Movie > results;
int totalPages;
int totalResults;
MovieResponse({
this.page,
this.results,
this.totalPages,
this.totalResults
});
MovieResponse.fromJson(Map < String, dynamic > json) {
try {
page = json['page'];
if (json['results'] != null) {
results = <Movie>[];
json['results'].forEach((v) {
results.add(new Movie.fromJson(v));
});
}
totalPages = json['total_pages'];
totalResults = json['total_results'];
} catch (error) {
print("Error is $error");
}
}
}
We will also use an enum to represent each type of movie we are interested in.
enum MovieType { popular, latest, now_playing, top_rated, upcoming }
Then, finally, we will have an extension function on MovieType to get the name and the value as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
extension MovieTypeExtension on MovieType { String get value => toString() .split('.') .last; String get name { String name; switch (this) { case MovieType.popular: name = "Popular"; break; case MovieType.latest: name = "Latest"; break; case MovieType.now_playing: name = "Now Playing"; break; case MovieType.top_rated: name = "Top rated"; break; case MovieType.upcoming: name = "Upcoming"; break; } return name; } }
We will use a provider to provide the Dio library to be used to make the network calls;
1 2 3 4 5
final dioProvider = Provider < Dio > ((ref) { return Dio(BaseOptions( baseUrl: EnvironmentConfig.BASE_URL, )); });
We need another provider to manipulate the movie type. For this we use a StateProvider;
final movieTypeProvider = StateProvider((ref)=>MovieType.popular);
Then, finally, we use a future provider to fetch the movies from the API as follows;
1 2 3 4 5 6 7 8 9 10 11
final moviesProvider = FutureProvider < List < Movie >> ((ref) async { final movieType = ref.watch(movieTypeProvider) .state; final dio = ref.watch(dioProvider); final response = await dio.get('movie/{movieType.value}', queryParameters: { 'api_key': EnvironmentConfig.API_KEY }); return MovieResponse.fromJson(response.data) .results; });
Notice that we are watching the movieTypeProvider state to fetch the corresponding movies.
First, to use the Riverpod package, you need to wrap the entire widget tree in a ProviderScope.
1
2
3
void main() {
runApp(ProviderScope(child: MyMovieApp()));
}
The MyMovieApp class should look like this;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MyMovieApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod Movie App',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: Scaffold(
appBar: AppBar(
title: Title(),
),
body: Column(
children: [
MovieTags(),
Expanded(child: MovieList()),
],
),
),
);
}
}
Since the home page title is dynamic we need to watch the state of the movieTypeProvider and display the appropriate movie. The title widget will have to extend the ConsumerWidget to achieve this.
1
2
3
4
5
6
7
8
class Title extends ConsumerWidget {
@override
Widget build(BuildContext context, watch) {
final movieType = watch(movieTypeProvider)
.state;
return Text("{movieType.name} movies");
}
}
The MovieTags widget is responsible for changing the movie type, it will also extend ConsumerWidget as follows;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MovieTags extends ConsumerWidget {
@override
Widget build(BuildContext context, watch) {
final movieType = watch(movieTypeProvider)
.state;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: MovieType.values
.map((type) => InkWell(
onTap: () => context.read(movieTypeProvider)
.state = type,
child: Chip(
label: Text(
"{type.name}",
),
backgroundColor: type == movieType ? Colors.blue : null,
),
))
.toList(),
);
}
}
The movie list widget is responsible for displaying the movies in a grid view. To show the appropriate movies it needs to watch the moviesProvider provider and rebuild when the movie type changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class MovieList extends ConsumerWidget {
@override
Widget build(BuildContext context, watch) {
final moviesAsyncValue = watch(moviesProvider);
return moviesAsyncValue.maybeWhen(
orElse: () => Center(child: CircularProgressIndicator()),
data: (movies) => Center(
child: GridView.builder(
itemCount: movies?.length ?? 0,
itemBuilder: (BuildContext context, int index) {
final movie = movies[index];
return Card(
child: Container(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(
"{EnvironmentConfig.IMAGE_BASE_URL}{movie.posterPath}",
))),
),
),
const SizedBox(height: 8),
Text("{movie.title}")
],
),
),
);
},
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2)),
));
}
}
The details page will be used to provide additional information about the selected movie. We will not pass the movie in the constructor of the MovieDetailsPage page but instead, we will use a ScopedProvider to provide the individual movies.
final movieProvider = ScopedProvider<Movie>((_) => throw UnimplementedError());
We threw an UnimplementedError error because we don’t have the movie object at the moment. Next, we go inside the movie list and add an InkWell widget to push to the MovieDetailsPage.
1 2 3 4 5 6 7 8 9
return InkWell( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => ProviderScope(overrides: [ movieProvider.overrideWithValue(movie) ], child: MovieDetailsPage()))); }, …
Notice how we added another ProviderScope and override the movieProvider provider with the actual value. This will ensure that the selected movie is what will be provided to the MovieDetailsPage.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class MovieDetailsPage extends StatelessWidget {
const MovieDetailsPage();
@override
Widget build(BuildContext context) {
return Consumer(
builder: (BuildContext context,
T Function < T > (ProviderBase < Object, T > ) watch, Widget child) {
final movie = watch(movieProvider);
return Scaffold(
appBar: AppBar(
title: Text('{movie.title}'),
elevation: 0,
),
body: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Stack(
overflow: Overflow.visible,
children: [
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
'{EnvironmentConfig.IMAGE_BASE_URL}{movie.backdropPath}'),
fit: BoxFit.cover)),
),
Positioned(
left: 20,
bottom: -80,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
height: 120,
width: 100,
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
'{EnvironmentConfig.IMAGE_BASE_URL}{movie.posterPath}'),
fit: BoxFit.cover)),
),
const SizedBox(width: 20),
Column(
children: [
Text(
"{movie.title}",
style: Theme.of(context)
.textTheme.headline6,
),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text("{movie.releaseDate}"),
const SizedBox(width: 50),
Icon(
Icons.star,
size: 18,
),
Text("{movie.voteAverage}/10"),
],
)
],
)
],
),
)
],
),
const SizedBox(height: 100),
Divider(
thickness: 1.5,
),
const SizedBox(height: 10),
Text("{movie.overview}")
],
),
),
);
},
);
}
}
Notice how we used the Consumer widget from the Riverpod library to watch the movieProvider provider and use its state.
I hope you enjoyed the article, and I also hope you will start using the Riverpod library in your Flutter apps. In the next article we will write a unit and widget test for our app. See you next time!
Github link: https://github.com/De-Morgan/Movie-App