Line data Source code
1 : library routemaster;
2 :
3 : export 'src/parser.dart';
4 : export 'src/route_info.dart';
5 : export 'src/pages/guard.dart';
6 :
7 : import 'package:flutter/cupertino.dart';
8 : import 'package:flutter/foundation.dart';
9 : import 'package:flutter/material.dart';
10 : import 'package:flutter/widgets.dart';
11 : import 'package:path/path.dart';
12 : import 'package:collection/collection.dart';
13 : import 'src/pages/guard.dart';
14 : import 'src/route_dart.dart';
15 : import 'src/system_nav.dart';
16 : import 'src/trie_router/trie_router.dart';
17 : import 'src/route_info.dart';
18 :
19 : part 'src/pages/stack.dart';
20 : part 'src/pages/tab_pages.dart';
21 : part 'src/pages/standard.dart';
22 :
23 : typedef RoutemasterBuilder = Widget Function(
24 : BuildContext context,
25 : List<Page> pages,
26 : PopPageCallback onPopPage,
27 : GlobalKey<NavigatorState> navigatorKey,
28 : );
29 :
30 : typedef PageBuilder = Page Function(RouteInfo info);
31 :
32 : typedef UnknownRouteCallback = Page Function(
33 : String route,
34 : BuildContext context,
35 : );
36 :
37 : class DefaultUnknownRoutePage extends StatelessWidget {
38 : final String route;
39 :
40 2 : DefaultUnknownRoutePage({required this.route});
41 :
42 2 : @override
43 : Widget build(BuildContext context) {
44 2 : return Scaffold(
45 6 : body: Text("Page '$route' wasn't found."),
46 : );
47 : }
48 : }
49 :
50 : /// An abstract class that can provide a map of routes
51 : abstract class RouteConfig {
52 : /// Called when there's no match for a route. By default this returns
53 : /// [DefaultUnknownRoutePage], a simple page not found page.
54 : ///
55 : /// There are two general options for this callback's operation:
56 : ///
57 : /// 1. Return a page, which will be displayed.
58 : ///
59 : /// or
60 : ///
61 : /// 2. Use the routing delegate to, for instance, redirect to another route
62 : /// and return null.
63 : ///
64 2 : Page onUnknownRoute(String route, BuildContext context) {
65 2 : return MaterialPage<void>(
66 2 : child: DefaultUnknownRoutePage(route: route),
67 : );
68 : }
69 :
70 : /// Generate a single [RouteResult] for the given [path]. Returns null if the
71 : /// path isn't valid.
72 : RouterResult? get(String path);
73 :
74 : /// Generate all [RouteResult] objects required to build the navigation tree
75 : /// for the given [path]. Returns null if the path isn't valid.
76 : List<RouterResult>? getAll(String path);
77 : }
78 :
79 : @immutable
80 : abstract class DefaultRouterConfig extends RouteConfig {
81 : final _router = TrieRouter();
82 :
83 9 : DefaultRouterConfig() {
84 27 : _router.addAll(routes);
85 : }
86 :
87 6 : @override
88 12 : RouterResult? get(String route) => _router.get(route);
89 :
90 9 : @override
91 18 : List<RouterResult>? getAll(String route) => _router.getAll(route);
92 :
93 : Map<String, PageBuilder> get routes;
94 : }
95 :
96 : /// A standard simple routing table which takes a map of routes.
97 : class RouteMap extends DefaultRouterConfig {
98 : /// A map of paths and [PageBuilder] delegates that return [Page] objects to
99 : /// build.
100 : @override
101 : final Map<String, PageBuilder> routes;
102 :
103 : final UnknownRouteCallback? _onUnknownRoute;
104 :
105 9 : RouteMap({
106 : required this.routes,
107 : UnknownRouteCallback? onUnknownRoute,
108 : }) : _onUnknownRoute = onUnknownRoute;
109 :
110 3 : @override
111 : Page onUnknownRoute(String route, BuildContext context) {
112 3 : if (_onUnknownRoute != null) {
113 4 : return _onUnknownRoute!(route, context);
114 : }
115 :
116 2 : return super.onUnknownRoute(route, context);
117 : }
118 : }
119 :
120 : class Routemaster extends RouterDelegate<RouteData> with ChangeNotifier {
121 : /// Used to override how the [Navigator] builds.
122 : final RoutemasterBuilder? builder;
123 : final TransitionDelegate? transitionDelegate;
124 : final RouteConfig Function(BuildContext context) routesBuilder;
125 :
126 : _RoutemasterState _state = _RoutemasterState();
127 : bool _isBuilding = false;
128 :
129 9 : Routemaster({
130 : required this.routesBuilder,
131 : this.builder,
132 : this.transitionDelegate,
133 : });
134 :
135 2 : static Routemaster of(BuildContext context) {
136 : return context
137 2 : .dependOnInheritedWidgetOfExactType<_RoutemasterWidget>()!
138 2 : .delegate;
139 : }
140 :
141 : /// Called by the [Router] when the [Router.backButtonDispatcher] reports that
142 : /// the operating system is requesting that the current route be popped.
143 2 : @override
144 : Future<bool> popRoute() {
145 4 : if (_state.stack == null) {
146 1 : return SynchronousFuture(false);
147 : }
148 :
149 6 : return _state.stack!.maybePop();
150 : }
151 :
152 : /// Passed to top-level [Navigator] widget, called when the navigator requests
153 : /// that it wants to pop a page.
154 1 : bool onPopPage(Route<dynamic> route, dynamic result) {
155 3 : return _state.stack!.onPopPage(route, result);
156 : }
157 :
158 : /// Replaces the current route with [path].
159 1 : void replace(String path, {Map<String, String>? queryParameters}) {
160 : if (kIsWeb) {
161 0 : SystemNav.replaceLocation(path, queryParameters);
162 : } else {
163 1 : push(path, queryParameters: queryParameters);
164 : }
165 : }
166 :
167 : /// Pushes [path] into the navigation tree.
168 6 : void push(String path, {Map<String, String>? queryParameters}) {
169 6 : final getAbsolutePath = _getAbsolutePath(path, queryParameters);
170 :
171 : // Schedule request for next build. This makes sure the routing table is
172 : // updated before processing the new path.
173 12 : _state.pendingNavigation = getAbsolutePath;
174 6 : _markNeedsUpdate();
175 : }
176 :
177 6 : String _getAbsolutePath(String path, Map<String, String>? queryParameters) {
178 : final absolutePath =
179 15 : isAbsolute(path) ? path : join(currentConfiguration!.path, path);
180 :
181 : if (queryParameters == null) {
182 : return absolutePath;
183 : }
184 :
185 2 : return Uri(path: absolutePath, queryParameters: queryParameters).toString();
186 : }
187 :
188 : /// Generates all pages and sub-pages.
189 9 : List<Page> createPages(BuildContext context) {
190 18 : assert(_state.stack != null,
191 : 'Stack must have been created when createPages() is called');
192 27 : final pages = _state.stack!.createPages();
193 9 : assert(pages.isNotEmpty, 'Returned pages list must not be empty');
194 9 : _updateCurrentConfiguration();
195 :
196 : assert(
197 27 : pages.none((page) => page is Redirect),
198 : 'Returned pages list must not have redirect',
199 : );
200 :
201 : return pages;
202 : }
203 :
204 9 : void _markNeedsUpdate() {
205 9 : _updateCurrentConfiguration();
206 :
207 9 : if (!_isBuilding) {
208 9 : notifyListeners();
209 : }
210 : }
211 :
212 9 : void _processPendingNavigation() {
213 18 : if (_state.pendingNavigation != null) {
214 18 : _processNavigation(_state.pendingNavigation!);
215 12 : _state.pendingNavigation = null;
216 : }
217 : }
218 :
219 6 : void _processNavigation(String path) {
220 6 : final pages = _createAllPageWrappers(path);
221 18 : _state.stack = StackPageState(delegate: this, routes: pages);
222 : }
223 :
224 9 : @override
225 : Widget build(BuildContext context) {
226 9 : return _DependencyTracker(
227 : delegate: this,
228 9 : builder: (context) {
229 9 : _isBuilding = true;
230 9 : _init(context);
231 9 : _processPendingNavigation();
232 9 : final pages = createPages(context);
233 9 : _isBuilding = false;
234 :
235 9 : return _RoutemasterWidget(
236 : delegate: this,
237 9 : child: builder != null
238 6 : ? builder!(context, pages, onPopPage, _state.stack!.navigatorKey)
239 9 : : Navigator(
240 : pages: pages,
241 9 : onPopPage: onPopPage,
242 27 : key: _state.stack!.navigatorKey,
243 9 : transitionDelegate: transitionDelegate ??
244 : const DefaultTransitionDelegate<dynamic>(),
245 : ),
246 : );
247 : },
248 : );
249 : }
250 :
251 : // Returns a [RouteData] that matches the current route state.
252 : // This is used to update a browser's current URL.
253 :
254 9 : @override
255 : RouteData? get currentConfiguration {
256 18 : return _state.currentConfiguration;
257 : }
258 :
259 9 : void _updateCurrentConfiguration() {
260 18 : if (_state.stack == null) {
261 : return;
262 : }
263 :
264 54 : final path = _state.stack!._getCurrentPages().last.routeInfo.path;
265 18 : print("Updated path: '$path'");
266 27 : _state.currentConfiguration = RouteData(path);
267 : }
268 :
269 : // Called when a new URL is set. The RouteInformationParser will parse the
270 : // URL, and return a new [RouteData], that gets passed this this method.
271 : //
272 : // This method then modifies the state based on that information.
273 1 : @override
274 : Future<void> setNewRoutePath(RouteData routeData) {
275 2 : push(routeData.path);
276 1 : return SynchronousFuture(null);
277 : }
278 :
279 9 : @override
280 : Future<void> setInitialRoutePath(RouteData configuration) {
281 36 : _state.currentConfiguration = RouteData(configuration.path);
282 9 : return SynchronousFuture(null);
283 : }
284 :
285 9 : void _init(BuildContext context, {bool isRebuild = false}) {
286 18 : if (_state.routeConfig == null) {
287 36 : _state.routeConfig = routesBuilder(context);
288 :
289 : final path =
290 36 : _state.pendingNavigation ?? currentConfiguration?.path ?? '/';
291 9 : final pageStates = _createAllPageWrappers(path);
292 :
293 9 : assert(pageStates.isNotEmpty);
294 27 : _state.stack = StackPageState(delegate: this, routes: pageStates);
295 : }
296 : }
297 :
298 : /// Called when dependencies of the [routesBuilder] changed.
299 9 : void _didChangeDependencies(BuildContext context) {
300 9 : if (currentConfiguration == null) {
301 : return;
302 : }
303 :
304 36 : WidgetsBinding.instance?.addPostFrameCallback((_) => _markNeedsUpdate());
305 :
306 : // Reset state
307 18 : _state.routeConfig = null;
308 18 : _state.stack = null;
309 :
310 9 : _isBuilding = true;
311 9 : _init(context, isRebuild: true);
312 9 : _isBuilding = false;
313 : }
314 :
315 3 : PageWrapper _onUnknownRoute(String requestedPath) {
316 6 : print("Router couldn't find a match for path '$requestedPath''");
317 :
318 9 : final result = _state.routeConfig!.onUnknownRoute(
319 : requestedPath,
320 9 : _state.globalKey.currentContext!,
321 : );
322 :
323 3 : if (result is Redirect) {
324 2 : return _getPageWrapper(
325 2 : Uri(
326 2 : path: result.path,
327 2 : queryParameters: result.queryParameters,
328 2 : ).toString(),
329 : );
330 : }
331 :
332 : // Return 404 page
333 3 : final routeInfo = RouteInfo(requestedPath);
334 3 : return StatelessPage(routeInfo: routeInfo, page: result);
335 : }
336 :
337 9 : List<PageWrapper> _createAllPageWrappers(
338 : String requestedPath, {
339 : List<String>? redirects,
340 : }) {
341 27 : final routerResult = _state.routeConfig!.getAll(requestedPath);
342 :
343 9 : if (routerResult == null || routerResult.isEmpty) {
344 2 : return [_onUnknownRoute(requestedPath)];
345 : }
346 :
347 28 : final currentRoutes = _state.stack?._getCurrentPages().toList();
348 9 : var result = <PageWrapper>[];
349 : var i = 0;
350 :
351 18 : for (final routerData in routerResult.reversed) {
352 9 : final isLastRoute = i == 0;
353 9 : final routeInfo = RouteInfo.fromRouterResult(
354 : routerData,
355 : // Only the last route gets query parameters
356 8 : isLastRoute ? requestedPath : routerData.pathSegment,
357 : );
358 :
359 9 : final current = _getOrCreatePageWrapper(
360 : requestedPath,
361 : routeInfo,
362 : currentRoutes,
363 : routerData,
364 : );
365 :
366 9 : if (current is _RedirectWrapper) {
367 : if (isLastRoute) {
368 : if (kDebugMode) {
369 : if (redirects == null) {
370 2 : redirects = [requestedPath];
371 : } else {
372 1 : if (redirects.contains(requestedPath)) {
373 1 : redirects.add(requestedPath);
374 1 : throw RedirectLoopError(redirects);
375 : }
376 1 : redirects.add(requestedPath);
377 : }
378 : }
379 :
380 2 : return _createAllPageWrappers(
381 4 : current.redirectPage.absolutePath,
382 : redirects: redirects,
383 : );
384 : } else {
385 : continue;
386 : }
387 : }
388 :
389 15 : if (result.isNotEmpty && current.maybeSetChildPages(result)) {
390 4 : result = [current];
391 : } else {
392 9 : result.insert(0, current);
393 : }
394 :
395 9 : i++;
396 : }
397 :
398 9 : assert(result.isNotEmpty, "_createAllStates can't return empty list");
399 : return result;
400 : }
401 :
402 : /// If there's a current route matching the path in the tree, return it.
403 : /// Otherwise create a new one. This could possibly be made more efficient
404 : /// By using a map rather than iterating over all currentRoutes.
405 9 : PageWrapper _getOrCreatePageWrapper(
406 : String requestedPath,
407 : RouteInfo routeInfo,
408 : List<PageWrapper>? currentRoutes,
409 : RouterResult routerResult,
410 : ) {
411 : if (currentRoutes != null) {
412 5 : final currentState = currentRoutes.firstWhereOrNull(
413 15 : ((element) => element.routeInfo == routeInfo),
414 : );
415 :
416 : if (currentState != null) {
417 : return currentState;
418 : }
419 : }
420 :
421 9 : return _createPageWrapper(
422 : requestedPath: requestedPath,
423 18 : page: routerResult.builder(routeInfo),
424 : routeInfo: routeInfo,
425 : );
426 : }
427 :
428 : /// Try to get the route for [requestedPath]. If no match, returns default path.
429 : /// Returns null if validation fails.
430 6 : PageWrapper _getPageWrapper(String requestedPath) {
431 18 : final routerResult = _state.routeConfig!.get(requestedPath);
432 : if (routerResult == null) {
433 1 : return _onUnknownRoute(requestedPath);
434 : }
435 :
436 6 : final routeInfo = RouteInfo.fromRouterResult(routerResult, requestedPath);
437 12 : final page = routerResult.builder(routeInfo);
438 :
439 6 : if (page is Redirect) {
440 2 : return _getPageWrapper(page.path);
441 : }
442 :
443 6 : return _createPageWrapper(
444 : requestedPath: requestedPath,
445 : page: page,
446 : routeInfo: routeInfo,
447 : );
448 : }
449 :
450 9 : PageWrapper _createPageWrapper({
451 : required String requestedPath,
452 : required Page page,
453 : required RouteInfo routeInfo,
454 : }) {
455 9 : while (page is ProxyPage) {
456 2 : if (page is GuardedPage) {
457 6 : final context = _state.globalKey.currentContext!;
458 4 : if (!page.validate(routeInfo, context)) {
459 3 : print("Validation failed for '${routeInfo.path}'");
460 :
461 1 : if (page.onValidationFailed == null) {
462 1 : return _onUnknownRoute(requestedPath);
463 : }
464 :
465 2 : final result = page.onValidationFailed!(routeInfo, context);
466 1 : return _createPageWrapper(
467 : requestedPath: requestedPath,
468 : page: result,
469 : routeInfo: routeInfo,
470 : );
471 : }
472 : }
473 :
474 2 : page = page.child;
475 : }
476 :
477 9 : if (page is StatefulPage) {
478 4 : return page.createState(this, routeInfo);
479 : }
480 :
481 9 : if (page is Redirect) {
482 2 : return _RedirectWrapper(page);
483 : }
484 :
485 9 : assert(page is! Redirect, 'Redirect has not been followed');
486 9 : assert(page is! ProxyPage, 'ProxyPage has not been unwrapped');
487 :
488 : // Page is just a standard Flutter page, create a wrapper for it
489 9 : return StatelessPage(routeInfo: routeInfo, page: page);
490 : }
491 : }
492 :
493 : /// Used internally so descendent widgets can use `Routemaster.of(context)`.
494 : class _RoutemasterWidget extends InheritedWidget {
495 : final Routemaster delegate;
496 :
497 9 : const _RoutemasterWidget({
498 : required Widget child,
499 : required this.delegate,
500 9 : }) : super(child: child);
501 :
502 7 : @override
503 : bool updateShouldNotify(covariant _RoutemasterWidget oldWidget) {
504 21 : return delegate != oldWidget.delegate;
505 : }
506 : }
507 :
508 : class _RoutemasterState {
509 : final globalKey = GlobalKey(debugLabel: 'routemaster');
510 : StackPageState? stack;
511 : RouteConfig? routeConfig;
512 : RouteData? currentConfiguration;
513 : String? pendingNavigation;
514 : }
515 :
516 : /// Widget to trigger router rebuild when dependencies change
517 : class _DependencyTracker extends StatefulWidget {
518 : final Routemaster delegate;
519 : final Widget Function(BuildContext context) builder;
520 :
521 9 : _DependencyTracker({
522 : required this.delegate,
523 : required this.builder,
524 27 : }) : super(key: delegate._state.globalKey);
525 :
526 9 : @override
527 9 : _DependencyTrackerState createState() => _DependencyTrackerState();
528 : }
529 :
530 : class _DependencyTrackerState extends State<_DependencyTracker> {
531 : late _RoutemasterState _delegateState;
532 :
533 9 : @override
534 : Widget build(BuildContext context) {
535 27 : return widget.builder(context);
536 : }
537 :
538 9 : @override
539 : void initState() {
540 9 : super.initState();
541 36 : _delegateState = widget.delegate._state;
542 36 : widget.delegate._state = _delegateState;
543 : }
544 :
545 7 : @override
546 : void didUpdateWidget(_DependencyTracker oldWidget) {
547 7 : super.didUpdateWidget(oldWidget);
548 28 : widget.delegate._state = _delegateState;
549 : }
550 :
551 9 : @override
552 : void didChangeDependencies() {
553 9 : super.didChangeDependencies();
554 36 : widget.delegate._didChangeDependencies(this.context);
555 : }
556 : }
557 :
558 : class RedirectLoopError extends Error {
559 : final List<String> redirects;
560 :
561 1 : RedirectLoopError(this.redirects);
562 :
563 1 : @override
564 : String toString() {
565 1 : return 'Routemaster is stuck in an endless redirect loop:\n\n' +
566 1 : redirects
567 4 : .take(redirects.length - 1)
568 2 : .mapIndexed((i, path1) =>
569 4 : " * '$path1' redirected to '${redirects[i + 1]}'")
570 2 : .join('\n') +
571 : '\n\nThis is an error in your routing map.';
572 : }
573 : }
|