My Flutter project is migrating to go_router and I have trouble understanding how to access riverpod providers in either a GoRoute's build method or redirect method, as I need to access the user's data to control the navigation.
I have a top-level redirect that checks if a user is logged in and sends them to a LoginPage if not. All users can access so-called activities in my app, but only admins can edit them. Whether a user is an admin is stored in a riverpod userDataProvider, which always contains a user if the user is logged in. Now if a user attempts to enter the route /:activityId?edit=true, I want to check whether they are allowed to by accessing the userDataProvider. However, I do not see what the clean way of accessing this provider is.
I found somewhere (can't find the thread anymore), that one way is to use ProviderScope.containerOf(context).read(userDataProvider), but I have never seen this before and it seems a bit exotic to me. Is this the way to go?
My GoRoute looks something like this:
GoRoute(
path: RouteName.event.relPath,
builder: (context, state) {
final String? id = state.params['id'];
final bool edit = state.queryParams['edit'] == 'true';
if (state.extra == null) {
// TODO: Fetch data
}
final data = state.extra! as Pair<ActivityData, CachedNetworkImage?>;
if (edit) {
return CreateActivity(
isEdit: true,
data: data.a,
banner: data.b,
);
}
return ActivityPage(
id: id!,
data: data.a,
banner: data.b,
);
},
redirect: (context, state) {
final bool edit = state.queryParams['edit'] == 'true';
if (edit) {
// IMPORTANT: How to access the ref here?
final bool isAdmin =
ref.read(userDataProvider).currentUser.customClaims.admin;
if (isAdmin) {
return state.location; // Includes the queryParam edit
} else {
return state.subloc; // Does not include queryParam
}
} else {
return state.path;
}
},
),
In my current application, I used something similar approach like this :
Provider registration part (providers.dart) :
final routerProvider = Provider<GoRouter>((ref) {
final router = RouterNotifier(ref);
return GoRouter(
debugLogDiagnostics: true,
refreshListenable: router,
redirect: (context, state) {
router._redirectLogic(state);
return null;
},
routes: ref.read(routesProvider));
});
class RouterNotifier extends ChangeNotifier {
final Ref _ref;
RouterNotifier(this._ref) {
_ref.listen<AuthState>(authNotifierProvider, (_, __) => notifyListeners());
}
String? _redirectLogic(GoRouterState state) {
final loginState = _ref.watch(authNotifierProvider);
final areWeLoggingIn = state.location == '/login';
if (loginState.state != AuthenticationState.authenticated) {
return areWeLoggingIn ? null : '/login';
}
if (areWeLoggingIn) return '/welcome';
return null;
}
}
Main app building as router (app.dart):
class App extends ConsumerWidget {
const App({Key? key}) : super(key: key);
// This widget is the root of your application.
#override
Widget build(BuildContext context, WidgetRef ref) {
final GoRouter router = ref.watch(routerProvider);
return MaterialApp.router(
routeInformationProvider: router.routeInformationProvider,
routeInformationParser: router.routeInformationParser,
routerDelegate: router.routerDelegate,
debugShowCheckedModeBanner: false,
title: 'Flutter Auth',
}
}
}
And as entrypoint (main.dart):
Future<void> main() async {
F.appFlavor = Flavor.dev;
WidgetsFlutterBinding.ensureInitialized();
await setup();
runApp(ProviderScope(
observers: [
Observers(),
],
child: const App(),
));
}
Related
I am trying to set the theme of my app on the response of login data after getting the role but my theme is not updating as per expectation. this is how my main() looks. my code is showing no error and I tried to debug nothing seems wrong.
Widget build(BuildContext context) {
return ChangeNotifierProvider<ThemeModel>(
create: (_) => ThemeModel(),
child: Consumer<ThemeModel>(
builder: (context, ThemeModel themeNotifier, child) {
return Sizer(builder: (context, orientation, deviceType) {
return MaterialApp(
theme: themeNotifier.theme == 'consultant'
? counsultantApptheme()
: themeNotifier.theme == 'rmo'
? rmoApptheme()
: counsultantApptheme(),
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
initialRoute: startroute.toString(),
routes: routes,
);
});
}));
and this how I am updating after response of login API
if (snapshot.data!.data!.consultantYN == 'Y') {
Provider.of<ThemeModel>(context, listen: false).theme =
'consultant';
} else {
Provider.of<ThemeModel>(context, listen: false).theme = 'rmo';
}
and this is my function where I am setting theme and calling notifyListeners() in class extends by ChangeNotifier
//theme_model.dart
import 'package:flutter/material.dart';
import 'package:nmc/widgets/theme_config/theme_preference.dart';
class ThemeModel extends ChangeNotifier {
late String _theme;
late ThemePreferences _preferences;
String get theme => _theme;
ThemeModel() {
_theme = 'default';
_preferences = ThemePreferences();
getPreferences();
}
//Switching themes in the flutter apps - Flutterant
set theme(String value) {
_theme = value;
_preferences.setTheme(value);
notifyListeners();
}
getPreferences() async {
_theme = await _preferences.getTheme();
notifyListeners();
}
}
i am quite new with flutter. I am trying to add a ChangeNotifierProvider into my app. I use flutter_azure_b2c to log in a user, in order to handle to login outcome I have the following code:
AzureB2C.registerCallback(B2COperationSource.POLICY_TRIGGER_INTERACTIVE,
(result) async {
if (result.reason == B2COperationState.SUCCESS) {
List<String>? subjects = await AzureB2C.getSubjects();
if (subjects != null && subjects.isNotEmpty) {
B2CAccessToken? token = await AzureB2C.getAccessToken(subjects[0]);
if (!mounted || token == null) return;
final encodedPayload = token.token.split('.')[1];
final payloadData =
utf8.fuse(base64).decode(base64.normalize(encodedPayload));
final claims = Claims.fromJson(jsonDecode(payloadData));
var m = Provider.of<LoginModel>(context);
m.logIn(claims);
}
}
});
The problem is that when it arrives to var m = Provider.of<LoginModel>(context); the execution stops with out errors without executing m.logIn(claims);, so the model is not changed and the consumer is not called.
Any idea?
This is my consumer:
class App extends StatelessWidget {
const App({super.key});
#override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => LoginModel(),
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: appTheme,
home: Consumer<LoginModel>(
builder: (context, value, child) =>
value.claims != null ? const Home() : const Login(),
)),
);
}
}
class LoginModel extends ChangeNotifier {
Claims? _claims;
logIn(Claims claims) {
_claims = claims;
notifyListeners();
}
logOut() {
_claims = null;
notifyListeners();
}
Claims? get claims => _claims;
}
My LoginWidget:
class Login extends StatefulWidget {
const Login({super.key});
#override
LoginState createState() => LoginState();
}
class LoginState extends State<Login> {
B2CConfiguration? _configuration;
checkLogin(BuildContext context) async {
List<String>? subjects = await AzureB2C.getSubjects();
if (subjects != null && subjects.isNotEmpty) {
B2CAccessToken? token = await AzureB2C.getAccessToken(subjects[0]);
if (!mounted || token == null) return;
final encodedData = token.token.split('.')[1];
final data =
utf8.fuse(base64).decode(base64.normalize(encodedData));
final claims = Claims.fromJson(jsonDecode(data));
var m = Provider.of<LoginModel>(context, listen: true);
m.logIn(claims); //<-- debugger never reaches this line
}
}
#override
Widget build(BuildContext context) {
// It is possible to register callbacks in order to handle return values
// from asynchronous calls to the plugin
AzureB2C.registerCallback(B2COperationSource.INIT, (result) async {
if (result.reason == B2COperationState.SUCCESS) {
_configuration = await AzureB2C.getConfiguration();
if (!mounted) return;
await checkLogin(context);
}
});
AzureB2C.registerCallback(B2COperationSource.POLICY_TRIGGER_INTERACTIVE,
(result) async {
if (result.reason == B2COperationState.SUCCESS) {
if (!mounted) return;
await checkLogin(context);
}
});
// Important: Remeber to handle redirect states (if you want to support
// the web platform with redirect method) and init the AzureB2C plugin
// before the material app starts.
AzureB2C.handleRedirectFuture().then((_) => AzureB2C.init("auth_config"));
const String assetName = 'assets/images/logo.svg';
final Widget logo = SvgPicture.asset(
assetName,
);
return SafeArea(
child: //omitted,
);
}
}
I opened an issue as well, but it did not help me.
Try this
var m = Provider.of<LoginModel>(context, listen: false)._claims;
You are using the Provider syntax but not doing anything really with it. You need to set it like this Provider.of<LoginModel>(context, listen: false).login(claims) and call it like this Provider.of<LoginModel>(context, listen: false)._claims;
I fixed it, moving the callback registrations from the build method to the initState method.
I'm integrating GoRouter in my Flutter app where I'm already using Riverpod. I have an isAuthorizedProvider defined as follows:
final isAuthorizedProvider = Provider<bool>((ref) {
final authStateChanged = ref.watch(_authStateChangedProvider);
final user = authStateChanged.asData?.value;
return user != null;
});
And I'm not sure how to define a GoRouter that depends on the Provider above. I've come up with the following:
final goRouterProvider = Provider<GoRouter>((ref) => GoRouter(
debugLogDiagnostics: true,
redirect: (state) {
final isAuthorized = ref.watch(isAuthorizedProvider);
final isSigningIn = state.subloc == state.namedLocation('sign_in');
if (!isAuthorized) {
return isSigningIn ? null : state.namedLocation('sign_in');
}
// if the user is logged in but still on the login page, send them to
// the home page
if (isSigningIn) return '/';
// no need to redirect at all
return null;
},
routes: [
GoRoute(
path: '/',
...,
),
GoRoute(
name: 'sign_in',
path: '/sign_in',
...,
),
GoRoute(
name: 'main',
path: '/main',
...,
),
...
],
));
class MyApp extends ConsumerWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context, WidgetRef ref) {
final goRouter = ref.watch(goRouterProvider);
return MaterialApp.router(
routeInformationParser: goRouter.routeInformationParser,
routerDelegate: goRouter.routerDelegate,
);
}
Is this the right way to do it?
I don't thing you should be calling this line
ref.watch(isAuthorizedProvider);
inside the redirect block, because that will cause your entire GoRouter instance to rebuild (and you'll lose the entire nav stack).
This is how I've done it:
class AppRouterListenable extends ChangeNotifier {
AppRouterListenable({required this.authRepository}) {
_authStateSubscription =
authRepository.authStateChanges().listen((appUser) {
_isLoggedIn = appUser != null;
notifyListeners();
});
}
final AuthRepository authRepository;
late final StreamSubscription<AppUser?> _authStateSubscription;
var _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
#override
void dispose() {
_authStateSubscription.cancel();
super.dispose();
}
}
final appRouterListenableProvider =
ChangeNotifierProvider<AppRouterListenable>((ref) {
final authRepository = ref.watch(authRepositoryProvider);
return AppRouterListenable(authRepository: authRepository);
});
final goRouterProvider = Provider<GoRouter>((ref) {
final authRepository = ref.watch(authRepositoryProvider);
final appRouterListenable =
AppRouterListenable(authRepository: authRepository);
return GoRouter(
debugLogDiagnostics: false,
initialLocation: '/',
redirect: (state) {
if (appRouterListenable.isLoggedIn) {
// on login complete, redirect to home
if (state.location == '/signIn') {
return '/';
}
} else {
// on logout complete, redirect to home
if (state.location == '/account') {
return '/';
}
// TODO: Only allow admin pages if user is admin (#125)
if (state.location.startsWith('/admin') ||
state.location.startsWith('/orders')) {
return '/';
}
}
// disallow card payment screen if not on web
if (!kIsWeb) {
if (state.location == '/cart/checkout/card') {
return '/cart/checkout';
}
}
return null;
},
routes: [],
);
}
Note that this code is not reactive in the sense that it will refresh the router when the authState changes. So in combination with this, you need to perform an explicit navigation event when you sign-in/sign-out.
Alternatively, you can use the refreshListenable argument.
You can do it this way using redirect, however I've come up with a way that uses navigatorBuilder. This way you maintain the original navigator state (you will be redirected back to whichever page you originally visited on web or with deep linking), and the whole router doesn't have to be constantly be rebuilt.
final routerProvider = Provider((ref) {
return GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const OrdersScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => const AuthScreen(),
),
],
navigatorBuilder: (context, state, child) {
return Consumer(
builder: (_, ref, __) =>
ref.watch(authControllerProvider).asData?.value == null
? Navigator(
onGenerateRoute: (settings) => MaterialPageRoute(
builder: (context) => AuthScreen(),
),
)
: child,
);
},
);
});
navigatorBuilder basically allows you to inject some widget between the MaterialApp and the Navigator. We use Riverpod's consumer widget to access the ref and then the whole router doesn't have to be rebuilt, and we can access auth state using the ref.
In my example, ref.watch(authControllerProvider) returns an AsyncValue<AuthUser?>, so if the user is logged in, we return the child (current navigated route), if they are logged out, show them login screen, and if they are loading we can show a loading screen etc.
If you want to redirect users based on roles (e.g. only admin can see admin dashboard), then that logic should go into the redirect function using a listenable as #bizz84 described.
All examples that I've found are using "navigatorObservers" from the MaterialApp constructor
static FirebaseAnalytics analytics = FirebaseAnalytics.instance;
static FirebaseAnalyticsObserver observer =
FirebaseAnalyticsObserver(analytics: analytics);
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Firebase Analytics Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
navigatorObservers: <NavigatorObserver>[observer],
home: MyHomePage(
title: 'Firebase Analytics Demo',
analytics: analytics,
observer: observer,
),
);
}
but my app uses MatterialApp.router from the Navigator 2.0 pattern and could not find an equivalent for attaching an navigatorObserver in order to track screen change events for firebase analytics. Any workarounds or suggestions on this?
The MaterialApp.router constructor has required routerDelegate property. This delegate is usually a wrapper of the Navigator widget. This widget has observers property - that is exactly what you are looking for.
Here is an example of the RouterDelegate, which registers both Firebase and Segment observers:
class AppNavigator extends RouterDelegate<void>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<void> {
AppNavigator({
#required Page<void> initialPage,
this.analyticsObserver,
this.segmentObserver
}) : assert(initialPage != null),
navigatorKey = GlobalKey<NavigatorState>() {
_pagesStack = [initialPage];
}
final FirebaseAnalyticsObserver analyticsObserver;
final SegmentObserver segmentObserver;
...
#override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [..._pagesStack],
observers: [analyticsObserver, segmentObserver],
onPopPage: (route, dynamic result) {
if (!route.didPop(result)) {
return false;
}
for (final page in _pagesStack) {
if (page == route.settings) {
_pagesStack.remove(page);
notifyListeners();
break;
}
}
return true;
},
);
}
}
Note, that under the hood by default the Firebase Analytics module expects your page routes to have a name property set as a part of RouteSettings:
// From FirebaseAnalyticsObserver
void _sendScreenView(PageRoute<dynamic> route) {
final String? screenName = nameExtractor(route.settings);
if (screenName != null) {
analytics.setCurrentScreen(screenName: screenName).catchError(
(Object error) {
final _onError = this._onError;
if (_onError == null) {
debugPrint('$FirebaseAnalyticsObserver: $error');
} else {
_onError(error as PlatformException);
}
},
test: (Object error) => error is PlatformException,
);
}
}
You can override this behavior by providing custom nameExtractor property to the FirebaseAnalyticsObserver constructor.
I use Firebase dynamic links and also named routes. What I want is to install a global listener for the dynamic link events and forward to register page if a token is provided. In the code below I got the exception The context used to push or pop routes from the Navigator must be that of a widget that is a descendant of a Navigator widget. which means I have to put navigation code below the home: property of MaterialApp. But when doing this I had to implement the dynamic links event handler for earch route.
class MyApp extends StatelessWidget {
String title = "Framr";
#override
Widget build(BuildContext context) {
FirebaseDynamicLinks.instance.onLink(
onSuccess: (linkData) {
if (linkData != null) {
try {
Navigator.pushNamed(context, '/register', arguments: linkData);
// throws: The context used to push or pop routes from the Navigator must be that of a widget that is a descendant of a Navigator widget.
} catch(e) {
print(e);
}
}
return null;
}
);
return MaterialApp(
title: "...",
home: LoginPage(),
routes: {
'/createEvent': (context) => CreateEventPage(),
'/showEvent': (context) => ShowEventPage(),
'/register': (context) => RegisterPage(),
},
);
}
}
I was able to get this work by following the example provided from the dynamic link README with the use of the no_context_navigation package or GlobalKey to workaround around the lack of context to call Navigator.pushNamed(...). Note: You don't have to use no_context_navigation. You can implement the no context routing yourself. Here's an example.
// Add this
import 'package:no_context_navigation/no_context_navigation.dart';
void main() {
runApp(MaterialApp(
title: 'Dynamic Links Example',
// Add this
navigatorKey: NavigationService.navigationKey,
routes: <String, WidgetBuilder>{
'/': (BuildContext context) => MyHomeWidget(), // Default home route
'/helloworld': (BuildContext context) => MyHelloWorldWidget(),
},
));
}
class MyHomeWidgetState extends State<MyHomeWidget> {
.
.
.
#override
void initState() {
super.initState();
this.initDynamicLinks();
}
void initDynamicLinks() async {
FirebaseDynamicLinks.instance.onLink(
onSuccess: (PendingDynamicLinkData dynamicLink) async {
// Add this.
final NavigationService navService = NavigationService();
final Uri deepLink = dynamicLink?.link;
if (deepLink != null) {
// This doesn't work due to lack of context
// Navigator.pushNamed(context, deepLink.path);
// Use this instead
navService.pushNamed('/helloworld', args: dynamicLink);
}
},
onError: (OnLinkErrorException e) async {
print('onLinkError');
print(e.message);
}
);
final PendingDynamicLinkData data = await FirebaseDynamicLinks.instance.getInitialLink();
final Uri deepLink = data?.link;
if (deepLink != null) {
// This doesn't work due to lack of context
// Navigator.pushNamed(context, deepLink.path);
// Use this instead
navService.pushNamed('/helloworld', args: dynamicLink);
}
}
.
.
.
}
// pubspec.yaml
no_context_navigation: ^1.0.4