mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
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 | | --- | --- | |  |  | <!-- 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:
@@ -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 */,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
26
mobile/ios/Runner/ToastLayer.swift
Normal file
26
mobile/ios/Runner/ToastLayer.swift
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
12
mobile/lib/shared/widgets/buzz_toast_host.dart
Normal file
12
mobile/lib/shared/widgets/buzz_toast_host.dart
Normal 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);
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user