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)
|

<!-- buzz-review-completed -->

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-31 16:17:47 +00:00
committed by GitHub
parent 120cbae8e5
commit d14f33a170
9 changed files with 206 additions and 34 deletions

View File

@@ -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 = "<group>"; };
BC0010000000000000000004 /* BrowserAuthBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAuthBridgeTests.swift; sourceTree = "<group>"; };
BC0010000000000000000002 /* BuzzBrowserAuthBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzBrowserAuthBridge.swift; sourceTree = "<group>"; };
F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupRecoveryTests.swift; sourceTree = "<group>"; };
@@ -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 */,

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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,
),
],
),
),
),
),

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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});

View File

@@ -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<NavigatorState>();
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<void>(
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<UiKitView>(find.byType(UiKitView));
expect(layer.viewType, 'buzz/toast_layer');
expect(tester.getSize(find.byType(UiKitView)), const Size(1, 1));
expect(tester.widget<PhysicalShape>(surface).color, Colors.transparent);
expect(tester.getRect(surface).center.dy, closeTo(origin.center.dy, 1));
await tester.pump(const Duration(milliseconds: 140));