Using Fauna as the Data API of a Flutter App
James Sinkala
Posted on December 7, 2021
Unless you are building an offline app or consuming someone else’s API, when developing a mobile app, most times you will have the burden of setting up a database and adding an API layer so you can consume that data.
Of all the options out there, Fauna is among the few that I have personally found myself working with the most due to various advantages it holds over others. It gives me the playground to experiment with things without having to worry much about breaking my app or the bank. I have loved seeing it grow since I first started using it.
In the mobile app environment, especially hybrid app development I have never looked back since I came across Flutter; it has been my go to toolkit when developing for fun or work. One of Flutter’s major advantages is its hot reload, which helps speed up the development process. As a developer, I appreciate that with it, I can see changes to code almost immediately without having to restart the app.
I recently worked on a mobile app with these two technologies and given that project’s complexity and how this stack simplified the work on the development side I was pleased with the results.
In this article, we are going to see how these two technologies simply work together on the making of a small data listing mobile app.
The App we are building - ‘My Movies’
To see the Flutter-Fauna stack in practice we are going to create ‘My Movies’. A simple movie information mobile app made with Flutter and Fauna as its data API.
The ‘My Movies’ app has three screens. A home screen which will be listing all of the movies on our database, a movie details screen which will be showing detailed movie information and a search page where users can search movies by title.
Preparing the database
If you do not have a Fauna account, head over to Fauna’s registration page and create one.
Create a new database and name it my_movies, and inside, add a movies collection in which we’ll be storing the movies data .Afterwards, create a new index and name it all_movies.
CreateIndex({
name: "all_movies",
unique: false,
serialized: true,
source: Collection(""movies"),
values: [
{
field: ["data", "title"]
},
{
field: ["data", "runtime"]
},
{
field: ["data", "plot"]
},
{
field: ["data", "genre"]
},
{
field: ["data", "poster"]
},
{
field: ["ref", "id"]
}
]
})
Next, create a new Admin key by clicking on Security in the left sidebar, followed by clicking the New key button. Copy the key’s secret displayed on the next page as it is never displayed again, secure it safely.
Run the Fauna FQL (Fauna Query Language) script found on this gist on the dashboard shell or locally if you have installed Fauna-shell to populate the movies collection with some initial data.
Setting up the Flutter project
Start by creating a new flutter project.
flutter create my_movies_app
We are going to use an unofficial Fauna package for Flutter flutterdb_http to make FQL queries against our database.
Add the package to the dependencies:
flutter pub add faunadb_http
We’ll use Bloc to separate our business logic from the presentational layer, hence proceed to installing the following packages:
flutter pub add flutter_bloc
flutter pub add bloc
flutter pub add equatable
Let’s proceed with setting up our Movie data model:
/// Movie model
class Movie {
final String title;
final double runtime;
final String plot;
final String genre;
final String poster;
final String refId;
final double rating;
Movie(this.title,
{required this.runtime,
required this.plot,
required this.genre,
required this.poster,
required this.refId,
this.rating = 0.0});
Map<String, Object> toJson() {
return {
"title": title,
"runtime": runtime,
"plot": plot,
"genre": genre,
"poster": poster,
"refId": refId,
"rating": rating
};
}
factory Movie.fromJson(Map<String, dynamic> json) => Movie(json["title"],
runtime: double.parse(json["runtime"].toString()),
plot: json["plot"],
genre: json["genre"],
poster: json["poster"] ??
'default_movie_poster_url',
refId: json['ref_id']);
}
The App’s Events and States
Next we’ll create the MoviesEvent class which will work with the bloc layer to define our app’s expected events.
These are the events that we expect to be performed in our app.
/// blocs/movies/event.dart
abstract class MoviesEvent extends Equatable {
const MoviesEvent();
@override
List<Object> get props => [];
}
class HomeScreenInitial extends MoviesEvent {}
class FetchMovies extends MoviesEvent {}
class SearchScreenInitial extends MoviesEvent {}
class SubmitSearch extends MoviesEvent {
final String searchQuery;
const SubmitSearch(this.searchQuery);
@override
List<Object> get props => [searchQuery];
@override
String toString() => 'MoviesLoad { searchQuery: $searchQuery }';
}
Let’s also set up the bloc’s state for all the states we expect the app to have resulting from the above events and data transactions with the data repositories.
/// blocs/movies/state.dart
class MoviesState extends Equatable {
const MoviesState();
@override
List<Object> get props => [];
}
class MoviesStateInitial extends MoviesState {}
class LoadingState extends MoviesState {}
class MoviesFetched extends MoviesState {
final List<Movie> movies;
const MoviesFetched([this.movies = const []]);
@override
List<Object> get props => [movies];
@override
String toString() => "MoviesLoadSuccess {Movies: $movies.toString()}";
}
class MoviesFetchingFailure extends MoviesState {
final String errorMessage;
const MoviesFetchingFailure({this.errorMessage = "Experienced some error"});
@override
List<Object> get props => [errorMessage];
@override
String toString() => 'MoviesFetchingFailure { Movie: $Movie.toString() }';
}
class SearchScreenInitialState extends MoviesState {
final String message;
const SearchScreenInitialState(
{this.message = "Type above to search for movies"});
@override
List<Object> get props => [message];
@override
String toString() => 'SearchScreenInitialState { message: $message }';
}
class SearchFailure extends MoviesState {
final String errorMessage;
const SearchFailure({this.errorMessage = "Experienced some error"});
@override
List<Object> get props => [errorMessage];
@override
String toString() => 'MoviesFetchingFailure { Movie: $Movie.toString() }';
}
class SearchResultsFetched extends MoviesState {
final List<Movie> movies;
const SearchResultsFetched([this.movies = const []]);
@override
List<Object> get props => [movies];
@override
String toString() => "SearchResultsFetched {Movies: $movies.toString()}";
}
Lets create a router class that’s going to help us navigate around our app.
/// app_router.dart
class AppRouter {
Route onGenerateRoute(RouteSettings settings) {
switch (settings.name) {
case "movie-details":
return MaterialPageRoute(
builder: (_) =>
MovieDetailsScreen(movie: settings.arguments as Movie));
case "search":
return MaterialPageRoute(builder: (_) => const SearchScreen());
default:
return MaterialPageRoute(builder: (_) => const HomeScreen());
}
}
}
Create an intermediary AppState widget that will be the root node providing the MoviesBloc to the rest of the widget tree and disposing it when not needed.
/// app_state.dart
class AppState extends StatefulWidget {
const AppState({Key? key}) : super(key: key);
@override
_AppState createState() => _AppState();
}
class _AppState extends State<AppState> {
final _appRouter = AppRouter();
late final MoviesBloc _moviesBloc;
_AppState()
: _moviesBloc = MoviesBloc(moviesRepository: MovieDataRepository()),
super();
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _moviesBloc,
child: MaterialApp(
onGenerateRoute: _appRouter.onGenerateRoute,
debugShowCheckedModeBanner: false,
),
);
}
@override
void dispose() {
super.dispose();
_moviesBloc.close();
}
}
Set AppState as the child widget of our root widget MyApp.
/// main.dart
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const AppState();
}
}
Setting up the data layer
To consume data for our app in a clean and functional manner we’ll set up a data layer that involves a movies provider which will be fetching raw data for us from Fauna and a movies repository which will be transacting data between the provider and bloc. From the bloc, data can be presented to our pages through states.
Start off by configuring the Fauna client that will enable us to send FQL queries to Fauna, place this in it’s own class to prevent code repetition.
/// faunadb_service.dart
class FaunaDBInstance {
late final FaunaConfig config;
late final FaunaClient client;
FaunaDBInstance(String secret) {
config = FaunaConfig.build(
secret: secret,
);
client = FaunaClient(config);
}
FaunaClient faunaDBClient() {
return client;
}
faunaClose() {
client.close();
}
}
We follow this up by setting up our data provider FaunaDBMoviesProvider.
/// providers/faunadb_movies_provider.dart
class FaunaDBMoviesProvider {
late FaunaDBInstance _fauna;
late FaunaClient _client;
FaunaDBMoviesProvider() {
_fauna = FaunaDBInstance('SECRET_KEY');
_client = _fauna.faunaDBClient();
}
Future<dynamic> movies() async {
final requestQuery = Map_(
Paginate(Match(Index("all_movies"))),
Lambda(
"movieDoc",
Let(
{'movie': Var('movieDoc')},
Obj({
'title': Select(0, Var('movie')),
'runtime': Select(1, Var('movie')),
'plot': Select(2, Var('movie')),
'genre': Select(3, Var('movie')),
'poster': Select(4, Var('movie')),
'ref_id': Select(5, Var('movie')),
}))));
try {
FaunaResponse? response = await _client.query(requestQuery);
return response;
} catch (e) {
rethrow;
}
}
Future<dynamic> searchForMovies(String searchQuery) async {
final requestQuery = Map_(
Paginate(Match(Index("all_movies"))),
Lambda(
"movieDoc",
Let(
{'movie': Var('movieDoc')},
Obj({
'title': Select(0, Var('movie')),
'runtime': Select(1, Var('movie')),
'plot': Select(2, Var('movie')),
'genre': Select(3, Var('movie')),
'poster': Select(4, Var('movie')),
'ref_id': Select(5, Var('movie')),
}))));
try {
FaunaResponse? response = await _client.query(requestQuery);
return response;
} catch (e) {
rethrow;
}
}
}
Choose any amongst the many possible ways to secure your secret_key, a topic I won’t be getting into here. You should never hardcode it inside your code.
Next we’ll set up a repository MovieDataRepository that will be in charge of supplying data to the MoviesBloc.
With this setup we get the advantage of having multiple providers supplying data to a single repository without having to change much of the App’s core codebase.
/// repositories/faunadb_movies_repository.dart
class MovieDataRepository {
final FaunaDBMoviesProvider _faunaDBMoviesProvider;
MovieDataRepository()
: _faunaDBMoviesProvider = FaunaDBMoviesProvider(),
super();
Future<dynamic> movies() async {
FaunaResponse response = await _faunaDBMoviesProvider.movies();
if (response.hasErrors) {
return response.errors.toString();
} else {
if (response.asMap()['resource']['data'].length > 0) {
return List<Movie>.from(
response.asMap()['resource']['data'].map((item) {
return Movie.fromJson(item);
}));
}
return [];
}
}
Future<dynamic> searchForMovies(String searchQuery) async {
FaunaResponse response =
await _faunaDBMoviesProvider.searchForMovies(searchQuery);
if (response.hasErrors) {
return response.errors.toString();
} else {
if (response.asMap()['resource']['data'].length > 0) {
List<Movie> searchresults = [];
List<Movie> allMovies =
List<Movie>.from(response.asMap()['resource']['data'].map((item) {
return Movie.fromJson(item);
}));
for (Movie movie in allMovies) {
if (movie.title.toLowerCase().contains(searchQuery.toLowerCase())) {
searchresults.add(movie);
}
}
return searchresults;
}
return [];
}
}
}
Inside the MovieDataRepository class we see the faunadb_http package at work helping us make native FQL queries just as we would have on any other setting involving Fauna data and FQL queries.
For any function that conflicts with Dart’s, just add an underscore as the function’s suffix. E.g we’d have Map_() instead of Map() so as not to conflict the two.
Finally we set up our MoviesBloc which up to this point has been referenced countlessly.
/// bloc/movies/bloc.dart
class MoviesBloc extends Bloc<MoviesEvent, MoviesState> {
final MovieDataRepository moviesRepository;
MoviesBloc({required this.moviesRepository}) : super(MoviesStateInitial());
@override
Stream<MoviesState> mapEventToState(MoviesEvent event) async* {
if (event is MoviesScreenInitial) {
yield* _mapMoviesStateInitialToState(event);
}
if (event is FetchMovies) {
yield* _mapFetchMoviesToState();
}
if (event is SearchScreenInitial) {
yield const SearchScreenInitialState();
}
if (event is SubmitSearch) {
yield LoadingState();
yield* _mapSubmitSearchToState(event);
}
}
Stream<MoviesState> _mapMoviesStateInitialToState(
MoviesScreenInitial event) async* {
yield LoadingState();
var movies = await moviesRepository.movies();
if (movies is! List<Movie>) {
yield const MoviesFetchingFailure(errorMessage: "Some Error!");
}
if (movies is List<Movie>) {
yield MoviesFetched(movies);
}
}
Stream<MoviesState> _mapFetchMoviesToState() async* {
var movies = await moviesRepository.movies();
if (movies is! List<Movie>) {
yield MoviesFetchingFailure(errorMessage: movies.toString());
}
if (movies is List<Movie>) {
yield MoviesFetched(movies);
}
}
Stream<MoviesState> _mapSubmitSearchToState(SubmitSearch event) async* {
var movies = await moviesRepository.searchForMovies(event.searchQuery);
if (movies is! List<Movie>) {
yield MoviesFetchingFailure(errorMessage: movies.toString());
}
if (movies is List<Movie>) {
yield SearchResultsFetched(movies);
}
}
}
All that is left is to add our presentational layer to render all the above configuration useful.
The Presentational Layer
On the HomeScreen we use a BlocConsumer that gives us the ability to listen to states and react programmatically to them on top of the builder that enables us to rebuild our widgets.
/// screens/home_screen.dart
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
_HomeScreen createState() => _HomeScreen();
}
class _HomeScreen extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
final _moviesBloc = BlocProvider.of<MoviesBloc>(context);
_moviesBloc.add(MoviesScreenInitial());
return BlocConsumer<MoviesBloc, MoviesState>(
listener: (context, state) {},
builder: (context, state) {
Widget widgetToDisplay = const SizedBox();
if (state is LoadingState) {
widgetToDisplay = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
],
);
}
if (state is MoviesFetchingFailure) {
widgetToDisplay = Column(
children: [
Expanded(
child: Center(
child: Text(state.errorMessage),
),
)
],
);
}
if (state is MoviesFetched) {
widgetToDisplay = state.movies.isNotEmpty
? ListView(
children: List<Widget>.from(
state.movies.map((movie) => MovieListItem(movie))))
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Expanded(
child: Center(
child: Text("No Movies in here!"),
),
)
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("My Movies"),
elevation: .5,
actions: [
IconButton(
onPressed: () {
Navigator.pushNamed(context, "search");
},
icon: const Icon(
Icons.search,
color: Colors.white,
))
],
),
body: SafeArea(
child: widgetToDisplay,
),
);
});
}
}
We discern the states we receive from MoviesBloc and convey the received states to the screen.
We do the same for the SearchScreen along with adding a form field which the searches will be typed into.
/// screens/search_screen.dart
class SearchScreen extends StatefulWidget {
const SearchScreen({Key? key}) : super(key: key);
@override
_SearchScreenState createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
@override
Widget build(BuildContext searchScreenContext) {
final _moviesBloc = BlocProvider.of<MoviesBloc>(searchScreenContext);
_moviesBloc.add(SearchScreenInitial());
TextEditingController searchQuery = TextEditingController();
void _onSubmission(String query) {
_moviesBloc.add(SubmitSearch(query));
}
return BlocConsumer<MoviesBloc, MoviesState>(
listener: (context, state) {},
builder: (context, state) {
Widget widgetToDisplay = const Center();
if (state is SearchScreenInitialState) {
widgetToDisplay = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Center(
child: Text(state.message),
),
)
],
);
}
if (state is LoadingState) {
widgetToDisplay = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
],
);
}
if (state is SearchResultsFetched) {
widgetToDisplay = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
],
);
}
if (state is MoviesFetchingFailure) {
widgetToDisplay = Column(
children: [
Expanded(
child: Center(
child: Text(state.errorMessage),
),
)
],
);
}
if (state is SearchResultsFetched) {
widgetToDisplay = state.movies.isNotEmpty
? ListView(
children: List<Widget>.from(
state.movies.map((movie) => MovieListItem(movie))))
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Expanded(
child: Center(
child: Text("No Movies in here with that title!"),
),
)
],
);
}
return Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () => Navigator.of(context)
.pushNamedAndRemoveUntil("home", (route) => false),
),
title: TextFormField(
controller: searchQuery,
onFieldSubmitted: _onSubmission,
cursorColor: Colors.white,
decoration: const InputDecoration(
hintText: "Search",
hintStyle: TextStyle(
color: Colors.white,
)),
style: const TextStyle(
color: Colors.white,
),
),
),
body: SafeArea(
child: widgetToDisplay,
),
);
});
}
}
For the movie details screen just as seen on the AppRoute, we are passing RouteSettings to it with the movie data as the arguments parameter.
/// app_router.dart
...
case "movie-details":
return MaterialPageRoute(
builder: (_) =>
MovieDetailsScreen(movie: settings.arguments as Movie));
...
Let’s create the MovieListItem widget used to plot a movie item on the movie lists.
/// components/movie_list_item.dart
class MovieListItem extends StatefulWidget {
const MovieListItem(this.movie, {Key? key}) : super(key: key);
final Movie movie;
@override
_MovieListItemState createState() => _MovieListItemState();
}
class _MovieListItemState extends State<MovieListItem> {
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: () => Navigator.pushNamed(context, "movie-details",
arguments: widget.movie),
child: ListTile(
leading: SizedBox(
width: 40,
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: Image.network(
widget.movie.poster,
fit: BoxFit.cover,
),
),
),
title: Text(widget.movie.title),
subtitle: Text(widget.movie.genre),
));
}
}
And lastly we have our MovieDetailsScreen which will be presenting the movie data passed from the above component.
/// screens/movie_details_screen.dart
class MovieDetailsScreen extends StatefulWidget {
const MovieDetailsScreen({Key? key, required this.movie}) : super(key: key);
final Movie movie;
@override
_MovieDetailsScreenState createState() => _MovieDetailsScreenState();
}
class _MovieDetailsScreenState extends State<MovieDetailsScreen> {
@override
Widget build(BuildContext context) {
Size deviceConstraints = MediaQuery.of(context).size;
double topContainerHeight =
deviceConstraints.height > 600.0 ? deviceConstraints.height / 2 : 300.0;
double coverWidth = deviceConstraints.width * (2 / 3) - 45;
double detailsWidth = deviceConstraints.width * (1 / 3) - 45;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: .5,
leading: const BackButton(color: Colors.black),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 20),
children: [
Container(
height: topContainerHeight,
decoration: BoxDecoration(
color: Colors.yellow[50],
borderRadius: const BorderRadius.all(Radius.circular(30))),
child: Row(
children: [
Container(
padding: const EdgeInsets.only(right: 10),
child: Column(children: [
SizedBox(
width: coverWidth,
child: ClipRRect(
clipBehavior: Clip.hardEdge,
borderRadius:
const BorderRadius.all(Radius.circular(30)),
child: Image.network(
widget.movie.poster,
fit: BoxFit.cover,
height: topContainerHeight,
),
),
)
]),
),
Container(
padding: const EdgeInsets.only(left: 10),
margin: const EdgeInsets.symmetric(vertical: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: 80,
width: detailsWidth,
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
const Text("runtime"),
Text(
"${(widget.movie.runtime).toInt()} min",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16),
)
],
),
decoration: const BoxDecoration(
color: Colors.yellow,
borderRadius:
BorderRadius.all(Radius.circular(20)))),
Container(
height: 80,
width: detailsWidth,
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
const Text("rating"),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text(
"8",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20),
),
Icon(Icons.star)
],
)
],
),
decoration: const BoxDecoration(
color: Colors.yellow,
borderRadius:
BorderRadius.all(Radius.circular(20)))),
Container(
height: 80,
width: detailsWidth,
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
const Text("year"),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text(
"2021",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20),
)
],
)
],
),
decoration: const BoxDecoration(
color: Colors.yellow,
borderRadius:
BorderRadius.all(Radius.circular(20))),
),
],
),
)
],
)),
const SizedBox(
height: 20,
),
Row(
children: [
Expanded(
child: Text(
widget.movie.title,
style: Theme.of(context).textTheme.headline6,
),
)
],
),
Container(
margin: const EdgeInsets.symmetric(vertical: 10),
height: 1,
color: Colors.grey[400],
),
Row(
children: [
Expanded(
child: Text(
"Synopsis",
style: Theme.of(context).textTheme.subtitle1,
),
)
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
widget.movie.plot,
style: Theme.of(context).textTheme.bodyText1,
),
)
],
)
],
)));
}
}
If we have all the above code intact, we’ll have the app functioning as follows when we run it:
In summary we have learnt the following from the above example:
- Consuming FaunaDB data with FQL from a Flutter app by using an unofficial Fauna package for Flutter.
- Bloc state management of a Flutter app and the advantages it brings from separating logic and responsibilities within the app to maintaining clean code.
- Setting up a Fauna database, its collections, indexes and keys.
- Requesting data from Fauna by using its Query Language (FQL).
I believe there is so much in store that this stack can offer, Fauna’s custom attribute-based access control (ABAC) for user authentication being one of them with its expansive support for third party authentication options providing the flexibility on that end.
With the swift scaling capabilities of Fauna and the agility provided in mobile development by Flutter, this stack can possibly be the go to when it comes to creating small to large scale projects on the mobile environment.
Finally I hope that an official support for the Dart language is in the works, or at the least in plan so that developers and the like can explore this stack in building solutions.
I for one will personally keep exploring this combination for a long foreseeable future.
Posted on December 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.