Making Flutter and REST API Work Together — Implementing Filtering (Part 10)
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:
- 👉 Part 1: Creating API Provider
- 👉 Part 2: Debugging with Unit Tests
- 👉 Part 3: Preventing Application Freezing
- 👉 Part 4: Sending POST Request
- 👉 Part 5: Code Review
- 👉 Part 6: Building Dart Types from Swagger/OpenAPI Schemas
- 👉 Part 7: Efficient Pagination
- 👉 Part 8: Efficient Data Merging
- 👉 Part 9: Optimizing Search
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!
- 👉 Read Part 11: Seamless Data Caching Integration