I Learn by Making A Simple Pokédex Mobile App by Using Recent Technology — Flutter

Gerwin Jo
11 min readMar 26, 2023

--

Flutter Development (https://docs.flutter.dev/).

Dear my friends.

It’s been a while to see you again, since I’ve joined at Medium and started again to write this.

Today, I want to share you about my findings in several weeks. I started to explore what opportunities that I can do, and it was coming to this. I wondered if I made a fun simple app, just an experimental and scientific purpose to satisfy my idea and thoughts.

Finally, I got an idea. How about making a simple app called Pokédex? Voila. I write into 2parts. The first part is the recent technology I used. The second part is the implementation itself. Here’s the recap:

Part 1: The Recent Technology

The app is primarily made by Flutter, a famous and rapid multiplatform development app made by Google. These can found at https://pub.dev/, an official package library for Dart and Flutter. For those, who doesn’t familiar with Flutter, Dart or its environment, I will write it in the next part.

This project used Flutter 3.7.0 stable release with Dart 2.19.1 and DevTools 2.20.1 and UI Material 3.

Here are some of libraries that primarily I used:

  • syncfusion_flutter_charts: ^20.4.50: A chart made by Syncfusion with a lot of chart inside.
  • http: ^0.13.5: A Dart library for services.
  • hive: ^2.2.3: An offline No-SQL Database (just like Realm in Android).
  • hive_flutter: ^1.1.0: An offline No-SQL Database for Flutter.
  • json_serializable: ^6.6.1: A generator tools, easy to use by binding object to JSON models. It’s like toJson() and fromJson().
  • freezed: ^2.3.2: A generator tools for models to deep copy and deep linking objects/models.
  • lottie: ^2.2.0: A library that support animation just like SVG/ImageNetwork.
  • cached_network_image: ^3.2.3: A library for image caching.
  • pull_to_refresh: ^2.0.0: A library for refresh and lazyloading.
  • get: ^4.6.5: A popular framework for state management using GetX. (It’s similiar to MVC with a little twist of MVVM (Observer) in UI bindings).
  • google_fonts: ^4.0.3: A library for fonts.
  • build_runner: ^2.3.3: A library generator tools from Dart.
  • font_awesome_flutter: ^10.4.0: A libary for icons.
  • flutter_staggered_grid_view: ^0.6.2: A library for making staggering layout.
  • shared_preferences: ^2.0.18: A library for cache with a simple direct data.
  • hive_generator: ^2.0.0: A library for generate objects/adapters for Hive.

Part 2: The Preparation and The Process

I called it the alpha version :). Yeah, it just in my head though. What kind of feature that will be? So far, I want my app to be:

  • Seeing latest update pokemon.
  • Seeing every detail and stats in every pokemon
  • Seeing favorite pokemon, therefore, users no need to effort for searching.

The additional are:

  • Dark mode
  • Real time update

So, I prepare 3 screens only. First is splash screen. It would be nice if there is splash screen, wouldn’t it? Second is home screen. It includes latest pokemons (generator random) and your favorite pokemons. Third is detail screen. It includes stats, location, types, elements of pokemons.

Part 3: The Coding and Implementation

Let’s go to code.

Here is the main.dart. The first main cycle of Flutter lifecycle is ran.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:stocks/config/constant.dart';
import 'package:stocks/config/global_styles.dart';
import 'package:stocks/model/types/types.dart';
import 'package:stocks/screens/home/home_screens.dart';
import 'package:stocks/screens/splash_screens.dart';
import 'package:get/get.dart';

import 'package:google_fonts/google_fonts.dart';
import 'package:stocks/utils/shared_pref_utils.dart';

import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

void main() async {
await Hive.initFlutter();
await initDb();
runApp(const MainApp());
}

initDb() async {
Hive.registerAdapter(TypesHiveAdapter());
await Hive.openBox<TypesHive>("pokemonType");
}

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

@override
Widget build(BuildContext context) {
getDarkTheme();
startTimer();

return GetMaterialApp(
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.system,
theme: ThemeData(
brightness: Brightness.light,
useMaterial3: true,
textTheme: GoogleFonts.latoTextTheme(
TextTheme(bodyLarge: GoogleFonts.montserrat()),
),
primaryColor: ColorConstant.APP_PRIMARY,
primaryColorLight: ColorConstant.APP_PRIMARY,
primaryColorDark: ColorConstant.APP_PRIMARY_DARK,
appBarTheme: AppBarTheme(
titleTextStyle: GoogleFonts.montserrat(
textStyle:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 22),
),
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
useMaterial3: true,
textTheme: GoogleFonts.latoTextTheme(
TextTheme(
bodyLarge: GoogleFonts.montserrat(
color: Colors.white,
),
).apply(
bodyColor: Colors.white,
displayColor: Colors.white,
decorationColor: Colors.white,
),
),
primaryColor: Colors.white,
primaryColorLight: ColorConstant.APP_PRIMARY,
primaryColorDark: ColorConstant.APP_PRIMARY_DARK,
primaryIconTheme: const IconThemeData(
color: Colors.white,
),
appBarTheme: AppBarTheme(
titleTextStyle: GoogleFonts.montserrat(
textStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 22,
color: Colors.white,
),
),
),
),
builder: (context, child) {
return ScrollConfiguration(
behavior: DisableGrowBehavior(), child: child!);
},
home: const SafeArea(
child: Scaffold(
body: SplashScreens(),
),
),
);
}

startTimer() {
Timer(DurationConstant.SPLASH_SCREEN_DURATION, () {
Get.to(const HomeScreens());
});
}

getDarkTheme() async {
final result = await SharedPrefUtils().getDarkMode();
Get.changeThemeMode(result ? ThemeMode.dark : ThemeMode.light);
}
}

class DisableGrowBehavior extends ScrollBehavior {
@override
Widget buildViewportChrome(
BuildContext context, Widget child, AxisDirection axisDirection) {
return child;
}
}

As you can see, there is main method. Flutter will go through here. After main, there is Hive.initFlutter(). Hive is best for offline database compared to SQFLite based on its performance, readability and writeability.

One of the best state management is GetX. GetX is a framework, unlike provider and bloc, it have some flexibilities such as navigator, theming, and many more. As you can see, if you want to navigate through HomeScreen, you just make it with Get.to(const HomeScreens());, it will be directly through splash screen for 3 seconds (DurationConstant). If you want to change theme you can change with Get.changeThemeMode().

The second screen is home screen, the main home.

Dark Mode

As you can see, there are 2 mode here include dark and light mode. There are 3 list includes feature pokemon (a random paging generator pokemon), generations and favorite cards.

A quick start for GetX, GetX is a tool to separate logic between UI and Services. Therefore, there are 4 elements such as UI/Components, Controller, Services and Data Models. For example, for home screen, there are 3 files, such as landing_screen.dart, landing_controller.dart and landing_services.dart.

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:stocks/config/constant.dart';
import 'package:stocks/config/global_styles.dart';
import 'package:stocks/controller/detail/save_dtl_controller.dart';
import 'package:stocks/controller/landing/landing_controller.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:stocks/screens/detail/detail_screens.dart';
import 'package:stocks/services/common/common_services.dart';

class LandingScreen extends StatelessWidget {
const LandingScreen({super.key});

@override
Widget build(BuildContext context) {
final LandingController landingController = Get.put(LandingController());

return Obx(() => SmartRefresher(
controller: landingController.refreshController.value,
enablePullDown: true,
enablePullUp: true,
header: WaterDropHeader(
waterDropColor: ColorConstant.APP_PRIMARY,
idleIcon: const Icon(
Icons.catching_pokemon,
color: Colors.white,
),
complete: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LottieBuilder.network(
UrlConstant.SPLASH_SCREEN_URL,
width: 14,
height: 14,
),
const SizedBox(
height: 10,
width: 10,
),
Text(
"Pokédex completed. Yahoo!!!",
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontSize: 14),
)
],
),
),
footer: CustomFooter(
builder: ((context, mode) {
debugPrint("Mode: $mode");
if (mode == LoadStatus.idle) {
return const Center(
child: Text("That's all folks!"),
);
} else if (mode == LoadStatus.loading) {
return Center(
child: LottieBuilder.network(
UrlConstant.SPLASH_SCREEN_URL,
width: 20,
height: 20,
),
);
} else if (mode == LoadStatus.noMore) {
return const Center(
child: Text("That's all folks!"),
);
} else {
return Container();
}
}),
),
onRefresh: () async {
await landingController.onRefresh();
landingController.refreshController.value.refreshCompleted();
},
onLoading: () async {
landingController.refreshController.value.loadComplete();
},
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
children: [
featuredPokemonWidget(context, landingController),
const SizedBox(
height: 25,
width: 25,
),
discoveryPokeGenerationWidget(context, landingController),
const SizedBox(
height: 25,
width: 25,
),
savedPokemonWidget(context, landingController),
const SizedBox(
height: 25,
width: 25,
),
],
)));
}

featuredPokemonWidget(context, landingController) {
return Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"Featured Pokemon",
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontWeight: FontWeight.bold),
),
Text(
"Latest Pokemon Here",
style: Theme.of(context).textTheme.bodySmall,
)
],
),
ActionChip(
labelStyle: Theme.of(context).textTheme.labelSmall,
label: const Text(
"See all",
),
onPressed: () {},
),
],
),
const SizedBox(
height: 10,
width: 10,
),
SizedBox(
height: 125,
child: ListView.separated(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
primary: false,
itemBuilder: (context, index) => Card(
elevation: 2,
child: InkWell(
onTap: () {
Get.to(
DetailScreens(
title: landingController.listPokemon[index].name
.toString()
.capitalizeFirst!,
url: landingController.listPokemon[index].url,
),
)!
.then((value) async {
await landingController.onRefresh();
});
},
child: SizedBox(
height: 100,
width: 100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
child: CachedNetworkImage(
imageUrl: landingController
.listSprites[index].front_default,
width: 75,
height: 75,
errorWidget: (context, url, error) =>
const Icon(
Icons.catching_pokemon_outlined),
),
),
Text(
landingController.listPokemon[index].name
.toString()
.capitalizeFirst!,
maxLines: 3,
style: Theme.of(context)
.textTheme
.titleSmall!
.copyWith(fontWeight: FontWeight.bold),
),
],
),
),
),
),
separatorBuilder: (context, index) => const SizedBox(
width: 10,
),
itemCount: landingController.listSprites.length),
),
],
),
);
}

discoveryPokeGenerationWidget(context, landingController) {
return Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"Discover The Generation",
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontWeight: FontWeight.bold),
),
Text.rich(
TextSpan(text: "Beyond the", children: [
TextSpan(
text: " generations of Pokémon",
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(fontWeight: FontWeight.bold))
]),
style: Theme.of(context).textTheme.bodySmall,
)
],
),
],
),
const SizedBox(
height: 10,
width: 10,
),
SizedBox(
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
primary: false,
itemBuilder: (context, index) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
listGenerationCards(
context,
(landingController.listGenerations.length ~/ 2 * 0 +
index),
landingController),
const SizedBox(
height: 10,
width: 10,
),
listGenerationCards(
context,
(landingController.listGenerations.length ~/ 2 * 1 +
index),
landingController),
],
),
separatorBuilder: (context, index) => const SizedBox(
width: 10,
),
itemCount: landingController.listGenerations.length ~/ 2),
),
],
),
);
}

listGenerationCards(context, index, landingController) {
return Card(
elevation: 2,
child: InkWell(
onTap: () {},
child: SizedBox(
height: 50,
width: 150,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.catching_pokemon_rounded),
const SizedBox(
height: 10,
width: 10,
),
Text(
landingController.listGenerations[index].name
.toString()
.capitalizeFirst!,
maxLines: 3,
style: Theme.of(context)
.textTheme
.titleSmall!
.copyWith(fontWeight: FontWeight.bold),
),
],
),
),
),
);
}

savedPokemonWidget(context, LandingController landingController) {
final SaveDtlController saveDtlController = Get.put(SaveDtlController());
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
"Discover Your Card",
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontWeight: FontWeight.bold),
),
Text(
"Check your favorite card here",
style: Theme.of(context).textTheme.bodySmall,
)
],
),
ActionChip(
labelStyle: Theme.of(context).textTheme.labelSmall,
label: const Text(
"See all",
),
onPressed: () {},
)
],
),
const SizedBox(
height: 10,
width: 10,
),
Obx(
() => landingController.listFavoritePokemon.value.isNotEmpty
? ListView.separated(
physics: const ScrollPhysics(),
scrollDirection: Axis.vertical,
itemCount:
landingController.listFavoritePokemon.value.length,
shrinkWrap: true,
primary: false,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(
backgroundColor: CommonServices().getColorStatus(
landingController
.listFavoritePokemon.value[index].color!,
),
child: CachedNetworkImage(
imageUrl: landingController
.listFavoritePokemon.value[index].urlSprites!,
errorWidget: (context, url, error) => const Icon(
Icons.catching_pokemon,
),
),
),
title: Text(
landingController
.listFavoritePokemon.value[index].name!,
style: Theme.of(context).textTheme.labelLarge,
),
onTap: () {
Get.to(
DetailScreens(
title: landingController
.listFavoritePokemon.value[index].name
.toString()
.capitalizeFirst!,
url: landingController
.listFavoritePokemon.value[index].url,
),
)!
.then(
(value) async {
await landingController.onRefresh();
},
);
},
trailing: IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
),
onPressed: () async {
await saveDtlController.unsavePokemon(
landingController.listFavoritePokemon.value[index],
);
await landingController.onRefresh();
},
),
),
separatorBuilder: (context, index) => const Divider(),
)
: const Center(
child: Text(
"No favorite, folks!",
),
),
),
],
),
);
}

getAvatar(index) {
Widget icon = const SizedBox.shrink();
switch (index) {
case 2: // flying
icon = const Icon(Icons.wind_power_outlined);
break;
case 3: // poison
icon = const Icon(Icons.crisis_alert_outlined);
break;
case 4: // ground
icon = const Icon(Icons.park_outlined);
break;
case 5: // rock
icon = const Icon(Icons.rocket_outlined);
break;
case 6: // bug
icon = const Icon(Icons.bug_report_outlined);
break;
case 7: // ghost
icon = const Icon(Icons.person_2_outlined);
break;
case 8: // steel
icon = const Icon(Icons.iron_outlined);
break;
case 9: // fire
icon = const Icon(Icons.fireplace_outlined);
break;
case 10: // water
icon = const Icon(Icons.water_drop_outlined);
break;
case 11: // grass
icon = const Icon(Icons.grass_outlined);
break;
case 12:
icon = const Icon(Icons.thunderstorm_outlined);
break; // electric
case 13:
icon = const Icon(Icons.person_3_outlined);
break; // physics
case 14:
icon = const Icon(Icons.icecream_outlined);
break; // iceon
case 16:
icon = const Icon(Icons.dark_mode_outlined);
break; // dark
default:
icon = const Icon(Icons.catching_pokemon_outlined);
}

return icon;
}
}
import 'package:get/get.dart';
import 'package:stocks/config/constant.dart';
import 'package:stocks/model/common/paging.dart';
import 'package:stocks/services/common/base_services.dart';
import 'package:stocks/model/types/types.dart';

class LandingServices extends GetxService with BaseServices {
final String urlType = "${UrlConstant.POKE_URL}/type";
final String urlPokemon = "${UrlConstant.POKE_URL}/pokemon/";
final String urlPokemonGenerations = "${UrlConstant.POKE_URL}/generation/";

getPokeType() async {
final response = await getService(urlType);
if (response != null) {
return response;
}
}

getPokemonCreature(
{int pageLimit = PagingInfoConstant.PAGING_LIMIT,
int offsetLimit = PagingInfoConstant.OFFSET_LIMIT}) async {
String param = "?limit=$pageLimit&offset=$offsetLimit";
final response = await getService(urlPokemon + param);
if (response != null) {
return response;
}
}

getPokemonGenerations() async {
final response = await getService(urlPokemonGenerations);
if (response != null) {
return response;
}
}
}
import 'dart:math';

import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:stocks/model/common/paging.dart';
import 'package:stocks/model/pokemon/pokemon_detail.dart';
import 'package:stocks/model/sprites/sprites.dart';
import 'package:stocks/model/types/types.dart';
import 'package:stocks/services/detail/detail_poke_services.dart';
import 'package:stocks/services/home/landing_services.dart';

class LandingController extends GetxController {
var refreshController = RefreshController(initialRefresh: false).obs;
var selectedChipTypeIdx = 0.obs;

Paging paging = const Paging();
List<Types>? listTypes = <Types>[].obs;
List<Types>? listPokemon = <Types>[].obs;
List<PokemonDetail>? listPokemonDetail = <PokemonDetail>[].obs;
List<Sprites>? listSprites = <Sprites>[].obs;
List<Types>? listGenerations = <Types>[].obs;
var listFavoritePokemon = <TypesHive>[].obs;

@override
void onInit() {
getPokeType();
getPokemonCreature();
getPokemonGenerations();
getFavoritePokemon();
super.onInit();
}

getPokeType() async {
final response = await LandingServices().getPokeType();
if (response != null) {
listTypes = [];
paging = Paging.fromJson(response);
listTypes?.addAll((response["results"] as List)
.map((item) => Types.fromJson(item))
.toList());
}
}

getPokemonCreature() async {
int offsetLimit = Random().nextInt(90) + 10;
final response =
await LandingServices().getPokemonCreature(offsetLimit: offsetLimit);
if (response != null) {
listPokemon = [];
listPokemonDetail = [];
paging = Paging.fromJson(response);
listPokemon?.addAll((response["results"] as List)
.map((item) => Types.fromJson(item))
.toList());

for (Types type in listPokemon!) {
final res =
await DetailPokeServices().getDetailPokemonCreature(type.url!);
if (res != null) {
listSprites?.add(Sprites.fromJson(res["sprites"]));
}
}
}
}

getPokemonGenerations() async {
final response = await LandingServices().getPokemonGenerations();
if (response != null) {
listGenerations = [];
listGenerations?.addAll((response["results"] as List)
.map((item) => Types.fromJson(item))
.toList());
}
}

getFavoritePokemon() async {
listFavoritePokemon.value = [];
Box<TypesHive> pokeBaseDb = await Hive.openBox<TypesHive>("pokemonType");
for (int i = 0; i < pokeBaseDb.length; i++) {
TypesHive th = pokeBaseDb.getAt(i) ?? TypesHive();
listFavoritePokemon.value.add(th);
}
}

onRefresh() async {
await getFavoritePokemon();
}
}

So, how it works?

Let’s say the landing_screen.dart is the main page. We can use Get.put to define our controller like here:

final LandingController landingController = Get.put(LandingController());

There are 2 types of retrieving value: controller and observer. When we use controller and observer? I find the controller used when it call the method and there’ve no affect the value, while the observer affect on the value. We can check the observer through the code by Obx().

The last one is detail screen. Detail screen here include about, stats, location & encounters, and gallery. Each of them are url generated.

Stats Detail Mode

As you can see, the about screens is about pokemon. Stats is about strength, weakness, moves, types and many more. Location is about the location of pokemon. Gallery is about the picture of the pokemon itself. Each detail screen have a favorite (heart), which Hive is implemented.

As a quick start, Hive is one of the offline database supported by Flutter to save data. It’s similiar with indexed DB in web and it save on our app mobile. In this app, we separate the DB to the controller.

import 'dart:convert';

import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:stocks/model/types/types.dart';

class SaveDtlController extends GetxController {
var exists = false.obs;

savePokemon(TypesHive types) async {
Box<TypesHive> box = await Hive.openBox<TypesHive>("pokemonType");
await box.add(types);
exists.value = true;
Hive.close();
}

unsavePokemon(TypesHive types) {
types.delete();
exists.value = false;
}

checkIfExists(TypesHive types) async {
Box<TypesHive> box = await Hive.openBox<TypesHive>("pokemonType");
for (int i = 0; i < box.length; i++) {
TypesHive? temp = box.getAt(i);
if (types.name == temp?.name) {
exists.value = true;
return true;
}
}
return false;
}
}

As you can see, every time we want to access the DB, operate and do CRUD operations (Create-Read-Update-Delete), we must define a box.

Box<TypesHive> box = await Hive.openBox<TypesHive>("pokemonType");

Having a box and you can do some operation CRUD include put, get, delete, add, and many more. There are many operations on this Hive and I would like to discuss it into next article. Hive have a guide that you need in this link: https://docs.hivedb.dev/#/.

Part 4: Special Thanks

I would like to thank you for pub.dev and https://pokeapi.co/docs/v2 for the resources. It is a free API educational purpose for anybody who want to learn making application from API.

Part 5: Resources

Feel free to checkout my project. A lot of improvements should be there cause limited time and works. You can checkout my project on my repo and up to my connection:

Github: https://github.com/gerwinjonathan/pokedex

Linkedin: https://www.linkedin.com/in/gerwinjonathan/

A Demo App

--

--

Gerwin Jo

Application Development Specialist @ PT. Surya Madistrindo | Enthusiast @Art 🎨, Music 🎵, and Technology 💻.

No responses yet