diff --git a/examples/interceptors/.gitignore b/examples/interceptors/.gitignore new file mode 100644 index 0000000..0fa6b67 --- /dev/null +++ b/examples/interceptors/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/examples/interceptors/.metadata b/examples/interceptors/.metadata new file mode 100644 index 0000000..9cef17b --- /dev/null +++ b/examples/interceptors/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "b0850beeb25f6d5b10426284f506557f66181b36" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: web + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/examples/interceptors/README.md b/examples/interceptors/README.md new file mode 100644 index 0000000..9ffb2bf --- /dev/null +++ b/examples/interceptors/README.md @@ -0,0 +1,8 @@ +# Interceptors Example + +This example shows what you can achieve with BeamInterceptors. Interceptors are similar to BeamGuards, but interceptors can be dynamically added and removed from a BeamerDelegate. + +

+example-interceptors + +Run `flutter create .` to generate all necessary files, if needed. diff --git a/examples/interceptors/analysis_options.yaml b/examples/interceptors/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/examples/interceptors/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/examples/interceptors/example-interceptors.gif b/examples/interceptors/example-interceptors.gif new file mode 100644 index 0000000..c3ec807 Binary files /dev/null and b/examples/interceptors/example-interceptors.gif differ diff --git a/examples/interceptors/lib/app/beam_intercept_example_app.dart b/examples/interceptors/lib/app/beam_intercept_example_app.dart new file mode 100644 index 0000000..a24c23d --- /dev/null +++ b/examples/interceptors/lib/app/beam_intercept_example_app.dart @@ -0,0 +1,21 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/material.dart'; +import 'package:interceptors/main.dart'; + +class BeamInterceptExampleApp extends StatelessWidget { + const BeamInterceptExampleApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerDelegate: beamerDelegate, + routeInformationParser: BeamerParser(), + backButtonDispatcher: BeamerBackButtonDispatcher(delegate: beamerDelegate), + debugShowCheckedModeBanner: false, + builder: (context, child) => Scaffold( + appBar: AppBar(), + body: child, + ), + ); + } +} diff --git a/examples/interceptors/lib/main.dart b/examples/interceptors/lib/main.dart new file mode 100644 index 0000000..f968431 --- /dev/null +++ b/examples/interceptors/lib/main.dart @@ -0,0 +1,38 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/material.dart'; +import 'package:interceptors/app/beam_intercept_example_app.dart'; +import 'package:interceptors/stack/interceptor_stack_to_block.dart'; +import 'package:interceptors/stack/test_interceptor_stack.dart'; + +final beamerDelegate = BeamerDelegate( + transitionDelegate: const NoAnimationTransitionDelegate(), + beamBackTransitionDelegate: const NoAnimationTransitionDelegate(), + stackBuilder: BeamerStackBuilder( + beamStacks: [ + TestInterceptorStack(), + IntercepterStackToBlock(), + ], + ), +); + +final BeamInterceptor allowNavigatingInterceptor = BeamInterceptor( + intercept: (context, delegate, currentPages, origin, target, deepLink) => false, + enabled: true, // this can be false too + name: 'allow', +); + +final BeamInterceptor blockNavigatingInterceptor = BeamInterceptor( + intercept: (context, delegate, currentPages, origin, target, deepLink) => target is IntercepterStackToBlock, + enabled: true, + name: 'block', +); + +void main() { + Beamer.setPathUrlStrategy(); + runApp( + BeamerProvider( + routerDelegate: beamerDelegate, + child: const BeamInterceptExampleApp(), + ), + ); +} diff --git a/examples/interceptors/lib/screens/allow_screen.dart b/examples/interceptors/lib/screens/allow_screen.dart new file mode 100644 index 0000000..70fa956 --- /dev/null +++ b/examples/interceptors/lib/screens/allow_screen.dart @@ -0,0 +1,40 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/material.dart'; +import 'package:interceptors/main.dart'; + +class AllowScreen extends StatelessWidget { + const AllowScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BeamInterceptorPopScope( + interceptors: [allowNavigatingInterceptor], + beamerDelegate: beamerDelegate, + child: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + context.beamToNamed('/block-this-route'); + + ScaffoldMessenger.of(beamerDelegate.navigator.context).removeCurrentSnackBar(); + ScaffoldMessenger.of(beamerDelegate.navigator.context).showSnackBar( + const SnackBar(content: Text('This route is NOT intercepted and thus NOT blocked.')), + ); + }, + child: const Text('Go to /block-this-route (not blocked)'), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Go to back'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/examples/interceptors/lib/screens/block_screen.dart b/examples/interceptors/lib/screens/block_screen.dart new file mode 100644 index 0000000..0f84951 --- /dev/null +++ b/examples/interceptors/lib/screens/block_screen.dart @@ -0,0 +1,40 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/material.dart'; +import 'package:interceptors/main.dart'; + +class BlockScreen extends StatelessWidget { + const BlockScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BeamInterceptorPopScope( + interceptors: [ + blockNavigatingInterceptor, + ], + beamerDelegate: beamerDelegate, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + context.beamToNamed('/block-this-route'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(beamerDelegate.navigator.context).removeCurrentSnackBar(); + ScaffoldMessenger.of(beamerDelegate.navigator.context).showSnackBar( + const SnackBar(content: Text('This route is intercepted and thus blocked.')), + ); + }); + }, + child: const Text('Go to blocked route'), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Go to back'), + ), + ], + ), + ); + } +} diff --git a/examples/interceptors/lib/stack/interceptor_stack_to_block.dart b/examples/interceptors/lib/stack/interceptor_stack_to_block.dart new file mode 100644 index 0000000..19fa80c --- /dev/null +++ b/examples/interceptors/lib/stack/interceptor_stack_to_block.dart @@ -0,0 +1,50 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/material.dart'; + +class IntercepterStackToBlock extends BeamStack { + @override + List get pathPatterns => ['/block-this-route']; + + @override + List buildPages(BuildContext context, BeamState state) { + final pages = [ + BeamPage( + key: const ValueKey('block-this-route'), + title: 'block-this-route', + type: BeamPageType.noTransition, + child: UnconstrainedBox( + child: SizedBox( + width: 500, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Super secret page', style: Theme.of(context).textTheme.titleLarge), + Padding( + padding: const EdgeInsets.only(top: 16), + child: SizedBox( + height: 50, + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (context.canBeamBack) { + context.beamBack(); + } else if (Navigator.canPop(context)) { + Navigator.pop(context); + } else { + context.beamToNamed('/'); + } + }, + child: const Text('Go back'), + ), + ), + ), + ], + ), + ), + ), + ), + ]; + + return pages; + } +} diff --git a/examples/interceptors/lib/stack/test_interceptor_stack.dart b/examples/interceptors/lib/stack/test_interceptor_stack.dart new file mode 100644 index 0000000..910dc6a --- /dev/null +++ b/examples/interceptors/lib/stack/test_interceptor_stack.dart @@ -0,0 +1,68 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/material.dart'; +import 'package:interceptors/screens/allow_screen.dart'; +import 'package:interceptors/screens/block_screen.dart'; + +class TestInterceptorStack extends BeamStack { + @override + List get pathPatterns => ['/', '/allow', '/block']; + + @override + List buildPages(BuildContext context, BeamState state) { + final pages = [ + BeamPage( + key: const ValueKey('intercepter'), + title: 'Intercepter', + type: BeamPageType.noTransition, + child: UnconstrainedBox( + child: SizedBox( + width: 250, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: SizedBox( + height: 50, + width: double.infinity, + child: ElevatedButton( + onPressed: () => context.beamToNamed('/allow'), + child: const Text('Allow navigating to test'), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: SizedBox( + height: 50, + width: double.infinity, + child: ElevatedButton( + onPressed: () => context.beamToNamed('/block'), + child: const Text('Block navigating to test'), + ), + ), + ), + ], + ), + ), + ), + ), + if (state.uri.toString().contains('allow')) + const BeamPage( + key: ValueKey('allow'), + title: 'Allow', + type: BeamPageType.noTransition, + child: AllowScreen(), + ), + if (state.uri.toString().contains('block')) + const BeamPage( + key: ValueKey('block'), + title: 'Block', + type: BeamPageType.noTransition, + child: BlockScreen(), + ), + ]; + + return pages; + } +} diff --git a/examples/interceptors/pubspec.yaml b/examples/interceptors/pubspec.yaml new file mode 100644 index 0000000..8194024 --- /dev/null +++ b/examples/interceptors/pubspec.yaml @@ -0,0 +1,22 @@ +name: interceptors +description: Interceptors example app. + +publish_to: 'none' + +version: 1.0.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + beamer: + path: ../../package + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/package/.vscode/settings.json b/package/.vscode/settings.json new file mode 100644 index 0000000..d50cea8 --- /dev/null +++ b/package/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.lineLength": 80 +} \ No newline at end of file diff --git a/package/lib/beamer.dart b/package/lib/beamer.dart index a3c3343..53ae2af 100644 --- a/package/lib/beamer.dart +++ b/package/lib/beamer.dart @@ -1,13 +1,15 @@ library beamer; +export 'src/beam_guard.dart'; +export 'src/beam_interceptor.dart'; +export 'src/beam_interceptor_scope.dart'; export 'src/beam_page.dart'; -export 'src/beam_state.dart'; export 'src/beam_stack.dart'; -export 'src/beam_guard.dart'; -export 'src/beamer_parser.dart'; -export 'src/beamer_delegate.dart'; +export 'src/beam_state.dart'; export 'src/beamer.dart'; -export 'src/beamer_provider.dart'; export 'src/beamer_back_button_dispatcher.dart'; +export 'src/beamer_delegate.dart'; +export 'src/beamer_parser.dart'; +export 'src/beamer_provider.dart'; export 'src/stack_builders.dart'; export 'src/transition_delegates.dart'; diff --git a/package/lib/src/beam_guard.dart b/package/lib/src/beam_guard.dart index e5798e3..8173921 100644 --- a/package/lib/src/beam_guard.dart +++ b/package/lib/src/beam_guard.dart @@ -1,9 +1,7 @@ -import 'package:beamer/src/utils.dart'; -import 'package:flutter/widgets.dart'; - -import 'package:beamer/src/beamer_delegate.dart'; -import 'package:beamer/src/beam_stack.dart'; import 'package:beamer/src/beam_page.dart'; +import 'package:beamer/src/beam_stack.dart'; +import 'package:beamer/src/beamer_delegate.dart'; +import 'package:flutter/widgets.dart'; /// Checks whether current [BeamStack] is allowed to be beamed to /// and provides steps to be executed following a failed check. @@ -37,7 +35,7 @@ class BeamGuard { /// but will not match '/books'. To match '/books' and everything after it, /// use '/books*'. /// - /// See [_hasMatch] for more details. + /// See [BeamStack.shouldCheckGuard] for more details. /// /// For RegExp: /// You can use RegExp instances and the delegate will check for a match using [RegExp.hasMatch] @@ -102,7 +100,8 @@ class BeamGuard { /// Whether or not the guard should [check] the [stack]. bool shouldGuard(BeamStack stack) { - return guardNonMatching ? !_hasMatch(stack) : _hasMatch(stack); + final shouldCheckGuard = stack.shouldCheckGuard(this); + return guardNonMatching ? shouldCheckGuard == false : shouldCheckGuard; } /// Applies the guard. @@ -186,34 +185,4 @@ class BeamGuard { return false; } - - /// Matches [stack]'s pathBlueprint to [pathPatterns]. - /// - /// If asterisk is present, it is enough that the pre-asterisk substring is - /// contained within stack's pathPatterns. - /// Else, the path (i.e. the pre-query substring) of the stack's uri - /// must be equal to the pathPattern. - bool _hasMatch(BeamStack stack) { - for (final pathPattern in pathPatterns) { - final path = stack.state.routeInformation.uri.path; - if (pathPattern is String) { - final asteriskIndex = pathPattern.indexOf('*'); - if (asteriskIndex != -1) { - if (stack.state.routeInformation.uri - .toString() - .contains(pathPattern.substring(0, asteriskIndex))) { - return true; - } - } else { - if (pathPattern == path) { - return true; - } - } - } else { - final regexp = Utils.tryCastToRegExp(pathPattern); - return regexp.hasMatch(path); - } - } - return false; - } } diff --git a/package/lib/src/beam_interceptor.dart b/package/lib/src/beam_interceptor.dart new file mode 100644 index 0000000..fad57d7 --- /dev/null +++ b/package/lib/src/beam_interceptor.dart @@ -0,0 +1,48 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/widgets.dart'; + +class BeamInterceptor { + /// Creates a [BeamInterceptor] with defined properties. + /// + /// [name] and [intercept] must not be null. + const BeamInterceptor({ + this.enabled = true, + required this.name, + required this.intercept, + }); + + /// A name of the interceptor. + /// + /// It is used to compare interceptors. + final String name; + + /// Whether the interceptor is enabled. + final bool enabled; + + /// The interceptor function. + /// + /// Returns `true` if the interceptor should be applied and `false` otherwise. + /// + /// The interceptor can be disabled by setting [enabled] to `false`. + /// + /// The targetBeamStack is the [BeamStack] that is beeing pushed or popped to. (destination) + final bool Function( + BuildContext context, + BeamerDelegate delegate, + List currentPages, + BeamStack origin, + BeamStack target, + String? deepLink, + ) intercept; + + @override + bool operator ==(other) { + if (other is! BeamInterceptor) { + return false; + } + return name == other.name; + } + + @override + int get hashCode => name.hashCode; +} diff --git a/package/lib/src/beam_interceptor_scope.dart b/package/lib/src/beam_interceptor_scope.dart new file mode 100644 index 0000000..d40f251 --- /dev/null +++ b/package/lib/src/beam_interceptor_scope.dart @@ -0,0 +1,70 @@ +import 'package:beamer/beamer.dart'; +import 'package:flutter/widgets.dart'; + +/// This works like [PopScope], but with beam-interceptors. +/// +/// See [BeamInterceptor] for more information. +/// +/// If any of the interceptors return true, the pop will not be invoked. +/// +/// This works on Navigator.pop as well as all the Beamer's beaming functions. +/// +/// ```dart +/// BeamInterceptorScope( +/// interceptors: [BeamInterceptor(...), ...], +/// child: Center( +/// child: ElevatedButton( +/// child: const Text('Go back'), +/// onPressed: () => Beamer.of(context).beamToNamed('/'), +/// ), +/// ), +/// ); +/// ``` +class BeamInterceptorScope extends StatefulWidget { + const BeamInterceptorScope({ + required this.child, + required this.interceptors, + this.beamerDelegate, + super.key, + }); + + final Widget child; + + /// The interceptors to check upon any beaming or popping. + final List interceptors; + + /// The [BeamerDelegate] to apply the interceptors to. + final BeamerDelegate? beamerDelegate; + + @override + State createState() => _BeamInterceptorScopeState(); +} + +class _BeamInterceptorScopeState extends State { + late BeamerDelegate beamerDelegate; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + beamerDelegate = widget.beamerDelegate ?? Beamer.of(context); + + for (var interceptor in widget.interceptors) { + beamerDelegate.addInterceptor(interceptor); + } + }); + } + + @override + void dispose() { + for (var interceptor in widget.interceptors) { + beamerDelegate.removeInterceptor(interceptor); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/package/lib/src/beam_page.dart b/package/lib/src/beam_page.dart index 7199fb1..9e5581b 100644 --- a/package/lib/src/beam_page.dart +++ b/package/lib/src/beam_page.dart @@ -54,6 +54,8 @@ class BeamPage extends Page { this.fullScreenDialog = false, this.opaque = true, this.keepQueryOnPop = false, + this.transitionDuration, + this.reverseTransitionDuration, }) : super(key: key, name: name); /// A [BeamPage] to be the default for [BeamerDelegate.notFoundPage]. @@ -206,6 +208,20 @@ class BeamPage extends Page { /// See [BeamPageType] for available types. final BeamPageType type; + /// The transition duration for this [BeamPage]. + /// + /// Defaults to `Duration(milliseconds: 300)`. (use flutter's default) + /// + /// This is not used when [type] is [BeamPageType.cupertino] or [BeamPageType.material]. + final Duration? transitionDuration; + + /// The reverse transition duration for this [BeamPage]. + /// + /// Defaults to `Duration(milliseconds: 300)`. (use flutter's default) + /// + /// This is not used when [type] is [BeamPageType.cupertino] or [BeamPageType.material]. + final Duration? reverseTransitionDuration; + /// A builder for custom [Route] to use in [createRoute]. /// /// `context` is the build context. @@ -249,6 +265,9 @@ class BeamPage extends Page { opaque: opaque, settings: this, pageBuilder: (_, __, ___) => child, + transitionDuration: transitionDuration ?? Duration(milliseconds: 300), + reverseTransitionDuration: + reverseTransitionDuration ?? Duration(milliseconds: 300), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, child: child, @@ -260,6 +279,9 @@ class BeamPage extends Page { opaque: opaque, settings: this, pageBuilder: (_, __, ___) => child, + transitionDuration: transitionDuration ?? Duration(milliseconds: 300), + reverseTransitionDuration: + reverseTransitionDuration ?? Duration(milliseconds: 300), transitionsBuilder: (_, animation, __, child) => SlideTransition( position: animation.drive( Tween(begin: const Offset(0, 1), end: const Offset(0, 0)) @@ -273,6 +295,9 @@ class BeamPage extends Page { opaque: opaque, settings: this, pageBuilder: (_, __, ___) => child, + transitionDuration: transitionDuration ?? Duration(milliseconds: 300), + reverseTransitionDuration: + reverseTransitionDuration ?? Duration(milliseconds: 300), transitionsBuilder: (_, animation, __, child) => SlideTransition( position: animation.drive( Tween(begin: const Offset(1, 0), end: const Offset(0, 0)) @@ -286,6 +311,9 @@ class BeamPage extends Page { opaque: opaque, settings: this, pageBuilder: (_, __, ___) => child, + transitionDuration: transitionDuration ?? Duration(milliseconds: 300), + reverseTransitionDuration: + reverseTransitionDuration ?? Duration(milliseconds: 300), transitionsBuilder: (_, animation, __, child) => SlideTransition( position: animation.drive( Tween(begin: const Offset(-1, 0), end: const Offset(0, 0)) @@ -299,6 +327,9 @@ class BeamPage extends Page { opaque: opaque, settings: this, pageBuilder: (_, __, ___) => child, + transitionDuration: transitionDuration ?? Duration(milliseconds: 300), + reverseTransitionDuration: + reverseTransitionDuration ?? Duration(milliseconds: 300), transitionsBuilder: (_, animation, __, child) => SlideTransition( position: animation.drive( Tween(begin: const Offset(0, -1), end: const Offset(0, 0)) @@ -312,6 +343,9 @@ class BeamPage extends Page { opaque: opaque, settings: this, pageBuilder: (_, __, ___) => child, + transitionDuration: transitionDuration ?? Duration(milliseconds: 300), + reverseTransitionDuration: + reverseTransitionDuration ?? Duration(milliseconds: 300), transitionsBuilder: (_, animation, __, child) => ScaleTransition( scale: animation, child: child, @@ -322,6 +356,8 @@ class BeamPage extends Page { fullscreenDialog: fullScreenDialog, opaque: opaque, settings: this, + transitionDuration: transitionDuration ?? Duration.zero, + reverseTransitionDuration: reverseTransitionDuration ?? Duration.zero, pageBuilder: (context, animation, secondaryAnimation) => child, ); default: diff --git a/package/lib/src/beam_stack.dart b/package/lib/src/beam_stack.dart index aa322f3..acf60bb 100644 --- a/package/lib/src/beam_stack.dart +++ b/package/lib/src/beam_stack.dart @@ -318,7 +318,115 @@ abstract class BeamStack /// Can this handle the [uri] based on its [pathPatterns]. /// /// Can be useful in a custom [BeamerDelegate.stackBuilder]. - bool canHandle(Uri uri) => Utils.canBeamStackHandleUri(this, uri); + bool canHandle(Uri uri) { + for (final pathPattern in pathPatterns) { + if (pathPattern is String) { + // If it is an exact match or asterisk pattern + if (pathPattern == uri.path || + pathPattern == '/*' || + pathPattern == '*') { + return true; + } + + // Clean URI path segments + final uriPathSegments = uri.pathSegments.toList(); + if (uriPathSegments.length > 1 && uriPathSegments.last == '') { + uriPathSegments.removeLast(); + } + + final pathPatternSegments = Uri.parse(pathPattern).pathSegments; + + // If we're in strict mode and URI has fewer segments than pattern, + // we don't have a match so can continue. + if (strictPathPatterns && + uriPathSegments.length < pathPatternSegments.length) { + continue; + } + + // If URI has more segments and pattern doesn't end with asterisk, + // we don't have a match so can continue. + if (uriPathSegments.length > pathPatternSegments.length && + (pathPatternSegments.isEmpty || + !pathPatternSegments.last.endsWith('*'))) { + continue; + } + + var checksPassed = true; + // Iterating through URI segments + for (var i = 0; i < uriPathSegments.length; i++) { + // If all checks have passed up to i, + // if pattern has no more segments to traverse and it ended with asterisk, + // it is a match and we can break, + if (pathPatternSegments.length < i + 1 && + pathPatternSegments.last.endsWith('*')) { + checksPassed = true; + break; + } + + // If pattern has asterisk at i-th position, + // anything matches and we can continue. + if (pathPatternSegments[i] == '*') { + continue; + } + // If they are not the same and pattern doesn't expects path parameter, + // there's no match and we can break. + if (uriPathSegments[i] != pathPatternSegments[i] && + !pathPatternSegments[i].startsWith(':')) { + checksPassed = false; + break; + } + } + // If no check failed, beamStack can handle this URI. + if (checksPassed) { + return true; + } + } else { + final regex = pathPattern.toRegExp; + final hasMatch = regex.hasMatch(uri.toString()); + + if (hasMatch) { + return true; + } else { + continue; + } + } + } + return false; + } + + /// Matches pathBlueprint to [pathPatterns]. + /// + /// If asterisk is present, it is enough that the pre-asterisk substring is + /// contained within stack's pathPatterns. + /// Else, the path (i.e. the pre-query substring) of the stack's uri + /// must be equal to the pathPattern. + bool shouldCheckGuard(BeamGuard guard) { + for (final guardPathPattern in guard.pathPatterns) { + final uri = state.routeInformation.uri; + final path = uri.path; + + if (guardPathPattern is! String) { + final regex = guardPathPattern.toRegExp; + final hasMatch = regex.hasMatch(path); + + if (hasMatch) { + return true; + } else { + continue; + } + } + + final asteriskIndex = guardPathPattern.indexOf('*'); + + if (asteriskIndex == -1) return guardPathPattern == path; + + return uri + .toString() + .contains(guardPathPattern.substring(0, asteriskIndex)); + } + + return false; + } /// Gives the ability to wrap the [navigator]. /// @@ -360,6 +468,8 @@ abstract class BeamStack /// If this is false (default), then a path pattern '/some/path' will match /// '/' and '/some' and '/some/path'. /// If this is true, then it will match just '/some/path'. + /// + /// __This only applies if the pattern is of type STRING, not REGEXP__ bool get strictPathPatterns => false; /// Creates and returns the list of pages to be built by the [Navigator] @@ -577,10 +687,13 @@ class RoutesBeamStack extends BeamStack { matched[route] = createMatch(path, uri.queryParameters); } } else { - final regexp = Utils.tryCastToRegExp(route); - if (regexp.hasMatch(uri.toString())) { - final path = uri.toString(); - matched[regexp] = createMatch(path, uri.queryParameters); + final regex = route.toRegExp; + final path = uri.toString(); + + final hasMatch = regex.hasMatch(path); + + if (hasMatch) { + matched[regex] = createMatch(path, uri.queryParameters); } } } diff --git a/package/lib/src/beamer.dart b/package/lib/src/beamer.dart index f66d2d4..84d5ba6 100644 --- a/package/lib/src/beamer.dart +++ b/package/lib/src/beamer.dart @@ -1,8 +1,7 @@ import 'package:beamer/beamer.dart'; -import 'package:flutter/widgets.dart'; - import 'package:beamer/src/path_url_strategy_nonweb.dart' if (dart.library.html) 'path_url_strategy_web.dart' as url_strategy; +import 'package:flutter/widgets.dart'; /// Represents a navigation area and is a wrapper for [Router]. /// @@ -48,6 +47,20 @@ class Beamer extends StatefulWidget { } } + /// Access Beamer's [routerDelegate]. + /// + /// Giving `true` to [root] gets the root beamer if the closest beamer is + /// nested under another beamer. + /// + /// This is the same as `Beamer.of(context, root: true)`. But returns `null` if no Beamer is found in the context. + static BeamerDelegate? maybeOf(BuildContext context, {bool root = false}) { + try { + return of(context, root: root); + } catch (e) { + return null; + } + } + /// Change the strategy to use for handling browser URL to `PathUrlStrategy`. /// /// `PathUrlStrategy` uses the browser URL's pathname to represent Beamer's route name. diff --git a/package/lib/src/beamer_back_button_dispatcher.dart b/package/lib/src/beamer_back_button_dispatcher.dart index 88fc547..328cdcf 100644 --- a/package/lib/src/beamer_back_button_dispatcher.dart +++ b/package/lib/src/beamer_back_button_dispatcher.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; - import 'package:beamer/src/beamer_delegate.dart'; +import 'package:flutter/material.dart'; /// Overrides default back button behavior in [RootBackButtonDispatcher] /// to do custom [onBack] or [BeamerDelegate.beamBack]. diff --git a/package/lib/src/beamer_delegate.dart b/package/lib/src/beamer_delegate.dart index c6a4140..68fdb4f 100644 --- a/package/lib/src/beamer_delegate.dart +++ b/package/lib/src/beamer_delegate.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:beamer/beamer.dart'; import 'package:beamer/src/browser_tab_title_util_non_web.dart' if (dart.library.html) 'package:beamer/src/browser_tab_title_util_web.dart' @@ -40,7 +42,9 @@ class BeamerDelegate extends RouterDelegate this.updateFromParent = true, this.updateParent = true, this.clearBeamingHistoryOn = const {}, + List? interceptors, }) { + this.interceptors = interceptors ?? []; _currentBeamParameters = BeamParameters( transitionDelegate: transitionDelegate, ); @@ -216,6 +220,8 @@ class BeamerDelegate extends RouterDelegate /// and stack of pages will be updated as is configured in [BeamGuard]. final List guards; + late final List interceptors; + /// The list of observers for the [Navigator]. final List navigatorObservers; @@ -432,13 +438,22 @@ class BeamerDelegate extends RouterDelegate // run guards on _beamStackCandidate final context = _context; if (context != null) { - final didApply = _runGuards(context, _beamStackCandidate); + final didGuardsBlock = _runGuards(context, _beamStackCandidate); + _didRunGuards = true; - if (didApply) { + + if (didGuardsBlock) { return; } else { // TODO revert configuration if guard just blocked navigation } + + // run interceptors on _beamStackCandidate + final isIntercepted = _isIntercepted(context, _beamStackCandidate); + + if (isIntercepted) { + return; + } } // adds the candidate to history @@ -758,7 +773,7 @@ class BeamerDelegate extends RouterDelegate _context = context; if (!_didRunGuards) { - _runGuards(_context!, _beamStackCandidate); + _runGuards(context, _beamStackCandidate); _addToBeamingHistory(_beamStackCandidate); } if (!_initialConfigurationReady && active && parent != null) { @@ -848,7 +863,12 @@ class BeamerDelegate extends RouterDelegate } bool _runGuards(BuildContext context, BeamStack targetBeamStack) { - final allGuards = (parent?.guards ?? []) + guards + targetBeamStack.guards; + final allGuards = [ + ...?parent?.guards, + ...guards, + ...targetBeamStack.guards + ]; + for (final guard in allGuards) { if (guard.shouldGuard(targetBeamStack)) { final wasApplied = guard.apply( @@ -869,6 +889,29 @@ class BeamerDelegate extends RouterDelegate return false; } + bool _isIntercepted(BuildContext context, BeamStack targetBeamStack) { + final allInterceptors = [...?parent?.interceptors, ...interceptors]; + + for (var i = 0; i < allInterceptors.length; i++) { + final interceptor = allInterceptors[i]; + final isIntercepted = interceptor.enabled && + interceptor.intercept( + context, + this, + _currentPages, + currentBeamStack, + targetBeamStack, + _deepLink, + ); + + // If any interceptor was intercepted, return true + if (isIntercepted) { + return true; + } + } + return false; + } + void _initBeamStack(BeamStack beamStack) { beamStack.initState(); beamStack.onUpdate(); @@ -1072,6 +1115,20 @@ class BeamerDelegate extends RouterDelegate } } + FutureOr addInterceptor(BeamInterceptor interceptor) async { + if (interceptors.contains(interceptor)) return; + + interceptors.add(interceptor); + } + + FutureOr removeInterceptor(BeamInterceptor interceptor) async { + if (interceptors.contains(interceptor) == false) return; + + interceptors.remove(interceptor); + } + + void removeAllInterceptors() => interceptors.clear(); + void _update() => update(); // Updates only if it can handle the configuration diff --git a/package/lib/src/transition_delegates.dart b/package/lib/src/transition_delegates.dart index fefd162..ec9b193 100644 --- a/package/lib/src/transition_delegates.dart +++ b/package/lib/src/transition_delegates.dart @@ -29,7 +29,9 @@ class NoAnimationTransitionDelegate extends TransitionDelegate { final pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; if (pagelessRoutes != null) { for (final pagelessRoute in pagelessRoutes) { - if (pagelessRoute.isWaitingForExitingDecision) pagelessRoute.markForRemove(); + if (pagelessRoute.isWaitingForExitingDecision) { + pagelessRoute.markForRemove(); + } } } } diff --git a/package/lib/src/utils.dart b/package/lib/src/utils.dart index 7fbbf86..454e0f0 100644 --- a/package/lib/src/utils.dart +++ b/package/lib/src/utils.dart @@ -19,7 +19,7 @@ abstract class Utils { BeamParameters? beamParameters, }) { for (final beamStack in beamStacks) { - if (canBeamStackHandleUri(beamStack, uri)) { + if (beamStack.canHandle(uri)) { final routeInformation = RouteInformation(uri: uri, state: routeState); if (!beamStack.isCurrent) { beamStack.create(routeInformation, beamParameters); @@ -32,161 +32,87 @@ abstract class Utils { return NotFound(path: uri.path); } - /// Can a [beamStack], depending on its `pathPatterns`, handle the [uri]. + /// Creates a state for [BeamStack] based on incoming [uri]. /// - /// Used in [BeamStack.canHandle] and [chooseBeamStack]. - static bool canBeamStackHandleUri(BeamStack beamStack, Uri uri) { - for (final pathPattern in beamStack.pathPatterns) { - if (pathPattern is String) { - // If it is an exact match or asterisk pattern - if (pathPattern == uri.path || - pathPattern == '/*' || - pathPattern == '*') { - return true; + /// Used in [BeamState.copyForStack]. + static BeamState createBeamState( + Uri uri, { + BeamStack? beamStack, + Object? routeState, + }) { + final pathParameters = {}; + + for (final pathPattern in [...?beamStack?.pathPatterns]) { + if (pathPattern is! String) { + final pathPatternRegex = pathPattern.toRegExp; + final url = uri.toString(); + + if (pathPatternRegex.hasMatch(url)) { + final matches = pathPatternRegex.allMatches(url); + + for (final match in matches) { + for (final groupName in match.groupNames) { + pathParameters[groupName] = match.namedGroup(groupName) ?? ''; + } + } + } + } else { + if (pathPattern == uri.path || pathPattern == '/*') { + return BeamState( + pathPatternSegments: uri.pathSegments, + queryParameters: uri.queryParameters, + routeState: routeState, + ); } - // Clean URI path segments final uriPathSegments = uri.pathSegments.toList(); if (uriPathSegments.length > 1 && uriPathSegments.last == '') { uriPathSegments.removeLast(); } - final pathPatternSegments = Uri.parse(pathPattern).pathSegments; + final beamStackPathBlueprintSegments = + Uri.parse(pathPattern).pathSegments; + var pathSegments = []; - // If we're in strict mode and URI has fewer segments than pattern, - // we don't have a match so can continue. - if (beamStack.strictPathPatterns && - uriPathSegments.length < pathPatternSegments.length) { - continue; - } - - // If URI has more segments and pattern doesn't end with asterisk, - // we don't have a match so can continue. - if (uriPathSegments.length > pathPatternSegments.length && - (pathPatternSegments.isEmpty || - !pathPatternSegments.last.endsWith('*'))) { - continue; - } + if (uriPathSegments.length > beamStackPathBlueprintSegments.length && + !beamStackPathBlueprintSegments.contains('*')) continue; var checksPassed = true; - // Iterating through URI segments + for (var i = 0; i < uriPathSegments.length; i++) { - // If all checks have passed up to i, - // if pattern has no more segments to traverse and it ended with asterisk, - // it is a match and we can break, - if (pathPatternSegments.length < i + 1 && - pathPatternSegments.last.endsWith('*')) { + if (beamStackPathBlueprintSegments[i] == '*') { + pathSegments = uriPathSegments.toList(); checksPassed = true; break; } - // If pattern has asterisk at i-th position, - // anything matches and we can continue. - if (pathPatternSegments[i] == '*') { - continue; - } - // If they are not the same and pattern doesn't expects path parameter, - // there's no match and we can break. - if (uriPathSegments[i] != pathPatternSegments[i] && - !pathPatternSegments[i].startsWith(':')) { + if (uriPathSegments[i] != beamStackPathBlueprintSegments[i] && + beamStackPathBlueprintSegments[i][0] != ':') { checksPassed = false; break; + } else if (beamStackPathBlueprintSegments[i][0] == ':') { + pathParameters[beamStackPathBlueprintSegments[i].substring(1)] = + uriPathSegments[i]; + pathSegments.add(beamStackPathBlueprintSegments[i]); + } else { + pathSegments.add(uriPathSegments[i]); } } - // If no check failed, beamStack can handle this URI. if (checksPassed) { - return true; + return BeamState( + pathPatternSegments: pathSegments, + pathParameters: pathParameters, + queryParameters: uri.queryParameters, + routeState: routeState, + ); } - } else { - final regexp = tryCastToRegExp(pathPattern); - return regexp.hasMatch(uri.toString()); } } - return false; - } - /// Creates a state for [BeamStack] based on incoming [uri]. - /// - /// Used in [BeamState.copyForStack]. - static BeamState createBeamState( - Uri uri, { - BeamStack? beamStack, - Object? routeState, - }) { - if (beamStack != null) { - // TODO: abstract this and reuse in canBeamStackHandleUri - for (final pathBlueprint in beamStack.pathPatterns) { - if (pathBlueprint is String) { - if (pathBlueprint == uri.path || pathBlueprint == '/*') { - BeamState( - pathPatternSegments: uri.pathSegments, - queryParameters: uri.queryParameters, - routeState: routeState, - ); - } - final uriPathSegments = uri.pathSegments.toList(); - if (uriPathSegments.length > 1 && uriPathSegments.last == '') { - uriPathSegments.removeLast(); - } - final beamStackPathBlueprintSegments = - Uri.parse(pathBlueprint).pathSegments; - var pathSegments = []; - final pathParameters = {}; - if (uriPathSegments.length > beamStackPathBlueprintSegments.length && - !beamStackPathBlueprintSegments.contains('*')) { - continue; - } - var checksPassed = true; - for (var i = 0; i < uriPathSegments.length; i++) { - if (beamStackPathBlueprintSegments[i] == '*') { - pathSegments = uriPathSegments.toList(); - checksPassed = true; - break; - } - if (uriPathSegments[i] != beamStackPathBlueprintSegments[i] && - beamStackPathBlueprintSegments[i][0] != ':') { - checksPassed = false; - break; - } else if (beamStackPathBlueprintSegments[i][0] == ':') { - pathParameters[beamStackPathBlueprintSegments[i].substring(1)] = - uriPathSegments[i]; - pathSegments.add(beamStackPathBlueprintSegments[i]); - } else { - pathSegments.add(uriPathSegments[i]); - } - } - if (checksPassed) { - return BeamState( - pathPatternSegments: pathSegments, - pathParameters: pathParameters, - queryParameters: uri.queryParameters, - routeState: routeState, - ); - } - } else { - final regexp = tryCastToRegExp(pathBlueprint); - final pathParameters = {}; - final url = uri.toString(); - - if (regexp.hasMatch(url)) { - regexp.allMatches(url).forEach((match) { - for (final groupName in match.groupNames) { - pathParameters[groupName] = match.namedGroup(groupName) ?? ''; - } - }); - return BeamState( - pathPatternSegments: uri.pathSegments, - pathParameters: pathParameters, - queryParameters: uri.queryParameters, - routeState: routeState, - ); - } - } - } - } return BeamState( pathPatternSegments: uri.pathSegments, queryParameters: uri.queryParameters, + pathParameters: pathParameters, routeState: routeState, ); } @@ -210,26 +136,11 @@ abstract class Utils { } return true; } else { - final regExpPattern = tryCastToRegExp(pattern); + final regExpPattern = pattern.toRegExp; return regExpPattern.hasMatch(exact.toString()); } } - /// Wraps the casting of pathBlueprint to RegExp inside a try-catch - /// and throws a nice FlutterError. - static RegExp tryCastToRegExp(Pattern pathBlueprint) { - try { - return pathBlueprint as RegExp; - } on TypeError catch (_) { - throw FlutterError.fromParts([ - DiagnosticsNode.message('Path blueprint can either be:', - level: DiagnosticLevel.summary), - DiagnosticsNode.message('1. String'), - DiagnosticsNode.message('2. RegExp instance') - ]); - } - } - /// Removes the trailing / in an URI path and returns the new URI. /// /// If there is no trailing /, returns the input URI. @@ -305,3 +216,21 @@ extension BeamerRouteInformationExtension on RouteInformation { return uri == other.uri && state == other.state; } } + +/// Some convenient extension methods on [Pattern]. +extension PatternExtension on Pattern { + /// Wraps the casting of this pattern to RegExp inside a try-catch + /// and throws a nice FlutterError. + RegExp get toRegExp { + try { + return this as RegExp; + } on TypeError catch (_) { + throw FlutterError.fromParts([ + DiagnosticsNode.message('Path blueprint can either be:', + level: DiagnosticLevel.summary), + DiagnosticsNode.message('1. String'), + DiagnosticsNode.message('2. RegExp instance') + ]); + } + } +} diff --git a/package/test/beaming_history_test.dart b/package/test/beaming_history_test.dart index 67fc020..0bd2fc8 100644 --- a/package/test/beaming_history_test.dart +++ b/package/test/beaming_history_test.dart @@ -1,6 +1,7 @@ import 'package:beamer/beamer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + import 'test_stacks.dart'; class StackA extends BeamStack { diff --git a/package/test/utils_test.dart b/package/test/utils_test.dart index 9ead37f..788b8aa 100644 --- a/package/test/utils_test.dart +++ b/package/test/utils_test.dart @@ -119,7 +119,7 @@ void main() { test('tryCastToRegExp throws', () { expect( - () => Utils.tryCastToRegExp('not-regexp'), + () => 'not-regexp'.toRegExp, throwsA(isA()), ); });