Why are child widget StreamBuilders not receiving errors? - dart

I am struggling with RxDart (maybe just straight up Rx programming). I currently have a stateful widget that calls my bloc in it's didChangeDependencies(). That call goes out and gets data via http request and adds it to a stream. I'm using BehaviorSubject and this works fine. I have child widgets using StreamBuilders and they get data no problem. My issue comes in dealing with errors. If my http request fails, I hydrate the stream with addError('whatever error') but my child widget's StreamBuilder is not receiving that error. It doesn't get anything at all.
So I have a few questions.
Is that expected?
Should error handling not be done in StreamBuilder? Ideally, I want to show something in the UI if something goes wrong so not sure how else to do it.
I could make my child widget stateful and use stream.listen. I do receive the errors there but it seems like overkill to have that and the StreamBuilder.
Am I missing something fundamental here about streams and error handling?
Here is my bloc:
final _plans = BehaviorSubject<List<PlanModel>>();
Observable<List<PlanModel>> get plans => _plans.stream;
fetchPlans() async {
try {
final _plans = await _planRepository.getPlans();
_plans.add(_plans);
}
on AuthenticationException {
_plans.addError('authentication error');
}
on SocketException {
_plans.addError('no network connection');
}
catch(error) {
_plans.addError('fetch unsuccessful');
}
}
Simplified Parent Widget:
class PlanPage extends StatefulWidget {
#override
PlanPageState createState() {
return new PlanPageState();
}
}
class PlanPageState extends State<PlanPage> {
#override
void didChangeDependencies() async {
super.didChangeDependencies();
var planBloc = BaseProvider.of<PlanBloc>(context);
planBloc.fetchPlans();
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar( title: const Text('Your Plan') ),
body: PlanWrapper()
);
}
}
Simplified Child Widget with StreamBuilder:
class PlanWrapper extends StatelessWidget {
#override
Widget build(BuildContext context) {
var planBloc = BaseProvider.of<PlanBloc>(context);
return StreamBuilder(
stream: planBloc.plans,
builder: (BuildContext context, AsyncSnapshot<List<PlanModel>> plans) {
if (plans.hasError) {
//ERROR NEVER COMES IN HERE
switch(plans.error) {
case 'authentication error':
return RyAuthErrorCard();
case 'no network connection':
return RyNetworkErrorCard();
default:
return RyGenericErrorCard(GeneralException().errorMessages()['message']);
}
}
if (plans.hasData && plans.data.isNotEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: _buildPlanTiles(context, plans.data)
);
}
return Center(child: const CircularProgressIndicator());
}
);
}
}

There was an issue about this in the RxDart GitHub (https://github.com/ReactiveX/rxdart/issues/227). BehaviorSubjects were not replaying errors to new listeners.
It was fixed in version 0.21.0. "Breaking Change: BehaviorSubject will now emit an Error, if the last event was also an Error. Before, when an Error occurred before a listen, the subscriber would not be notified of that Error."

Related

Equivalent of viewWillAppear() in Flutter

I'm rebuilding an iOS app using Flutter and the flow is as followed:
Everytime the user lands on the homepage, the user data is reloaded from the backend to check if anything has changed.
The way I achieve this in Swift / iOS is by using the viewDidLoad() function.
My Flutter code is like this:
class HomePage extends StatefulWidget {
#override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
User user = User();
#override
void dispose() {
super.dispose();
}
#override
void initState() {
super.initState();
_fetchData(context);
}
#override
Widget build(BuildContext context) {
return Container(
color: RColor.COLOR_main,
child: Column(
children: [
Container(
height: MediaQuery.of(context).size.height / 7,
width: MediaQuery.of(context).size.width,
padding: EdgeInsets.all(20),
child: Container(
child: Text("This is the homepage"),
alignment: Alignment.bottomCenter,
),
),
],
));
}
Future _fetchData(BuildContext context) async {
_fetchUserAPI(context);
}
Future _fetchUserAPI(BuildContext context) async {
try {
SharedPreferences prefs = await SharedPreferences.getInstance();
var accessToken = prefs.getString("access_token");
var url = RConstants.API_BASE_URL + "/v1/users/self";
Response response = await Dio()
.get(url, options: Options(headers: {"Authorization": accessToken}));
setState(() {
user = User.fromJson(response.data);
});
} catch (e) {
print(e.toString());
Alert(
context: context,
title: "Something Went Wrong",
desc: "Something went wrong while fetching your user data",
type: AlertType.error)
.show();
}
}
}
void initState() however, doesn't get triggered each time the user lands on the homepage. What is the correct way to achieve this?
Well, it really depends on what exactly you mean by "Everytime the user lands on the homepage".
If user navigates from the HomePage to some other view via Navigator.push and then goes back via Navigator.pop then the HomePage's state remain the same and of course the initState method does not trigger.
If you want to get notified on the HomePage if the route above it in the navigator gets popped then you need to use this method, override it and then inside it you will be able to call the _fetchData() and update the homepage's state.
One more thing: when you have some async call like _fetchData() it is a wrong pattern to just invoke it inside the initState() or any other framework methods. Because it will be invoked and the build() method of your state will almost always be invoked immediately before the result of async call will come back. The correct way to handle such situations is to use the FutureBuilder widget.
If "Everytime the user lands on the homepage" means something else, like e.g. the app was in the background and gets brought foreground or when there is support for push notifications implemented and users click on the notification and the app is opened - such cases also can be handled but that is a broader topic.
RouteAware can help.
define final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>(); in main.dart.
set navigatorObservers: [ routeObserver ], in MaterialApp function.
mixin RouteAware in your page that need achieve viewWillAppear feature.
override didChangeDependencies and didPopNext methods and subscribe this page to routeObserver.
For example:
main.dart
final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>();
void main() {
runApp(MaterialApp(
home: HomePage(),
navigatorObservers: [ routeObserver ],
));
}
home_page.dart
class _HomePageState extends State<HomePage> with RouteAware {
#override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context)!);
}
#override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
#override
void didPopNext() {
super.didPopNext();
debugPrint("viewWillAppear");
}
}

How to fix FutureBuilder open multiple times error?

these my two classes(two pages). these two classes open multiple times.
I put debug point in futurebuilder in two classes.
debug point running,
MainCategory page and got to the next page
SubCategory page and again running MainCategory page(previous page) futurebuilder and again running MainCategory page futurebuilder
navigate subcategory page to third page running subcategory page and main category page
I upload my two classes to GitHub and please let me know what the issue is.
MainCategory code: https://github.com/bhanuka96/ios_login/blob/master/MainCategory.dart
SubCategory code: https://github.com/bhanuka96/ios_login/blob/master/subCategory.dart
As stated in the documentation, you should not fetch the Future for the Futurebuilder during the widget's build event.
https://docs.flutter.io/flutter/widgets/FutureBuilder-class.html
The future must have been obtained earlier, e.g. during
State.initState, State.didUpdateConfig, or
State.didChangeDependencies. It must not be created during the
State.build or StatelessWidget.build method call when constructing the
FutureBuilder. If the future is created at the same time as the
FutureBuilder, then every time the FutureBuilder's parent is rebuilt,
the asynchronous task will be restarted.
So, try to move your call to getRegister method outside the build method and replace it with the returned Future value.
For example, below I have a class that returns a Future value which will be consumed with the help of FutureBuilder.
class MyApiHelper{
static Future<List<String>> getMyList() async {
// your implementation to make server calls
return List<String>();
}
}
Now, inside your widget, you will have something like this:
class _MyHomePageState extends State<MyHomePage> {
Future<List<String>> _myList;
#override
void initState() {
super.initState();
_myList = MyApiHelper.getMyList();
}
#override
Widget build(BuildContext context) {
return Scaffold(body: FutureBuilder(
future: _myList,
builder: (_, AsyncSnapshot<List<String>> snapLs) {
if(!snapLs.hasData) return CircularProgressIndicator();
return ListView.builder(
itemCount: snapLs.data.length,
itemBuilder: (_, index) {
//show your list item row here...
},
);
},
));
}
}
As shown above, the Future is fetched in the initState function and used inside the build method and used by FutureBuilder.
I hope this was helpful.
Thanks.
If you happen to use Provider, here's (in my opinion) a clearer alternative based on your question:
class MyHomePage extends StatelessWidget {
#override
Widget build(BuildContext context) {
return FutureProvider<List<String>>(
create: (_) => MyApiHelper.getMyList(),
child: Consumer<List<String>>(
builder: (_, list, __) {
if (list == null) return CircularProgressIndicator();
return ListView.builder(
itemCount: list.length,
itemBuilder: (_, index) {
//show your list item row here...
},
);
};
),
);
}
}
This can also be achieved of course as a StatefulWidget as suggested by the other answer, or even with flutter_hooks as explained in Why is my Future/Async Called Multiple Times?
You can create new Widget and pass Function to
returnFuture as
() {
return YourFuture;
}
import 'dart:developer';
import 'package:flutter/material.dart';
class MyFutureBuilder<T> extends StatefulWidget {
final Future<T> Function() returnFuture;
final AsyncWidgetBuilder<T> builder;
final T initialData;
MyFutureBuilder({
this.returnFuture,
#required this.builder,
this.initialData,
Key key,
}) : super(key: key);
#override
_MyFutureBuilderState<T> createState() => _MyFutureBuilderState<T>();
}
class _MyFutureBuilderState<T> extends State<MyFutureBuilder<T>> {
bool isLoading = false;
Future<T> future;
#override
void initState() {
super.initState();
future = widget.returnFuture();
}
#override
Widget build(BuildContext context) {
return FutureBuilder(
builder: widget.builder,
initialData: widget.initialData,
future: future,
);
}
}
Example
MyFutureBuilder<List<User>>(
returnFuture: () {
return moderatorUserProvider
.getExecutorsAsModeratorByIds(val.users,
save: true);
},
builder: (cont, asyncData) {
if (asyncData.connectionState !=
ConnectionState.done) {
return Center(
child: MyCircularProgressIndicator(
color: ModeratorColor.executors.color,
),
);
}
return Column(
children: asyncData.data
.map(
(singlExecutor) =>
ChooseInfoButton(
title:
'${singlExecutor.firstName} ${singlExecutor.secondName}',
subTitle: 'Business analyst',
middleText: '4.000 NOK',
subMiddleText: 'full time',
label: 'test period',
subLabel: '1.5 month',
imageUrl:
assetsUrl + 'download.jpeg',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
ModeratorExecutorEditPage(),
),
);
},
),
)
.toList());
},
)
```

flutter: Unhandled Exception: Bad state: Cannot add new events after calling close

I am trying to use the bloc pattern to manage data from an API and show them in my widget. I am able to fetch data from API and process it and show it, but I am using a bottom navigation bar and when I change tab and go to my previous tab, it returns this error:
Unhandled Exception: Bad state: Cannot add new events after calling
close.
I know it is because I am closing the stream and then trying to add to it, but I do not know how to fix it because not disposing the publishsubject will result in memory leak.
here is my Ui code:
class CategoryPage extends StatefulWidget {
#override
_CategoryPageState createState() => _CategoryPageState();
}
class _CategoryPageState extends State<CategoryPage> {
#override
void initState() {
serviceBloc.getAllServices();
super.initState();
}
#override
void dispose() {
serviceBloc.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return StreamBuilder(
stream: serviceBloc.allServices,
builder: (context, AsyncSnapshot<ServiceModel> snapshot) {
if (snapshot.hasData) {
return _homeBody(context, snapshot);
}
if (snapshot.hasError) {
return Center(
child: Text('Failed to load data'),
);
}
return CircularProgressIndicator();
},
);
}
}
_homeBody(BuildContext context, AsyncSnapshot<ServiceModel> snapshot) {
return Stack(
Padding(
padding: EdgeInsets.only(top: screenAwareSize(400, context)),
child: _buildCategories(context, snapshot))
],
);
}
_buildCategories(BuildContext context, AsyncSnapshot<ServiceModel> snapshot) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisSpacing: 3.0),
itemCount: snapshot.data.result.length,
itemBuilder: (BuildContext context, int index) {
return InkWell(
child: CategoryWidget(
title: snapshot.data.result[index].name,
icon: Icons.phone_iphone,
),
onTap: () {},
);
},
),
);
}
here is my bloc code:
class ServiceBloc extends MainBloc {
final _repo = new Repo();
final PublishSubject<ServiceModel> _serviceController =
new PublishSubject<ServiceModel>();
Observable<ServiceModel> get allServices => _serviceController.stream;
getAllServices() async {
appIsLoading();
ServiceModel movieItem = await _repo.getAllServices();
_serviceController.sink.add(movieItem);
appIsNotLoading();
}
void dispose() {
_serviceController.close();
}
}
ServiceBloc serviceBloc = new ServiceBloc();
I did not include the repo and API code because it is not in the subject of this error.
Use StreamController.isClosed to check if the controller is closed or not, if not closed add data to it.
if (!_controller.isClosed)
_controller.sink.add(...); // safe to add data as _controller isn't closed yet
From Docs:
Whether the stream controller is closed for adding more events.
The controller becomes closed by calling the close method. New events cannot be added, by calling add or addError, to a closed controller.
If the controller is closed, the "done" event might not have been delivered yet, but it has been scheduled, and it is too late to add more events.
If the error is actually caused by the code you posted, I'd just add a check to ensure no new events are added after dispose() was called.
class ServiceBloc extends MainBloc {
final _repo = new Repo();
final PublishSubject<ServiceModel> _serviceController =
new PublishSubject<ServiceModel>();
Observable<ServiceModel> get allServices => _serviceController.stream;
getAllServices() async {
// do nothing if already disposed
if(_isDisposed) {
return;
}
appIsLoading();
ServiceModel movieItem = await _repo.getAllServices();
_serviceController.sink.add(movieItem);
appIsNotLoading();
}
bool _isDisposed = false;
void dispose() {
_serviceController.close();
_isDisposed = true;
}
}
ServiceBloc serviceBloc = new ServiceBloc();
I run into same error and noticed that if you check isClosed, the screen is not updated. In your code you have to remove the last line from Bloc file:
ServiceBloc serviceBloc = new ServiceBloc();
and put this line in CategoryPage just before the initState(). This way your widget is creating and disposing the bloc. Before, the widget only disposes the bloc but it is never re-created when the widget is re-created.
besides the provided solution I think you should also drain the stream allServices used in your ServiceBloc with:
#override
void dispose() {
...
allServices?.drain();
}
#cwhisperer is absolutely right. Initialize and dispose your block inside widget just like bellow.
final ServiceBloc serviceBloc = new ServiceBloc();
#override
void initState() {
serviceBloc.getAllServices();
super.initState();
}
#override
void dispose() {
serviceBloc.dispose();
super.dispose();
}
and delete ServiceBloc serviceBloc = new ServiceBloc(); from your class ServiceBloc
You should not worry about memory leak while using flutter_bloc as
When using bloc you do not need to close the bloc manually, if you have used a bloc provider to inject the bloc. Bloc Providers handle that for you out of the box as mentioned in the flutter_bloc docs.
BlocProvider is responsible for creating the bloc, it will automatically handle closing the bloc
You can test this in your application. Try printing on the close() override of bloc.
If the Screen at which the bloc was provided is removed from navigation stack then the close() method for that given bloc is called out of the box.
ServiceBloc serviceBloc = new ServiceBloc();
// remove this code
// don't init class in the same page that will cause of bad state.
I also faced this issue in production, and I realized that we should either dispose BehaviorSubject (or any other StreamController) when the Widget is disposed or Check to see if Stream is closed before adding new value.
Here is a nice extension to do all the job:
extension BehaviorSubjectExtensions <T> on BehaviorSubject<T> {
set safeValue(T newValue) => isClosed == false ? add(newValue) : () {};
}
You can use it like so:
class MyBloc {
final _data = BehaviorSubject<String>();
void fetchData() {
// get your data from wherever it is located
_data.safeValue = 'Safe to add data';
}
void dispose() {
_data.close();
}
}
How to dispose in Widget:
class CategoryPage extends StatefulWidget {
#override
_CategoryPageState createState() => _CategoryPageState();
}
class _CategoryPageState extends State<CategoryPage> {
late MyBloc bloc;
#override
void initState() {
bloc = MyBloc();
bloc.fetchData();
super.initState();
}
#override
void dispose() {
bloc.dispose();
super.dispose();
}
// Other part of your Widget
}
even better, if you aren't sure you won't reuse the stream after disposing:
call the drain() function on the stream before closing the stream.
dispose() async{
await _coinDataFetcher.drain();
_coinDataFetcher.close();
_isdisposed = true;
}
Check if the bloc/cubit is closed by isClosed variable. Wrap this if conditions to those states which are throwing exception.
Example code
class LandingCubit extends Cubit<LandingState> {
LandingCubit(this.repository) : super(LandingInitial());
final CoreRepository repository;
// Fetches image urls that needs to shown in landing page
void getLandingImages() async {
emit(LandingImagesLoading());
try {
List<File> landingImages = await repository.landingImages();
if (!isClosed) {
emit(LandingImagesSuccess(landingImages));
}
} catch (e) {
if (!isClosed) {
emit(LandingImagesFetchError(e.toString()));
}
}
}
}

How to detect whether the route is the current route in its build method

I use FutureBuilder in my routes, which displays data after getting data from database.
I found that when I open the second route, the build method of home route was called even home route is not the current route. However, I hope the build method does not get data if home route isn't the current one.
That's the code I try to implement:
class HomeRoute extends StatefulWidget { State<StatefulWidget> createState() => HomeRouteState(); }
class HomeRouteState extends State<HomeRoute> {
#override
Widget build(BuildContext context) => Scaffold(
//...
drawer: Drawer(
// There is a ListTile that can push SecondRoute
),
body: FutureBuilder(
future: _getData(),
builder: (BuildContext context, AsyncSnapshot snapshot) {/* ... */}
),
);
_getData() async {
if(/* this route is the current one */) {
// get data
}
}
}
So basically you want to prevent the build() of your home page from getting called when it is not visible. Here is what you can try.
bool _isHomeVisible = true;
#override
Widget build(BuildContext context) {
return _isHomeVisible ? YourWidgetImplementation() : Container();
}
And before you navigate do this.
void _navigateToNewPage() {
_isHomeVisible = false; // going to new page, make it false
Navigator.push(...).then((_) {
_isHomeVisible = true; // coming back to home page, make it true
});
}

Bloc pattern does not show newly added user to sqflite on screen

I am using sqflite database to save user list.
I have user list screen, which shows list of user and it has a fab button,
on click of fab button, user is redirected to next screen where he can add new user to database.
The new user is properly inserted to the database
but when user presses back button and go backs to user list screen,
the newly added user is not visible on the screen.
I have to close the app and reopen it,then the newly added user is visible on the screen.
I am using bloc pattern and following is my code to show user list
class _UserListState extends State<UserList> {
UserBloc userBloc;
#override
void initState() {
super.initState();
userBloc = BlocProvider.of<UserBloc>(context);
userBloc.fetchUser();
}
#override
void dispose() {
userBloc?.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed("/detail");
},
child: Icon(Icons.add),
),
body: StreamBuilder(
stream: userBloc.users,
builder: (context, AsyncSnapshot<List<User>> snapshot) {
if (!snapshot.hasData) {
return Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.data != null) {
return ListView.builder(
itemBuilder: (context, index) {
return Dismissible(
key: Key(snapshot.data[index].id.toString()),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
userBloc.deleteParticularUser(snapshot.data[index]);
},
child: ListTile(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => UserDetail(
user: snapshot.data[index],
)));
},
title: Text(snapshot.data[index].name),
subtitle:
Text("Mobile Number ${snapshot.data[index].userId}"),
trailing:
Text("User Id ${snapshot.data[index].mobileNumber}"),
),
);
},
itemCount: snapshot.data.length,
);
}
},
),
);
}
}
Following is my bloc code
class UserBloc implements BlocBase {
final _users = BehaviorSubject<List<User>>();
Observable<List<User>> get users => _users.stream;
fetchUser() async {
await userRepository.initializeDatabase();
final users = await userRepository.getUserList();
_users.sink.add(users);
}
insertUser(String name,int id,int phoneNumber) async {
userRepository.insertUser(User(id, name, phoneNumber));
fetchUser();
}
updateUser(User user) async {
userRepository.updateUser(user);
}
deleteParticularUser(User user) async {
userRepository.deleteParticularUser(user);
}
deleteAllUser() {
return userRepository.deleteAllUsers();
}
#override
void dispose() {
_users.close();
}
}
As Remi posted answer saying i should try BehaviorSubject and ReplaySubject which i tried but it does not help. I have also called fetchUser(); inside insertUser() as pointed in comments
Following is the link of the full example
https://github.com/pritsawa/sqflite_example
Follow up from the comments, it seems you don't have a single instance of your UsersBloc in those two pages. Both the HomePage and UserDetails return a BlocProvider which instantiate a UsersBloc instance. Because you have two blocs instances(which you shouldn't have) you don't update the streams properly.
The solution is to remove the BlocProvider from those two pages(HomePage and UserDetail) and wrap the MaterialApp widget in it to make the same instance available to both pages.
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return BlocProvider(
bloc: UserBloc(),
child:MaterialApp(...
The HomePage will be:
class HomePage extends StatelessWidget {
#override
Widget build(BuildContext context) {
return UserList(),
);
}
}
Remove the BlocProvider from UserDetail as well.
In the UsersBloc then call fetchUser() inside the insertUser() method after the user insertion, to requery the database and update the users stream.
Also as RĂ©mi Rousselet said, use one of the subjects that return previous values.
The issue is that you're using a PublishSubject.
When a new listener subscribes to a PublishSubject, it does not receive the previously sent value and will only receive the next events.
The easiest solution is to use a BehaviorSubject or a ReplaySubject instead. These two will directly call their listener with the latest values.
#override
void initState() {
super.initState();
userBloc = BlocProvider.of<UserBloc>(context);
userBloc.fetchUser();
}
The problem is that you have called the userBloc.fetchUser() function in the initState of the page.
Bloc stream emits whenever a new data is added to it and the userBloc.fetchUser() function does exactly that, it adds the userList that you fetch from the Sqflite database.
Whenever you come back to the userlist screen from add user screen, init function is NOT called. It is only called when the userlist screen is created, that is, whenever you push it to the navigation stack.
The workaround is to call userBloc.fetchUser() whenever your StreamBuilder's snapshot data is null.
...
if (!snapshot.hasData) {
userBloc.fetchUser();
return Center(
child: CircularProgressIndicator(),
);
}
...

Resources