From 8120f7b85f029265343d6f85107df3c3b7cc21cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 19:04:03 +0000 Subject: [PATCH] feat: replace inner Navigator with Overlay for 5.0.0 AppLock now layers lock and inactive screens via OverlayEntry instead of a nested Navigator, fixing Hero animations (#31) and Android back navigation after dialogs (#35). - Imperative overlay sync with first-frame initialEntries - Defer background lock until app resumes when timer fires off-screen - Offstage app content when lock or inactive overlay is visible - Remove deprecated lockScreen, enabled, and backgroundLockLatency APIs - Add overlay and navigation regression tests - Extend example app with Hero and dialog/back demos Co-authored-by: Tom Alabaster --- CHANGELOG.md | 6 + README.md | 2 +- example/lib/screens/my_home_page.dart | 66 +++++- example/pubspec.lock | 2 +- lib/src/app_lock.dart | 299 ++++++++++++++++++-------- lib/src/no_animation_page.dart | 57 ----- pubspec.yaml | 2 +- test/src/app_lock_overlay_test.dart | 246 +++++++++++++++++++++ 8 files changed, 527 insertions(+), 153 deletions(-) delete mode 100644 lib/src/no_animation_page.dart create mode 100644 test/src/app_lock_overlay_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5455fe2..4a45e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 5.0.0 - 1st June 2026 + +- **Breaking change:** `AppLock` now uses an `Overlay` instead of a nested `Navigator` to show the lock and inactive screens. This fixes Hero animations ([#31](https://github.com/tomalabaster/flutter_app_lock/issues/31)) and Android back navigation after dialogs ([#35](https://github.com/tomalabaster/flutter_app_lock/issues/35)). +- **Breaking change:** removed deprecated `lockScreen`, `enabled`, and `backgroundLockLatency` constructor parameters. Use `lockScreenBuilder`, `initiallyEnabled`, and `initialBackgroundLockLatency` instead. +- Background lock is deferred until the app returns to the foreground when the latency timer fires while the app is still backgrounded. + ## 4.3.0 - 19th August 2025 - `InactiveBehavior` enum to control whether the widget returned by [AppLock.inactiveBuilder] is shown only when [AppLock] is enabled or whether it should always been shown diff --git a/README.md b/README.md index e24b62f..58d09fa 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ In your flutter project add the dependency: ```yaml dependencies: ... - flutter_app_lock: ^4.2.0+2 + flutter_app_lock: ^5.0.0 ``` For help getting started with Flutter, view the online documentation. diff --git a/example/lib/screens/my_home_page.dart b/example/lib/screens/my_home_page.dart index ccefe69..cf486e3 100644 --- a/example/lib/screens/my_home_page.dart +++ b/example/lib/screens/my_home_page.dart @@ -35,8 +35,13 @@ class _MyHomePageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - 'You have pushed the button this many times:', + const Hero( + tag: 'CounterIntroText', + child: Material( + child: Text( + 'You have pushed the button this many times:', + ), + ), ), Text( '$_counter', @@ -76,6 +81,63 @@ class _MyHomePageState extends State { onPressed: () => AppLock.of(context)! .setBackgroundLockLatency(const Duration(seconds: 5)), ), + ElevatedButton( + key: const Key('HeroTest'), + child: const Text('Hero test'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Hero detail')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Hero( + tag: 'CounterIntroText', + child: Material(child: Text('Hero detail')), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Pop'), + ), + ], + ), + ), + ), + ), + ), + ), + ElevatedButton( + key: const Key('DialogBackTest'), + child: const Text('Dialog then back test'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: const Text('Second screen')), + body: Center( + child: ElevatedButton( + key: const Key('ShowDialog'), + onPressed: () async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + content: const Text('Example dialog'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + }, + child: const Text('Show dialog'), + ), + ), + ), + ), + ), + ), ], ), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 84ee02e..9767852 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -76,7 +76,7 @@ packages: path: ".." relative: true source: path - version: "4.3.0" + version: "5.0.0" flutter_driver: dependency: "direct dev" description: flutter diff --git a/lib/src/app_lock.dart b/lib/src/app_lock.dart index 9e0b687..bd1a0df 100644 --- a/lib/src/app_lock.dart +++ b/lib/src/app_lock.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_app_lock/src/no_animation_page.dart'; +import 'package:flutter/scheduler.dart'; /// [InactiveBehavior] controls whether the widget returned by /// [AppLock.inactiveBuilder] is shown only when [AppLock] is enabled or @@ -61,48 +61,21 @@ enum InactiveBehavior { /// [Duration] argument. class AppLock extends StatefulWidget { final Widget Function(BuildContext context, Object? launchArg) builder; - final Widget? lockScreen; - final WidgetBuilder? lockScreenBuilder; + final WidgetBuilder lockScreenBuilder; final WidgetBuilder? inactiveBuilder; final InactiveBehavior inactiveBehavior; - final bool _initiallyEnabled; - final Duration _initialBackgroundLockLatency; + final bool initiallyEnabled; + final Duration initialBackgroundLockLatency; const AppLock({ super.key, required this.builder, - @Deprecated( - 'Use `lockScreenBuilder` instead. `lockScreen` will be removed in version 5.0.0.') - this.lockScreen, - this.lockScreenBuilder, + required this.lockScreenBuilder, this.inactiveBuilder, this.inactiveBehavior = InactiveBehavior.showWhenEnabled, - @Deprecated( - 'Use `initiallyEnabled` instead. `enabled` will be removed in version 5.0.0.') - bool? enabled, - bool? initiallyEnabled, - @Deprecated( - 'Use `initialBackgroundLockLatency` instead. `backgroundLockLatency` will be removed in version 5.0.0.') - Duration? backgroundLockLatency, - Duration? initialBackgroundLockLatency, - }) : _initiallyEnabled = initiallyEnabled ?? enabled ?? true, - _initialBackgroundLockLatency = initialBackgroundLockLatency ?? - backgroundLockLatency ?? - Duration.zero, - assert( - (lockScreen == null && lockScreenBuilder != null) || - (lockScreen != null && lockScreenBuilder == null), - 'Only 1 of either `lockScreenBuilder` or `lockScreen` should be set.'), - assert( - (enabled == null && initiallyEnabled != null) || - (enabled != null && initiallyEnabled == null), - 'Only 1 of either `initiallyEnabled` or `enabled` should be set.'), - assert( - (backgroundLockLatency == null && - initialBackgroundLockLatency != null) || - (backgroundLockLatency != null && - initialBackgroundLockLatency == null), - 'Only 1 of either `initialBackgroundLockLatency` or `backgroundLockLatency` should be set.'); + this.initiallyEnabled = true, + this.initialBackgroundLockLatency = Duration.zero, + }); static AppLockState? of(BuildContext context) => context.findAncestorStateOfType(); @@ -112,6 +85,12 @@ class AppLock extends StatefulWidget { } class AppLockState extends State with WidgetsBindingObserver { + final GlobalKey _overlayKey = GlobalKey(); + + late final OverlayEntry _appOverlayEntry; + late final OverlayEntry _lockScreenOverlayEntry; + OverlayEntry? _inactiveOverlayEntry; + late bool _didUnlockForAppLaunch; late bool _locked; late bool _enabled; @@ -120,10 +99,29 @@ class AppLockState extends State with WidgetsBindingObserver { late Duration _backgroundLockLatency; Timer? _backgroundLockLatencyTimer; + bool _pendingBackgroundLock = false; + bool _overlaySyncScheduled = false; + bool _isFirstBuild = true; Object? _launchArg; - Completer? _didUnlockCompleter; + Completer? _didUnlockCompleter; + + @visibleForTesting + OverlayEntry get appOverlayEntry => _appOverlayEntry; + + @visibleForTesting + OverlayEntry get lockScreenOverlayEntry => _lockScreenOverlayEntry; + + @visibleForTesting + OverlayEntry? get inactiveOverlayEntry => _inactiveOverlayEntry; + + bool get _shouldShowInactive => + _inactive && + widget.inactiveBuilder != null && + (widget.inactiveBehavior == InactiveBehavior.alwaysShow || + (widget.inactiveBehavior == InactiveBehavior.showWhenEnabled && + _enabled)); @override void initState() { @@ -131,12 +129,30 @@ class AppLockState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); - _didUnlockForAppLaunch = !widget._initiallyEnabled; - _locked = widget._initiallyEnabled; - _enabled = widget._initiallyEnabled; + _appOverlayEntry = OverlayEntry( + maintainState: true, + builder: (context) => Offstage( + offstage: _locked || _shouldShowInactive, + child: widget.builder(context, _launchArg), + ), + ); + + _lockScreenOverlayEntry = OverlayEntry( + builder: (context) => _lockScreen, + ); + + if (widget.inactiveBuilder != null) { + _inactiveOverlayEntry = OverlayEntry( + builder: (context) => widget.inactiveBuilder!(context), + ); + } + + _didUnlockForAppLaunch = !widget.initiallyEnabled; + _locked = widget.initiallyEnabled; + _enabled = widget.initiallyEnabled; _inactive = false; - _backgroundLockLatency = widget._initialBackgroundLockLatency; + _backgroundLockLatency = widget.initialBackgroundLockLatency; } @override @@ -147,19 +163,29 @@ class AppLockState extends State with WidgetsBindingObserver { _inactive = state == AppLifecycleState.inactive; }); + if (state == AppLifecycleState.resumed) { + _backgroundLockLatencyTimer?.cancel(); + + if (_pendingBackgroundLock && _enabled && !_locked) { + _pendingBackgroundLock = false; + unawaited(showLockScreen()); + } + } + if (!_enabled) { + _scheduleSyncOverlays(); return; } if (state == AppLifecycleState.hidden && !_locked) { _backgroundLockLatencyTimer?.cancel(); - _backgroundLockLatencyTimer = - Timer(_backgroundLockLatency, () => showLockScreen()); + _backgroundLockLatencyTimer = Timer( + _backgroundLockLatency, + _onBackgroundLockTimerFired, + ); } - if (state == AppLifecycleState.resumed) { - _backgroundLockLatencyTimer?.cancel(); - } + _scheduleSyncOverlays(); } @override @@ -173,50 +199,56 @@ class AppLockState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - return Navigator( - onPopPage: (route, result) => route.didPop(result), - pages: [ - if (_didUnlockForAppLaunch) - MaterialPage( - key: const ValueKey('App'), - child: widget.builder(context, _launchArg), - ), - if (_locked) - MaterialPage( - key: const ValueKey('LockScreen'), - child: _lockScreen, - ) - else if ((_inactive && widget.inactiveBuilder != null) && - ((widget.inactiveBehavior == InactiveBehavior.alwaysShow) || - ((widget.inactiveBehavior == - InactiveBehavior.showWhenEnabled) && - _enabled))) - NoAnimationPage( - key: const ValueKey('InactiveScreen'), - child: widget.inactiveBuilder!(context), - ), - ], + if (_isFirstBuild) { + _isFirstBuild = false; + + return Overlay( + key: _overlayKey, + initialEntries: _overlayEntriesForCurrentState(), + ); + } + + _scheduleSyncOverlays(); + + return Overlay( + key: _overlayKey, + initialEntries: const [], ); } + List _overlayEntriesForCurrentState() { + final entries = []; + + if (_didUnlockForAppLaunch) { + entries.add(_appOverlayEntry); + } + + if (_locked) { + entries.add(_lockScreenOverlayEntry); + } else if (_shouldShowInactive && _inactiveOverlayEntry != null) { + entries.add(_inactiveOverlayEntry!); + } + + return entries; + } + Widget get _lockScreen { return PopScope( canPop: false, - child: (widget.lockScreenBuilder?.call(context) ?? widget.lockScreen)!, + child: widget.lockScreenBuilder(context), ); } - /// Causes `AppLock` to either pop the [AppLock.lockScreen] (or preferably - /// the [Widget] returned from [AppLock.lockScreenBuilder]) if the app is - /// already running or instantiates widget returned from the - /// [AppLock.builder] method if the app is cold launched. + /// Causes `AppLock` to either hide the [Widget] returned from + /// [AppLock.lockScreenBuilder] if the app is already running or instantiates + /// widget returned from the [AppLock.builder] method if the app is cold + /// launched. /// /// [launchArg] is an optional argument which will get passed to the /// [AppLock.builder] method when built. Use this when you want to inject - /// objects created from the [AppLock.lockScreen] (or preferably the [Widget] - /// returned from [AppLock.lockScreenBuilder]) in to the rest of your app so - /// you can better guarantee that some objects, services or databases are - /// already instantiated before using them. + /// objects created from the [Widget] returned from [AppLock.lockScreenBuilder] + /// in to the rest of your app so you can better guarantee that some objects, + /// services or databases are already instantiated before using them. void didUnlock([Object? launchArg]) { if (_didUnlockForAppLaunch) { _didUnlockOnAppPaused(); @@ -225,12 +257,13 @@ class AppLockState extends State with WidgetsBindingObserver { } _didUnlockCompleter?.complete(); + _scheduleSyncOverlays(); } - /// Makes sure that [AppLock] shows the [AppLock.lockScreen] (or preferably - /// the [Widget] returned from [AppLock.lockScreenBuilder]) on subsequent app - /// pauses if [enabled] is true of makes sure it isn't shown on subsequent - /// app pauses if [enabled] is false. + /// Makes sure that [AppLock] shows the [Widget] returned from + /// [AppLock.lockScreenBuilder] on subsequent app pauses if [enabled] is true + /// of makes sure it isn't shown on subsequent app pauses if [enabled] is + /// false. /// /// This is a convenience method for calling the [enable] or [disable] method /// based on [enabled]. @@ -242,36 +275,41 @@ class AppLockState extends State with WidgetsBindingObserver { } } - /// Makes sure that [AppLock] shows the [lockScreen] (or preferably the - /// [Widget] returned from [lockScreenBuilder]) on subsequent app pauses. + /// Makes sure that [AppLock] shows the [Widget] returned from + /// [lockScreenBuilder] on subsequent app pauses. void enable() { setState(() { _enabled = true; }); + + _scheduleSyncOverlays(); } - /// Makes sure that [AppLock] doesn't show the [AppLock.lockScreen] (or - /// preferably the [Widget] returned from [AppLock.lockScreenBuilder]) on - /// subsequent app pauses. + /// Makes sure that [AppLock] doesn't show the [Widget] returned from + /// [AppLock.lockScreenBuilder] on subsequent app pauses. void disable() { setState(() { _enabled = false; }); + + _scheduleSyncOverlays(); } - /// Manually show the [AppLock.lockScreen] (or preferably the [Widget] - /// returned from [AppLock.lockScreenBuilder]). + /// Manually show the [Widget] returned from [AppLock.lockScreenBuilder]. Future showLockScreen() async { if (_locked && _didUnlockCompleter != null) { return _didUnlockCompleter!.future; } - _didUnlockCompleter = Completer(); + _didUnlockCompleter = Completer(); setState(() { _locked = true; + _pendingBackgroundLock = false; }); + _scheduleSyncOverlays(); + return _didUnlockCompleter!.future; } @@ -280,10 +318,89 @@ class AppLockState extends State with WidgetsBindingObserver { _backgroundLockLatency = backgroundLockLatency; /// An argument that is passed to [didUnlock] for the first time after showing - /// [AppLock.lockScreen] (or preferably the [Widget] returned from - /// [AppLock.lockScreenBuilder]) on launch. + /// the [Widget] returned from [AppLock.lockScreenBuilder] on launch. Object? get launchArg => _launchArg; + void _onBackgroundLockTimerFired() { + if (!mounted || _locked || !_enabled) { + return; + } + + if (SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) { + unawaited(showLockScreen()); + return; + } + + _pendingBackgroundLock = true; + } + + void _scheduleSyncOverlays() { + if (_overlaySyncScheduled) { + return; + } + + _overlaySyncScheduled = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _overlaySyncScheduled = false; + + if (!mounted) { + return; + } + + _syncOverlays(); + }); + } + + void _syncOverlays() { + final overlay = _overlayKey.currentState; + if (overlay == null) { + return; + } + + if (_didUnlockForAppLaunch) { + if (!_appOverlayEntry.mounted) { + overlay.insert(_appOverlayEntry); + } else { + _appOverlayEntry.markNeedsBuild(); + } + } else if (_appOverlayEntry.mounted) { + _appOverlayEntry.remove(); + } + + if (_locked) { + if (!_lockScreenOverlayEntry.mounted) { + overlay.insert(_lockScreenOverlayEntry); + } + + if (_inactiveOverlayEntry?.mounted ?? false) { + _inactiveOverlayEntry!.remove(); + } + + if (_appOverlayEntry.mounted) { + _appOverlayEntry.markNeedsBuild(); + } + return; + } + + if (_lockScreenOverlayEntry.mounted) { + _lockScreenOverlayEntry.remove(); + } + + final inactiveOverlayEntry = _inactiveOverlayEntry; + if (_shouldShowInactive && inactiveOverlayEntry != null) { + if (!inactiveOverlayEntry.mounted) { + overlay.insert(inactiveOverlayEntry); + } + } else if (inactiveOverlayEntry?.mounted ?? false) { + inactiveOverlayEntry!.remove(); + } + + if (_appOverlayEntry.mounted) { + _appOverlayEntry.markNeedsBuild(); + } + } + void _didUnlockOnAppLaunch(Object? launchArg) { setState(() { _launchArg = launchArg; diff --git a/lib/src/no_animation_page.dart b/lib/src/no_animation_page.dart deleted file mode 100644 index 6bfc8ad..0000000 --- a/lib/src/no_animation_page.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; - -class NoAnimationPage extends Page { - const NoAnimationPage({ - required this.child, - this.maintainState = true, - this.fullscreenDialog = false, - this.allowSnapshotting = true, - super.key, - super.name, - super.arguments, - super.restorationId, - }); - - final Widget child; - final bool maintainState; - final bool fullscreenDialog; - final bool allowSnapshotting; - - @override - Route createRoute(BuildContext context) => _NoAnimationPageRoute( - page: this, allowSnapshotting: allowSnapshotting); -} - -class _NoAnimationPageRoute extends PageRoute { - _NoAnimationPageRoute({ - required NoAnimationPage page, - super.allowSnapshotting, - }) : super(settings: page) { - assert(opaque); - } - - NoAnimationPage get _page => settings as NoAnimationPage; - - @override - bool get maintainState => _page.maintainState; - - @override - bool get fullscreenDialog => _page.fullscreenDialog; - - @override - String get debugLabel => '${super.debugLabel}(${_page.name})'; - - @override - Color? get barrierColor => null; - - @override - String? get barrierLabel => null; - - @override - Widget buildPage(BuildContext context, Animation animation, - Animation secondaryAnimation) => - _page.child; - - @override - Duration get transitionDuration => Duration.zero; -} diff --git a/pubspec.yaml b/pubspec.yaml index 03b596d..ce0f23e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_app_lock description: A Flutter package for showing a lock screen on app open and app pause. -version: 4.3.0 +version: 5.0.0 homepage: https://github.com/tomalabaster/flutter_app_lock environment: diff --git a/test/src/app_lock_overlay_test.dart b/test/src/app_lock_overlay_test.dart new file mode 100644 index 0000000..b8b6ebb --- /dev/null +++ b/test/src/app_lock_overlay_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app_lock/flutter_app_lock.dart'; +import 'package:flutter_test/flutter_test.dart'; + +OverlayEntry appOverlayEntry(WidgetTester widgetTester) => + widgetTester.state(find.byType(AppLock)).appOverlayEntry; + +OverlayEntry lockScreenOverlayEntry(WidgetTester widgetTester) => + widgetTester + .state(find.byType(AppLock)) + .lockScreenOverlayEntry; + +OverlayEntry? inactiveOverlayEntry(WidgetTester widgetTester) => + widgetTester + .state(find.byType(AppLock)) + .inactiveOverlayEntry; + +void enableAppLockAfterLaunch(WidgetTester widgetTester) { + widgetTester.state(find.byType(AppLock)).enable(); +} + +Widget appLockHarness({ + required bool initiallyEnabled, + Duration initialBackgroundLockLatency = Duration.zero, + InactiveBehavior inactiveBehavior = InactiveBehavior.showWhenEnabled, + WidgetBuilder? inactiveBuilder, + required Widget home, +}) { + return MaterialApp( + builder: (context, child) => AppLock( + initiallyEnabled: initiallyEnabled, + initialBackgroundLockLatency: initialBackgroundLockLatency, + inactiveBehavior: inactiveBehavior, + builder: (context, launchArg) => child!, + lockScreenBuilder: (context) => const Scaffold( + key: Key('LockScreen'), + ), + inactiveBuilder: inactiveBuilder, + ), + home: home, + ); +} + +void main() { + group('Overlay-based AppLock', () { + testWidgets('shows the lock overlay on launch when initially enabled', + (widgetTester) async { + await widgetTester.pumpWidget( + appLockHarness( + initiallyEnabled: true, + home: const Scaffold(key: Key('Home')), + ), + ); + await widgetTester.pumpAndSettle(); + + expect(lockScreenOverlayEntry(widgetTester).mounted, isTrue); + expect(appOverlayEntry(widgetTester).mounted, isFalse); + expect(find.byKey(const Key('LockScreen')), findsOneWidget); + }); + + testWidgets('shows the app overlay after unlock on launch', + (widgetTester) async { + await widgetTester.pumpWidget( + appLockHarness( + initiallyEnabled: true, + home: const Scaffold(key: Key('Home')), + ), + ); + await widgetTester.pumpAndSettle(); + + widgetTester.state(find.byType(AppLock)).didUnlock(); + await widgetTester.pumpAndSettle(); + + expect(appOverlayEntry(widgetTester).mounted, isTrue); + expect(lockScreenOverlayEntry(widgetTester).mounted, isFalse); + expect(find.byKey(const Key('Home')), findsOneWidget); + }); + + testWidgets('defers background lock until the app resumes', + (widgetTester) async { + await widgetTester.pumpWidget( + appLockHarness( + initiallyEnabled: false, + initialBackgroundLockLatency: const Duration(seconds: 1), + home: const Scaffold(key: Key('Home')), + ), + ); + await widgetTester.pumpAndSettle(); + + enableAppLockAfterLaunch(widgetTester); + + widgetTester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + await widgetTester.pump(const Duration(seconds: 2)); + + expect(lockScreenOverlayEntry(widgetTester).mounted, isFalse); + + widgetTester.binding + .handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await widgetTester.pumpAndSettle(); + + expect(lockScreenOverlayEntry(widgetTester).mounted, isTrue); + expect(find.byKey(const Key('LockScreen')), findsOneWidget); + }); + + testWidgets('shows inactive overlay when disabled and alwaysShow', + (widgetTester) async { + await widgetTester.pumpWidget( + appLockHarness( + initiallyEnabled: false, + inactiveBehavior: InactiveBehavior.alwaysShow, + inactiveBuilder: (context) => const Scaffold( + key: Key('InactiveScreen'), + ), + home: const Scaffold(key: Key('Home')), + ), + ); + await widgetTester.pumpAndSettle(); + + widgetTester.binding + .handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await widgetTester.pumpAndSettle(); + + expect(inactiveOverlayEntry(widgetTester)?.mounted, isTrue); + expect(find.byKey(const Key('InactiveScreen')), findsOneWidget); + expect(find.byKey(const Key('Home')), findsNothing); + }); + }); + + group('AppLock navigation regressions', () { + testWidgets('hero navigation works through AppLock', (widgetTester) async { + await widgetTester.pumpWidget( + appLockHarness( + initiallyEnabled: false, + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Hero( + tag: 'hero-tag', + child: Material( + child: Text('Hero source'), + ), + ), + Builder( + builder: (context) => ElevatedButton( + key: const Key('PushHeroRoute'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const Scaffold( + key: Key('DetailScreen'), + body: Hero( + tag: 'hero-tag', + child: Material( + child: Text('Hero destination'), + ), + ), + ), + ), + ), + child: const Text('Open detail'), + ), + ), + ], + ), + ), + ), + ), + ); + await widgetTester.pumpAndSettle(); + + await widgetTester.tap(find.byKey(const Key('PushHeroRoute'))); + await widgetTester.pumpAndSettle(); + + expect(find.byKey(const Key('DetailScreen')), findsOneWidget); + expect(find.byType(Hero), findsOneWidget); + }); + + testWidgets('android back pops the route after a dialog is dismissed', + (widgetTester) async { + await widgetTester.pumpWidget( + appLockHarness( + initiallyEnabled: false, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + key: const Key('OpenSecondScreen'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + key: const Key('SecondScreen'), + body: Center( + child: ElevatedButton( + key: const Key('ShowDialog'), + onPressed: () async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + content: const Text('Dialog content'), + actions: [ + TextButton( + key: const Key('CloseDialog'), + onPressed: () => + Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + }, + child: const Text('Show dialog'), + ), + ), + ), + ), + ), + child: const Text('Open second screen'), + ), + ), + ), + ), + ), + ); + await widgetTester.pumpAndSettle(); + + await widgetTester.tap(find.byKey(const Key('OpenSecondScreen'))); + await widgetTester.pumpAndSettle(); + + await widgetTester.tap(find.byKey(const Key('ShowDialog'))); + await widgetTester.pumpAndSettle(); + + await widgetTester.tap(find.byKey(const Key('CloseDialog'))); + await widgetTester.pumpAndSettle(); + + expect(find.byKey(const Key('SecondScreen')), findsOneWidget); + + final didPop = await widgetTester.binding.handlePopRoute(); + await widgetTester.pumpAndSettle(); + + expect(didPop, isTrue); + expect(find.byKey(const Key('SecondScreen')), findsNothing); + expect(find.byKey(const Key('OpenSecondScreen')), findsOneWidget); + }); + }); +}