From bc9e7de9043970f4472d4bc73f6da42153f88bcb Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 19 Oct 2026 15:09:17 -0700 Subject: [PATCH] fix(mobile): stop Settings rows blinking while Settings opens (#8463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary On iOS, opening Settings made the Status, Profile and Notifications cards disappear for most of the slide-up. They came back when the transition finished. Closing Settings did the same thing. **Cause:** Flutter's iOS compositor (`flow/view_slicer.cc`) puts Flutter content that's painted after a platform view and overlaps no native view into the background canvas. During the transition, Home's native nav bar and glass tab bar are still on screen under the full-screen Settings page, so the overlay created for Home's tab bar already covers the whole background. Everything painted after the native presence pill, outside a native view's rect, was hidden behind it. "Send identity to desktop" stayed visible only because it overlaps Home's tab bar. **Fix:** `IosPresenceMenu` mounts its native view only after its route settles (animation completed, nothing covering it). During route transitions, and while the native view is being created, it draws a matching Flutter stand-in: same capsule, colors, SF Pro Text at the same size and weight. ### Related issue N/A, none found. Reported by Wes from a screen recording. ### Testing - Reproduced in an uncommitted harness on an iOS 27 simulator: Home with native nav bar and glass tabs, then `BottomUpPageRoute`, then a Settings-shaped page using the real `IosPresenceMenu`. The cards blank out on every open and close. - A/B in the same harness: the blink goes away if any one of the three is removed (native pill, Home nav bar, Home glass tabs). - With this change, the open and close cycles in the harness show no blank frames. At rest the native pill is mounted again. Its pixels are identical to before, and the stand-in matches it within about 1px of text rasterization. - `settings_overlay_test.dart` now checks that the native pill isn't mounted mid-transition, and that exactly one native view is created after settling. The new assertion fails without the fix. - Pre-push mobile checks passed: analyze, format, full mobile test suite. **Human test:** on iOS, open and close Settings from Home a few times. Every card should stay visible throughout, and the "Online" pill should open the native availability menu once Settings has settled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- .../features/profile/ios_presence_menu.dart | 123 +++++++++++++++--- .../settings/settings_overlay_test.dart | 15 +++ 2 files changed, 118 insertions(+), 20 deletions(-) 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)); }