From d14f33a17088600a947ea23a19416e461152737b Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 31 Oct 2026 16:17:47 +0000 Subject: [PATCH] fix(mobile): keep toasts above native navigation and sheets (#8866) ## Summary Keep mobile toasts above navigation and modal sheets with an app-level overlay and a tiny transparent iOS composition boundary. Preserve Retry, automatic dismissal, and taps on underlying controls. ### Related issue No duplicate found in the toast issue/PR search. ### Testing Full mobile tests and analysis pass; iOS simulator and device builds succeed. Verified layering, Retry, dismissal, and underlying taps in a native simulator preview; human confirmed on iPhone with their existing profile/community. Broader repository checks are in progress. Before / after (isolated native simulator preview using production widgets): | Before | After | | --- | --- | | ![Toast hidden by native navigation](https://github.com/user-attachments/assets/981bf68f-dcea-4584-a071-94ecdad8f1a7) | ![Toast above native navigation](https://github.com/user-attachments/assets/d75a28c6-cfce-4eed-87a3-548d03c72559) | Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 2 + mobile/ios/Runner/NativeProfileSetup.swift | 1 + mobile/ios/Runner/ToastLayer.swift | 26 +++++++ mobile/lib/app.dart | 47 ++++++------ mobile/lib/shared/widgets/buzz_toast.dart | 61 ++++++++++++--- .../lib/shared/widgets/buzz_toast_host.dart | 12 +++ .../settings/connection_section_test.dart | 12 ++- .../test/shared/widgets/buzz_toast_test.dart | 75 +++++++++++++++++++ 9 files changed, 206 insertions(+), 34 deletions(-) create mode 100644 mobile/ios/Runner/ToastLayer.swift create mode 100644 mobile/lib/shared/widgets/buzz_toast_host.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index e5e9678f91..f232a45498 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + ACAF00000000000000000001 /* ToastLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACAF00000000000000000002 /* ToastLayer.swift */; }; BC0010000000000000000003 /* BrowserAuthBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0010000000000000000004 /* BrowserAuthBridgeTests.swift */; }; BC0010000000000000000001 /* BuzzBrowserAuthBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0010000000000000000002 /* BuzzBrowserAuthBridge.swift */; }; F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */; }; @@ -103,6 +104,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + ACAF00000000000000000002 /* ToastLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastLayer.swift; sourceTree = ""; }; BC0010000000000000000004 /* BrowserAuthBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAuthBridgeTests.swift; sourceTree = ""; }; BC0010000000000000000002 /* BuzzBrowserAuthBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzBrowserAuthBridge.swift; sourceTree = ""; }; F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupRecoveryTests.swift; sourceTree = ""; }; @@ -299,6 +301,7 @@ BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, BZZ0000000000000000002A /* PushSnapshotBridge.swift */, + ACAF00000000000000000002 /* ToastLayer.swift */, DCAF00000000000000000002 /* IosNavigationBar.swift */, DAAB00000000000000000002 /* NativeTabler.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, @@ -646,6 +649,7 @@ BZZ00000000000000000023 /* PushNativeState.swift in Sources */, BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */, + ACAF00000000000000000001 /* ToastLayer.swift in Sources */, DCAF00000000000000000001 /* IosNavigationBar.swift in Sources */, DAAB00000000000000000001 /* NativeTabler.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 7e208bd55a..0bdc27bb5e 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -67,6 +67,8 @@ import os.log func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + engineBridge.pluginRegistry.registrar(forPlugin: "BuzzToastLayer")?.register( + ToastLayerFactory(), withId: "buzz/toast_layer") if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosMediaCarousel") { registrar.register(IosMediaCarouselFactory(messenger: registrar.messenger()), withId: "buzz/ios_media_carousel") } diff --git a/mobile/ios/Runner/NativeProfileSetup.swift b/mobile/ios/Runner/NativeProfileSetup.swift index 74d6cbab07..73e82abbbb 100644 --- a/mobile/ios/Runner/NativeProfileSetup.swift +++ b/mobile/ios/Runner/NativeProfileSetup.swift @@ -185,6 +185,7 @@ final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationContr engine.registrar(forPlugin: "BuzzNativeSkinToneControl")?.register(NativeSkinToneControlFactory(messenger: messenger), withId: "buzz/native_skin_tone_control") let editor = FlutterViewController(engine: engine, nibName: nil, bundle: nil) avatarController = editor + engine.registrar(forPlugin: "BuzzToastLayer")?.register(ToastLayerFactory(), withId: "buzz/toast_layer") engine.registrar(forPlugin: "BuzzIosNavigationBar")?.register(IosNavigationBarFactory(messenger: messenger, parent: editor), withId: "buzz/ios_navigation_bar") editor.isViewOpaque = false editor.view.backgroundColor = .clear diff --git a/mobile/ios/Runner/ToastLayer.swift b/mobile/ios/Runner/ToastLayer.swift new file mode 100644 index 0000000000..870514fd4a --- /dev/null +++ b/mobile/ios/Runner/ToastLayer.swift @@ -0,0 +1,26 @@ +import Flutter +import UIKit + +/// An unclipped, transparent composition boundary for Flutter toast content. +/// Flutter paints the toast in the overlay slice above this native view, rather +/// than inheriting the rounded clip of a platform view inside a modal sheet. +final class ToastLayerFactory: NSObject, FlutterPlatformViewFactory { + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { + ToastLayerView(frame: frame) + } +} + +private final class ToastLayerView: NSObject, FlutterPlatformView { + private let surface: UIView + + init(frame: CGRect) { + surface = UIView(frame: frame) + surface.backgroundColor = .clear + surface.isOpaque = false + surface.isUserInteractionEnabled = false + surface.accessibilityElementsHidden = true + super.init() + } + + func view() -> UIView { surface } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 00544ac3f6..6e0fe49b2f 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -45,6 +45,7 @@ import 'shared/emoji/emoji_burst.dart'; import 'shared/relay/relay.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; +import 'shared/widgets/buzz_toast_host.dart'; const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0'; @@ -354,29 +355,31 @@ class App extends HookConsumerWidget { // route that was pushed while the store signal request was in flight. builder: (context, child) => switch (ageSignalState) { AgeSignalState.restricted => const AgeRestrictionPage(), - _ => IosNavigationMetricsHost( - child: AppMarkdownTheme( - child: MobileHuddleShell( - navigatorKey: _mobileRootNavigatorKey, - child: EmojiBurstOverlay( - child: Stack( - fit: StackFit.expand, - children: [ - ExcludeSemantics( - excluding: pairedCommunity != null, - child: AbsorbPointer( - absorbing: pairedCommunity != null, - child: child ?? const SizedBox.shrink(), + _ => BuzzToastHost( + child: IosNavigationMetricsHost( + child: AppMarkdownTheme( + child: MobileHuddleShell( + navigatorKey: _mobileRootNavigatorKey, + child: EmojiBurstOverlay( + child: Stack( + fit: StackFit.expand, + children: [ + ExcludeSemantics( + excluding: pairedCommunity != null, + child: AbsorbPointer( + absorbing: pairedCommunity != null, + child: child ?? const SizedBox.shrink(), + ), ), - ), - // Cover the navigator while onboarding is removed and the - // destination installs the matching avatar-flight route. - if (pairedCommunity != null) - CommunityLoadingSurface( - name: pairedCommunity.name, - relayUrl: pairedCommunity.relayUrl, - ), - ], + // Cover the navigator while onboarding is removed and the + // destination installs the matching avatar-flight route. + if (pairedCommunity != null) + CommunityLoadingSurface( + name: pairedCommunity.name, + relayUrl: pairedCommunity.relayUrl, + ), + ], + ), ), ), ), diff --git a/mobile/lib/shared/widgets/buzz_toast.dart b/mobile/lib/shared/widgets/buzz_toast.dart index 4f47066101..bb0ceee64a 100644 --- a/mobile/lib/shared/widgets/buzz_toast.dart +++ b/mobile/lib/shared/widgets/buzz_toast.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; import 'package:flutter_hooks/flutter_hooks.dart'; import '../theme/theme.dart'; @@ -43,17 +44,19 @@ void showBuzzToast( coordinator.active?.remove(); late final _ToastHandle handle; final entry = OverlayEntry( - builder: (_) => _ToastBubble( - message: message, - islandOrigin: islandOrigin, - islandSpeed: islandSpeed, - onDismiss: handle.remove, - onRetry: onRetry == null - ? null - : () { - handle.remove(); - if (context.mounted) onRetry(); - }, + builder: (_) => _ToastLayer( + child: _ToastBubble( + message: message, + islandOrigin: islandOrigin, + islandSpeed: islandSpeed, + onDismiss: handle.remove, + onRetry: onRetry == null + ? null + : () { + handle.remove(); + if (context.mounted) onRetry(); + }, + ), ), ); handle = _ToastHandle(entry, () { @@ -68,6 +71,42 @@ void showBuzzToast( unawaited(toastHaptic(kind)); } +// iOS applies a platform view's rounded clip to the Flutter overlay slice +// painted after it. Start an unclipped native slice so a sheet's close button +// cannot clip this toast back underneath earlier native navigation views. +class _ToastLayer extends HookWidget { + const _ToastLayer({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final ready = useState(false); + if (defaultTargetPlatform != TargetPlatform.iOS) return child; + return Stack( + fit: StackFit.expand, + children: [ + // A single point in the window corner establishes the composition + // slice without a native hit-test surface over any app controls. + Positioned( + top: 0, + left: 0, + width: 1, + height: 1, + child: ExcludeSemantics( + child: UiKitView( + viewType: 'buzz/toast_layer', + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + onPlatformViewCreated: (_) => ready.value = true, + ), + ), + ), + if (ready.value) child, + ], + ); + } +} + class _ToastHandle { _ToastHandle(this.entry, this.onRemove); diff --git a/mobile/lib/shared/widgets/buzz_toast_host.dart b/mobile/lib/shared/widgets/buzz_toast_host.dart new file mode 100644 index 0000000000..74812eb56a --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_toast_host.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +/// Keeps transient feedback above the entire app, including navigator routes +/// and app-level chrome. Install in MaterialApp.builder outside the app shell. +class BuzzToastHost extends StatelessWidget { + const BuzzToastHost({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => Overlay.wrap(child: child); +} diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index 6a702cc38e..b9fc77624d 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -17,6 +17,16 @@ import '../../helpers/widget_helpers.dart'; void main() { testWidgets('shows a compact copyable identity row', (tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ), + ); var hapticCount = 0; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel('buzz/haptics'), @@ -126,7 +136,7 @@ void main() { expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); await tester.tap(find.text('Copy public key (npub)')); - await tester.pump(); + await tester.pumpAndSettle(); expect(hapticCount, 1); expect(clipboardCall?.method, 'Clipboard.setData'); expect(clipboardCall?.arguments, {'text': expectedNpub}); diff --git a/mobile/test/shared/widgets/buzz_toast_test.dart b/mobile/test/shared/widgets/buzz_toast_test.dart index 833c463251..cfdeed4735 100644 --- a/mobile/test/shared/widgets/buzz_toast_test.dart +++ b/mobile/test/shared/widgets/buzz_toast_test.dart @@ -2,11 +2,83 @@ import 'dart:ui' show SemanticsAction; import 'package:buzz/shared/clipboard_utils.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/buzz_toast.dart'; +import 'package:buzz/shared/widgets/buzz_toast_host.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (_) async => null, + ); + }); + tearDown(() { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + }); + testWidgets('toast stays above app chrome and subsequently pushed routes', ( + tester, + ) async { + final navigator = GlobalKey(); + var retries = 0; + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + builder: (context, child) => BuzzToastHost( + child: Stack( + fit: StackFit.expand, + children: [ + child!, + const Positioned( + top: 0, + left: 0, + right: 0, + height: 160, + child: ModalBarrier(color: Colors.blue, dismissible: false), + ), + ], + ), + ), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showBuzzToast( + context, + 'Try again', + onRetry: () => retries++, + ), + child: const Text('Show'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + expect(find.text('Retry').hitTestable(), findsOneWidget); + + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Next route')), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Next route'), findsOneWidget); + expect(find.text('Retry').hitTestable(), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(retries, 1); + expect(find.text('Try again'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( 'iPhone toast automatically expands from the island and returns', (tester) async { @@ -40,6 +112,9 @@ void main() { expect(find.text('Island preview'), findsNothing); await tester.pump(); } + final layer = tester.widget(find.byType(UiKitView)); + expect(layer.viewType, 'buzz/toast_layer'); + expect(tester.getSize(find.byType(UiKitView)), const Size(1, 1)); expect(tester.widget(surface).color, Colors.transparent); expect(tester.getRect(surface).center.dy, closeTo(origin.center.dy, 1)); await tester.pump(const Duration(milliseconds: 140));