Async task UI made easy 😄
Minimalist framework to easily handle UI states (loading, error and data) for asynchonous tasks (like network calls), in an unified way.
It provides two main widgets, with automatic handling of all common UI states:
FetchBuilder
: fetch then display data (example: a weather info page)SubmitBuilder
: submit data (example: a form page)
Simplicity in mind: you directly provide a Future
(likely network call, which may throw), the widget handles everything else.
Package developed following the KISS principle: no fuss, no glitter, just an easy-to-use API, using easy-to-read code.
Fetcher Bloc
fetcher
package was designed with BLoC pattern in mind.
We recommend using fetcher
with the provided BlocProvider
to split UI and business logic, and with the value_stream
package to handle synchronous UI changes (based on StreamBuilder
).
A handy export file is provided in that purpose: flutter_bloc.dart
.
Features
- Minimalist library: mostly uses native Flutter components & logic
- Ready to use: default widgets provided
- Basic usage should be very simple and straighforward, while advanced usage is possible
- Global configuration with local overrides
- Error & retry handling, with common UX behavior in mind
- Can be plugged into an error reporting service
- Fade transition between states to allow smooth UI
- Optional components to use with BLoC pattern (recommended)
Main Widgets
FetchBuilder
: fetch then display data- Handle loading, error and data states
- Retry system
SubmitBuilder
: submit data- Handle loading and error states
- Display barrier to prevent user interaction while loading (avoid double clicks)
Additional Widgets
-
EventFetchBuilder
: listen to anEventStream
and display data- It's like
FetchBuilder
but instead of directly calling a task once, it will listen to a stream and his updates.
- It's like
-
PagedListViewFetcher
: paginated version ofFetchBuilder
- with infinite scrolling
-
SubmitFormBuilder
: submit data with automatic form validation- use default Flutter Form system
-
AsyncEditBuilder
: fetch then display data, and submit a change if needed (example: an async switch)
Fetcher Bloc
BlocProvider
mixin to make a Bloc class easily accessible from widget's state.- Exports
value_stream
package, recommended way to handle synchonous UI changes, based onStreamBuilder
.
Usage examples
Fetch data
This example fetches data from a server, then displays data directly
FetchBuilder<Weather>(
task: api.getWeather,
builder: (context, weather) => Text('Weather: ${weather.temperature}')
)
Where getWeather
is an async function that returns a Future<Weather>
, and may throw (no internet, bad request, etc.).
By default, it will use global (or default) config. To override locally you can use config parameter:
FetchBuilder<Weather>(
task: api.getWeather,
config: FetcherConfig(
fetchingBuilder: (context) => const CircularProgressIndicator(),
),
builder: (context, weather) => Text('Weather: ${weather.temperature}')
)
Submit data
This example submits data to server, and then pops the page.
SubmitBuilder<void>(
task: () => api.submitData('new data value'),
onSuccess: (_) => Navigator.pop(context),
builder: (context, runTask) => ElevatedButton(
onPressed: runTask,
child: const Text('Submit'),
),
)
Where submitData is an async function that sends new data to server, and may throw (no internet, bad request, etc.).
If task throws, it will call onDisplayMessage
callback (see config) and stay on the page to allow user to try again. onSuccess
is only called if task returns without errors.
task
can optionally return an object that will be passed to the onSuccess
callback, for advanced usage.
If task
depends of the child context (for instance, if you have two buttons starting two different tasks), you can pass the desired task in the runTask
callback, instead of the task
argument of SubmitBuilder:
SubmitBuilder<void>(
onSuccess: (_) => Navigator.pop(context),
builder: (context, runTask) {
return Column(
children: [
ElevatedButton(
onPressed: runTask(() => api.submitData('data 1')),
child: const Text('Submit 1'),
),
ElevatedButton(
onPressed: runTask(() => api.submitData('data 2')),
child: const Text('Submit 2'),
),
],
);
},
)
Fetcher Bloc
This example uses BlocProvider
mixin to provide a bloc class to the widget state.
The bloc class exposes anything you need (business logic), here is a simple value:
class MyBloc with Disposable {
final String value = 'Hello';
}
The widget (generally the page widget) uses a BlocProvider
mixin to give access to the bloc from the state:
class MyPage extends StatefulWidget {
const MyPage({super.key});
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> with BlocProvider<MyPage, MyBloc> {
@override
MyBloc initBloc() => MyBloc();
@override
Widget build(BuildContext context) {
// You have access to the bloc instance anywhere from the state
return Text(bloc.value);
}
}
More
See the example project for more examples.
Getting started
- Add package as dependency in pubspec.yaml
- Import
fetcher_bloc.dart
to use fetcher with BLoC pattern (recommended)- Or
fetcher.dart
to just use fetcher widgets directly. You then may useextra.dart
to use additional components.
Optional
Wrap your app widget with DefaultFetcherConfig to set global configuration:
DefaultFetcherConfig(
config: FetcherConfig(
fetchingBuilder: (context) => const Center(child: CircularProgressIndicator(color: Colors.red)),
onDisplayError: (context, error) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(error.toString()),
backgroundColor: Colors.red,
)),
onError: (exception, stack, {reason}) {
// Report error
},
),
child: MaterialApp(
title: 'Fetcher Example',
home: const MyHomePage(),
),
)
From there you're good to go 🎉️
Fetcher Bloc complete example / tutorial
A detailed example to illustrate how to use Fetcher Bloc for a common use-case: a basic news reader app.
- Fetch latest news article from server
- User has the option to either like or dislike the article
Full source code is available in the example project.
1. Fetch data
First, let's build the bloc with the method that will fetch last article from server:
import 'package:fetcher/fetcher_bloc.dart';
class NewsReaderBloc with Disposable {
Future<NewsArticle> fetchArticle() async {
// Simulate network request
await Future.delayed(const Duration(seconds: 2));
// Return an article
return NewsArticle('Title 1', 'Random content generated for page 1, at ${DateTime.now()}');
}
}
We just need a class that extends Disposable
, with an async method returning the fetched data (here NewsArticle
).
No need for any error handling here, all is handled by FetchBuilder
.
With corresponding data object:
class NewsArticle {
const NewsArticle(this.title, this.content);
final String title;
final String content;
}
On the UI side, we need to create a new stateful widget for the page that holds the bloc in its state:
import 'package:fetcher/fetcher_bloc.dart';
import 'package:flutter/material.dart';
import 'news_reader.bloc.dart';
class NewsReaderPage extends StatefulWidget {
const NewsReaderPage({super.key});
@override
State<NewsReaderPage> createState() => _NewsReaderPageState();
}
class _NewsReaderPageState extends State<NewsReaderPage> with BlocProvider<NewsReaderPage, NewsReaderBloc> {
@override
NewsReaderBloc initBloc() => NewsReaderBloc();
@override
Widget build(BuildContext context) {
return SizedBox();
}
}
Now NewsReaderBloc
instance is accessible from widget's state.
Let's build a basic UI to fetch and then display data:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('News reader #1'),
),
body: FetchBuilder<NewsArticle>(
task: bloc.fetchArticle,
builder: (context, article) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Title
Text(
article.title,
style: Theme.of(context).textTheme.headlineSmall,
),
// Content
const SizedBox(height: 15),
Text(
article.content,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
),
);
}
Key part here is the FetchBuilder<NewsArticle>
widget, that does all the job: we just give it the task
(network request from bloc), it handles the rest.
While it's loading, a default loading widget will be displayed.
If task throws, a default error widget will be displayed, with a retry button.
When data is available, builder
will be called, with direct access to the data.
We now have a basic page that fetch data, then displays article.title
and article.content
.
We can go further and customize the fetching widget (optional).
We have a config
argument of type FetcherConfig
, which allows to customize this widget.
Here we use fetchingBuilder
argument to override the default loading widget:
...
task: bloc.fetchArticle,
config: FetcherConfig(
fetchingBuilder: (context) => Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 10),
Text('Fetching article 1...'),
],
),
),
),
),
builder: ...
2. Dynamic widget
If you need some widget to change dynamically, for instance on user interaction, and the related task is synchronous and safe (does not throw), you can use a basic DataStream
and its widget DataStreamBuilder
.
DataStream
is just a Dart Stream
that holds the latest value for easy access, and also guarantees that the value is always accessible (no error handling).
In our example, we can use that for the user to select a vote (like or dislike).
First, add an enum on the bloc:
enum ArticleVote {
like,
dislike,
}
Then instantiate a new DataStream
, and close it on the dispose
method:
final selectedVote = DataStream<ArticleVote?>(null);
@override
void dispose() {
selectedVote.close();
super.dispose();
}
Because vote can be null (nothing selected), DataStream
type must be nullable: ArticleVote?
Initial value is null
(unselected), we must explicitly pass it to the constructor.
You can add an extra handy bloc method to apply a vote (optional):
void selectVote(ArticleVote vote) => selectedVote.add(vote, skipSame: true);
On the UI side, use corresponding DataStreamBuilder:
DataStreamBuilder<ArticleVote?>(
stream: bloc.selectedVote,
builder: (context, selectedVote) {
return ToggleButtons(
isSelected: [
selectedVote == ArticleVote.like,
selectedVote == ArticleVote.dislike,
],
onPressed: (index) => bloc.selectVote(index == 0 ? ArticleVote.like : ArticleVote.dislike),
children: const [
Icon(Icons.thumb_up),
Icon(Icons.thumb_down),
],
);
},
),
When bloc.selectedVote
stream updates (using the strem's add()
method), DataStreamBuilder.builder
part will be rebuilt.
So you should always wrap the smallest possible part with a DataStreamBuilder
, to only rebuild the part that changes.
Now we have a working ToggleButtons 😄
3. Submit data
Now we want to submit that vote to the server. And because it's an asynchonous task (and unsafe), we have to handle all states properly. So we will use the perfectly adapted SubmitBuilder widget.
First, add a new method to submit the vote to the server on the bloc:
Future<ArticleVote> voteArticle([ArticleVote? vote]) async {
// If no vote is provided, use the selected vote
vote ??= selectedVote.value;
// If still no vote, throw an error
if (vote == null) throw Exception('Select a vote first');
// Simulate network request
await Future.delayed(const Duration(seconds: 1));
// Return the vote
return vote;
}
Method takes the current stream value of selectedVote
, then sends it to the network.
If selection is empty, we throw an error with a displayable message (by default it will be displayed to user).
Now on UI side, just wrap your page with SubmitBuilder
:
Widget build(BuildContext context) {
return SubmitBuilder<ArticleVote>(
task: bloc.voteArticle,
onSuccess: (vote) {
// Display a success message
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Voted successfully: ${vote.name}'),
backgroundColor: Colors.green,
));
// Navigate to the next page
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => NewsReaderPage()));
},
builder: (context, runTask) {
...
ElevatedButton(
onPressed: runTask,
child: const Text('Vote'),
),
...
}
);
}
task
is executed when runTask
(in builder
) is called (usually from a button).
While task
is executed, a barrier with a loader is displayed, blocking user interaction (that's why we need the SubmitBuilder
to be relatively high in the widget tree).
If task
throws, a message is displayed, and state is reverted, so user can retry.
When task
is completed with success, onSuccess
is called. This is where you should put navigation logic (all logic that needs a BuildContext
).
If you need to pass an argument to task
from the builder
(or if you have different buttons calling different tasks), you may pass a task to the runTask
callback, that will override the widget's task
.
In our example, let's call a secondary task from a new button.
In the bloc:
Future<ArticleVote> voteArticleWithError() => voteArticle(ArticleVote.error);
In the page:
ElevatedButton(
onPressed: () => runTask(bloc.voteArticleWithError),
child: const Text('Vote with error'),
),
That's all!
This example illustrates the base of fetcher_bloc
usage.
Test & see full example code in the example project.
FAQ
Controller
In a FetchBuilder, if you need to call a task programmatically (for instance to refresh data from a pull-to-refresh), you can use the controller
argument.
- Instantiate a new
FetchBuilderController
(orFetchBuilderWithParameterController
) in the bloc or in a state - Pass instance to
controller
parameter ofFetchBuilder
- Call
controller.refresh()
from a function
FetchBuilder with extra argument
For advanced usage, you can use FetchBuilderWithParameter
widget to pass an object to the task.
For instance, it can be useful for a search feature, passing the search string in the refresh
method of the controller
, each time search field changes.
Fetch vs Submit
Both corresponding widgets handle an asynchonous task states (loading, error, data, etc). But differences in usage is important as state flow is a bit different.
Fetch should be used when you need to fetch a data before displaying it to the user. So while data is fetching, you have nothing to display but a loader. Similarly, when an error occurs, an error widget will be displayed instead of the data widget.
Submit should be used when you need to send/post/submit/commit a data asynchonously OR just call an asynchonous task. Typical example is a form submission. Task is usually called after a user interaction (i.e. a button), displaying a barrier with a loader ABOVE all content. If an error occurs, barrier is removed and state reverted, so user can try again using same context (you stay on the form page). If it's a success, you usually want to navigate elsewhere.
You may look at the news reader example to go deeper.