Handling deep links notifications in flutter - 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.

Related

How Can I navigate to different screens on startup based on which type of user<Admin, Customer> is logged in by making the use of Streams

In my flutter app there are two kinds of users, Admin and Customer. I wish to implement a functionality which will navigate The Customer to CustomerHomePage() and Admin to AdminHomePage().
I have wrapped my home property of MaterialApp with StreamBuilder which should to listen to any added values to the currentUserStream and alter the UI accordingly :
class MyApp extends StatelessWidget {
const MyApp({super.key});
#override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
// home: const MyHomePage(title: 'Flutter Demo Home Page'),
home: Scaffold(
body: StreamBuilder(
initialData: null,
stream: FirestoreServices.currentUserStream,
builder: (context, snapshot) {
Widget widget = LogInPage();
if (snapshot.data != null) {
// Go to AdminHomePage if the logged in User is a Admin
print("Logged in Usertype : ${snapshot.data!.userType.toString()}");
if (snapshot.data!.userType == UserType.admin) {
widget = AdminHomePage(caUser: snapshot.data!);
}
// Go to CustomerHomePage if the logged in User is a Customer
else if (snapshot.data?.userType == UserType.customer) {
widget = CustomerHomePage(
caUser: snapshot.data!,
);
}
} else {
widget = LogInPage();
}
return widget;
}),
));
}
}+
the Stream so used in this streamBuilder is is a static property of FirestoreServices Class which is made the following way :
static Stream<CAUser> get currentUserStream async*{
FirebaseAuth.instance.authStateChanges().map(
(event) async* {
yield await FirestoreServices().uidToCAUser(event!.uid);
});
}
According to me the problem that's occuring is the values are either not getting added to the stream or they aren't getting read by the StreamBuilder. The effect of this is that the screen isn't navigationg to any of the HomePages
I tried the code which I just posted above, and I expect there's something wrong with the getter function.
type here

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).

Stream builds a stack of Widget

So, I am using a stream to track the user's authentication state. Here is my setup, which works fine so far.
class Root extends ConsumerWidget {
final Widget _loadingView = Container(color: Colors.white, alignment: Alignment.center, child: UiHelper.circularProgress);
#override
Widget build(BuildContext context, ScopedReader watch) {
return watch(userStreamProvider).when(
loading: () => _loadingView,
error: (error, stackTrace) => _loadingView,
data: (user) => user?.emailVerified == true ? Products() : Login(),
);
}
}
The problem is, stream builds the UI multiple times. And I have a welcome dialog inside of my products page, which opens multiple times and as soon as I start the app it becomes a mess.
What should I do to avoid this scenario?
** Here I am using riverpod package
I personally recommend wrapping your widget with a StreamBuilder using the onAuthStateChanged stream. This stream automatically updates when the user change its state (logged in or out). Here is an example that may help you!
Stream<FirebaseUser> authStateChanges() {
FirebaseAuth _firebaseInstance = FirebaseAuth.instance;
return _firebaseInstance.onAuthStateChanged;
}
return StreamBuilder(
stream: authStateChanges(),
builder: (context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
// isLoggedIn
} else if (snapshot.hasData == false &&
snapshot.connectionState == ConnectionState.active) {
// isLoggedOut
} else {
// loadingView
}
},
);

Flutter Provider rebuilt widget before parent's Consumer

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?

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.