Skip to content

Commit eda9b66

Browse files
committed
app: Maintain that the navigator stack is never empty
Signed-off-by: Zixuan James Li <[email protected]>
1 parent eee09ff commit eda9b66

File tree

2 files changed

+74
-1
lines changed

2 files changed

+74
-1
lines changed

lib/widgets/app.dart

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,13 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
201201
theme: themeData,
202202

203203
navigatorKey: ZulipApp.navigatorKey,
204-
navigatorObservers: widget.navigatorObservers ?? const [],
204+
navigatorObservers: [
205+
if (widget.navigatorObservers != null)
206+
...widget.navigatorObservers!,
207+
// This must be the last item to maintain the invariant
208+
// that the navigator stack is always non-empty.
209+
_EmptyStackNavigatorObserver(),
210+
],
205211
builder: (BuildContext context, Widget? child) {
206212
if (!ZulipApp.ready.value) {
207213
SchedulerBinding.instance.addPostFrameCallback(
@@ -232,6 +238,39 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
232238
}
233239
}
234240

241+
/// Pushes a route whenever the observed navigator stack becomes empty.
242+
class _EmptyStackNavigatorObserver extends NavigatorObserver {
243+
void _pushRouteIfEmptyStack() async {
244+
final navigator = await ZulipApp.navigator;
245+
bool isEmptyStack = true;
246+
// TODO: find a better way to inspect the navigator stack
247+
navigator.popUntil((route) {
248+
isEmptyStack = false;
249+
return true; // never actually pops
250+
});
251+
if (isEmptyStack) {
252+
unawaited(navigator.push(
253+
MaterialWidgetRoute(page: const ChooseAccountPage())));
254+
}
255+
}
256+
257+
@override
258+
void didRemove(Route<void> route, Route<void>? previousRoute) async {
259+
if (previousRoute == null) {
260+
// The route removed is the bottom-most one.
261+
_pushRouteIfEmptyStack();
262+
}
263+
}
264+
265+
@override
266+
void didPop(Route<void> route, Route<void>? previousRoute) async {
267+
if (previousRoute == null) {
268+
// The route popped is the bottom-most one.
269+
_pushRouteIfEmptyStack();
270+
}
271+
}
272+
}
273+
235274
class ChooseAccountPage extends StatelessWidget {
236275
const ChooseAccountPage({super.key});
237276

test/widgets/app_test.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:checks/checks.dart';
44
import 'package:flutter/material.dart';
55
import 'package:flutter_test/flutter_test.dart';
66
import 'package:zulip/log.dart';
7+
import 'package:zulip/model/actions.dart';
78
import 'package:zulip/model/database.dart';
89
import 'package:zulip/widgets/app.dart';
910
import 'package:zulip/widgets/home.dart';
@@ -57,6 +58,39 @@ void main() {
5758
});
5859
});
5960

61+
group('_EmptyStackNavigatorObserver', () {
62+
late List<Route<void>> pushedRoutes;
63+
late List<Route<void>> removedRoutes;
64+
65+
Future<void> prepare(WidgetTester tester) async {
66+
addTearDown(testBinding.reset);
67+
68+
pushedRoutes = [];
69+
removedRoutes = [];
70+
final testNavObserver = TestNavigatorObserver();
71+
testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route);
72+
testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route);
73+
74+
await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
75+
await tester.pump(); // start to load account
76+
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
77+
pushedRoutes.clear();
78+
}
79+
80+
testWidgets('push route when removing last route on stack', (tester) async {
81+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
82+
await prepare(tester);
83+
84+
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
85+
await tester.pump(TestGlobalStore.removeAccountDuration);
86+
await future;
87+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
88+
.single.equals(eg.selfAccount.id);
89+
check(removedRoutes).single.isA<WidgetRoute>().page.isA<HomePage>();
90+
check(pushedRoutes).single.isA<WidgetRoute>().page.isA<ChooseAccountPage>();
91+
});
92+
});
93+
6094
group('ChooseAccountPage', () {
6195
Future<void> setupChooseAccountPage(WidgetTester tester, {
6296
required List<Account> accounts,

0 commit comments

Comments
 (0)