diff --git a/mobile/lib/features/profile/ios_presence_menu.dart b/mobile/lib/features/profile/ios_presence_menu.dart index 0b2816e22e..2212bb70e4 100644 --- a/mobile/lib/features/profile/ios_presence_menu.dart +++ b/mobile/lib/features/profile/ios_presence_menu.dart @@ -26,6 +26,37 @@ class IosPresenceMenu extends StatefulWidget { class _IosPresenceMenuState extends State { MethodChannel? _channel; + ModalRoute? _route; + bool _routeSettled = false; + bool _nativeVisible = false; + + // iOS composites Flutter content painted after a platform view outside + // every native view rect into the background, beneath earlier overlays. + // While Home's native bars still show through a route transition, that + // hides the Settings rows below this pill, so the native view mounts only + // once its route has settled and a drawn stand-in covers the transitions. + static bool _isSettled(ModalRoute? route) => + (route?.animation?.isCompleted ?? true) && + (route?.secondaryAnimation?.isDismissed ?? true); + + void _syncRouteSettled([AnimationStatus? _]) { + final settled = _isSettled(_route); + if (settled == _routeSettled) return; + if (!settled) { + _channel?.setMethodCallHandler(null); + _channel = null; + _nativeVisible = false; + } + setState(() => _routeSettled = settled); + } + + void _listenToRoute(ModalRoute? route) { + _route?.animation?.removeStatusListener(_syncRouteSettled); + _route?.secondaryAnimation?.removeStatusListener(_syncRouteSettled); + _route = route; + route?.animation?.addStatusListener(_syncRouteSettled); + route?.secondaryAnimation?.addStatusListener(_syncRouteSettled); + } Map _configuration(BuildContext context) => { 'presence': widget.presence, @@ -36,6 +67,19 @@ class _IosPresenceMenuState extends State { 'fontSize': MediaQuery.textScalerOf(context).scale(15), }; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route != null && identical(route, _route)) return; + _listenToRoute(route); + // A newly pushed route can still report a completed animation while it + // first builds, so decide once that frame has been laid out. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _syncRouteSettled(); + }); + } + @override void didUpdateWidget(covariant IosPresenceMenu oldWidget) { super.didUpdateWidget(oldWidget); @@ -44,6 +88,7 @@ class _IosPresenceMenuState extends State { @override void dispose() { + _listenToRoute(null); _channel?.setMethodCallHandler(null); super.dispose(); } @@ -65,27 +110,65 @@ class _IosPresenceMenuState extends State { key: const ValueKey('settings-presence-target'), width: width, height: height, - child: UiKitView( - key: const ValueKey('settings-presence-menu'), - viewType: 'buzz/presence_menu', - creationParams: _configuration(context), - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: (id) { - final channel = MethodChannel('buzz/presence_menu/$id'); - _channel = channel; - channel.setMethodCallHandler((call) async { - if (!mounted || call.method != 'selected') return; - final value = call.arguments; - if (value is String && - const ['online', 'away', 'offline'].contains(value)) { - widget.onSelected(value); - } - }); - unawaited( - channel.invokeMethod('update', _configuration(context)), - ); - }, + child: Stack( + fit: StackFit.expand, + children: [ + if (!_nativeVisible) _standIn(fontSize), + if (_routeSettled) _nativeMenu(context), + ], ), ); } + + /// Draws the native pill's capsule and title while the native view is + /// absent, so route transitions and view creation do not blink the pill. + Widget _standIn(double fontSize) => Semantics( + label: 'Availability', + value: widget.label, + excludeSemantics: true, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: DecoratedBox( + decoration: ShapeDecoration( + color: widget.background, + shape: const StadiumBorder(), + ), + child: Center( + child: Text( + widget.label, + maxLines: 1, + textScaler: TextScaler.noScaling, + style: TextStyle( + fontFamily: 'CupertinoSystemText', + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: widget.foreground, + ), + ), + ), + ), + ), + ); + + Widget _nativeMenu(BuildContext context) => UiKitView( + key: const ValueKey('settings-presence-menu'), + viewType: 'buzz/presence_menu', + creationParams: _configuration(context), + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (id) { + if (!mounted || !_routeSettled) return; + final channel = MethodChannel('buzz/presence_menu/$id'); + _channel = channel; + setState(() => _nativeVisible = true); + channel.setMethodCallHandler((call) async { + if (!mounted || call.method != 'selected') return; + final value = call.arguments; + if (value is String && + const ['online', 'away', 'offline'].contains(value)) { + widget.onSelected(value); + } + }); + unawaited(channel.invokeMethod('update', _configuration(context))); + }, + ); } diff --git a/mobile/test/features/settings/settings_overlay_test.dart b/mobile/test/features/settings/settings_overlay_test.dart index 2605292197..86a93e6ffe 100644 --- a/mobile/test/features/settings/settings_overlay_test.dart +++ b/mobile/test/features/settings/settings_overlay_test.dart @@ -138,6 +138,21 @@ void main() { appBar.nativeActions! .singleWhere((action) => action.label == 'Settings') .onPressed!(); + // Mid-transition, Home's native bars still composite beneath Settings; + // the presence pill must stay drawn by Flutter until the route settles. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + find.byKey(const ValueKey('settings-presence-menu')), + findsNothing, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('settings-presence-target')), + matching: find.text('Offline'), + ), + findsOneWidget, + ); } else { await tester.tap(find.byType(ProfileAvatar)); }