Flutter Provider rebuilt widget before parent's Consumer - flutter

I have got a problem with the provider package.
I want to be able to clean an attribute (_user = null) of a provider ChangeNotifier class (it is a logout feature).
The problem is when I am doing that from a Widget that use info from this Provider.
My main app is like :
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => AuthProvider(),
builder: (context, _) => App(),
),
);
}
class App extends StatelessWidget {
#override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(builder: (_, auth, __) {
Widget displayedWidget;
switch (auth.loginState) {
case ApplicationLoginState.initializing:
displayedWidget = LoadingAppScreen();
break;
case ApplicationLoginState.loggedIn:
displayedWidget = HomeScreen();
break;
case ApplicationLoginState.loggedOut:
default:
displayedWidget = AuthenticationScreen(
signInWithEmailAndPassword: auth.signInWithEmailAndPassword,
registerAccount: auth.registerAccount,
);
}
return MaterialApp(
title: 'My App',
home: displayedWidget,
routes: {
ProfileScreen.routeName: (_) => ProfileScreen(),
},
);
});
}
}
My Provider class (simplified) :
class AuthProvider extends ChangeNotifier {
ApplicationLoginState _loginState;
ApplicationLoginState get loginState => _loginState;
bool get loggedIn => _loginState == ApplicationLoginState.loggedIn;
User _user;
User get user => _user;
void signOut() async {
// Cleaning the user which lead to the error later
_user = null;
_loginState = ApplicationLoginState.loggedOut;
notifyListeners();
}
}
My Profile screen which is accessible via named Route
class ProfileScreen extends StatelessWidget {
static const routeName = '/profile';
#override
Widget build(BuildContext context) {
final User user = Provider.of<AuthProvider>(context).user;
return Scaffold(
// drawer: AppDrawer(),
appBar: AppBar(
title: Text('Profile'),
),
body: Column(
children: [
Text(user.displayName),
FlatButton(
child: Text('logout'),
onPressed: () {
// Navigator.pushAndRemoveUntil(
// context,
// MaterialPageRoute(builder: (BuildContext context) => App()),
// ModalRoute.withName('/'),
// );
Provider.of<AuthProvider>(context, listen: false).signOut();
},
)
],
),
);
}
}
When I click the logout button from the profile screen, I don't understand why i get the error :
As I am using a Consumer<AuthProvider> at the top level of my app (this one includes my route (ProfileScreen), I thought it would redirect to the AuthenticationScreen due to the displayedWidget computed from the switch.
But it seems to rebuild the ProfileScreen first leading to the error. the change of displayedWidget do not seems to have any effect.
I'm pretty new to Provider. I don't understand what I am missing in the Provider pattern here ? Is my App / Consumer wrongly used ?
I hope you can help me understand what I've done wrong here ! Thank you.
Note : the commented Navigator.pushAndRemoveUntil redirect correctly to the login screen but I can see the error screen within a few milliseconds.

Your user is null, and you tried to get the name of him. You need to check it before using it. It will look like this:
user == null ?
Text("User Not Found!"),
Text(user.displayName),

From the provider API reference of Provider.of :
Obtains the nearest Provider up its widget tree and returns its
value.
If listen is true, later value changes will trigger a new State.build
to widgets, and State.didChangeDependencies for StatefulWidget.
So I think the line final User user = Provider.of<AuthProvider>(context).user; in your profile screen calls a rebuild when the _user variable is modified, and then the _user can be null in your ProfileScreen.
Have you tried to Navigator.pop the profile screen before clearing the _user variable?

Related

Flutter awesome notifications how to fix StateError (Bad state: Stream has already been listened to.)

I am getting this error when I have signed out from my flutter app and trying to log in again:
StateError (Bad state: Stream has already been listened to.)
The code that gives me this error is on my first page:
#override
void initState() {
AwesomeNotifications().actionStream.listen((notification) async {
if (notification.channelKey == 'scheduled_channel') {
var payload = notification.payload['payload'];
var value = await FirebaseFirestore.instance
.collection(widget.user.uid)
.doc(payload)
.get();
navigatorKey.currentState.push(PageRouteBuilder(
pageBuilder: (_, __, ___) => DetailPage(
user: widget.user,
i: 0,
docname: payload,
color: value.data()['color'].toString(),
createdDate: int.parse((value.data()['date'].toString())),
documentId: value.data()['documentId'].toString(),)));
}
});
super.initState();
}
And on another page that contains the sign out code.
await FirebaseAuth.instance.signOut();
if (!mounted) return;
Navigator.pushNamedAndRemoveUntil(context,
"/login", (Route<dynamic> route) => false);
What can I do to solve this? Is it possible to stop listen to actionstream when I log out? Or should I do it in another way?
Streams over all are single use, they replace the callback hell that that ui is, at first a single use streams can seem useless but that may be for a lack of foresight. Over all (at lest for me) flutter provides all the necessary widgets to not get messy with streams, you can find them in the Implementers section of ChangeNotifier and all of those implement others like TextEditingController.
With that, an ideal (again, at least for me) is to treat widgets as clusters where streams just tie them in a use case, for example, the widget StreamBuilder is designed to build on demand so it only needs something that pumps changes to make a "live object" like in a clock, a periodic function adds a new value to the stream and the widget just needs to listen and update.
To fix your problem you can make .actionStream fit the case you are using it or change a bit how are you using it (having a monkey patch is not good but you decide if it is worth it).
This example is not exactly a "this is what is wrong, fix it", it is more to showcase a use of how pushNamedAndRemoveUntil and StreamSubscription can get implemented. I also used a InheritedWidget just because is so useful in this cases. One thing you should check a bit more is that the variable count does not stop incrementing when route_a is not in focus, the stream is independent and it will be alive as long as the widget is, which in your case, rebuilding the listening widget is the error.
import 'dart:async';
import 'package:flutter/material.dart';
void main() => runApp(App());
const String route_a = '/route_a';
const String route_b = '/route_b';
const String route_c = '/route_c';
class App extends StatelessWidget {
Stream<int> gen_nums() async* {
while (true) {
await Future.delayed(Duration(seconds: 1));
yield 1;
}
}
#override
Widget build(BuildContext ctx) {
return ReachableData(
child: MaterialApp(
initialRoute: route_a,
routes: <String, WidgetBuilder>{
route_a: (_) => Something(stream: gen_nums()),
route_b: (_) => FillerRoute(),
route_c: (_) => SetMount(),
},
),
);
}
}
class ReachableData extends InheritedWidget {
final data = ReachableDataState();
ReachableData({super.key, required super.child});
static ReachableData of(BuildContext ctx) {
final result = ctx.dependOnInheritedWidgetOfExactType<ReachableData>();
assert(result != null, 'Context error');
return result!;
}
#override
bool updateShouldNotify(ReachableData old) => false;
}
class ReachableDataState {
String? mount;
}
// route a
class Something extends StatefulWidget {
// If this widget needs to be disposed then use the other
// constructor and this call in the routes:
// Something(subscription: gen_nums().listen(null)),
// final StreamSubscription<int> subscription;
// Something({required this.subscription, super.key});
final Stream<int> stream;
Something({required this.stream, super.key});
#override
State<Something> createState() => _Something();
}
class _Something extends State<Something> {
int count = 0;
void increment_by(int i) => setState(
() => count += i,
);
#override
void initState() {
super.initState();
widget.stream.listen(increment_by);
// To avoid any funny errors you should set the subscription
// on pause or the callback to null on dispose
// widget.subscription.onData(increment_by);
}
#override
Widget build(BuildContext ctx) {
var mount = ReachableData.of(ctx).data.mount ?? 'No mount';
return Scaffold(
body: InkWell(
child: Text('[$count] Push Other / $mount'),
onTap: () {
ReachableData.of(ctx).data.mount = null;
Navigator.of(ctx).pushNamed(route_b);
},
),
);
}
}
// route b
class FillerRoute extends StatelessWidget {
const FillerRoute({super.key});
#override
Widget build(BuildContext ctx) {
return Scaffold(
body: InkWell(
child: Text('Go next'),
// Option 1: go to the next route
// onTap: () => Navigator.of(ctx).pushNamed(route_c),
// Option 2: go to the next route and extend the pop
onTap: () => Navigator.of(ctx)
.pushNamedAndRemoveUntil(route_c, ModalRoute.withName(route_a)),
),
);
}
}
// route c
class SetMount extends StatelessWidget {
const SetMount({super.key});
#override
Widget build(BuildContext ctx) {
return Scaffold(
body: InkWell(
child: Text('Set Mount'),
onTap: () {
ReachableData.of(ctx).data.mount = 'Mounted';
// Option 1: pop untill reaches the correct route
// Navigator.of(ctx).popUntil(ModalRoute.withName(route_a));
// Option 2: a regular pop
Navigator.of(ctx).pop();
},
),
);
}
}

Handling deep links notifications in flutter

I'm trying to wrap my head around external data handling in Flutter application.
The idea is following:
An invitation comes as deep link of form https://host/join?id=<id>
Router handles such deep link by opening invites page and calling acceptExternalInvitation():
class App extends StatelessWidget {
#override
Widget build(BuildContext context) {
final notificationBloc = NotificationCubit();
final dataSharingBloc = IncomingDataBloc(notificationBloc);
return MultiBlocProvider(
...
child: MaterialApp(
...
home: SafeArea(child: _MyHomePage()),
onGenerateRoute: (settings) => _getRoute(dataSharingBloc, settings)));
}
Route? _getRoute(IncomingDataBloc incomingDataBloc, RouteSettings settings) {
if (settings.name == null) {
return null;
}
final route = settings.name!;
if (route.startsWith('/join')) {
final match = RegExp(r"/join\?id=(.+)$").firstMatch(route);
if (match != null) {
incomingDataBloc.acceptExternalInvitation(match.group(1)!);
return MaterialPageRoute(
builder: (Navcontext) => InvitesPage(), settings: settings);
}
}
return null;
}
}
class _MyHomePage extends StatelessWidget {
#override
Widget build(BuildContext context) =>
BlocConsumer<NotificationCubit, NotificationState>(
listener: (context, state) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(state.message)));
},
builder: (context, state) => BlocBuilder<IncomingDataBloc, IncomingDataState>(
builder: (context, state) => Scaffold(
appBar: AppBar(),
drawer: _getDrawer(context),
body: OverviewPage())));
...
}
IncomingDataBloc is responsible for handling all events that come from outside of the application such as deep links here. IncomingDataBloc.acceptExternalInvitation is defined as following:
void acceptExternalInvitation(String invitationEncodedString) {
final invitation = MemberInvitation.fromBase64(invitationEncodedString);
emit(NewAcceptedInviteState(invitation));
_notificationCubit.notify("Got invitation from ${invitation.from}");
}
Main point here is emitting the NewAcceptedInviteState(invitation), which should be handled by the invitations page.
Invitations page in general contains list of invitations and allows to accept or reject them. Invitations coming from deep links should trigger acceptance action:
class InvitesPage extends StatelessWidget {
final MemberInvitation? invitation;
InvitesPage({this.invitation});
#override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text("Invitations")),
body: BlocListener<IncomingDataBloc, IncomingDataState>(
listener: (context, state) async {
Logger.root.finest("--------Accepting invitation");
await _acceptInvite(context, (state as NewAcceptedInviteState).invitation);
Navigator.pop(context);
},
listenWhen: (previous, current) => current is NewAcceptedInviteState,
child: ...
)
);
The problem I have is with the listener in InvitesPage. When I send a link to the application using adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://host/join?id=MY_ID" my.app the handling of the link is performed without problems, the notification from p.3 is shown and the InvitesPage is opened. However the code of listener is not executed.
If however I set a break point in listener code it's hit and code is executed.
How can I fix that? Can it be caused by using BlocListener<IncomingDataBloc, IncomingDataState> in home page, which is higher in widgets hierarchy?
And may be more generic question - is such approach of handling external event right one?
Ok, I've figured that out.
Indeed the problem was consuming states in two places. So instead of calling a Bloc method in route, which in my opinion wasn't conceptually correct I just open an invites pages with an invitation provided and let all invitation related logic remain contained in invites page, bloc and state:
Route? _getRoute(RouteSettings settings) {
if (settings.name == null) {
return null;
}
final route = settings.name!;
if (route == '/settings') {
return MaterialPageRoute(builder: (context) => SettingsPage());
}
if (route.startsWith('/join')) {
final match = RegExp(r"/join\?invitation=(.+)$").firstMatch(route);
if (match != null) {
return MaterialPageRoute(
builder: (Navcontext) =>
InvitesPage(invitation: MemberInvitation.fromBase64(match.group(1)!)),
settings: settings);
}
}
return null;
}
}
only thing I don't really like is handling invitation upon InvitesPage creation. For that I had to make it a stateful widget and implement initState():
#override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (invitation != null) {
context.read<InviteListCubit>().acceptExternalInvitation(invitation!);
}
});
}
But I think it makes sense as state of the page can change with new invitation coming from outside.

Why Won't This StreamBuilder Display Data without Restarting the App - Revised Code for Review

CURRENT BEHAVIOR: When I log out of my app and then log back in as a different user, I am taken to a dashboard page with no data. I have to restart the app from the IDE in order to load the user's data.
DESIRED BEHAVIOR: When I log in as a given user, the dashboard page should show that user's data without having to reload/restart/refresh anything.
Based on feedback in this thread, I've condensed my code as much as possible while trying not to remove anything that might help identify my issue. Apologies for the ugliness of the code - I removed as much white space and formatting as I could in order to shorten the paste.
I am working on an app that uses the Firebase Realtime Database as a back-end. The app is user-based, so each user will have a directory, with several subdirectories within each user directory. I'm trying to display a simple list of items returned from the database. Currently I have to restart the app each time I log out and log back in as a different user, which isn't what I'm looking for. I need a given user's data to appear upon login. I don't quite understand what all is happening here (I stumbled across a functional solution after several days of trial and error and googling), but I thought a Stream was more or less a 'live' stream of data from a particular source.
The code snippet below is actually taken from three or four different files in my project; I've put everything in one file and stripped out formatting and white space to make it more compact. I don't think I removed anything material to my problem.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const FlipBooks());}
class FlipBooks extends StatelessWidget {const FlipBooks({super.key});
#override
Widget build(BuildContext context) => const MaterialApp(home: AuthService());}
class AuthService extends StatelessWidget {const AuthService({super.key});
static String getUid() => FirebaseAuth.instance.currentUser!.uid;
#override
Widget build(BuildContext context) {
return Scaffold(
body: StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.hasData) {return const DashboardPage();
} else {return const LoginPage();}}))}}
class DashboardPage extends StatelessWidget {const DashboardPage({super.key});
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
child: GestureDetector(onTap: () {FirebaseAuth.instance.signOut();},
child: const Icon(Icons.logout))]),
body: StreamBuilder(
// kPAYEES_NODE is defined in constants.dart as
// kUSER_NODE.child('payees')
// kUSER_NODE is defined as FirebaseDatabase.instance.ref('users/${AuthService.getUid()}')
stream: kPAYEES_NODE.onValue,
builder: (context, snapshot) {
final payees = <Payee>[];
if (!snapshot.hasData) {return Center(child: Column(children: const [Text('No Data')]));
} else {
final payeeData = (snapshot.data!).snapshot.value as Map<Object?, dynamic>;
payeeData.forEach((key, value) {
final dataLast = Map<String, dynamic>.from(value);
final payee = Payee(id: dataLast['id'], name: dataLast['name'], note: dataLast['note']);
payees.add(payee);});
return ListView.builder(
shrinkWrap: true,
itemCount: payees.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text(payees[index].name), subtitle: Text(payees[index].id));});}}),
floatingActionButton: FloatingActionButton(
onPressed: () {Navigator.push(context, MaterialPageRoute(
builder: (context) => AddThing(), fullscreenDialog: true));},
child: const Icon(Icons.add));}}
class LoginPage extends StatefulWidget {const LoginPage({super.key});
#override
State<LoginPage> createState() => _LoginPageState();}
class _LoginPageState extends State<LoginPage> {
// variables for FocusNodes, TextEditingControllers, FormKey
Future signIn() async {
try {await FirebaseAuth.instance.signInWithEmailAndPassword(email, password);
} on FirebaseAuthException catch (e) {context.showErrorSnackBar(message: e.toString());}}
Future signUp() async {
try {await FirebaseAuth.instance.createUserWithEmailAndPassword(email, password);
} on FirebaseAuthException catch (e) {context.showErrorSnackBar(message: e.toString());}}
Future sendEm() async {
var methods = await FirebaseAuth.instance.fetchSignInMethodsForEmail(email);
if (methods.contains('password')) {return signIn();
} else {showDialog(...); // give user option to register or try again
return;}}
Future passwordReset() async {
try {await FirebaseAuth.instance.sendPasswordResetEmail(email);
showDialog(...); // show reset email sent dialog
} on FirebaseAuthException catch (e) {context.showErrorSnackBar(message: e.toString());}}
#override
void dispose() {...}
#override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(...), // logo, welcome text
Form(...), // email+pw fields, forgot pw link => passwordReset(), submit button => sendEm()
]))));}}
As I said in my answer to your previous question, you're defining static String getUid() => FirebaseAuth.instance.currentUser!.uid; which means that is only evaluates the current user once. Since the user can sign in and out, the UID is a stream and that requires that you use authStateChanges to expose it to your DashboardPage, just as you already do in the AuthService itself.
class AuthService extends StatelessWidget {const AuthService({super.key});
static Stream<String?> getUid() => FirebaseAuth.instance. authStateChanges().map<String?>((user) => user?.uid);
...
}
I didn't run the above code, so there might be some typos or minor errors in it.
Now you can use a StreamBuilder when you call getUid() and get a stream of UID values (or null when no one is signed in).

App widget is not generating the route | firebase signup

Upon successful signup, I am trying to send users to the homepage (home) explaining how to use the app. I am doing so through this code block on my signup.dart
onPressed: () async {
try {
User user =
(await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: _emailController.text,
password: _passwordController.text,
))
.user;
if (user != null) {
user.updateProfile(displayName: _nameController.text);
Navigator.of(context).pushNamed(AppRoutes.home);
}
}
Which is pointing to the home route
class AppRoutes {
AppRoutes._();
static const String authLogin = '/auth-login';
static const String authSignUp = '/auth-signup';
static const String home = '/home';
static Map<String, WidgetBuilder> define() {
return {
authLogin: (context) => Login(),
authSignUp: (context) => SignUp(),
home: (context) => Home(),
};
}
}
However, when I sign up, the data is rendering in firebase, but the user is not being sent to the home page, and throws this error in my console
Make sure your root app widget has provided a way to generate
this route.
Generators for routes are searched for in the following order:
1. For the "/" route, the "home" property, if non-null, is used.
2. Otherwise, the "routes" table is used, if it has an entry for the route.
3. Otherwise, onGenerateRoute is called. It should return a non-null value for any valid route not handled by "home" and "routes".
4. Finally if all else fails onUnknownRoute is called.
Unfortunately, onUnknownRoute was not set.
Any thoughts on how to rectify?
Have you added onGenerateRoute in your MaterialApp? Like this:
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateRoute: Router.generateRoute,
initialRoute: yourRoute,
child: YouApp(),
);
}
}
class Router {
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case AppRoutes.home:
return MaterialPageRoute(builder: (_) => Home());
case AppRoutes.authLogin:
return MaterialPageRoute(builder: (_) => Login());
case AppRoutes.authSignUp:
return MaterialPageRoute(builder: (_) => SignUp());
default:
return MaterialPageRoute(
builder: (_) => Scaffold(
body: Center(
child: Text('No route defined for ${settings.name}')),
));
}
}
}
}
}

Declarative auth routing with Firebase

Rather than pushing the user around with Navigator.push when they sign in or out, I've been using a stream to listen for sign in and sign out events.
StreamProvider<FirebaseUser>.value(
value: FirebaseAuth.instance.onAuthStateChanged,
)
It works great for the home route as it handles logging in users immediately if they're still authed.
Consumer<FirebaseUser>(
builder: (_, user, __) {
final isLoggedIn = user != null;
return MaterialApp(
home: isLoggedIn ? HomePage() : AuthPage(),
// ...
);
},
);
However, that's just for the home route. For example, if the user then navigates to a settings page where they click a button to sign out, there's no programmatic logging out and kicking to the auth screen again. I either have to say Navigator.of(context).pushNamedAndRemoveUntil('/auth', (_) => false) or get an error about user being null.
This makes sense. I'm just looking for possibly another way that when they do get logged out I don't have to do any stack management myself.
I got close by adding the builder property to the MaterialApp
builder: (_, widget) {
return isLoggedIn ? widget : AuthPage();
},
This successfully moved me to the auth page after I was unauthenticated but as it turns out, widget is actually the Navigator. And that means when I went back to AuthPage I couldn't call anything that relied on a parent Navigator.
What about this,you wrap all your screens that depend on this stream with this widget which hides from you the logic of listening to the stream and updating accordingly(you should provide the stream as you did in your question):
class AuthDependentWidget extends StatelessWidget {
final Widget childWidget;
const AuthDependentWidget({Key key, #required this.childWidget})
: super(key: key);
#override
Widget build(BuildContext context) {
return StreamBuilder(
stream: FirebaseAuth.instance.onAuthStateChanged,
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {//you handle other cases...
if (snapshot.currentUser() != null) return childWidget();
} else {
return AuthScreen();
}
},
);
}
}
And then you can use it when pushing from other pages as follows:
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (ctx) => AuthDependentWidget(
childWidget: SettingsScreen(),//or any other screen that should listen to the stream
)));
I found a way to accomplish this (LoVe's great answer is still completely valid) in case anyone else steps on this issue:
You'll need to take advantage of nested navigators. The Root will be the inner navigator and the outer navigator is created by MaterialApp:
return MaterialApp(
home: isLoggedIn ? Root() : AuthPage(),
routes: {
Root.routeName: (_) => Root(),
AuthPage.routeName: (_) => AuthPage(),
},
);
Your Root will hold the navigation for an authed user
class Root extends StatefulWidget {
static const String routeName = '/root';
#override
_RootState createState() => _RootState();
}
class _RootState extends State<Root> {
final _appNavigatorKey = GlobalKey<NavigatorState>();
#override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final canPop = _appNavigatorKey.currentState.canPop();
if (canPop) {
await _appNavigatorKey.currentState.maybePop();
}
return !canPop;
},
child: Navigator(
initialRoute: HomePage.routeName,
onGenerateRoute: (RouteSettings routeSettings) {
return MaterialPageRoute(builder: (_) {
switch (routeSettings.name) {
case HomePage.routeName:
return HomePage();
case AboutPage.routeName:
return AboutPage();
case TermsOfUsePage.routeName:
return TermsOfUsePage();
case SettingsPage.routeName:
return SettingsPage();
case EditorPage.routeName:
return EditorPage();
default:
throw 'Unknown route ${routeSettings.name}';
}
});
},
),
);
}
}
Now you can unauthenticate (FirebaseAuth.instance.signout()) inside of the settings page (or any other page) and immediately get kicked out to the auth page without calling a Navigator method.