Building a D&D API Explorer: A Flutter Guide to HTTP and JSON
In this part of our Flutter series, we’ll focus on something fundamental to modern app development: network communication. In particular, we’ll learn how to work with APIs (Application Programming Interfaces), which allow apps to retrieve and send data over the internet.
In the last article, From Future to Stream: A Practical Guide to Async in Flutter, we explored how to manage asynchronous operations. This article builds on that by introducing how to make HTTP requests to fetch data from an API, handle JSON responses, and integrate this into a Flutter app.
All examples in this series are designed to be runnable in DartPad so you can jump straight into coding without setting up a full IDE.
What is an API?
An API (Application Programming Interface) is a set of rules that define how different software components should interact with each other. In our context, APIs allow our app to communicate with a server to retrieve or send data.
For example, many apps, like weather apps, social media apps, or news apps, rely on APIs to fetch data from servers. APIs define endpoints and request methods that you can use to interact with the server.
Endpoints: URLs that represent specific resources or functionalities in an API.
Request Methods: Actions you can perform on a resource.
The most common ones are:
- GET: Retrieve data.
- POST: Send data.
- PUT/PATCH: Update existing data.
- DELETE: Remove data.
Understanding HTTP Requests
An HTTP request is the mechanism by which your app communicates with an API. The most common request types are:
- GET Requests: Used to retrieve data from an API.
- POST Requests: Used to send new data to an API.
- PUT/PATCH Requests: Used to update data.
- DELETE Requests: Used to delete data.
In Flutter, the most commonly used library for making HTTP requests is http
. Here’s how a basic GET request works:
Future<http.Response> fetchData() {
return http.get(Uri.parse('<https://jsonplaceholder.typicode.com/posts>'));
}
In this case, we’re sending a GET request to the API at https://jsonplaceholder.typicode.com/posts
, and we expect to receive some data (in this case, a list of posts) in return.
Working with JSON Data
Almost all modern APIs use JSON (JavaScript Object Notation) as the data format for sending and receiving information. JSON is lightweight and easy to read, which is why it’s widely used.
Here’s an example of a simple JSON response from an API:
{
"id": 1,
"title": "Hello World",
"content": "This is a test post."
}
In Flutter, we can easily parse JSON using Dart’s built-in json
package. Here’s an example of parsing JSON data:
Future<void> fetchPost() async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
if (response.statusCode == 200) {
var jsonData = json.decode(response.body);
print(jsonData['title']);
} else {
throw Exception('Failed to load post');
}
}
Why Use APIs?
APIs allow apps to:
- Fetch dynamic content: Instead of hardcoding data in the app, APIs allow you to fetch real-time data, whether it be weather reports, news, or user data.
- Offload processing to servers: APIs can handle heavy tasks on the server side, such as complex calculations, image processing, or large-scale data storage, keeping the mobile app lightweight.
- Integrate with other services: Want to connect to social media platforms, payment gateways, or third-party services like Google Maps? APIs allow you to integrate seamlessly.
In our D&D 5e API Explorer example, we are working with an API that provides data on Dungeons & Dragons mechanics, rules, and monsters. Now, let’s break down how our app interacts with this API.
Making a GET Request
In the Monsters Page of our app, we send a GET request to fetch a list of monsters from the API:
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'D&D 5e API App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: HomePage(),
);
}
}
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _selectedIndex = 0;
static List<Widget> _widgetOptions = [
MonstersPage(),
RulesPage(),
MechanicsPage(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('D&D 5e API Explorer'),
),
body: Center(
child: _widgetOptions.elementAt(_selectedIndex),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.adb),
label: 'Monsters',
),
BottomNavigationBarItem(
icon: Icon(Icons.rule),
label: 'Rules',
),
BottomNavigationBarItem(
icon: Icon(Icons.extension),
label: 'Mechanics',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.blueAccent,
onTap: _onItemTapped,
),
);
}
}
class MonstersPage extends StatefulWidget {
@override
_MonstersPageState createState() => _MonstersPageState();
}
class _MonstersPageState extends State<MonstersPage> {
late Future<List<dynamic>> _monsters;
@override
void initState() {
super.initState();
_monsters = fetchMonsters();
}
Future<List<dynamic>> fetchMonsters() async {
final response = await http.get(Uri.parse('https://www.dnd5eapi.co/api/monsters'));
if (response.statusCode == 200) {
final jsonData = json.decode(response.body);
return jsonData['results'];
} else {
throw Exception('Failed to load monsters');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<List<dynamic>>(
future: _monsters,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: Colors.blue),
);
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
var monster = snapshot.data![index];
return ListTile(
title: Text(monster['name']),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MonsterDetailPage(index: monster['index']),
),
);
},
);
},
);
} else {
return Text('No data available');
}
},
),
);
}
}
- Sending the Request: Using the
http
package, we send a GET request to the/monsters
endpoint of the D&D API. The response contains a list of monsters in JSON format. - Handling the Response: Once the response is received, we decode the JSON data and extract the list of monsters. If there’s an error (e.g., network issues), we display an error message.
- Displaying the Data: We use
ListView.builder
to display the fetched monsters. Each monster is clickable, and when tapped, it navigates to the MonsterDetailPage.
Navigating and Fetching Monster Details
When a user taps on a monster in the list, we navigate to the MonsterDetailPage, where we make another API request to fetch detailed information about that specific monster:
class MonsterDetailPage extends StatefulWidget {
final String index;
MonsterDetailPage({required this.index});
@override
_MonsterDetailPageState createState() => _MonsterDetailPageState();
}
class _MonsterDetailPageState extends State<MonsterDetailPage> {
late Future<Map<String, dynamic>> _monsterDetail;
@override
void initState() {
super.initState();
_monsterDetail = fetchMonsterDetail(widget.index);
}
Future<Map<String, dynamic>> fetchMonsterDetail(String index) async {
final response = await http.get(Uri.parse('https://www.dnd5eapi.co/api/monsters/$index'));
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('Failed to load monster details');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Monster Details'),
),
body: FutureBuilder<Map<String, dynamic>>(
future: _monsterDetail,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: Colors.blue),
);
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else if (snapshot.hasData) {
var monster = snapshot.data!;
var specialAbilities = monster['special_abilities'] ?? [];
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
monster['name'],
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
if (monster['desc'] != null)
Text(
monster['desc'],
style: TextStyle(fontSize: 16),
),
SizedBox(height: 20),
Text('Size: ${monster['size']}', style: TextStyle(fontSize: 18)),
Text('Type: ${monster['type']}', style: TextStyle(fontSize: 18)),
if (monster['subtype'] != null)
Text('Subtype: ${monster['subtype']}', style: TextStyle(fontSize: 18)),
Text('XP: ${monster['xp']}', style: TextStyle(fontSize: 18)),
SizedBox(height: 20),
if (specialAbilities.isNotEmpty)
Text(
'Special Abilities',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
for (var ability in specialAbilities)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ability['name'],
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(ability['desc']),
],
),
),
],
),
);
} else {
return Center(child: Text('No data available'));
}
},
),
);
}
}
Here’s what happens:
- Requesting Data: We send a GET request to the
/monsters/{index}
endpoint, where{index}
is the unique identifier for the selected monster. - Parsing JSON: We parse the JSON response and extract various fields such as the monster’s name, size, type, and special abilities.
- Displaying the Data: The monster’s information is displayed in a detailed view, including its abilities.
Understanding JSON Structures in API Responses
Different APIs will return different JSON structures depending on the data they provide. Here’s a breakdown of the common types of JSON structures you’ll encounter:
1. Single Object: When an API returns a single resource (e.g., a specific monster or user), it’s typically structured as a JSON object:
{
"id": 1,
"name": "Aboleth",
"type": "Aberration"
}
2. List of Objects: When fetching a list of resources (e.g., all monsters), the API will return an array of JSON objects:
[
{
"id": 1,
"name": "Aboleth",
"type": "Aberration"
},
{
"id": 2,
"name": "Acolyte",
"type": "Humanoid"
}
]
In our app, we handle both of these cases. For the Monsters Page, we fetch a list of objects, and for the MonsterDetailPage, we fetch a single object.
Using Dropdowns to Fetch Data
In the Rules Page and Mechanics Page, we use DropdownButton to allow the user to select a rule or condition. When a selection is made, we fetch the corresponding data from the API and display it.
class RulesPage extends StatefulWidget {
@override
_RulesPageState createState() => _RulesPageState();
}
class _RulesPageState extends State<RulesPage> {
String? _selectedIndex;
late Future<String> _ruleContent;
List<String> _ruleSections = [
'ability-checks',
'ability-scores-and-modifiers',
'actions-in-combat',
'activating-an-item',
'advantage-and-disadvantage',
'attunement',
'between-adventures',
'casting-a-spell',
'cover',
'damage-and-healing',
'diseases',
'fantasy-historical-pantheons',
'madness',
'making-an-attack',
'mounted-combat',
'movement',
'movement-and-position',
'objects',
'poisons',
'proficiency-bonus',
'resting',
'saving-throws',
'sentient-magic-items',
'standard-exchange-rates',
'the-environment',
'the-order-of-combat',
'the-planes-of-existence',
'time',
'traps',
'underwater-combat',
'using-each-ability',
'wearing-and-wielding-items',
'what-is-a-spell',
];
@override
void initState() {
super.initState();
_selectedIndex = _ruleSections[0];
_ruleContent = fetchRule(_selectedIndex!);
}
Future<String> fetchRule(String index) async {
final response = await http.get(Uri.parse('https://www.dnd5eapi.co/api/rule-sections/$index'));
if (response.statusCode == 200) {
final jsonData = json.decode(response.body);
return jsonData['desc'];
} else {
throw Exception('Failed to load rule');
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
DropdownButton<String>(
value: _selectedIndex,
items: _ruleSections.map((String section) {
return DropdownMenuItem<String>(
value: section,
child: Text(section),
);
}).toList(),
onChanged: (String? newValue) {
setState(() {
_selectedIndex = newValue;
_ruleContent = fetchRule(_selectedIndex!);
});
},
),
Expanded(
child: FutureBuilder<String>(
future: _ruleContent,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: Colors.blue),
);
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else if (snapshot.hasData) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Text(snapshot.data!, style: TextStyle(fontSize: 16)),
);
} else {
return Text('No data available');
}
},
),
),
],
);
}
}
class MechanicsPage extends StatefulWidget {
@override
_MechanicsPageState createState() => _MechanicsPageState();
}
class _MechanicsPageState extends State<MechanicsPage> {
String? _selectedIndex;
late Future<Map<String, dynamic>> _mechanicContent;
final List<String> _mechanics = [
'blinded',
'charmed',
'deafened',
'exhaustion',
'frightened',
'grappled',
'incapacitated',
'invisible',
'paralyzed',
'petrified',
'poisoned',
'prone',
'restrained',
'stunned',
'unconscious',
];
@override
void initState() {
super.initState();
_selectedIndex = _mechanics[0];
_mechanicContent = fetchMechanic(_selectedIndex!);
}
Future<Map<String, dynamic>> fetchMechanic(String index) async {
final response = await http.get(Uri.parse('https://www.dnd5eapi.co/api/conditions/$index'));
if (response.statusCode == 200) {
final jsonData = json.decode(response.body);
return jsonData;
} else {
throw Exception('Failed to load mechanic');
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
DropdownButton<String>(
value: _selectedIndex,
items: _mechanics.map((String condition) {
return DropdownMenuItem<String>(
value: condition,
child: Text(condition),
);
}).toList(),
onChanged: (String? newValue) {
setState(() {
_selectedIndex = newValue;
_mechanicContent = fetchMechanic(_selectedIndex!);
});
},
),
Expanded(
child: FutureBuilder<Map<String, dynamic>>(
future: _mechanicContent,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: Colors.blue),
);
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else if (snapshot.hasData) {
var mechanic = snapshot.data!;
String name = mechanic['name'];
List<dynamic> descriptions = mechanic['desc'];
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
child: Text(
name,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(height: 10),
...descriptions.map((desc) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
desc,
style: TextStyle(fontSize: 16),
),
)),
],
),
);
} else {
return Text('No data available');
}
},
),
),
],
);
}
}
- Dropdown Handling: We populate the dropdown with different rule sections or conditions (like blinded, charmed, etc.).
- Fetching Data: Once a selection is made, we send a GET request to the relevant API endpoint to fetch the data.
- Displaying Data: The fetched data is displayed below the dropdown in a scrollable view.
Conclusion
In this article, we’ve taken a deep dive into network communication in Flutter, focusing on how to:
- Make HTTP requests to an API.
- Parse JSON data.
- Display the data in a user-friendly format in Flutter.
- Navigate between pages using BottomNavigationBar.
APIs are the lifeblood of modern apps, powering everything from real-time data updates to seamless integration with external services. Mastering how to work with APIs in Flutter opens up endless possibilities — whether it’s building interactive features, connecting with vast data sources, or creating personalized user experiences. The more you explore API interactions, the more versatile and powerful your apps will become, transforming static code into dynamic, responsive applications that can adapt and grow with user needs.
Feel free to share your progress, questions, or suggestions in the comments. Don’t forget to subscribe for more tutorials, and keep pushing your Flutter skills to the next level!
Special Thanks to D&D 5e SRD API
While this is not a sponsored mention, I want to express my gratitude to the team behind the D&D 5e SRD API. Their well-documented and reliable API played an important role in shaping this tutorial. The work they’ve done is outstanding, and it’s a valuable resource for developers and D&D enthusiasts alike. Thank you for providing such an excellent service!