Resuming an app with nested navigation, only restores the child navigation and not the parent - flutter

I have an app, where the user can navigate to a 'normal' views, and then a container view with a bottom nav that has it's own navigator. In the normal flow, a user can click a link and the app checks to see if this should be displayed in the container view.
If the container doesn't exist : Create the container, push it on to the parent navigator create the new page in the container, push it on to the local stack.
If the container DOES exist : popUntil the parent navigator until the container page is displayed. Add the page to the container and push that view to the local navigator.
This all works, until the app is paused. When the app resumes if the last paged displayed is in the second case. (When the container widget already exists), the container view is missing, and only the page from the local navigator is displayed. If I press the back button, the last page...Is the same again!
So my question is : When resuming from pause, is there a known/preferred/correct method for restoring parent and nested navigation correctly?

When an app is paused and resumed, the state of the app is usually saved and restored using the saveState and restoreState methods in the NavigatorState object. When you have a nested navigator, you need to make sure that you correctly save and restore the state of both the parent and child navigators.
Here are some tips that you can follow to correctly save and restore the state of your parent and child navigators:
In the parent navigator, save the current route and the child navigator's state when the app is paused:
#override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_savedRoute = ModalRoute.of(context);
_savedState = _childNavigatorKey.currentState?.saveState();
}
}
When the app is resumed, check if the saved route is the same as the current route. If they are different, pop the current route and push the saved route:
#override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (_savedRoute != ModalRoute.of(context)) {
Navigator.of(context).popUntil((route) => route == _savedRoute);
_childNavigatorKey.currentState?.restoreState(_savedState);
}
}
}
In the child navigator, save and restore the state in a similar way:
#override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_savedState = currentState?.saveState();
}
}
#override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (_savedState != null) {
currentState?.restoreState(_savedState!);
_savedState = null;
}
}
}
By following these tips, you should be able to correctly save and restore the state of both the parent and child navigators, and ensure that the correct views are displayed when the app is resumed.

Related

Flutter - Cubit & Navigation 2.0: emitting new page from page

I am trying to create a website with Flutter using Navigation 2.0 and BLoC pattern. To do so, I read the following guides:
https://medium.com/#JalalOkbi/flutter-navigator-2-0-with-bloc-the-ultimate-guide-6672b115adf
https://lucasdelsol01.medium.com/flutter-navigator-2-0-for-mobile-dev-bloc-state-management-integration-3a180b4d25b3
and this repo: https://lucasdelsol01.medium.com/flutter-navigator-2-0-for-mobile-dev-bloc-state-management-integration-3a180b4d25b3 (which implements the first guide).
However I am facing an issue where I am trying to push a new page from one of my website displayed page: the new page is never displayed!
To understand:
Each pages are pushed via a MainNavigationCubit. This cubit's state (meaning pages) is maintained within the NavigationStack.
My MainNavigationCubit is responsible for building the Navigator in my custom RouterDelegate (see code below). So upon a state change it rebuilds the Navigator with the proper list of pages.
The problem context:
I have a "Book" page which displays the details about a specific book.
In order to get the details, it expects a book id.
If the book id is invalid or not found, then the "404 not found page" is pushed via MainNavigationCubit.
This can happen, eg, if the user is manually inputting a correct URL to the book page but with an invalid ID.
However the "404 not found page" is never displayed although the MainNavigationCubit properly emits a new NavigationStack with relevant pages.
This is the code from my custom RouterDelegate:
#override
GlobalKey<NavigatorState> get navigatorKey => GlobalKey<NavigatorState>(debugLabel: 'main_navigation_key');
#override
Future<void> setNewRoutePath(PageConfig configuration) {
if (configuration.route != homeRoute) {
mainNavigationCubit.push(configuration.route, configuration.args);
} else {
mainNavigationCubit.clearToHome();
}
return SynchronousFuture(null);
}
#override
Widget build(BuildContext context) {
return BlocBuilder<MainNavigationCubit, NavigationStack>(
builder: (context, stack) {
return Navigator(
pages: stack.pages,
key: navigatorKey,
onPopPage: (route, result) => _onPopPage.call(route, result),
);
},
);
#override
PageConfig get currentConfiguration => mainNavigationCubit.state.last;
bool _onPopPage(Route<dynamic> route, dynamic result) {
final didPop = route.didPop(result);
if (!didPop) {
return false;
}
if (mainNavigationCubit.canPop()) {
mainNavigationCubit.pop();
return true;
} else {
return false;
}
}
And this is the code from my "Book" StatelessWidget page:
#override
Widget build(BuildContext context) {
if (bookId == -1) {
context.read<MainNavigationCubit>().showNotFound(); // let's assume this will be properly handled when I'll be creating this page's BLoC.
}
return // full book details UI;
}
And just in case the code of MainNavigationCubit.showNotFound():
void showNotFound() {
clearAndPush(notFound);
}
void clearAndPush(String path, [Map<String, dynamic>? args]) {
final PageConfig pageConfig = PageConfig(location: path, args: args);
emit(state.clearAndPush(pageConfig));
}
OK, so after a lot of investigation I have found the reason for my issue.
As the documentation says: a Cubit won't notify listeners upon emitting a new state that is equal to the current state.
In my case, my MainNavigationCubit's state is a NavigationStack which I took from this guide: https://medium.com/#JalalOkbi/flutter-navigator-2-0-with-bloc-the-ultimate-guide-6672b115adf
Looking at the code, the NavigationStack exposes methods that mutates an internal list of pages.
The problem is this list belongs to the current state, therefore modifying it means to also modify the current state.
As both current and new state rely on the same exact list, the Cubit won't emit the new state.

Flutter Make api call when home button(middle android button) is pressed and user comes back on app

In my app i have screens that fetched data from the Api, and sometimes the backend can update or change something on the api, so if the user is using the app, and the user presses the home button (middle android button. the circle) and comes back to the app later, i dont see the data being updated so no api call is being made, but when i press the back button and come back to the app the api call is being made same as when i close the app and remove it from the opened app history and open it again, the api call is being made, so i need that to happened the same when the home button is pressed, the user gets out the app by pressing the home button, comes back and the api call will be made. I looked in to it and i was told i need to use WidgetsBindingObserver class. Any help would be appreciated, let me know if you need any code to post.
i tried this but it wont work
class _StopWorkingScreenState extends State<StopWorkingScreen>
with WidgetsBindingObserver {
late Future<Response> futureDataForStatus;
#override
void initState() {
super.initState();
futureDataForStatus = getLocationStatus();
WidgetsBinding.instance!.addObserver(this);
}
#override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
print("Inactive");
break;
case AppLifecycleState.paused:
print("Paused");
break;
case AppLifecycleState.resumed:
futureDataForStatus;
break;
}
}
You need to call the function in your switch operator, not just pass a variable.
You could do something like:
#override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
...
case AppLifecycleState.resumed:
setState(() {
futureDataForStatus = getLocationStatus();
});
break;
}
}
This will update the state on opening of the app and reload the view with new data (assuming you are using futureDataForStatus as future of a FutureBuilder somewhere on the page.

Navigator 2.0 - WillPopScope vs BackButtonListener

I have an app with a BottomNavigationBar and an IndexedStack which shows the tab content. Each tab has its own Router with its own RouterDelegate to mimic iOS-style tab behavior (where each tab has its own navigation controller).
Before, this app was only published on iOS. I'm now working on the Android version and need to correctly support the Android hardware back button. I did this by implementing a ChildBackButtonDispatchers per tab, which are a child of the parent RootBackButtonDispatcher. This works.
The issue I'm having now is that I use WillPopScope widgets to save a user's input when they leave a screen. This works correctly if the user taps the back button in the AppBar, but the callback isn't triggered when the user taps the hardware back button. I implemented BackButtonListeners on these screens as well, but this means I have to wrap the screens in both WillPopScopes and BackButtonListeners, both calling the same callback.
It this how it's supposed to be, or am I doing something wrong?
Relevant widget hierarchy:
MaterialApp
Navigator
tab interface with IndexedStack
the selected tab Widget the tab's Router
Navigator
multiple pages, with on the last page in the stack...
BackButtonListener
WillPopScope
Scaffold
My (simplified) router delegate looks like this:
class AppRouterDelegate extends RouterDelegate<AppRoute>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoute> {
AppRouterDelegate({
List<MaterialPage> initialPages = const [],
}) : _pages = initialPages;
final navigatorKey = GlobalKey<NavigatorState>();
final List<MaterialPage> _pages;
List<MaterialPage> get pages => List.unmodifiable(_pages);
void push(AppRoute route) {
final shouldAddPage = _pages.isEmpty || (_pages.last.arguments as AppRoute != route);
if (!shouldAddPage) {
return;
}
_pages.add(route.page);
notifyListeners();
}
#override
Future<void> setNewRoutePath(AppRoute route) async {
_pages.clear();
_pages.add(route.page);
notifyListeners();
return SynchronousFuture(null);
}
#override
Future<bool> popRoute() {
if (canPop) {
pop();
return SynchronousFuture(true);
}
return SynchronousFuture(false);
}
bool get canPop => _pages.length > 1;
void pop() {
if (canPop) {
_pages.remove(_pages.last);
notifyListeners();
}
}
void popTillRoot() {
while (canPop) {
_pages.remove(_pages.last);
}
notifyListeners();
}
bool _onPopPage(Route<dynamic> route, result) {
final didPop = route.didPop(result);
if (!didPop) {
return false;
}
if (canPop) {
pop();
return true;
} else {
return false;
}
}
#override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onPopPage: _onPopPage,
pages: pages,
);
}
}
I found this Flutter issue which makes me think I shouldn't have the WillPopScope at all, but without it the taps in the AppBar are not caught...
I know this question is old, but here's an answer for others who arrive here.
From the AppBar leading documentation (emphasis mine):
If this is null and automaticallyImplyLeading is set to true, the AppBar will imply an appropriate widget. For example, if the AppBar is in a Scaffold that also has a Drawer, the Scaffold will fill this widget with an IconButton that opens the drawer (using Icons.menu). If there's no Drawer and the parent Navigator can go back, the AppBar will use a BackButton that calls Navigator.maybePop.
So in order to make the Android back button work the same way as the App Bar's back button, you need to use the Navigator.maybePop method, which will respect WillPopScope.
Conveniently, Flutter provides PopNavigatorRouterDelegateMixin to make this easy; it provides an implementation of popRoute that uses maybePop and therefore will work identically to the App Bar's automatically-generated back/dismiss button. The nice thing about Flutter being open source is that you can jump into the Flutter code to verify what the mixin is doing:
mixin PopNavigatorRouterDelegateMixin<T> on RouterDelegate<T> {
/// The key used for retrieving the current navigator.
///
/// When using this mixin, be sure to use this key to create the navigator.
GlobalKey<NavigatorState>? get navigatorKey;
#override
Future<bool> popRoute() {
final NavigatorState? navigator = navigatorKey?.currentState;
if (navigator == null)
return SynchronousFuture<bool>(false);
return navigator.maybePop();
}
}
So I think the only mistake in your code is that, even though you've mixed-in PopNavigatorRouterDelegateMixin on your router delegate, you are also providing your own override of popRoute. When the user taps the Android back button, your popRoute implementation is called, and it just pops the last page. If you delete your popRoute override and let the mixin do its thing, then the Android back button will function identically to the App Bar back/dismiss button.

How to execute some code just before app is swiped off from task list in flutter?

I want to execute some code(say calling a REST API) just before the App is swiped off the task list. So far I've tried used WidgetsBindingObserver to detect when the app reaches detached state. The connection is lost before reaching the detached state.
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (_lastLifecycleState == AppLifecycleState.detached) {
print('DETACHED!');
}
}

Redirect user from named route

So I have a similar issue as the person who asked this older question, except with different requirements that none of the answers there help with.
When a user opens the app, I want them to be greeted with the login page if they haven't logged in or the home page (a bottom nav bar view) if they did. I can define this in the MaterialApp as follows:
MaterialApp(
initialRoute: authProvider.isAuthenticated
? '/home'
: '/login',
routes: {
'/home': (_) =>
ChangeNotifierProvider<BottomNavigationBarProvider>(
child: AppBottomNavigationBar(),
create: (_) => BottomNavigationBarProvider()),
'/login': (_) => LoginView()
},
)
So far so good. Except I want this to work on the web, and now even though the default screen when a user first opens myapp.com is myapp.com/#/login, any user can bypass the login screen by simply accessing myapp.com/#/home.
Now I tried to redirect the user to the login page in the initState() of the bottom navigation bar (and setting the initialRoute to be /home), but on mobile this has undesirable behaviour.
If I try this:
void initState() {
super.initState();
if (!Provider.of<AuthProvider>(context, listen: false).isAuthenticated) {
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushNamed('/login');
});
}
}
then simply pressing back will return the user to the home page, again bypassing the login. If I try to use popAndPushNamed instead of just pushing, pressing back will open a blank screen (instead of closing the app).
Is there any way to do this correctly so it works on both web and mobile?
If you use the RouteAware mixin on your widget classes, they will be notified when they are navigated to (or away from). You can use this to check if the user is supposed to be there and to navigate them away if they are not:
To use it, first you need some global instance of RouteObserver that all your widget classes can access:
final routeObserver = RouteObserver<PageRoute>();
Then you need to register it with your MaterialApp:
MaterialApp(
routeObservers: [routeObserver],
)
Then register your widget to the route observer:
class HomeViewState extends State<HomeView> with RouteAware {
#override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context));
}
#override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
void didPop() {
// This gets called when this widget gets popped
}
void didPopNext() {
// This gets called when another route gets popped making this widget visible
}
void didPush() {
// This gets called when this widget gets pushed
}
void didPushNext() {
// This gets called with another widget gets pushed making this widget hidden
}
...
}
In your case, you can use the didPush route to navigate the user to the login page if they get to that page in error:
void didPush() {
if (checkLoginStateSomehow() == notLoggedIn) {
Navigator.of(context).pushReplacementNamed('login');
}
}