Refactors page API (#137792)

fixes https://github.com/flutter/flutter/issues/137458

Chagnes:
1. Navigator.pop will always pop page based route
2. add a onDidRemovePage callback to replace onPopPage
3. Page.canPop and Page.onPopInvoked mirrors the PopScope, but in Page class.

migration guide https://github.com/flutter/website/pull/10523
diff --git a/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart
index 04fbded..e619276 100644
--- a/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart
+++ b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart
@@ -168,14 +168,10 @@
       },
       child: Navigator(
         key: _navigatorKey,
-        onPopPage: (Route<void> route, void result) {
-          if (!route.didPop(null)) {
-            return false;
-          }
+        onDidRemovePage: (Page<Object?> page) {
           widget.onChangedPages(<_TabPage>[
             ...widget.pages,
           ]..removeLast());
-          return true;
         },
         pages: widget.pages.map((_TabPage page) {
           switch (page) {
diff --git a/examples/api/lib/widgets/page/page_can_pop.0.dart b/examples/api/lib/widgets/page/page_can_pop.0.dart
new file mode 100644
index 0000000..b100069
--- /dev/null
+++ b/examples/api/lib/widgets/page/page_can_pop.0.dart
@@ -0,0 +1,160 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This sample demonstrates showing a confirmation dialog before navigating
+// away from a page.
+
+import 'package:flutter/material.dart';
+
+void main() => runApp(const PageApiExampleApp());
+
+class PageApiExampleApp extends StatefulWidget {
+  const PageApiExampleApp({super.key});
+
+  @override
+  State<PageApiExampleApp> createState() => _PageApiExampleAppState();
+}
+
+class _PageApiExampleAppState extends State<PageApiExampleApp> {
+  final RouterDelegate<Object> delegate = MyRouterDelegate();
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp.router(
+      routerDelegate: delegate,
+    );
+  }
+}
+
+class MyRouterDelegate extends RouterDelegate<Object> with PopNavigatorRouterDelegateMixin<Object>, ChangeNotifier {
+  // This example doesn't use RouteInformationProvider.
+  @override
+  Future<void> setNewRoutePath(Object configuration) async => throw UnimplementedError();
+
+  @override
+  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+
+  static MyRouterDelegate of(BuildContext context) => Router.of(context).routerDelegate as MyRouterDelegate;
+
+  bool get showDetailPage => _showDetailPage;
+  bool _showDetailPage = false;
+  set showDetailPage(bool value) {
+    if (_showDetailPage == value) {
+      return;
+    }
+    _showDetailPage = value;
+    notifyListeners();
+  }
+
+  Future<bool> _showConfirmDialog() async {
+    return await showDialog<bool>(
+      context: navigatorKey.currentContext!,
+      barrierDismissible: false,
+      builder: (BuildContext context) {
+        return AlertDialog(
+          title: const Text('Are you sure?'),
+          actions: <Widget>[
+            TextButton(
+              child: const Text('Cancel'),
+              onPressed: () {
+                Navigator.of(context).pop(false);
+              },
+            ),
+            TextButton(
+              child: const Text('Confirm'),
+              onPressed: () {
+                Navigator.of(context).pop(true);
+              },
+            ),
+          ],
+        );
+      },
+    ) ?? false;
+  }
+
+  Future<void> _handlePopDetails(bool didPop, void result) async {
+    if (didPop) {
+      showDetailPage = false;
+      return;
+    }
+    final bool confirmed = await _showConfirmDialog();
+    if (confirmed) {
+      showDetailPage = false;
+    }
+  }
+
+  List<Page<Object?>> _getPages() {
+    return <Page<Object?>>[
+      const MaterialPage<void>(key: ValueKey<String>('home'), child: _HomePage()),
+      if (showDetailPage)
+        MaterialPage<void>(
+          key: const ValueKey<String>('details'),
+          child: const _DetailsPage(),
+          canPop: false,
+          onPopInvoked: _handlePopDetails,
+        ),
+    ];
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Navigator(
+      key: navigatorKey,
+      pages: _getPages(),
+      onDidRemovePage: (Page<Object?> page) {
+        assert(page.key == const ValueKey<String>('details'));
+        showDetailPage = false;
+      },
+    );
+  }
+}
+
+class _HomePage extends StatefulWidget {
+  const _HomePage();
+
+  @override
+  State<_HomePage> createState() => _HomePageState();
+}
+
+class _HomePageState extends State<_HomePage> {
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Home')),
+      body: Center(
+        child: TextButton(
+          onPressed: () {
+            MyRouterDelegate.of(context).showDetailPage = true;
+          },
+          child: const Text('Go to details'),
+        ),
+      ),
+    );
+  }
+}
+
+class _DetailsPage extends StatefulWidget {
+  const _DetailsPage();
+
+  @override
+  State<_DetailsPage> createState() => _DetailsPageState();
+}
+
+class _DetailsPageState extends State<_DetailsPage> {
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Details')),
+      body: Center(
+        child: TextButton(
+          onPressed: () {
+            Navigator.of(context).maybePop();
+          },
+          child: const Text('Go back'),
+        ),
+      ),
+    );
+  }
+}
diff --git a/examples/api/test/widgets/page/page_can_pop.0_test.dart b/examples/api/test/widgets/page/page_can_pop.0_test.dart
new file mode 100644
index 0000000..c78205e
--- /dev/null
+++ b/examples/api/test/widgets/page/page_can_pop.0_test.dart
@@ -0,0 +1,54 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_api_samples/widgets/page/page_can_pop.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+import '../navigator_utils.dart';
+
+void main() {
+  testWidgets('Can choose to stay on page', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const example.PageApiExampleApp(),
+    );
+
+    expect(find.text('Home'), findsOneWidget);
+
+    await tester.tap(find.text('Go to details'));
+    await tester.pumpAndSettle();
+    expect(find.text('Home'), findsNothing);
+    expect(find.text('Details'), findsOneWidget);
+
+    await simulateSystemBack();
+    await tester.pumpAndSettle();
+    expect(find.text('Are you sure?'), findsOneWidget);
+
+    await tester.tap(find.text('Cancel'));
+    await tester.pumpAndSettle();
+    expect(find.text('Home'), findsNothing);
+    expect(find.text('Details'), findsOneWidget);
+  });
+
+  testWidgets('Can choose to go back', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const example.PageApiExampleApp(),
+    );
+
+    expect(find.text('Home'), findsOneWidget);
+
+    await tester.tap(find.text('Go to details'));
+    await tester.pumpAndSettle();
+    expect(find.text('Home'), findsNothing);
+    expect(find.text('Details'), findsOneWidget);
+
+    await simulateSystemBack();
+    await tester.pumpAndSettle();
+    expect(find.text('Are you sure?'), findsOneWidget);
+
+    await tester.tap(find.text('Confirm'));
+    await tester.pumpAndSettle();
+    expect(find.text('Details'), findsNothing);
+    expect(find.text('Home'), findsOneWidget);
+  });
+}
diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart
index e139953..a5c23cf 100644
--- a/packages/flutter/lib/src/cupertino/route.dart
+++ b/packages/flutter/lib/src/cupertino/route.dart
@@ -347,6 +347,8 @@
     this.title,
     this.fullscreenDialog = false,
     this.allowSnapshotting = true,
+    super.canPop,
+    super.onPopInvoked,
     super.key,
     super.name,
     super.arguments,
diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart
index db781f5..a2a23a4 100644
--- a/packages/flutter/lib/src/material/page.dart
+++ b/packages/flutter/lib/src/material/page.dart
@@ -150,6 +150,8 @@
     this.fullscreenDialog = false,
     this.allowSnapshotting = true,
     super.key,
+    super.canPop,
+    super.onPopInvoked,
     super.name,
     super.arguments,
     super.restorationId,
diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart
index b5f9c48..8f539f3 100644
--- a/packages/flutter/lib/src/widgets/navigator.dart
+++ b/packages/flutter/lib/src/widgets/navigator.dart
@@ -86,6 +86,14 @@
 /// [Navigator.pages] list is next updated.)
 typedef PopPageCallback = bool Function(Route<dynamic> route, dynamic result);
 
+/// Signature for the [Navigator.onDidRemovePage] callback.
+///
+/// This must properly update the pages list the next time it is passed into
+/// [Navigator.pages] so that it no longer includes the input `page`.
+/// (Otherwise, the page will be interpreted as a new page to show when the
+/// [Navigator.pages] list is next updated.)
+typedef DidRemovePageCallback = void Function(Page<Object?> page);
+
 /// Indicates whether the current route should be popped.
 ///
 /// Used as the return value for [Route.willPop].
@@ -173,6 +181,8 @@
   RouteSettings get settings => _settings;
   RouteSettings _settings;
 
+  bool get _isPageBased => settings is Page<Object?>;
+
   /// The restoration scope ID to be used for the [RestorationScope] surrounding
   /// this route.
   ///
@@ -344,7 +354,14 @@
   ///
   ///  * [Form], which provides a [Form.canPop] boolean that is similar.
   ///  * [PopScope], a widget that provides a way to intercept the back button.
+  ///  * [Page.canPop], a way for [Page] to affect this property.
   RoutePopDisposition get popDisposition {
+    if (_isPageBased) {
+      final Page<Object?> page = settings as Page<Object?>;
+      if (!page.canPop) {
+        return RoutePopDisposition.doNotPop;
+      }
+    }
     return isFirst ? RoutePopDisposition.bubble : RoutePopDisposition.pop;
   }
 
@@ -366,7 +383,13 @@
   /// will still be called. The `didPop` parameter indicates whether or not the
   /// back navigation actually happened successfully.
   /// {@endtemplate}
-  void onPopInvokedWithResult(bool didPop, T? result) => onPopInvoked(didPop);
+  @mustCallSuper
+  void onPopInvokedWithResult(bool didPop, T? result) {
+    if (_isPageBased) {
+      final Page<Object?> page = settings as Page<Object?>;
+      page.onPopInvoked(didPop, result);
+    }
+  }
 
   /// Whether calling [didPop] would return false.
   bool get willHandlePopInternally => false;
@@ -621,6 +644,15 @@
 /// The type argument `T` is the corresponding [Route]'s return type, as
 /// used by [Route.currentResult], [Route.popped], and [Route.didPop].
 ///
+/// The [canPop] and [onPopInvoked] are used for intercepting pops.
+///
+/// {@tool dartpad}
+/// This sample demonstrates how to use this [canPop] and [onPopInvoked] to
+/// intercept pops.
+///
+/// ** See code in examples/api/lib/widgets/page/page_can_pop.0.dart **
+/// {@end-tool}
+///
 /// See also:
 ///
 ///  * [Navigator.pages], which accepts a list of [Page]s and updates its routes
@@ -632,8 +664,12 @@
     super.name,
     super.arguments,
     this.restorationId,
+    this.canPop = true,
+    this.onPopInvoked = _defaultPopInvokedHandler,
   });
 
+  static void _defaultPopInvokedHandler(bool didPop, Object? result) { }
+
   /// The key associated with this page.
   ///
   /// This key will be used for comparing pages in [canUpdate].
@@ -650,6 +686,28 @@
   ///    Flutter.
   final String? restorationId;
 
+  /// Called after a pop on the associated route was handled.
+  ///
+  /// It's not possible to prevent the pop from happening at the time that this
+  /// method is called; the pop has already happened. Use [canPop] to
+  /// disable pops in advance.
+  ///
+  /// This will still be called even when the pop is canceled. A pop is canceled
+  /// when the associated [Route.popDisposition] returns false, or when
+  /// [canPop] is set to false. The `didPop` parameter indicates whether or not
+  /// the back navigation actually happened successfully.
+  final PopInvokedWithResultCallback<T> onPopInvoked;
+
+  /// When false, blocks the associated route from being popped.
+  ///
+  /// If this is set to false for first page in the Navigator. It prevents
+  /// Flutter app from exiting.
+  ///
+  /// If there are any [PopScope] widgets in a route's widget subtree,
+  /// each of their `canPop` must be `true`, in addition to this canPop, in
+  /// order for the route to be able to pop.
+  final bool canPop;
+
   /// Whether this page can be updated with the [other] page.
   ///
   /// Two pages are consider updatable if they have same the [runtimeType] and
@@ -1465,6 +1523,10 @@
   const Navigator({
     super.key,
     this.pages = const <Page<dynamic>>[],
+    @Deprecated(
+      'Use onDidRemovePage instead. '
+      'This feature was deprecated after v3.16.0-17.0.pre.',
+    )
     this.onPopPage,
     this.initialRoute,
     this.onGenerateInitialRoutes = Navigator.defaultGenerateInitialRoutes,
@@ -1477,6 +1539,7 @@
     this.requestFocus = true,
     this.restorationScopeId,
     this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior,
+    this.onDidRemovePage,
   });
 
   /// The list of pages with which to populate the history.
@@ -1509,6 +1572,8 @@
   /// corresponding to [pages] in the initial history.
   final List<Page<dynamic>> pages;
 
+  /// This is deprecated and replaced by [onDidRemovePage].
+  ///
   /// Called when [pop] is invoked but the current [Route] corresponds to a
   /// [Page] found in the [pages] list.
   ///
@@ -1522,8 +1587,27 @@
   /// contain the [Page] for the given [Route]. The next time the [pages] list
   /// is updated, if the [Page] corresponding to this [Route] is still present,
   /// it will be interpreted as a new route to display.
+  @Deprecated(
+    'Use onDidRemovePage instead. '
+    'This feature was deprecated after v3.16.0-17.0.pre.',
+  )
   final PopPageCallback? onPopPage;
 
+  /// Called when the [Route] associated with the given [Page] has been removed
+  /// from the Navigator.
+  ///
+  /// This can happen when the route is removed or completed through
+  /// [Navigator.pop], [Navigator.pushReplacement], or its friends.
+  ///
+  /// This callback is responsible for removing the given page from the list of
+  /// [pages].
+  ///
+  /// The [Navigator] widget should be rebuilt with a [pages] list that does not
+  /// contain the given page [Page]. The next time the [pages] list
+  /// is updated, if the given [Page] is still present, it will be interpreted
+  /// as a new page to display.
+  final DidRemovePageCallback? onDidRemovePage;
+
   /// The delegate used for deciding how routes transition in or off the screen
   /// during the [pages] updates.
   ///
@@ -3084,6 +3168,11 @@
       currentState = _RouteLifecycle.idle;
       return false;
     }
+    route.onPopInvokedWithResult(true, pendingResult);
+    if (pageBased) {
+      final Page<Object?> page = route.settings as Page<Object?>;
+      navigator.widget.onDidRemovePage?.call(page);
+    }
     pendingResult = null;
     return true;
   }
@@ -3120,7 +3209,6 @@
     assert(isPresent);
     pendingResult = result;
     currentState = _RouteLifecycle.pop;
-    route.onPopInvokedWithResult(true, result);
   }
 
   bool _reportRemovalToObserver = true;
@@ -3553,37 +3641,40 @@
     }
   }
 
+  bool _debugCheckPageApiParameters() {
+    if (!_usingPagesAPI) {
+      return true;
+    }
+    if (widget.pages.isEmpty) {
+      FlutterError.reportError(
+        FlutterErrorDetails(
+          exception: FlutterError(
+            'The Navigator.pages must not be empty to use the '
+                'Navigator.pages API',
+          ),
+          library: 'widget library',
+          stack: StackTrace.current,
+        ),
+      );
+    } else if ((widget.onDidRemovePage == null) == (widget.onPopPage == null)) {
+      FlutterError.reportError(
+        FlutterErrorDetails(
+          exception: FlutterError(
+            'Either onDidRemovePage or onPopPage must be provided to use the '
+            'Navigator.pages API but not both.',
+          ),
+          library: 'widget library',
+          stack: StackTrace.current,
+        ),
+      );
+    }
+    return true;
+  }
+
   @override
   void initState() {
     super.initState();
-    assert(() {
-      if (_usingPagesAPI) {
-        if (widget.pages.isEmpty) {
-          FlutterError.reportError(
-            FlutterErrorDetails(
-              exception: FlutterError(
-                'The Navigator.pages must not be empty to use the '
-                'Navigator.pages API',
-              ),
-              library: 'widget library',
-              stack: StackTrace.current,
-            ),
-          );
-        } else if (widget.onPopPage == null) {
-          FlutterError.reportError(
-            FlutterErrorDetails(
-              exception: FlutterError(
-                'The Navigator.onPopPage must be provided to use the '
-                'Navigator.pages API',
-              ),
-              library: 'widget library',
-              stack: StackTrace.current,
-            ),
-          );
-        }
-      }
-      return true;
-    }());
+    assert(_debugCheckPageApiParameters());
     for (final NavigatorObserver observer in widget.observers) {
       assert(observer.navigator == null);
       NavigatorObserver._navigators[observer] = this;
@@ -3790,35 +3881,7 @@
   @override
   void didUpdateWidget(Navigator oldWidget) {
     super.didUpdateWidget(oldWidget);
-    assert(() {
-      if (_usingPagesAPI) {
-        // This navigator uses page API.
-        if (widget.pages.isEmpty) {
-          FlutterError.reportError(
-            FlutterErrorDetails(
-              exception: FlutterError(
-                'The Navigator.pages must not be empty to use the '
-                'Navigator.pages API',
-              ),
-              library: 'widget library',
-              stack: StackTrace.current,
-            ),
-          );
-        } else if (widget.onPopPage == null) {
-          FlutterError.reportError(
-            FlutterErrorDetails(
-              exception: FlutterError(
-                'The Navigator.onPopPage must be provided to use the '
-                'Navigator.pages API',
-              ),
-              library: 'widget library',
-              stack: StackTrace.current,
-            ),
-          );
-        }
-      }
-      return true;
-    }());
+    assert(_debugCheckPageApiParameters());
     if (oldWidget.observers != widget.observers) {
       for (final NavigatorObserver observer in oldWidget.observers) {
         NavigatorObserver._navigators[observer] = null;
@@ -4321,6 +4384,9 @@
             // We aren't allowed to remove this route yet.
             break;
           }
+          if (entry.pageBased) {
+            widget.onDidRemovePage?.call(entry.route.settings as Page<Object?>);
+          }
           entry.currentState = _RouteLifecycle.dispose;
           continue;
         case _RouteLifecycle.dispose:
@@ -5229,14 +5295,14 @@
 
     // TODO(justinmc): When the deprecated willPop method is removed, delete
     // this code and use only popDisposition, below.
-    final RoutePopDisposition willPopDisposition = await lastEntry.route.willPop();
+    if (await lastEntry.route.willPop() == RoutePopDisposition.doNotPop) {
+      return true;
+    }
     if (!mounted) {
       // Forget about this pop, we were disposed in the meantime.
       return true;
     }
-    if (willPopDisposition == RoutePopDisposition.doNotPop) {
-      return true;
-    }
+
     final _RouteEntry? newLastEntry = _lastRouteEntryWhereOrNull(_RouteEntry.isPresentPredicate);
     if (lastEntry != newLastEntry) {
       // Forget about this pop, something happened to our history in the meantime.
@@ -5287,7 +5353,7 @@
       return true;
     }());
     final _RouteEntry entry = _history.lastWhere(_RouteEntry.isPresentPredicate);
-    if (entry.pageBased) {
+    if (entry.pageBased && widget.onPopPage != null) {
       if (widget.onPopPage!(entry.route, result) && entry.currentState == _RouteLifecycle.idle) {
         // The entry may have been disposed if the pop finishes synchronously.
         assert(entry.route._popCompleter.isCompleted);
diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart
index 2df89b9..d8338dd 100644
--- a/packages/flutter/lib/src/widgets/routes.dart
+++ b/packages/flutter/lib/src/widgets/routes.dart
@@ -1740,6 +1740,7 @@
     for (final PopEntry<Object?> popEntry in _popEntries) {
       popEntry.onPopInvokedWithResult(didPop, result);
     }
+    super.onPopInvokedWithResult(didPop, result);
   }
 
   /// Enables this route to veto attempts by the user to dismiss it.
@@ -1797,8 +1798,8 @@
   ///  * [unregisterPopEntry], which performs the opposite operation.
   void registerPopEntry(PopEntry<Object?> popEntry) {
     _popEntries.add(popEntry);
-    popEntry.canPopNotifier.addListener(_handlePopEntryChange);
-    _handlePopEntryChange();
+    popEntry.canPopNotifier.addListener(_maybeDispatchNavigationNotification);
+    _maybeDispatchNavigationNotification();
   }
 
   /// Unregisters a [PopEntry] in the route's widget subtree.
@@ -1808,11 +1809,11 @@
   ///  * [registerPopEntry], which performs the opposite operation.
   void unregisterPopEntry(PopEntry<Object?> popEntry) {
     _popEntries.remove(popEntry);
-    popEntry.canPopNotifier.removeListener(_handlePopEntryChange);
-    _handlePopEntryChange();
+    popEntry.canPopNotifier.removeListener(_maybeDispatchNavigationNotification);
+    _maybeDispatchNavigationNotification();
   }
 
-  void _handlePopEntryChange() {
+  void _maybeDispatchNavigationNotification() {
     if (!isCurrent) {
       return;
     }
@@ -1881,6 +1882,7 @@
   void didPopNext(Route<dynamic> nextRoute) {
     super.didPopNext(nextRoute);
     changedInternalState();
+    _maybeDispatchNavigationNotification();
   }
 
   @override
diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart
index 3407f61..b58ff84 100644
--- a/packages/flutter/test/widgets/navigator_test.dart
+++ b/packages/flutter/test/widgets/navigator_test.dart
@@ -2894,8 +2894,8 @@
         error.toStringDeep(),
         equalsIgnoringHashCodes(
           'FlutterError\n'
-          '   The Navigator.onPopPage must be provided to use the\n'
-          '   Navigator.pages API\n',
+          '   Either onDidRemovePage or onPopPage must be provided to use the\n'
+          '   Navigator.pages API but not both.\n',
         ),
       );
     });