import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'logging.dart';
import 'match.dart';
import 'misc/constants.dart';
import 'misc/errors.dart';
import 'path_utils.dart';
import 'route.dart';
import 'router.dart' show GoRouter, OnEnter, RoutingConfig;
import 'state.dart';
typedef GoRouterRedirect =
FutureOr<String?> Function(BuildContext context, GoRouterState state);
typedef _NamedPath = ({String path, bool caseSensitive});
class RouteConfiguration {
RouteConfiguration(
this._routingConfig, {
required this.navigatorKey,
this.extraCodec,
this.router,
}) {
_onRoutingTableChanged();
_routingConfig.addListener(_onRoutingTableChanged);
}
static bool _debugCheckPath(List<RouteBase> routes, bool isTopLevel) {
for (final route in routes) {
late bool subRouteIsTopLevel;
if (route is GoRoute) {
if (route.path != '/') {
assert(
!route.path.endsWith('/'),
'route path may not end with "/" except for the top "/" route. Found: $route',
);
}
subRouteIsTopLevel = false;
} else if (route is ShellRouteBase) {
subRouteIsTopLevel = isTopLevel;
}
_debugCheckPath(route.routes, subRouteIsTopLevel);
}
return true;
}
static bool _debugCheckParentNavigatorKeys(
List<RouteBase> routes,
List<GlobalKey<NavigatorState>> allowedKeys,
) {
for (final route in routes) {
if (route is GoRoute) {
final GlobalKey<NavigatorState>? parentKey = route.parentNavigatorKey;
if (parentKey != null) {
assert(
allowedKeys.contains(parentKey),
'parentNavigatorKey $parentKey must refer to'
" an ancestor ShellRoute's navigatorKey or GoRouter's"
' navigatorKey',
);
_debugCheckParentNavigatorKeys(
route.routes,
<GlobalKey<NavigatorState>>[
...allowedKeys.sublist(0, allowedKeys.indexOf(parentKey) + 1),
],
);
} else {
_debugCheckParentNavigatorKeys(
route.routes,
<GlobalKey<NavigatorState>>[...allowedKeys],
);
}
} else if (route is ShellRoute) {
_debugCheckParentNavigatorKeys(
route.routes,
<GlobalKey<NavigatorState>>[...allowedKeys, route.navigatorKey],
);
} else if (route is StatefulShellRoute) {
for (final StatefulShellBranch branch in route.branches) {
assert(
!allowedKeys.contains(branch.navigatorKey),
'StatefulShellBranch must not reuse an ancestor navigatorKey '
'(${branch.navigatorKey})',
);
_debugCheckParentNavigatorKeys(
branch.routes,
<GlobalKey<NavigatorState>>[...allowedKeys, branch.navigatorKey],
);
}
}
}
return true;
}
static bool _debugVerifyNoDuplicatePathParameter(
List<RouteBase> routes,
Map<String, GoRoute> usedPathParams,
) {
for (final route in routes) {
if (route is! GoRoute) {
continue;
}
for (final String pathParam in route.pathParameters) {
if (usedPathParams.containsKey(pathParam)) {
final sameRoute = usedPathParams[pathParam] == route;
throw GoError(
"duplicate path parameter, '$pathParam' found in ${sameRoute ? '$route' : '${usedPathParams[pathParam]}, and $route'}",
);
}
usedPathParams[pathParam] = route;
}
_debugVerifyNoDuplicatePathParameter(route.routes, usedPathParams);
route.pathParameters.forEach(usedPathParams.remove);
}
return true;
}
bool _debugCheckStatefulShellBranchDefaultLocations(List<RouteBase> routes) {
for (final route in routes) {
if (route is StatefulShellRoute) {
for (final StatefulShellBranch branch in route.branches) {
if (branch.initialLocation == null) {
final GoRoute? defaultGoRoute = branch.defaultRoute;
final String? initialLocation = defaultGoRoute != null
? locationForRoute(defaultGoRoute)
: null;
assert(
initialLocation != null,
'The default location of a StatefulShellBranch must be '
'derivable from GoRoute descendant',
);
assert(
defaultGoRoute!.pathParameters.isEmpty,
'The default location of a StatefulShellBranch cannot be '
'a parameterized route',
);
} else {
final RouteMatchList matchList = findMatch(
Uri.parse(branch.initialLocation!),
);
assert(
!matchList.isError,
'initialLocation (${matchList.uri}) of StatefulShellBranch must '
'be a valid location',
);
final List<RouteBase> matchRoutes = matchList.routes;
final int shellIndex = matchRoutes.indexOf(route);
var matchFound = false;
if (shellIndex >= 0 && (shellIndex + 1) < matchRoutes.length) {
final RouteBase branchRoot = matchRoutes[shellIndex + 1];
matchFound = branch.routes.contains(branchRoot);
}
assert(
matchFound,
'The initialLocation (${branch.initialLocation}) of '
'StatefulShellBranch must match a descendant route of the '
'branch',
);
}
}
}
_debugCheckStatefulShellBranchDefaultLocations(route.routes);
}
return true;
}
static RouteMatchList _errorRouteMatchList(
Uri uri,
GoException exception, {
Object? extra,
}) {
return RouteMatchList(
matches: const <RouteMatch>[],
extra: extra,
error: exception,
uri: uri,
pathParameters: const <String, String>{},
);
}
void _onRoutingTableChanged() {
final RoutingConfig routingTable = _routingConfig.value;
assert(_debugCheckPath(routingTable.routes, true));
assert(
_debugVerifyNoDuplicatePathParameter(
routingTable.routes,
<String, GoRoute>{},
),
);
assert(
_debugCheckParentNavigatorKeys(
routingTable.routes,
<GlobalKey<NavigatorState>>[navigatorKey],
),
);
assert(_debugCheckStatefulShellBranchDefaultLocations(routingTable.routes));
_nameToPath.clear();
_cacheNameToPath('', routingTable.routes);
log(debugKnownRoutes());
}
GoRouterState buildTopLevelGoRouterState(RouteMatchList matchList) {
return GoRouterState(
this,
uri: matchList.uri,
fullPath: matchList.fullPath,
pathParameters: matchList.pathParameters,
matchedLocation: matchList.uri.path,
extra: matchList.extra,
pageKey: const ValueKey<String>('topLevel'),
topRoute: matchList.lastOrNull?.route,
error: matchList.error,
);
}
final ValueListenable<RoutingConfig> _routingConfig;
List<RouteBase> get routes => _routingConfig.value.routes;
GoRouterRedirect get topRedirect => _routingConfig.value.redirect;
OnEnter? get topOnEnter => _routingConfig.value.onEnter;
int get redirectLimit => _routingConfig.value.redirectLimit;
static Uri normalizeUri(Uri uri) {
if (uri.hasEmptyPath) {
return uri.replace(path: '/');
} else if (uri.path.length > 1 && uri.path.endsWith('/')) {
return uri.replace(path: uri.path.substring(0, uri.path.length - 1));
}
return uri;
}
final GlobalKey<NavigatorState> navigatorKey;
final Codec<Object?, Object?>? extraCodec;
final GoRouter? router;
final Map<String, _NamedPath> _nameToPath = <String, _NamedPath>{};
String namedLocation(
String name, {
Map<String, String> pathParameters = const <String, String>{},
Map<String, dynamic> queryParameters = const <String, dynamic>{},
String? fragment,
}) {
assert(() {
log(
'getting location for name: '
'"$name"'
'${pathParameters.isEmpty ? '' : ', pathParameters: $pathParameters'}'
'${queryParameters.isEmpty ? '' : ', queryParameters: $queryParameters'}'
'${fragment != null ? ', fragment: $fragment' : ''}',
);
return true;
}());
assert(_nameToPath.containsKey(name), 'unknown route name: $name');
final _NamedPath path = _nameToPath[name]!;
assert(() {
final paramNames = <String>[];
patternToRegExp(path.path, paramNames, caseSensitive: path.caseSensitive);
for (final paramName in paramNames) {
assert(
pathParameters.containsKey(paramName),
'missing param "$paramName" for $path',
);
}
for (final String key in pathParameters.keys) {
assert(paramNames.contains(key), 'unknown param "$key" for $path');
}
return true;
}());
final encodedParams = <String, String>{
for (final MapEntry<String, String> param in pathParameters.entries)
param.key: Uri.encodeComponent(param.value),
};
final String location = patternToPath(path.path, encodedParams);
return Uri(
path: location,
queryParameters: queryParameters.isEmpty ? null : queryParameters,
fragment: fragment,
).toString();
}
RouteMatchList findMatch(Uri uri, {Object? extra}) {
final pathParameters = <String, String>{};
final List<RouteMatchBase> matches = _getLocRouteMatches(
uri,
pathParameters,
);
if (matches.isEmpty) {
return _errorRouteMatchList(
uri,
GoException('no routes for location: $uri'),
extra: extra,
);
}
return RouteMatchList(
matches: matches,
uri: uri,
pathParameters: pathParameters,
extra: extra,
);
}
RouteMatchList reparse(RouteMatchList matchList) {
RouteMatchList result = findMatch(matchList.uri, extra: matchList.extra);
for (final ImperativeRouteMatch imperativeMatch
in matchList.matches.whereType<ImperativeRouteMatch>()) {
final match = ImperativeRouteMatch(
pageKey: imperativeMatch.pageKey,
matches: findMatch(
imperativeMatch.matches.uri,
extra: imperativeMatch.matches.extra,
),
completer: imperativeMatch.completer,
);
result = result.push(match);
}
return result;
}
List<RouteMatchBase> _getLocRouteMatches(
Uri uri,
Map<String, String> pathParameters,
) {
for (final RouteBase route in _routingConfig.value.routes) {
final List<RouteMatchBase> result = RouteMatchBase.match(
rootNavigatorKey: navigatorKey,
route: route,
uri: uri,
pathParameters: pathParameters,
);
if (result.isNotEmpty) {
return result;
}
}
return const <RouteMatchBase>[];
}
FutureOr<RouteMatchList> redirect(
BuildContext context,
FutureOr<RouteMatchList> prevMatchListFuture, {
required List<RouteMatchList> redirectHistory,
}) {
FutureOr<RouteMatchList> processRedirect(RouteMatchList prevMatchList) {
final prevLocation = prevMatchList.uri.toString();
FutureOr<RouteMatchList> processRouteLevelRedirect(
String? routeRedirectLocation,
) {
if (routeRedirectLocation != null &&
routeRedirectLocation != prevLocation) {
final RouteMatchList newMatch = _getNewMatches(
routeRedirectLocation,
prevMatchList.uri,
redirectHistory,
);
if (newMatch.isError) {
return newMatch;
}
return redirect(context, newMatch, redirectHistory: redirectHistory);
}
return prevMatchList;
}
final routeMatches = <RouteMatchBase>[];
prevMatchList.visitRouteMatches((RouteMatchBase match) {
if (match.route.redirect != null) {
routeMatches.add(match);
}
return true;
});
try {
final FutureOr<String?> routeLevelRedirectResult =
_getRouteLevelRedirect(context, prevMatchList, routeMatches, 0);
if (routeLevelRedirectResult is String?) {
return processRouteLevelRedirect(routeLevelRedirectResult);
}
return routeLevelRedirectResult
.then<RouteMatchList>(processRouteLevelRedirect)
.catchError((Object error) {
final GoException goException = error is GoException
? error
: GoException('Exception during route redirect: $error');
return _errorRouteMatchList(
prevMatchList.uri,
goException,
extra: prevMatchList.extra,
);
});
} catch (exception) {
final GoException goException = exception is GoException
? exception
: GoException('Exception during route redirect: $exception');
return _errorRouteMatchList(
prevMatchList.uri,
goException,
extra: prevMatchList.extra,
);
}
}
if (prevMatchListFuture is RouteMatchList) {
return processRedirect(prevMatchListFuture);
}
return prevMatchListFuture.then<RouteMatchList>(processRedirect);
}
FutureOr<RouteMatchList> applyTopLegacyRedirect(
BuildContext context,
RouteMatchList prevMatchList, {
required List<RouteMatchList> redirectHistory,
}) {
final prevLocation = prevMatchList.uri.toString();
FutureOr<RouteMatchList> done(String? topLocation) {
if (topLocation != null && topLocation != prevLocation) {
final RouteMatchList newMatch = _getNewMatches(
topLocation,
prevMatchList.uri,
redirectHistory,
);
return newMatch;
}
return prevMatchList;
}
try {
final FutureOr<String?> res = _runInRouterZone(() {
return _routingConfig.value.redirect(
context,
buildTopLevelGoRouterState(prevMatchList),
);
});
if (res is String?) {
return done(res);
}
return res.then<RouteMatchList>(done).catchError((Object error) {
final GoException goException = error is GoException
? error
: GoException('Exception during redirect: $error');
return _errorRouteMatchList(
prevMatchList.uri,
goException,
extra: prevMatchList.extra,
);
});
} catch (exception) {
final GoException goException = exception is GoException
? exception
: GoException('Exception during redirect: $exception');
return _errorRouteMatchList(
prevMatchList.uri,
goException,
extra: prevMatchList.extra,
);
}
}
FutureOr<String?> _getRouteLevelRedirect(
BuildContext context,
RouteMatchList matchList,
List<RouteMatchBase> routeMatches,
int currentCheckIndex,
) {
if (currentCheckIndex >= routeMatches.length) {
return null;
}
final RouteMatchBase match = routeMatches[currentCheckIndex];
FutureOr<String?> processRouteRedirect(String? newLocation) =>
newLocation ??
_getRouteLevelRedirect(
context,
matchList,
routeMatches,
currentCheckIndex + 1,
);
final RouteBase route = match.route;
try {
final FutureOr<String?> routeRedirectResult = _runInRouterZone(() {
return route.redirect!.call(context, match.buildState(this, matchList));
});
if (routeRedirectResult is String?) {
return processRouteRedirect(routeRedirectResult);
}
return routeRedirectResult.then<String?>(processRouteRedirect).catchError(
(Object error) {
final GoException goException = error is GoException
? error
: GoException('Exception during route redirect: $error');
throw goException;
},
);
} catch (exception) {
final GoException goException = exception is GoException
? exception
: GoException('Exception during route redirect: $exception');
throw goException;
}
}
RouteMatchList _getNewMatches(
String newLocation,
Uri previousLocation,
List<RouteMatchList> redirectHistory,
) {
try {
final Uri uri = normalizeUri(Uri.parse(newLocation));
final RouteMatchList newMatch = findMatch(uri);
if (!newMatch.isError) {
_addRedirect(redirectHistory, newMatch);
}
return newMatch;
} catch (exception) {
final GoException goException = exception is GoException
? exception
: GoException('Exception during redirect: $exception');
log('Redirection exception: ${goException.message}');
return _errorRouteMatchList(previousLocation, goException);
}
}
void _addRedirect(List<RouteMatchList> redirects, RouteMatchList newMatch) {
if (redirects.contains(newMatch)) {
throw GoException(
'redirect loop detected ${_formatRedirectionHistory(<RouteMatchList>[...redirects, newMatch])}',
);
}
if (redirects.length >= _routingConfig.value.redirectLimit) {
throw GoException(
'too many redirects ${_formatRedirectionHistory(<RouteMatchList>[...redirects, newMatch])}',
);
}
redirects.add(newMatch);
log('redirecting to $newMatch');
}
String _formatRedirectionHistory(List<RouteMatchList> redirections) {
return redirections
.map<String>(
(RouteMatchList routeMatches) => routeMatches.uri.toString(),
)
.join(' => ');
}
T _runInRouterZone<T>(T Function() callback) {
if (router == null) {
return callback();
}
T? result;
var errorOccurred = false;
runZonedGuarded<void>(
() {
result = callback();
},
(Object error, StackTrace stack) {
errorOccurred = true;
final GoException goException = error is GoException
? error
: GoException('Exception during redirect: $error');
throw goException;
},
zoneValues: <Object?, Object?>{currentRouterKey: router},
);
if (errorOccurred) {
throw GoException('Unexpected error in router zone');
}
return result as T;
}
String? locationForRoute(RouteBase route) =>
fullPathForRoute(route, '', _routingConfig.value.routes);
@override
String toString() {
return 'RouterConfiguration: ${_routingConfig.value.routes}';
}
@visibleForTesting
String debugKnownRoutes() {
final sb = StringBuffer();
sb.writeln('Full paths for routes:');
_debugFullPathsFor(
_routingConfig.value.routes,
'',
const <_DecorationType>[],
sb,
);
if (_nameToPath.isNotEmpty) {
sb.writeln('known full paths for route names:');
for (final MapEntry<String, _NamedPath> e in _nameToPath.entries) {
sb.writeln(
' ${e.key} => ${e.value.path}${e.value.caseSensitive ? '' : ' (case-insensitive)'}',
);
}
}
return sb.toString();
}
void _debugFullPathsFor(
List<RouteBase> routes,
String parentFullpath,
List<_DecorationType> parentDecoration,
StringBuffer sb,
) {
for (final (int index, RouteBase route) in routes.indexed) {
final List<_DecorationType> decoration = _getDecoration(
parentDecoration,
index,
routes.length,
);
final String decorationString = decoration
.map((_DecorationType e) => e.toString())
.join();
var path = parentFullpath;
if (route is GoRoute) {
path = concatenatePaths(parentFullpath, route.path);
final String? screenName = route.builder?.runtimeType
.toString()
.split('=> ')
.last;
sb.writeln(
'$decorationString$path '
'${screenName == null ? '' : '($screenName)'}',
);
} else if (route is ShellRouteBase) {
sb.writeln('$decorationString (ShellRoute)');
}
_debugFullPathsFor(route.routes, path, decoration, sb);
}
}
List<_DecorationType> _getDecoration(
List<_DecorationType> parentDecoration,
int index,
int length,
) {
final Iterable<_DecorationType> newDecoration = parentDecoration.map((
_DecorationType e,
) {
switch (e) {
case _DecorationType.branch:
return _DecorationType.parentBranch;
case _DecorationType.leaf:
return _DecorationType.none;
case _DecorationType.parentBranch:
return _DecorationType.parentBranch;
case _DecorationType.none:
return _DecorationType.none;
}
});
if (index == length - 1) {
return <_DecorationType>[...newDecoration, _DecorationType.leaf];
} else {
return <_DecorationType>[...newDecoration, _DecorationType.branch];
}
}
void _cacheNameToPath(String parentFullPath, List<RouteBase> childRoutes) {
for (final route in childRoutes) {
if (route is GoRoute) {
final String fullPath = concatenatePaths(parentFullPath, route.path);
if (route.name != null) {
final String name = route.name!;
assert(
!_nameToPath.containsKey(name),
'duplication fullpaths for name '
'"$name":${_nameToPath[name]!.path}, $fullPath',
);
_nameToPath[name] = (
path: fullPath,
caseSensitive: route.caseSensitive,
);
}
if (route.routes.isNotEmpty) {
_cacheNameToPath(fullPath, route.routes);
}
} else if (route is ShellRouteBase) {
if (route.routes.isNotEmpty) {
_cacheNameToPath(parentFullPath, route.routes);
}
}
}
}
}
enum _DecorationType {
parentBranch('│ '),
branch('├─'),
leaf('└─'),
none(' ');
const _DecorationType(this.value);
final String value;
@override
String toString() => value;
}