Making Flutter and REST API Work Together — Implementing Filtering (Part 10)

Alex Josef Bigler
Full Struggle Developer
5 min readApr 29, 2023

Welcome to Part 10 of the “Making Flutter and REST API Work Together” series! This is a special milestone as we’ve reached our 10th article in this series, and I’d like to thank you for following along.

In this part, we’ll be implementing filtering functionality for our album list using a range slider to select a year range. We’ll also add a reset button to clear the filter and display all albums.

To celebrate this special occasion, I have updated the cover image for this article with a stunning view of Santa Barbara, California 😂. I hope you enjoy it! If you’re boomer or millennial, you will🫡.

The following posts are suggested reading for this post to get you started:

For our work, we will be using a custom fake API based on json-server. It contains a sufficient amount of data for debugging purposes, as well as resources that are interrelated, which we can add at any time if we need something new.

If you want to deploy such a server yourself, you can read my article here 👈.

Suddenly, I learned that our API supports filtering:

GET /albums?artistId=17&year=1940

As well as comparison operators gte and lte:

GET /albums?artistId=17&year_gte=1940&year_lte=1980

So, we can use them to perform filtering on albums in our Flutter application.

In addition, this feature no longer requires a major overhaul of the application. Finally, we have reached this stage, which is a sign of maturity 🤘.

Let’s do it

To add filtering by a range of years in ApiProvider, we need to modify the fetchAlbums method to accept parameters for the year range:

class ApiProvider {
...

Future<List<Album>> fetchAlbums({
required int page,
required int pageSize,
int? minYear,
int? maxYear,
}) async {
String url = "$_baseUrl/albums?_page=$page&_limit=$pageSize";
if (minYear != null) {
url += "&year_gte=$minYear";
}
if (maxYear != null) {
url += "&year_lte=$maxYear";
}

final response = await http.get(Uri.parse(url));

final responseBody = processResponse(response);

List jsonResponse = json.decode(responseBody);
return jsonResponse.map((album) => Album.fromJson(album)).toList();
}

...
}

In the fetchAlbums method, we add two new parameters, minYear and maxYear, which indicate the minimum and maximum years respectively. Then, we add the corresponding parameters to the request URL if they are specified.

Now that the fetchAlbums method can filter by years, we can use it in the _fetchAlbums method in the AlbumListWidget to retrieve a list of albums within a specified range of years.

I’ve decided to add a Filter button and a modal window with a year range selection to the AlbumListWidget, so now we need to do the following.

Add a RangeValues variable, _yearRange, to the state of the _AlbumListWidgetState class to store the user-selected year range:

class _AlbumListWidgetState extends State<AlbumListWidget> {
...
RangeValues? _yearRange;
...
}

Next, add the _openFilterModal method to open the modal window where the user can select the year range:

void _openFilterModal() async {
final range = await showModalBottomSheet<RangeValues>(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(builder: (context, setState) {
return Container(
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text("Filter by year"),
const SizedBox(height: 16),
GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
double currentValue = _yearRange!.start + (details.delta.dx / context.size!.width) * (_yearRange!.end - _yearRange!.start);
currentValue = currentValue.clamp(1900.0, DateTime.now().year.toDouble());
setState(() {
_yearRange = RangeValues(currentValue, _yearRange!.end);
});
},
child: RangeSlider(
values: _yearRange ?? RangeValues(1900, DateTime.now().year.toDouble()),
min: 1900,
max: DateTime.now().year.toDouble(),
divisions: 1000,
onChanged: (RangeValues values) {
setState(() {
_yearRange = values;
});
},
labels: RangeLabels(
_yearRange?.start.toInt().toString() ?? '',
_yearRange?.end?.toInt().toString() ?? ''
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
child: const Text("Apply"),
onPressed: () => Navigator.pop(context, _yearRange),
),
if (_yearRange != null)
ElevatedButton(
child: const Text("Reset"),
onPressed: () {
setState(() {
_yearRange = null;
});
_clearAlbums();
_fetchAlbums();
},
),
],
),
],
),
);
});
},
);

if (range != null) {
_clearAlbums();
_fetchAlbums(null, range);
}
}

This method opens a modal window that contains a slider and buttons for filtering data. The RangeSlider is used to select a range of years for filtering. When the slider values are changed, the modal window is redrawn and the selected year range is stored in the _yearRange variable. When the Apply button is pressed, the selected year range is passed to Navigator.pop(). If the selection is confirmed, _clearAlbums() clears the old list of albums and _fetchAlbums() loads new albums that correspond to the selected year range. If the Reset button is pressed, _clearAlbums() resets the filter, _fetchAlbums() loads all albums, and setState() updates the interface.

Next, add a Filter button to the AppBar that calls the _openFilterModal method when pressed:

...
AppBar(
title: TextFormField(...),
actions: [
IconButton(
icon: Icon(Icons.filter_list),
onPressed: _openFilterModal,
),
if (_yearRange != null)
IconButton(
icon: Icon(Icons.clear),
onPressed: () {
setState(() {
_yearRange = null;
});
_clearAlbums();
_fetchAlbums();
},
),
],
)
...

In the build method, add a conditional statement to display the selected year range above the list of albums:

      Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_yearRange != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
"Filtered by year: ${_yearRange!.start.toInt()} - ${_yearRange!.end.toInt()}",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: _isLoading ? _albums.length + 1 : _albums.length,
itemBuilder: (BuildContext context, int index) {
if (index < _albums.length) {
Album album = _albums[index];
return ListTile(
title: Text(album.title),
subtitle: Text("${album.year} - ${album.artist?.name ?? ''}"),
leading: CachedNetworkImage(
imageUrl: album.cover,
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
),
trailing: Text("Listens: ${album.listens}"),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
],
),

Well, now let’s take a look at the result:

Not bad, not perfect, but it works.

Summary

We have reached a stage of maturity in our code where adding new features, such as filtering, no longer requires significant changes to existing code. Our filtering implementation now looks and works like a smoothly integrated function that modern users have come to expect.

Subscribe and don’t miss new materials!

--

--

Alex Josef Bigler
Full Struggle Developer

Enthusiast of new technologies, entrepreneur, and researcher. Writing about IT, economics, and other stuff. Exploring the world through the lens of data.