title

Blog of René Jochum

Blogging about Programming, Security, Linux, Networking and Web Apps.

Flutter simple router


For the Let’s Check App I’m writing I needed a simple router. I haven’t found anything that suited my needs so I decided to role my own.

My implementation supports:

  • Regex named Args
  • All routes named, this allows usage like: ( GlobalRouter().buildUri(routeSettingsConnection, buildArgs: {"alias": "JOCHUM"});)
  • Static/Dynamic routes (Static string or Regex)
  • Dynamicaly register/deregister routes as needed

The Router implementation

I have this saved as GlobalRouter.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

const routeHome = 'home';
const routeSplash = 'splash';
const routeSettings = 'settings';
const routeSettingsConnection = 'settings_connection';
const routeNotFound = 'not_found';
const routeHosts = 'hosts';
const routeServices = 'services';
const routeHost = 'host';
const routeService = 'service';

typedef RouteBuilder = Route<dynamic> Function(RouteSettings context);

class BuildError implements Exception {
  final String message;
  BuildError(this.message);

  String toString() => message;
}

abstract class GlobalRoute {
  String get key;
  RouteBuilder get route;
  bool matchesRoute(String route);
  Map<String, String> extractNamedArgs(BuildContext context);
  String buildUri({Map<String, String> buildArgs});
}

class ExactRoute implements GlobalRoute {
  final String key;
  final String uri;
  final RouteBuilder route;

  ExactRoute({@required this.key, @required this.uri, @required this.route});

  bool matchesRoute(String route) => route == uri;

  Map<String, String> extractNamedArgs(BuildContext context) => {};

  String buildUri({Map<String, String> buildArgs}) {
    assert(buildArgs == null);
    return uri;
  }
}

class NamedArgsRoute implements GlobalRoute {
  final String key;
  final String builderUri;
  final RegExp regex;
  final Map<String, int> args;
  final RouteBuilder route;
  final bool lastArgOptional;

  NamedArgsRoute(
      {@required this.key,
      @required this.builderUri,
      @required this.regex,
      @required this.args,
      @required this.route,
      this.lastArgOptional = false});

  bool matchesRoute(String route) {
    return regex.hasMatch(route);
  }

  Map<String, String> extractNamedArgs(BuildContext context) {
    var uri = ModalRoute.of(context).settings.name;
    if (!matchesRoute(uri)) {
      return {};
    }

    final match = regex.firstMatch(uri);

    Map<String, String> result = {};
    for (var name in args.keys) {
      if (match.groupCount >= args[name] && match.group(args[name]) != null) {
        result[name] = Uri.decodeComponent(match.group(args[name]));
      }
    }

    return result;
  }

  String buildUri({Map<String, String> buildArgs}) {
    if (buildArgs == null && lastArgOptional && args.length == 1) {
      return builderUri.replaceFirst(r'/{' + args.keys.first + r'}', "");
    } else if (buildArgs == null) {
      throw new BuildError("BuildArgs are not optional for route '$key'");
    }

    if (lastArgOptional && buildArgs.keys.length < args.keys.length - 1) {
      throw new BuildError("Not all args given for route '$key'");
    } else if (buildArgs.keys.length < args.keys.length) {
      throw new BuildError("Not all args given for route '$key'");
    }

    var result = builderUri;
    for (var argName in buildArgs.keys) {
      result = result.replaceAll('{$argName}', Uri.encodeComponent(buildArgs[argName]));
    }

    if (lastArgOptional && result.contains('{')) {
      result = result.replaceFirst(RegExp(r"(\/?\{\S+\})$"), "");
    }

    return result;
  }
}

GlobalRoute buildRoute(
    {@required String key,
    @required String uri,
    bool lastArgOptional = false,
    RouteBuilder route}) {
  var matches = RegExp(r"\{(\w+)\}").allMatches(uri);
  if (!matches.isNotEmpty) {
    return ExactRoute(key: key, uri: uri, route: route);
  }

  Map<String, int> args = {};
  var regex = r'^' + uri.replaceAll("/", r"\/") + r'$';

  var i = 1;
  for (var match in matches) {
    if (lastArgOptional && i == matches.length) {
      regex = regex.replaceFirst(r'\/{' + match.group(1) + r'}', r"((\/([^\/]+))?)\/?");
      args[match.group(1)] = i + 2;
      break;
    }

    regex = regex.replaceFirst('{' + match.group(1) + '}', r"([^\/]+)");
    args[match.group(1)] = i;

    i++;
  }

  return NamedArgsRoute(
      key: key,
      builderUri: uri,
      regex: new RegExp(regex),
      args: args,
      route: route,
      lastArgOptional: lastArgOptional);
}

class GlobalRouter {
  Map<String, GlobalRoute> routes = {};
  List<GlobalRoute> dynamicRoutes = [];
  Map<String, ExactRoute> exactRoutes = {};

  final List<String> requiredRoutes = [
    routeHome,
    routeSplash,
    routeSettings,
    routeSettingsConnection,
    routeNotFound
  ];

  static final GlobalRouter _singleton = GlobalRouter._internal();
  GlobalRouter._internal();

  factory GlobalRouter() {
    return _singleton;
  }

  bool validateRoutes() {
    requiredRoutes.forEach((name) {
      if (!routes.containsKey(name)) {
        return false;
      }
    });

    return true;
  }

  void clear() {
    routes.clear();
    exactRoutes.clear();
    dynamicRoutes.clear();
  }

  void add<T extends GlobalRoute>(T route) {
    routes[route.key] = route;
    if (route is ExactRoute) {
      exactRoutes[route.uri] = route;
    } else {
      dynamicRoutes.add(route);
    }
  }

  String buildUri(String key, {Map<String, String> buildArgs}) {
    return routes[key].buildUri(buildArgs: buildArgs);
  }

  Map<String, String> extractNamedArgs(BuildContext context, String key) {
    return routes[key].extractNamedArgs(context);
  }

  bool isCurrentRoute(BuildContext context, String key) {
    return routes[key].matchesRoute(ModalRoute.of(context).settings.name);
  }

  Route<dynamic> generateRoute(RouteSettings context) {
    if (kDebugMode) {
      print("Generating route for '${context.name}'");
    }

    if (exactRoutes.containsKey(context.name)) {
      if (kDebugMode) {
        print("... found route: ${context.name}");
      }
      return exactRoutes[context.name].route(context);
    }

    for (var route in dynamicRoutes) {
      if (route.matchesRoute(context.name)) {
        if (kDebugMode) {
          print("... found route: ${route.key}");
        }
        return route.route(context);
      }
    }

    print("... going to 404");
    return routes[routeNotFound].route(context);
  }
}

Usage of GlobalRouter

This is how a static route definition looks like:

class HomeScreen extends BaseSlimScreen {
  static final route = buildRoute(
      key: routeHome,
      uri: "/",
      route: (context) => MaterialPageRoute(
            settings: context,
            builder: (context) => HomeScreen(),
          ));

And this is a Regex Route:

class HostScreen extends BaseSlimScreen {
  static final route = buildRoute(
      key: routeHost,
      uri: "/conn/{alias}/host/{hostname}",
      lastArgOptional: false,
      route: (context) => MaterialPageRoute(
            settings: context,
            builder: (context) => HostScreen(),
          ));

Somewhere I have register Routes with GlobalRouter():

File is lib/screen/slim/slim_router.dart

import '../../global_router.dart';
import 'splash_screen.dart';
import 'home_screen.dart';
import 'settings_screen.dart';
import 'settings_connection_screen.dart';
import 'not_found_screen.dart';
import 'hosts_screen.dart';
import 'services_screen.dart';
import 'host_screen.dart';
import 'service_screen.dart';

export '../../global_router.dart';

void registerSlimRoutes() {
  GlobalRouter().add(HomeScreen.route);
  GlobalRouter().add(SplashScreen.route);
  GlobalRouter().add(SettingsScreen.route);
  GlobalRouter().add(SettingsConnectionScreen.route);
  GlobalRouter().add(NotFoundScreen.route);
  GlobalRouter().add(HostsScreen.route);
  GlobalRouter().add(ServicesScreen.route);
  GlobalRouter().add(HostScreen.route);
  GlobalRouter().add(ServiceScreen.route);

  assert(GlobalRouter().validateRoutes());
}

In main.dart i configure the router:

Future<void> main() async {
  var mediaWidth = MediaQueryData.fromWindow(window).size.width;
  mediaWidth >= ultraWideLayoutThreshold
      ? registerSlimRoutes() // UltraWide
      : mediaWidth > wideLayoutThreshold
          ? registerSlimRoutes() // Wide
          : registerSlimRoutes(); // Slim
}

And with that GlobalRouter() is in use:

class App extends StatelessWidget {
  App({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      onGenerateRoute: (routeContext) =>
          GlobalRouter().generateRoute(routeContext),
    );
  }
}

License

This is MIT Licensed do whatever you want with it but don’t blame me. I hope it helps you to make your own Router.