diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29197a1..f22de49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: working-directory: ./example run: flutter pub get + - name: Test + run: flutter test + - name: Build iOS working-directory: ./example run: flutter build ios --simulator --no-codesign diff --git a/CHANGELOG.md b/CHANGELOG.md index b3db56f..6266087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Next +- chore: Allow overriding the route filtering using a ctor param `routeFilter` ([#95](https://github.com/PostHog/posthog-flutter/pull/95)) + +```dart +bool myRouteFilter(Route? route) => + route is PageRoute || route is OverlayRoute; +final observer = PosthogObserver(routeFilter: myRouteFilter); +``` + ## 4.3.0 - add PrivacyInfo ([#94](https://github.com/PostHog/posthog-flutter/pull/94)) diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 65c5ffe..d955fc3 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -4,7 +4,7 @@ class Posthog { static PosthogFlutterPlatformInterface get _posthog => PosthogFlutterPlatformInterface.instance; - static final Posthog _instance = Posthog._internal(); + static final _instance = Posthog._internal(); factory Posthog() { return _instance; diff --git a/lib/src/posthog_observer.dart b/lib/src/posthog_observer.dart index e973924..3482e67 100644 --- a/lib/src/posthog_observer.dart +++ b/lib/src/posthog_observer.dart @@ -4,47 +4,76 @@ import 'posthog.dart'; typedef ScreenNameExtractor = String? Function(RouteSettings settings); +/// [PostHogRouteFilter] allows to filter out routes that should not be tracked. +/// +/// By default, only [PageRoute]s are tracked. +typedef PostHogRouteFilter = bool Function(Route? route); + String? defaultNameExtractor(RouteSettings settings) => settings.name; -class PosthogObserver extends RouteObserver> { - PosthogObserver({ScreenNameExtractor nameExtractor = defaultNameExtractor}) - : _nameExtractor = nameExtractor; +bool defaultPostHogRouteFilter(Route? route) => route is PageRoute; + +class PosthogObserver extends RouteObserver> { + PosthogObserver( + {ScreenNameExtractor nameExtractor = defaultNameExtractor, + PostHogRouteFilter routeFilter = defaultPostHogRouteFilter}) + : _nameExtractor = nameExtractor, + _routeFilter = routeFilter; final ScreenNameExtractor _nameExtractor; - void _sendScreenView(PageRoute route) { - String? screenName = _nameExtractor(route.settings); - if (screenName != null) { + final PostHogRouteFilter _routeFilter; + + bool _isTrackeableRoute(String? name) { + return name != null && name.trim().isNotEmpty; + } + + void _sendScreenView(Route? route) { + if (route == null) { + return; + } + + var screenName = _nameExtractor(route.settings); + if (_isTrackeableRoute(screenName)) { // if the screen name is the root route, we send it as root ("/") instead of only "/" if (screenName == '/') { screenName = 'root (\'/\')'; } - Posthog().screen(screenName: screenName); + Posthog().screen(screenName: screenName!); } } @override void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); - if (route is PageRoute) { - _sendScreenView(route); + + if (!_routeFilter(route)) { + return; } + + _sendScreenView(route); } @override void didReplace({Route? newRoute, Route? oldRoute}) { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - if (newRoute is PageRoute) { - _sendScreenView(newRoute); + + if (!_routeFilter(newRoute)) { + return; } + + _sendScreenView(newRoute); } @override void didPop(Route route, Route? previousRoute) { super.didPop(route, previousRoute); - if (previousRoute is PageRoute && route is PageRoute) { - _sendScreenView(previousRoute); + + if (!_routeFilter(previousRoute)) { + return; } + + _sendScreenView(previousRoute); } } diff --git a/pubspec.yaml b/pubspec.yaml index 3721730..dbb0b52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,9 @@ dependencies: plugin_platform_interface: ^2.0.2 dev_dependencies: - flutter_lints: ^2.0.0 + flutter_lints: ^3.0.0 + flutter_test: + sdk: flutter # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart new file mode 100644 index 0000000..fd67908 --- /dev/null +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -0,0 +1,13 @@ +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; + +class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { + String? screenName; + + @override + Future screen({ + required String screenName, + Map? properties, + }) async { + this.screenName = screenName; + } +} diff --git a/test/posthog_observer_test.dart b/test/posthog_observer_test.dart new file mode 100644 index 0000000..c51dd03 --- /dev/null +++ b/test/posthog_observer_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_flutter_io.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'package:posthog_flutter/src/posthog_observer.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + PageRoute route(RouteSettings? settings) => PageRouteBuilder( + pageBuilder: (_, __, ___) => Container(), + settings: settings, + ); + + final fake = PosthogFlutterPlatformFake(); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + PosthogFlutterPlatformInterface.instance = fake; + }); + + tearDown(() { + fake.screenName = null; + PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); + }); + + PosthogObserver getSut( + {ScreenNameExtractor nameExtractor = defaultNameExtractor, + PostHogRouteFilter routeFilter = defaultPostHogRouteFilter}) { + return PosthogObserver( + nameExtractor: nameExtractor, routeFilter: routeFilter); + } + + test('returns current route name', () { + final currentRoute = route(const RouteSettings(name: 'Current Route')); + + final sut = getSut(); + sut.didPush(currentRoute, null); + + expect(fake.screenName, 'Current Route'); + }); + + test('returns overriden route name', () { + final currentRoute = route(const RouteSettings(name: 'Current Route')); + + String? nameExtractor(RouteSettings settings) => 'overriden'; + + final sut = getSut(nameExtractor: nameExtractor); + sut.didPush(currentRoute, null); + + expect(fake.screenName, 'overriden'); + }); + + test('returns overriden root route name', () { + final currentRoute = route(const RouteSettings(name: '/')); + + final sut = getSut(); + sut.didPush(currentRoute, null); + + expect(fake.screenName, 'root (\'/\')'); + }); + + test('does not capture not named routes', () { + final currentRoute = route(const RouteSettings(name: null)); + + final sut = getSut(); + sut.didPush(currentRoute, null); + + expect(fake.screenName, null); + }); + + test('does not capture blank routes', () { + final currentRoute = route(const RouteSettings(name: ' ')); + + final sut = getSut(); + sut.didPush(currentRoute, null); + + expect(fake.screenName, null); + }); + + test('does not capture filtered routes', () { + // CustomOverlawRoute isn't a PageRoute + final overlayRoute = CustomOverlawRoute( + settings: const RouteSettings(name: 'Overlay Route'), + ); + + final sut = getSut(); + sut.didPush(overlayRoute, null); + + expect(fake.screenName, null); + }); + + test('allows overriding the route filter', () { + final overlayRoute = CustomOverlawRoute( + settings: const RouteSettings(name: 'Overlay Route'), + ); + + bool defaultPostHogRouteFilter(Route? route) => + route is PageRoute || route is OverlayRoute; + + final sut = getSut(routeFilter: defaultPostHogRouteFilter); + sut.didPush(overlayRoute, null); + + expect(fake.screenName, 'Overlay Route'); + }); +} + +class CustomOverlawRoute extends OverlayRoute { + CustomOverlawRoute({super.settings}); + + @override + Iterable createOverlayEntries() { + return []; + } +}