fetcher 3.1.0 fetcher: ^3.1.0 copied to clipboard
Flutter package to Fetch and display asynchronous data easily
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: directly provides a Future
(likely network call, which may throw), the widget handles the rest.
Package developed with 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.
Features #
- Minimalist library: mostly use 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 fetch a data from a server, then display data directly
FetchBuilder.basic<Weather>(
task: api.getWeather,
builder: (context, weather) => Text('Weather: ${weather.temperature}')
)
Were getWeather
is an async function that return a Future<Weather>
, and may throw (not internet, bad request, etc).
By default it will use global (or default) config. To override locally you can use config parameter:
FetchBuilder.basic<Weather>(
task: api.getWeather,
config: FetcherConfig(
fetchingBuilder: (context) => const CircularProgressIndicator(),
),
builder: (context, weather) => Text('Weather: ${weather.temperature}')
)
Submit data #
This example submit data to server, and then pop the page.
SubmitBuilder<void>(
task: () => api.submitData('new data value'),
onSuccess: (_) => Navigator.pop(context),
builder: (context, runTask) => ElevatedButton(
onPressed: runTask,
child: const Text('Submit'),
),
)
Were submitData is an async function that send new data to server, and may throw (not 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 it task return 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 2 buttons that starts 2 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 use BlocProvider
mixin to provide a bloc class to the widget state.
The bloc class, that exposes anything you need (business logic), here a simple value:
class MyBloc with Disposable {
final String value = 'Hello';
}
The widget (generally the page widget), that 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 usage 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 that returns 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;
}
UI side, we need to create a new stateful widget for the page, that holds the bloc in his 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.basic<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.basic<NewsArticle>
widget, that do 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, than 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 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 throws), you can use a basic DataStream
and his widget DataStreamBuilder
.
DataStream
is just a Dart
Stream
that holds the lattest value for easy access, and also guaranties that the value is alway 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 rebuild.
So you alway should 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 send it to the network.
If selection is empty, we throw an error with a displayable message (by default it will be displayed by 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 quite high on the 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 second 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 illustrate 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.
- Instanciate a new
BasicFetchBuilderController
(orParameterizedFetchBuilderController
) 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 FetchBuilder.parameterized
constructor to pass an object to the task.
For instance, it can be usefull for a search feature, passing the search string in the refresh
method of the controller
, each time searcg 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 is 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. Same when an error occurs, an error widget will be displayed instead of the data widget.
Submit is 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 get deeper.