Most mobile apps rely on external APIs and database storage to function effectively. Flutter, a powerful cross-platform framework that uses the Dart programming language, provides a streamlined way to integrate these components into your app development process. In this article, we’ll guide you through the essential steps of building a Flutter app that interacts with APIs and databases, demonstrating the simplicity and efficiency of this approach.
Getting Started
First things first, let’s tell Flutter that we want to create a new application called music_collector. We will assume that you already have Flutter installed on your development machine. If you have Android Studio installed, you can create a new application through the File menu system. Or, if you prefer, you can do it on the command line.
flutter create --org com.your_organization music_collector
Once the application has been created, we need to indicate which packages we plan on using. We can do this by either editing the pubspec.yaml directly or by using flutter on the command line.
flutter pub add elite_orm http numberpicker path path_provider sqflite xml -
This will add the following lines to your pubspec.yaml under the dependencies section:
elite_orm: ^1.0.9
http: ^1.2.0
numberpicker: ^2.1.2
path: ^1.8.3
path_provider: ^2.0.13
sqflite: ^2.3.2
xml: ^6.1.0
We’ll be using the elite_orm and sqflite packages to persist our music albums and the http and xml packages to access an online music database API.
After we get the project created and configured, we’re ready to start writing our app by creating the model.
Defining the Model
By using elite_orm, we can create a class that will serve as our data model and, in doing so, we will have essentially written all of the code needed to persist instances of our model in the database. This package greatly simplifies the effort required in order to create and read persistent data. First, let’s import the package.
import 'package:elite_orm/elite_orm.dart';
After that, it’s just a matter of extending the Entity class and adding our data members to describe what our model will contain. We define our data members and indicate a composite primary key to ensure that each album by the same artist is unique within the database
class Album extends Entity<Album> {
Album([artist = "", name = "", DateTime? release, Duration? length])
: super(Album.new) {
// The composite primary key is artist and name.
members.add(DBMember<String>("artist", artist, true));
members.add(DBMember<String>("name", name, true));
members.add(DateTimeDBMember("release", release ?? DateTime.now()));
members.add(DurationDBMember("length", length ?? const Duration()));
}
// Accessors.
String get artist => members[0].value;
String get name => members[1].value;
DateTime get release => members[2].value;
Duration get length => members[3].value;
}
That’s it! We can now Create, Read, Update, and Delete Album objects within our database using the Bloc class provided by elite_orm.
Accessing an Online API
There are a multitude of Dart packages that make a Flutter developer’s life easy. There’s a package for many of the basic tasks required to develop a complex application. Making HTTP requests and parsing XML are no exceptions.
We’ll be using the open music encyclopedia Musicbrainz. Our use of the online database will be fairly narrow and simple. We’re going to provide a list of albums released by an artist by performing two searches. The first will be an artist search based on input from the user and the second will be a search of the releases made by the artist based on the artist identifier obtained from the first search.
First, we need to import our packages and files.
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import '../model/album.dart';
For brevity, we’re not going to show the whole class. If you want to see all of the private helper methods, please see the github repository.
The entry point for consumers of this class is the getAlbums method. It takes a string that represents all or part of an artists/groups name. It makes a call to get the artist identifier and, after receiving it, the method makes another call to get the releases associated with that artist identifier. All in all, the processing of the XML is fairly simple and, frankly, not that interesting.
As you may already know, the leading underscore for members and methods indicates that it is private. We define a private API URL and a couple of private helper methods to get different URLs to help us get the data we need.
class MusicBrainz {
static const String _apiURL = "https://musicbrainz.org/ws/2";
String _getArtistURL(String artist) => "$_apiURL/artist?query=$artist";
String _getReleasesURL(String artistId) =>
"$_apiURL/artist/$artistId?inc=release-groups";
Future<List<Album>> getAlbums(String artist) async {
// Request a list of artists that partially match "artist".
http.Response response =
await http.get(Uri.parse(_getArtistURL(artist)));
if (response.statusCode == 200) {
// Get the artist id from the response, if possible.
final List<String> artistInfo = _getArtistInfo(artist, response.body);
// Now request albums from the artist (if we found one).
if (artistInfo.first.isNotEmpty) {
final String artistId = artistInfo[0], artistName = artistInfo[1];
response = await http.get(Uri.parse(_getReleasesURL(artistId)));
if (response.statusCode == 200) {
return _getAlbums(artistName, response.body);
}
}
}
return [];
}
}
Writing the User Interface
Our UI is going to be straightforward and utilitarian. We’ll have just two screens, one for adding or editing albums and another for listing the albums in our database. We’re going to start with the more complicated of the two, the add/edit album screen.
Editing Interface
The majority of the layout is the same for both adding and editing an album. If the EditAlbum object is constructed with an Album, we know we are editing an album. Without an Album, we’ll be adding a new one and we’ll need a couple of additional widgets to help the user fill in the information from the MusicBrainz site. As we can see, the EditAlbum widget doesn’t do much. The bulk of the functionality will be in the EditAlbumState class.
We will be using a BLoC to access the data in our database. A BLoC (Business Logic Component) helps separate logic from the user interface while maintaining the flutter reactive model of redrawing the UI when a state or stream changes.
import 'package:flutter/material.dart';
import 'package:numberpicker/numberpicker.dart';
import 'package:elite_orm/elite_orm.dart';
import '../model/album.dart';
import '../database/database.dart';
import '../utility/musicbrainz.dart';
import '../utility/error_dialog.dart';
import '../style/style.dart';
// There is only one Bloc object that we will use on both this screen and the
// home screen.
final bloc = Bloc(Album(), DatabaseProvider.database);
class EditAlbum extends StatefulWidget {
final Album? album;
const EditAlbum({super.key, this.album});
@override
State<EditAlbum> createState() => EditAlbumState();
}
class EditAlbumState extends State<EditAlbum> {
@override
Widget build(BuildContext context) => PopScope(
canPop: false,
onPopInvoked: _onWillPop,
child: Scaffold(
appBar: AppBar(
title: Text(widget.album == null ? "Add Album" : "Edit Album")),
body: SafeArea(child: _renderContent()),
bottomNavigationBar: BottomAppBar(
child: Container(
padding: Style.bottomBarPadding,
decoration: Style.bottomBarDecoration(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: _bottomIcons(),
),
),
),
),
);
As you can see, we override the build method with a typical Scaffold widget, which contains an app bar area, a body, and a bottom navigation bar. The whole thing is wrapped by a PopScope widget. This allows us to ask the user to save or discard their changes before leaving the screen. Our _onWillPop method will only leave the screen if there are no modifications or the user chooses to discard the modifications.
The SafeArea is what will hold the bulk of our UI. It provides a dynamic level of padding to avoid the operating system interface of the phone or tablet on which your app will run. It has only one required parameter which, in our case, is a widget created by the _renderContent method that contains the UI for this screen.
The last part of the UI is the bottom navigation bar. It contains a row of buttons that will allow the user to save new and existing albums and to delete existing albums. Later, we will take a closer look into what goes into making an app functional.
Next, we’ll take a look at the data members and initialization of the State object.
// Content editing
bool _modified = false;
int _minutes = 0;
int _seconds = 0;
final _artistController = TextEditingController();
final _titleController = TextEditingController();
final _dateController = TextEditingController();
// Keep track of searching and results.
bool _searching = false;
final List<Widget> _possible = [];
// These are static so that we can cache the previous search automatically.
static final List<Album> _albums = [];
static final _searchController = TextEditingController();
@override
void initState() {
super.initState();
// Fill in the widgets with data.
_fillSearchList();
if (widget.album != null) {
_fromAlbum(widget.album!);
}
// Set up listeners so that we can notify the user if there is unsaved data
// when they leave this screen.
_artistController.addListener(() => _modified = true);
_titleController.addListener(() => _modified = true);
_dateController.addListener(() => _modified = true);
}
Our UI will have a field for editing the artist, title, date, and duration. It will also have, when adding a new album, an artist field for searching and a list of albums as a search result. In our initState method, we first call a method to fill in our search related widgets and then, if this EditAlbumState object was constructed with an Album object, we will fill in the album editing fields with the data from the Album object. Next, we set up some listeners on the text editing controllers so that, when the user modifies them, we set our flag to keep track of modifications.
Now let’s take a look at the code to build the UI. It’s a fairly large method, but not complex at all.
Widget _renderContent() {
List<Widget> content = [];
if (widget.album == null) {
content.addAll([
const Padding(
padding: Style.columnPadding,
child: Text("Search by Artist", style: Style.titleText),
),
Padding(
padding: Style.columnPadding,
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: Style.inputDecoration,
textInputAction: TextInputAction.search,
onSubmitted: (s) => _searchArtist(),
),
),
IconButton(
icon: Icon(
Icons.search,
color: Theme.of(context).colorScheme.primary,
),
onPressed: _searchArtist,
),
],
),
),
Container(
height: 100,
margin: Style.columnPadding,
padding: Style.columnPadding,
decoration: Style.containerOutline(context),
child: _searching
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CircularProgressIndicator(),
Text("Searching...", style: Style.titleText)
],
)
: ListView(children: _possible),
),
]);
}
This first section adds an interface for searching for albums released by a particular artist. But, it is only added when we are creating a new Album, i.e., widget.album == null. When the search is in progress, the _searching boolean is true which will cause the UI to display a CircularProgressIndicator until _searching is set to false. When the search is finished, we will display the list of possible albums in a ListView.
content.addAll([
const Padding(
padding: Style.columnPadding,
child: Text("Artist", style: Style.titleText),
),
Padding(
padding: Style.textPadding,
child: TextField(
controller: _artistController,
textCapitalization: TextCapitalization.words,
decoration: Style.inputDecoration,
),
),
const Padding(
padding: Style.columnPadding,
child: Text("Title", style: Style.titleText),
),
Padding(
padding: Style.textPadding,
child: TextField(
controller: _titleController,
textCapitalization: TextCapitalization.words,
decoration: Style.inputDecoration,
),
),
This next bit simply adds text editing fields for the artist and album title. It’s all pretty straightforward.
const Padding(
padding: Style.columnPadding,
child: Text("Release Date", style: Style.titleText),
),
Padding(
padding: Style.columnPadding,
child: Row(
children: [
Expanded(
child: TextField(
readOnly: true,
controller: _dateController,
decoration: Style.inputDecoration,
),
),
IconButton(
icon: Icon(
Icons.calendar_month,
color: Theme.of(context).colorScheme.primary,
),
onPressed: _pickDate,
),
],
),
),
The Release Date field is also a text field. But, we’re not going to leave the date formatting up to the user. To make things easy, we make use of the flutter function showDatePicker which we call within the _pickDate method. The showDatePicker function is a modal dialog that provides a calendar from which the user can pick a date. Once the user picks a date, we update the Release Date text field to reflect the value that the user chose.
const Padding(
padding: Style.columnPadding,
child: Text("Duration", style: Style.titleText),
),
Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(3),
decoration: Style.containerOutline(context),
child: Column(
children: [
Padding(
padding: Style.columnPadding,
child: Row(
children: [
Expanded(
child: Column(
children: [
const Text("Minutes"),
NumberPicker(
value: _minutes,
axis: Axis.horizontal,
minValue: 0,
maxValue: 999,
itemWidth: 50,
onChanged: (value) => setState(() {
_minutes = value;
_modified = true;
}),
),
],
),
),
Expanded(
child: Column(
children: [
const Text("Seconds"),
NumberPicker(
value: _seconds,
axis: Axis.horizontal,
minValue: 0,
maxValue: 59,
itemWidth: 50,
onChanged: (value) => setState(() {
_seconds = value;
_modified = true;
}),
),
],
),
),
],
),
),
],
),
),
]);
For the duration of the album, we are going to use a NumberPicker widget for the minutes and another for the seconds. This widget provides a scrolling interface to select numbers within a specified range. If you recall, it’s one of the packages we installed in the beginning.
return ListView(children: content);
}
Once we have built up the set of widgets that make up our UI, we wrap them all in a ListView so that e can easily scroll the contents of the UI up and down, as it may be too long to display it all on the phone screen at the same time.
The last bit that we’re going to look at in the EditAlbumState class is how we save all of the information provided by the user to the database. When the user presses the save button, the _saveAlbum method is invoked.
Album _toAlbum() {
final DateTime releaseDate = DateTime.parse(_dateController.text);
final Duration duration = Duration(minutes: _minutes, seconds: _seconds);
return Album(
_artistController.text, _titleController.text, releaseDate, duration);
}
void _saveAlbum() async {
try {
final Album album = _toAlbum();
String message;
if (album.artist.isNotEmpty && album.name.isNotEmpty) {
if (widget.album == null) {
await bloc.create(album);
message = "Album Saved";
} else {
if (widget.album!.artist != album.artist ||
widget.album!.name != album.name) {
// Changing the name of the artist or album is the same as creating
// a new album. Because the artist and album make up the primary
// key, we have to create the new album and delete the old one.
// There's no way to just "rename" an entry in the database.
await bloc.create(album);
await bloc.delete(widget.album!);
} else {
// If the artist and name has not changed, then we can update.
await bloc.update(album);
}
message = "Album Updated";
}
_modified = false;
// Because we're using the build context after an await, we need to
// ensure that this widget is still mounted before using it.
if (mounted) { Navigator.pop(context);
}
} else {
message = "Invalid Album";
}
// Same here.
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
} catch (err) {
if (mounted) {
ErrorDialog.show(context, err.toString());
}
}
}
As we can see above, once we have the Album created we can then give that to the bloc to have it store it in the database. If this is a new album, we simply tell the bloc to create it. If it is an existing album, we have the bloc update it.
Below is a screenshot of what our UI will look like when adding a new album.
There’s more to this screen. If you’re interested in seeing more of the inner workings of this particular UI, please see the git repository. Now, we’re going to move on to the home screen.
Home Interface
The home screen is a much simpler UI. All it does is display a scrolling list of the albums that the user has saved in the database.
import 'package:flutter/material.dart';
import '../model/album.dart';
import '../screens/edit_album.dart';
import '../style/style.dart';
class ListAlbums extends StatefulWidget {
const ListAlbums({super.key});
@override
State<ListAlbums> createState() => _ListAlbumsState();
}
class _ListAlbumsState extends State<ListAlbums> {
Widget _renderAlbum(Album album) {
return GestureDetector(
child: Card(
shape: Style.cardShape(context),
child: ListTile(
subtitle: Text(album.artist),
title: Text(album.name, style: Style.cardTitleText),
),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => EditAlbum(album: album)),
);
},
);
}
The _renderAlbum is called for each album in the database, but only as they are visible on the screen due to the nature of the ListView.builder constructor. We use a Card wrapped in a GestureDetector so that when the user taps on the card, it will bring the user to the album editing screen through the Navigator using a MaterialPageRoute.
Widget _renderAlbums(AsyncSnapshot<List<Album>> snapshot) {
if (snapshot.hasData) {
// Sort the list by artist first and then the release date.
snapshot.data!.sort((a, b) {
final int cmp = a.artist.compareTo(b.artist);
return cmp == 0 ? a.release.compareTo(b.release) : cmp;
});
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return _renderAlbum(snapshot.data![index]);
},
);
} else {
return Center(
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CircularProgressIndicator(),
Text("Loading...", style: Style.titleText)
],
),
);
}
}
Widget _renderAlbumsWidget() {
return StreamBuilder(
stream: bloc.all,
builder: (context, snapshot) => _renderAlbums(snapshot),
);
}
The _renderAlbumsWidget method uses a StreamBuilder to create the scrolling list of albums. Whenever the bloc.all stream is updated, this widget will automatically recreate the list of albums. So, as albums are added, deleted, or updated, it will be reflected in our list automatically.
List<Widget> _bottomIcons() {
return [
IconButton(
icon: Icon(Icons.add, color: Theme.of(context).colorScheme.primary),
iconSize: Style.iconSize,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const EditAlbum()),
);
},
),
];
}
Our bottom row of buttons only contains a single icon for adding new albums. When the user presses the icon, it will take the user to our album adding screen.
@override
void initState() {
super.initState();
// Ensure that the bloc stream is filled.
bloc.get();
}
We override the initState method so that we can fill the bloc stream when the screen is created. This causes the UI to initially show the list of existing albums from the database.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Music Collector")),
body: SafeArea(child: _renderAlbumsWidget()),
bottomNavigationBar: BottomAppBar(
child: Container(
decoration: Style.bottomBarDecoration(context),
child: Row(children: _bottomIcons()),
),
),
);
}
}
As we did for our editing UI, we override the build method and use the Scaffold widget to contain our UI. The functionality, again, is delegated to other methods. As you can see, this screen is much simpler than the editing screen.
Main
The final part of the application that we’re going to look at is the code that actually starts the UI. This is main.dart and is the entry point into the application. We create a class that extends the StatelessWidget and creates a MaterialApp that runs our home screen, i.e. ListAlbums.
import 'package:flutter/material.dart';
import 'screens/list_albums.dart';
void main() {
runApp(const MusicCollector());
}
class MusicCollector extends StatelessWidget {
const MusicCollector({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
darkTheme: ThemeData(
brightness: Brightness.dark,
colorSchemeSeed: Colors.yellow.shade600,
),
theme: ThemeData(
brightness: Brightness.light,
colorSchemeSeed: Colors.red.shade800,
),
home: const ListAlbums(),
);
}
}
Conclusion
Flutter is a powerful mobile platform that can help you build complex applications that can run on Android, iOS, and other systems. There are many mobile development platforms available, but Flutter makes it quick and easy to get started writing mobile apps. This example application shows the basics and can be a good starting point for your own applications.
Be sure to follow and subscribe to be notified of future articles. Please visit Object Computing’s website to learn more about our application development services.
Chad Elliott is a Principal Software Engineer at Object Computing, with over 30 years experience in software development ranging from embedded software to server-side applications to mobile applications. The majority of his free time is spent developing mobile apps in Flutter.