Flutter Multi-Platform The Movie DB App Example With GetX State, Route & Dependency Managements

Hazar Belge
9 min readSep 11, 2021

--

Desktop Preview

I am working with Flutter about 7–8 months. After I learned the Get package, everything changed! Because with ‘Get’ package, developing apps with Flutter just got easier and easier. In this story, I am not going to explain all the beauty of Get package. You can look it from right here. Not ‘can’ actually, you have to look the docs. It gives you the key of the creating apps! Anyway, instead, I am going to build an app using GetX state management, route management and dependency management. Yes, we are going to manage it. By the way, this app is responsive for all available platforms(Android, iOS, Web, Windows, Mac & Linux). Full repo is here.

So, let’s begin our long but easy journey.

Now, just take a look at our main.dart. I’ll explain all of it, don’t worry.

import 'package:device_preview/device_preview.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

import '../../routes/index.dart';
import '../../util/index.dart';

void main() {
runApp(
const MovieApp(),
);
}

class MovieApp extends StatelessWidget {
const MovieApp({
Key? key,
}) : super(key: key);

static GlobalKey movieAppKey = GlobalKey();

@override
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false,
key: movieAppKey,
title: 'Flutter The Movie DB',
translations: GetTranslations(),
locale: Get.deviceLocale,
fallbackLocale: GetTranslations.fallbackLocale,
defaultTransition: Transition.fadeIn,
getPages: AppPages.routes,
initialRoute: AppRoutes.START,
);
}
}

First thing to do is change your MaterialApp to GetMaterialApp. The official doc says:

If you use Get only for state management or dependency management, it is not necessary to use GetMaterialApp. GetMaterialApp is necessary for routes, snackbars, internationalization, bottomSheets, dialogs, and high-level apis related to routes and absence of context.

We’ll use routing, snackbars, internationalization, dialogs, everything. So, change it.

Section 1: Internationalization with Get Translations

To use this feature of get package, we need to create a class which extends Translations Class and put all the Strings in it. For that, I created a new file called ‘getx_translations.dart’.

getx_translations.dart:

import 'package:flutter/material.dart';
import 'package:get/get.dart';

class GetTranslations extends Translations {
static const Locale fallbackLocale = Locale('tr', 'TR');

@override
Map<String, Map<String, String>> get keys => const <String, Map<String, String>>{
'tr_TR': <String, String>{
'app.title': 'Flutter TheMovieDB w/GetX',
'app.movies.title': 'Filmler',
'app.tv_series.title': 'Diziler',
'movies.now_playing.icon': 'Vizyonda',
'movies.popular.icon': 'Popüler',
'movies.top_rated.icon': 'En Çok Sevilen',
'movies.upcoming.icon': 'Yakında',
'tv_series.airing_today_tv.icon': 'Bugün',
'tv_series.on_the_air_tv.icon': 'Vizyonda',
'tv_series.popular_tv.icon': 'Popüler',
'tv_series.top_rated_tv.icon': 'En Çok Sevilen',
'details.widget.rating': 'Puan',
'details.grade': 'Oy Ver',
'details.more': 'Daha fazla',
'details.less': 'Daha az'
},
'en_US': <String, String>{
'app.title': 'Flutter TheMovieDB w/GetX',
'app.movies.title': 'Movies',
'app.tv_series.title': 'Tv Series',
'movies.now_playing.icon': 'Now Playing',
'movies.popular.icon': 'Popular',
'movies.top_rated.icon': 'Top Rated',
'movies.upcoming.icon': 'Upcoming',
'tv_series.airing_today_tv.icon': 'Airing Today',
'tv_series.on_the_air_tv.icon': 'On The Air',
'tv_series.popular_tv.icon': 'Popular',
'tv_series.top_rated_tv.icon': 'Top Rated',
'details.widget.rating': 'Rating',
'details.grade': 'Grade',
'details.more': 'More',
'details.less': 'Less'
},
};
}

You don’t need to use all of these strings from the beginning of course.

After created the file, pass this new created class to GetMaterialApp’s translations parameter. Set fallbackLocale with one of your existing locale. Last, pass Get.deviceLocale to GetMaterialApp’s locale parameter. Now, our GetMaterialApp will look like to this:

return GetMaterialApp(
translations: GetTranslations(),
locale: Get.deviceLocale,
fallbackLocale: GetTranslations.fallbackLocale,
);

It’s that easy. Then, you can use it like this:

Text(
'app.movies.title'.tr,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.normal,
color: Colors.white,
),
),

To change app’s locale in runtime, you can use this one single line:

Get.updateLocale(const Locale("tr", "TR"));

Let’s move to our second section, route management.

Section 2: Route Management with Get Pages

We will have 4 page in total. Start, HomeMovie, HomeTvSeries and Detail Page. I don’t count tabs like Now Playing or Popular. To navigate these pages, we need to generate a list of get pages and give each one a name. I created an abstract class to keep all the names in one file. Like this:

app_routes.dart:

abstract class AppRoutes {
static const String START = '/';
static const String HOME_MOVIE = '/home_movie';
static const String HOME_TV = '/home_tv';
static const String DETAIL = '/detail';
}

Then, use these names in AppPages class to create static GetPages List.

app_pages.dart:

import 'package:get/get.dart';

import '../../bindings/index.dart';
import '../../routes/index.dart';
import '../../ui/screens/index.dart';

class AppPages {
static final List<GetPage<dynamic>> routes = <GetPage<dynamic>>[
GetPage<StartScreen>(
name: AppRoutes.START,
page: () => const StartScreen(),
binding: StartBinding(),
),
GetPage<HomeMovieScreen>(
name: AppRoutes.HOME_MOVIE,
page: () => const HomeMovieScreen(),
transition: Transition.fadeIn,
binding: HomeMovieBinding(),
),
GetPage<HomeTvScreen>(
name: AppRoutes.HOME_TV,
page: () => const HomeTvScreen(),
transition: Transition.cupertino,
binding: HomeTvBinding(),
),
GetPage<DetailPage>(
name: AppRoutes.DETAIL,
page: () => const DetailPage(),
transition: Transition.zoom,
binding: DetailPageBinding(),
),
];
}

As you can see, we created a static list of GetPage, set the names, configured transitions and declared the widgets as pages. You can ask what the binding is. To get an answer for that question, you have to wait for the third and the last section. State Management. Now, what we’ll do with this GetPage list? We are going to pass this list to GetMaterialApp’s getPages parameter and set the initialRoute.

return GetMaterialApp(
translations: GetTranslations(),
locale: Get.deviceLocale,
fallbackLocale: GetTranslations.fallbackLocale,
defaultTransition: Transition.fadeIn,
getPages: AppPages.routes,
initialRoute: AppRoutes.START,
);

That’s it. After that, we can navigate with Get.toNamed method to any route which we want.

Get.toNamed(AppRoutes.HOME_MOVIE);

Section 3: State Management, SuperControllers & Get Connect

The subject of the hour, state management. What I’m about to say may surprise you, but I’ll say it anyway.

We don’t need to use StatefulWidgets. Whaatt! But how?

In Get package, there is a class called GetxController. It can listen the changes in spesific parts and rebuild with that data.You can use GetxController to keep data that you’re going to use on the page.

For that, we need to create controller classes that will extend GetxController to use instead of StatelessWidget.

Step 1: Create a new controller class.

class HomeMovieScreenController extends GetxController {
HomeMovieScreenController();

final RxInt currentIndex = 0.obs;
}

There is a lot of new things you probably didn’t understand if you are new to Get Package. Like RxInt type. It means currentIndex variable is an integer. But changeable and listenable. Like I said, you should check the docs -> GetX Docs

Step 2: Use it with GetView instead of StatelessWidget

class HomeMovieMobileScaffold extends GetView<HomeMovieScreenController> {
const HomeMovieMobileScaffold({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
const List<Widget> tabs = <Widget>[
NowPlayingMoviesTab(),
PopularMoviesTab(),
TopRatedMoviesTab(),
UpcomingMoviesTab(),
];
return Obx(
() => Scaffold(
appBar: const CustomAppBar(),
body: tabs[controller.currentIndex.value],
bottomNavigationBar: ConvexAppBar(
color: Colors.grey,
backgroundColor: darkAccentColor,
style: TabStyle.react,
onTap: (int index) => controller.currentIndex.value != index ? controller.currentIndex.value = index : null,
initialActiveIndex: controller.currentIndex.value,
),
),
);
}
}

GetView<GetxController> provides you a controller to access HomeMovieScreenController directly. To access currentIndex, use controller.currentIndex.

For example, in this code, the Obx widget will listen the changes. When currentIndex.value change, obx will rebuild the scaffold and show the selected tab.

Step 3: Instead of GetxController use SuperController to have superpowers.

class NowPlayingMoviesController extends SuperController<MovieWrapper?> {
NowPlayingMoviesController();
@override
void onInit() {
super.onInit();
debugPrint('onInit called');
}
@override
void onReady() {
super.onReady();
debugPrint('onReady called');
}
@override
void onClose() {
super.onClose();
debugPrint('onClose called');
}

@override
void onDetached() {
debugPrint('onDetached called');
}

@override
void onInactive() {
debugPrint('onInactive called');
}

@override
void onPaused() {
debugPrint('onPaused called');
}

@override
void onResumed() {
debugPrint('onResumed called');
}
}

You can access all the lifecycles. That is super effective. You can send a request in onInit, call a function when the widget tree built and ready (onReady). Or dispose animation controller or remove listener in onClose.

Yes, I know, SuperController has a type. What is this? This means the state of this controller will return a object from this type. In my example MovieWrapper class.

You can use this state like this:

controller.obx(
(MovieWrapper? movieWrapper) {
if (movieWrapper != null) {
return ProductList(
productList: movieWrapper.results,
isMovie: true,
);
} else {
return const SizedBox();
}
},
),

If the state changes, my product list will change too.

Step 4: Create providers and repositories to send requests.

With GetConnect, sending requests are easier. (I said it, everything is easier!)

We can set defaultDecoder:

httpClient.defaultDecoder = (dynamic val) => MovieWrapper.fromJson(val as Map<String, dynamic>);

We can set baseUrl:

httpClient.baseUrl = Url.movieDbBaseUrl;

We can interfere to request:

httpClient.addRequestModifier((Request<dynamic> request) {
request.headers['Content-Type'] = 'application/json';
request.headers['Accept'] = 'application/json';
return request;
});

We can even override get/post/put methods. I use it to add api_key query:

@override
Future<Response<T>> get<T>(String url, {Map<String, String>? headers, String? contentType, Map<String, dynamic>? query, Decoder<T>? decoder}) {
Map<String, dynamic>? parsedQuery = query?.map((String key, dynamic value) => MapEntry<String, dynamic>(key, value.toString()));
if (parsedQuery == null) {
parsedQuery = <String, dynamic>{
"api_key": Url.apiKey,
};
} else {
parsedQuery.addAll(
<String, dynamic>{
"api_key": Url.apiKey,
},
);
}
return super.get(url, headers: headers, contentType: contentType, query: parsedQuery, decoder: decoder);
}

We can use GET/POST/PUT/DELETE methods directly:

@override
Future<Response<MovieWrapper>> getNowPlayingMovie({required String path, Map<String, dynamic>? query}) => get(path, query: query);

Completed provider and repository classes:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart';

import '../../models/index.dart';
import '../../util/index.dart';

abstract class IHomeMovieProvider {
Future<Response<MovieWrapper>> getNowPlayingMovie({required String path, Map<String, dynamic>? query});
}

class HomeMovieProvider extends GetConnect implements IHomeMovieProvider {
@override
void onInit() {
httpClient.defaultDecoder = (dynamic val) => MovieWrapper.fromJson(val as Map<String, dynamic>);
httpClient.baseUrl = Url.movieDbBaseUrl;

httpClient.addRequestModifier((Request<dynamic> request) {
request.headers['Content-Type'] = 'application/json';
request.headers['Accept'] = 'application/json';
debugPrint("${request.url.host}/${request.url.path}/${request.url.query}");
return request;
});
}

@override
Future<Response<T>> get<T>(String url, {Map<String, String>? headers, String? contentType, Map<String, dynamic>? query, Decoder<T>? decoder}) {
Map<String, dynamic>? parsedQuery = query?.map((String key, dynamic value) => MapEntry<String, dynamic>(key, value.toString()));
if (parsedQuery == null) {
parsedQuery = <String, dynamic>{
"api_key": Url.apiKey,
};
} else {
parsedQuery.addAll(
<String, dynamic>{
"api_key": Url.apiKey,
},
);
}
return super.get(url, headers: headers, contentType: contentType, query: parsedQuery, decoder: decoder);
}

@override
Future<Response<MovieWrapper>> getNowPlayingMovie({required String path, Map<String, dynamic>? query}) => get(path, query: query);
}

abstract class IHomeMovieRepository {
Future<MovieWrapper?> getNowPlayingMovie({Map<String, dynamic>? query});
}

class HomeMovieRepository implements IHomeMovieRepository {
HomeMovieRepository({
required this.provider,
});

final HomeMovieProvider provider;

@override
Future<MovieWrapper?> getNowPlayingMovie({Map<String, dynamic>? query}) async {
final Response<MovieWrapper> response = await provider.getNowPlayingMovie(path: Url.nowPlayingMovies, query: query); debugPrint(response.bodyString.toString());
if (response.status.hasError) {
return Future<MovieWrapper>.error(response.statusText!);
} else {
return response.body;
}
}
}

Step 5: Send a request and fetch it to the state in onInit(or where you want).

class NowPlayingMoviesController extends SuperController<MovieWrapper?> {
NowPlayingMoviesController({
required this.homeMovieRepository,
});

final HomeMovieRepository homeMovieRepository;

Future<MovieWrapper?> _getInitialMovies() async {
final MovieWrapper? movieWrapper = await homeMovieRepository.getNowPlayingMovie(
query: <String, dynamic>{
"page": 1,
"language": Get.locale?.languageCode ?? 'tr-TR',
},
);
return movieWrapper;
}

@override
void onInit() {
super.onInit();
append(() => _getInitialMovies);
}

append method will update controller’s state when the future is complete.

Step 6: You can call update method directly to change state. In my example after loading data with pagination.

class NowPlayingMoviesController extends SuperController<MovieWrapper?> {
NowPlayingMoviesController({
required this.homeMovieRepository,
});

final HomeMovieRepository homeMovieRepository;
final ScrollController scrollController = ScrollController();
final RxBool isLoading = false.obs;

void pagination() {
if (scrollController.position.extentAfter < 400 && state != null && state!.totalPages != state!.page && !isLoading.value) {
_getMovies();
}
}

Future<void> _getMovies() async {
isLoading.value = true;
CustomProgressIndicator.openLoadingDialog();

final MovieWrapper? movieWrapper = await homeMovieRepository.getNowPlayingMovie(
query: <String, dynamic>{
"page": state!.page + 1,
"language": Get.locale?.languageCode ?? 'tr-TR',
},
);
state!.results.addAll(movieWrapper!.results);
state!.page = movieWrapper.page;
update();

CustomProgressIndicator.closeLoadingOverlay();
isLoading.value = false;
}

Future<MovieWrapper?> _getInitialMovies() async {
final MovieWrapper? movieWrapper = await homeMovieRepository.getNowPlayingMovie(
query: <String, dynamic>{
"page": 1,
"language": Get.locale?.languageCode ?? 'tr-TR',
},
);
return movieWrapper;
}

@override
void onInit() {
super.onInit();
append(() => _getInitialMovies);
scrollController.addListener(pagination);
}

@override
void onClose() {
super.onClose();
scrollController.removeListener(pagination);
}

Step 7: Bind providers, repositories and controllers with pages. Yes, the binding is here!

Now, we have to create a class which implements an abstract class called ‘Bindings’. With lazyPut method. All the controllers, repositories and providers will be created and be initialized when the page is shown.

For example, home_movie_binding.dart:

class HomeMovieBinding implements Bindings {
@override
void dependencies() {
Get.lazyPut<HomeMovieProvider>(() => HomeMovieProvider());
Get.lazyPut<HomeMovieRepository>(() => HomeMovieRepository(provider: Get.find()));
Get.lazyPut(() => HomeMovieScreenController());
Get.lazyPut(() => NowPlayingMoviesController(homeMovieRepository: Get.find()));
}
}

If we return to GetPages, it makes more sense.

GetPage<HomeMovieScreen>(
name: AppRoutes.HOME_MOVIE,
page: () => const HomeMovieScreen(),
transition: Transition.fadeIn,
binding: HomeMovieBinding(),
),

And my dear friend, that’s it. You used state, route and dependency managements to create beautiful, clean, well-architected and effective/optimized app. I shared with you only now_playing movies part and it looks like to this:

on Web & Desktop

on Mobile

For all I didn’t explain in this story,

-Responsive layout
-Mobile, Desktop and Web App
-Beautiful widgets and pages
-Useful utils

You can check full repo -> Flutter GetX The Movie DB Repo

--

--