I am working on testing how my navigator 2.0 setup handles url changes in the browser in flutter web.
The closest i have come to being able to test how my app handles url changes is to manually update state in the RouterDelegate by calling the setNewRoutePath with a config from the RouteInformationParser.
I would really like to test the navigator closer to the origin of the url change.
Any ideas and pointers would be appreciated.
My current code looks like this:
//Pass routeInformation to RouterInformationParser
RouteInformation selectShopRoute = RouteInformation(location: '/selectshop?token=321');
RouterConfig selectShopConfig = await app.myRouteParser.parseRouteInformation(selectShopRoute);
await app.myRouterDelegate.setNewRoutePath(selectShopConfig);
await tester.pumpAndSettle();
//Verify that navigator state is select shop
expect(app.myRouterDelegate.currentScreen, RouterEnum.selectshop);
//Verify that navigator token is set correctly
expect(app.myRouterDelegate.token, '321');
I had the same question and could not find a good approach. I came up with a way to test our code and wanted to share it to you.
Basically, we have a custom RouteInformationParser, in which a location is added only for the testing purpose.
class MyRouteInformationParser
extends RouteInformationParser<PageConfiguration> {
String? customPath; // only use for testing
#override
Future<PageConfiguration> parseRouteInformation(
RouteInformation routeInformation,
) async {
final location = customPath ?? routeInformation.location;
// Compute the configuration based on the location
return PageConfiguration()
}
}
In the widget test, we just create the route information parser and use it with the MaterialApp. Changing the customPath during testing has similar effect as changing the URL of the web browser.
final informationParser = MyRouteInformationParser();
informationParser.customPath = "my/expected/path";
Related
The problem I want to solve:
My app which uses GoRouter needs to be able to route to a named route from within main(). Since most routing is of the form 'context.go' I cannot do so within main.
Background
My app uses GoRouter. The ease with which GetX had let me define named routes and pass parameters from main() was perfect.
However, GetX and GoRouter eventually causes problems for me. GoRouter would eventually have no context in other parts of the app.
If there were a way to have them co-exist simply, I'd be open to it.
I had used the service locator pattern with the GetIt package to associate with a navigatorKey. It would work when I tested it -- but this involved creating two MaterialApps.
However, this app uses GoRouter which doesn't seem to use the navigatorKey.
I would like to go to a specific route from within main (). It seems like the service locator pattern could work for GoRouter as it did with Navigator 2.0 for MaterialApp -- but I can't find an example of how to do so.
More detailed context:
Here is what I have currently in main().
You can see the key challenge I have is that the listener for the data parameters being passed in lives in main (I got this from the third-party SDK -- I don't need it to be in main but it needs to listen regardless of the state of the app).
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FFAppState(); // Initialize FFAppState
GetSocial.addOnInitializedListener(() => {
// GetSocial SDK is ready to use
});
setupLocator();
runApp(MyApp());
locator<LandingPageData>().referralID = "defaultReferralID";
registerListeners();
}
void registerListeners() {
Invites.setOnReferralDataReceivedListener((received) {
globalReferralData = received;
print(globalReferralData);
print(globalReferralData.linkParams);
print("listener - socialdata");
String passedReferralID =
globalReferralData.linkParams['referralID'].toString();
String passedCreatorID =
globalReferralData.linkParams['creatorID'].toString();
String passedCampaignID =
globalReferralData.linkParams['\$campaign_id'].toString();
print(passedReferralID);
print(passedCreatorID);
print(passedCampaignID);
// How can I route to a named Route?
locator<LandingPageData>().referralID = passedReferralID;
locator<LandingPageData>().creatorID = passedCreatorID;
locator<LandingPageData>().campaignID = passedCampaignID;
});
}
Here is what the locator.service.dart looks like:
final locator = GetIt.instance;
class NavigationService {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// final GlobalKey<ScaffoldMessengerState> navigatorKey = GlobalKey<ScaffoldMessengerState>();
}
The above worked when I could attach to a navigatorKey and then navigate from within the listener. But that doesn't seem to work since the rest of the application uses GoRouter.
static BuildContext? get ctx => myGoRouter.routerDelegate.navigatorKey.currentContext;
you can get context in your NavigationService in this way and use it like
NavigationService.ctx?.go(...)
the problem you may face is that ctx will be null on app state till your first page starts to be built. In the case your listener has a data while ctx is still null, routing won't work. but you can handle this situation like:
define a global tempPageToGo in main func or a service and
var _ctx = NavigationService.ctx;
if(_ctx == null) {
tempPageToGo = anyPageDataYouWant;
while((await Future.delayed(Duration(seconds: 1))) == null) {
if(_ctx != null) {
_ctx!.go(...);
break;
}
}
} else _ctx!.go(...);
Unluckily, if I were you, I'd either drop the usage of GetX or of GoRouter.
Actually, I'd just drop GetX.
The reason is that GetX performs magic under the hood that lifts the developer the responsibility and usage of BuildContext, but that's clearly an anti-pattern, as the built-in navigation from Flutter clearly uses context: think of Navigator.of, for example.
GoRouter is built around context, and simplifies a lot of the implementations needed to perform "Navigator 2.0" actions.
If you're trying to implement deep linking, your MaterialApp should look like this in your root widget:
return MaterialApp.router( // Flutter's Router 2.0 usage
title: 'MyApp',
routeInformationProvider: myGoRouter.routeInformationProvider,
routeInformationParser: myGoRouter.routeInformationParser,
routerDelegate: myGoRouter.routerDelegate,
);
If GetX enables you to put myGoRouter there, then you should be good to go. But as I said before, everytime you need explicit navigation, you need context.
I'm in researching to adopt go_router in my project, and i was also stuckled for this usecase ( in my case i tried to prove that i can navigate from deferred link that callback from appsflyer SDK ).
For solution, like that go_router allows us to either navigate from context that is below the router declaration or from the redirect state. So we can wrap up all the state that effect the navigation on that.
This is how i redirect from appRouterState
redirect: (GoRouterState state) {
String? redirection(GoRouterState state) {
final appRouterState = ref.read(appRouterStateNotifierProvider);
final isAuthed = appRouterState.email != null;
if (appRouterState.deferredLink != state.location && appRouterState.deferredLink != null) {
return appRouterState.deferredLink;
}
if (state.location != '/login' && !isAuthed) return '/login';
if (state.location == '/login' && isAuthed) return '/';
return null;
}
final result = redirection(state);
return result;
},
In your case, you may implement setOnReferralDataReceivedListener in the appRouterStateProvider or something. And use it for refreshListenable param in the GoRouter constructor.
Hope this helps.
For a simple Email login with OTP code I have a structure as follows.
View
await _signUpCntrl.signUp(email, password);
Controller
_showOtpDialog(email);
_showOtpDialog func
return Get.dialog(
AlertDialog(
So the thing is _showOtpDialog function is inside a controller file. ie. /Controllers/controller_file.dart
I want do something like a blocListener, call the _showOtpDialog from a screen(view) file on signup success. (also relocate the _showOtpDialog to a view file)
Using GetX I have to use one of the builders either obs or getbuilder. Which is I think not a good approach to show a dialog box.
On internet it says Workers are the alternative to BlocListener. However Workers function resides on Controller file and with that the dialog is still being called on the controller file.
As OTP dialog will have its own state and a controller I wanted to put it inside a /view/viewfile.dart
How do I obtain this?
I tried using StateMixin but when I call Get.dialog() it throw an error.
visitChildElements() called during build
Unlike BLoC there's no BlocListener or BlocConsumer in GetX.
Instead GetX has RxWorkers. You can store your response object in a Rx variable:
class SomeController extends GetxController{
final response= Rxn<SomeResponse>();
Future<void> someMethod()async{
response.value = await someApiCall();
}
}
And then right before the return of your widget's build method:
class SomeWidget extends StatelessWidget{
final controller = Get.put(SomeController());
#override
Widget build(BuildContext context){
ever(controller.response, (SomeResponse res){
if(res.success){
return Get.dialog(SuccessDialog()); //Or snackbar, or navigate to another page
}
....
});
return UI();
}
First thing, you will need to enhance the quality of your question by making things more clearly. Add the code block and the number list, highlight those and making emphasize texts are bold. Use the code block instead of quote.
Seconds things, Depends on the state management you are using, we will have different approaches:
Bloc (As you already added to the question tag). By using this state management, you controller ( business logic handler) will act like the view model in the MVVM architecture. In terms of that, You will need to emit a state (e.g: Sent success event). Afterward, the UI will listen to the changes and update it value according to the event you have emitted. See this Bloc example
GetX (As your code and question pointed out): GetX will acts a little bit different. you have multiple ways to implement this:
Using callbacks (passed at the start when calling the send otp function)
Declare a general dialog for your application ( this is the most used when it comes to realization) and calling show Dialog from Bloc
Using Rx. You will define a Reactive Variable for e.g final success = RxBool(true). Then the view will listen and update whenever the success changes.
controller.dart
class MyController extends GetxController {
final success = RxBool(false);
void sendOtp() async {
final result = await repository.sendOTP();
success.update((val) => {true});
}
}
view.dart
class MyUI extends GetView<MyController> {
#override
Widget build(BuildContext context) {
ever(controller.success, (bool success) {
// This will update things whenever success is updated
if (success) {
Get.dialog(AlertDialog());
}
});
return Container();
}
}
I have this ViewModel and a Riverpod provider for it:
final signInViewModelProvider = Provider.autoDispose<SignInViewModel>((ref) {
final vm = SignInViewModel();
ref.onDispose(() {
vm.cleanUp();
});
return vm;
});
class SignInViewModel extends VpViewModelNew {
FormGroup get form => _form;
String get emailKey => _emailKey;
String get passwordKey => _passwordKey;
final String _emailKey = UserSignInFieldKeys.email;
final String _passwordKey = UserSignInFieldKeys.password;
final FormGroup _form = FormGroup({
UserSignInFieldKeys.email:
FormControl<String>(validators: [Validators.required]),
UserSignInFieldKeys.password:
FormControl<String>(validators: [Validators.required])
});
void cleanUp() {
print('cleaning up');
}
void onSubmitPressed(BuildContext context) {
// _saveRegistrationLocallyUseCase.invoke(
// form.control(_self.emailKey).value as String ?? '',
// form.control(_self.passwordKey).value as String ?? '');
}
}
abstract class VpViewModelNew {
VpViewModelNew() {
if (onCreate != null) {
onCreate();
print('creating');
}
}
void onCreate() {}
}
When I navigate to the page that has the signInViewModelProvider, it prints to the console:
flutter: signInPage building
flutter: creating
flutter: cleaning up
Then popping the page from the stack with Navigator.pop() prints nothing.
Then navigating to the page again prints the same 3 lines in the same order.
I expected onDispose to be called after Navigator.pop(), and not when navigating to the page that reads the provider. Why is onDispose being called directly after creation, and not when using Navigator.pop() (when I expected the provider to be disposed of since no other views reference it)?
Edit: I access the provider with final viewModel = context.read<SignInViewModel>(signInViewModelProvider);
I don't need to listen since I don't need to rebuild the page on
change. Is consumer less performant for this?
No, the performance is meaningless, even if it's listening it's not really affecting the performance because as a Provider there is no way to notify (which is not the case with a state notifier or change notifier)
Also if you don't care to listen after the value has been read The auto dispose understand no one is watching it and it disposes, it's better to use context.read when using tap or gestures that modify something
(I realize this is late to the party but maybe it'll help somebody)
The Riverpod docs come out pretty strongly against using read for the reason you said, i.e. performance/rebuilding concerns.
Basically you should always use watch except:
If you want your custom callback function called when it updates (use listen)
If the actual reading is happening asynchronously or in response to user action (like in an onPressed): this is the only time to use read.
If you're having issues with your widgets rebuilding too often, Riverpod has some ways to deal with that that don't involve using read.
I have screen with tabs and each screen implements AutomaticKeepAliveClientMixin. When I navigate to this screen(with tabs), each tab in initState fetches data from server like that:
fetchData()async{
final token = await getToken();//refresh if it is expired.
return fetchData(token);
}
I think it'd better if I initialize data for all the tabs in one request, because I can catch only one refresh token expired and socket exception in single place.
fetchAllData()async{
final token = await getToken();//refresh if it is expired.
return fetchAllData(token);
}
How would you build logic for screen and requests like that? Is my approach is something similar to what you use?
I would recommend you to use a Provider (https://pub.dev/packages/provider). By subscribing to the same Provider, you will be able to reuse the data you've once fetched. For instance, I've used this approach to provide to my App (at different places) the current user:
class UserModel extends ChangeNotifier {
User _currentUser;
void setUser(User user) {
_currentUser = user;
notifyListeners();
}
Future<User> getUser(BuildContext context) async {
if (_currentUser == null) {
_currentUser = await getUserRequest(context, hasRedirect: false);
}
return _currentUser;
}
}
Hope it will fit your needs !
You can add your fetchAllData method to the initState of the widget that holds all of the tabbed widgets. Then, you can you can pass the relevant data to the contructors of each of the tabbed widgets. Not the best solution, but it should work.
I'd still recommend Provider. State management systems are not all inclusive, nor are exclusive. Depending on how your state is presented your could use more than one state management system. Helll, the bloc library already includes the provider library.
I'm basically looking for a way to either start loading data, or navigate to the Login Screen.
The FutureProvider gets it's value from SharedPreferences. The default homescreen is just a logo with a spinner.
If the userID resolves to null, the app should Navigate to the Login Screen, otherwise it should call a method that will start loading data and then on completion navigate to the main page.
Can this be achieved with FutureProvider?
I add it to the page build to ensure the page widget will subscribe to the Provider:
Widget build(BuildContext context) {
userInfo = Provider.of<UserInfo>(context);
print('Building with $userInfo');
return PageWithLoadingIndicator();
....
I added it to didChangeDependencies to react to the change:
#override
void didChangeDependencies() {
print('Deps changed: $userInfo');
super.didChangeDependencies();
// userInfo = Provider.of<UserInfo>(context); // Already building, can't do this.
// print('And now: $userInfo');
if (userInfo == null) return;
if (userInfo.userId != null) {
startUp(); // Run when user is logged in
} else {
tryLogin(); // Navigate to Login
}
}
And since I can't use Provider.of in initState I added a PostFrameCallback
void postFrame(BuildContext context) {
print('PostFrame...');
userInfo = Provider.of<UserInfo>(context);
}
Main is very simple - it just sets up the MultiProvider with a single FutureProvider for now.
class MyApp extends StatelessWidget {
Future<UserInfo> getUserInfo() async {
String token = await UserPrefs.token;
return UserInfo.fromToken(
token,
);
}
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'App One',
theme: ThemeData(
primarySwatch: Colors.purple,
),
home: MultiProvider(
providers: [FutureProvider<UserInfo>(builder: (_) => getUserInfo())],
child: LoadingScreen(),
),
);
}
}
The problem is that as it is now I can see from the print statements that "didChangeDependencies" gets called twice, but the value of userInfo is always null, even though build() eventually gets an instance of UserInfo as evident by the print statement in the build() method.
I'm guessing I can add some logic into the build method but that screams against my sensibilities as the wrong place to do it... perhaps it isn't as bad as I think?
I've decided that this is conceptually the wrong approach.
In my case I wanted to use a FutureProvider to take the results from an Async function which create a "Config" object using SharedPreferences. The FutureProvider would then allow the rest of the app to access the user's config settings obtained from sharepreferences.
This still feels to me like a valid approach. But there are problems with this from an app flow perspective.
Mainly that the values from the shared preferences includes the logged in user session token and username.
The app starts by showing a Loading screen with a Circular Progress bar. The app then reads the shared preferences and connects online to check that the session is valid. If there is no session, or if it is not valid, the app navigates to the Login "wizzard" which asks username, then on the next page for the password and then on the next page for 2-factor login. After that it navigates to the landing page. If the loading page found a valid session, the login wizzard is skipped.
The thing is that the two things - app state and app flow are tangenially different. The app flow can result in changes being store in the app state, but the app state should not affect the app flow, at least not in this way, conceptually.
In practical terms I don't think calling Navigator.push() from a FutureProvider's build function is valid, even if context is available. I could be wrong about this, but I felt the flowing approach is more Flutteronic.
#override
void initState() {
super.initState();
_loadSharedPrefs().then((_) {
if(this.session.isValid()) _navToLandingPage();
else _navToLoginStepOne();
}
}
I'm open to better suggestions / guidance