Files
buzz/mobile/test/shared/widgets/buzz_toast_test.dart
klopez4212 99873459e9 Keep sign-in toasts usable at large text sizes (#9057)
At large text sizes, organization sign-in prompts could clip their
message and push recovery actions off-screen. Stack actions below the
message above 1.3× text, use fixed card corners, and skip the Dynamic
Island morph for that layout.

Follow-up to #9050, addressing [Wes’s large-text
blocker](https://github.com/block/buzz/pull/9050#discussion_r4469530660).
Normal-size styling and recovery callbacks are unchanged.

Validation: extended existing sign-in/cancel/error journeys at 1×, 2×
and 3× using Inter on a 320×568 viewport; assert text lies inside the
painted clip and the card stays on-screen. Removing the fix fails all
six enlarged-text cases. Mobile checks and full tests run through the
push hook. Human device testing of this follow-up is pending.

Local Flutter previews at 3× text (320×568):

| Before | After |
| --- | --- |
| ![Before: clipped text and action extending off
screen](https://github.com/user-attachments/assets/89b62c46-173c-4a1e-960a-f402f6d0346a)
| ![After: readable message above reachable Sign in
action](https://github.com/user-attachments/assets/ae3de975-3505-48ee-b9f3-b60aa710600f)
|

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 20:33:08 +00:00

483 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
tester.view.physicalSize = const Size(390, 844);
tester.view.padding = FakeViewPadding(top: 59);
tester.view.viewPadding = FakeViewPadding(top: 59);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
const origin = Rect.fromLTWH(135, 11, 120, 36);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showBuzzToast(context, 'Island preview'),
child: const Text('Show'),
),
),
),
),
);
await tester.tap(find.text('Show'));
await tester.pump();
final surface = find.ancestor(
of: find.text('Island preview'),
matching: find.byType(PhysicalShape),
);
// Shader readiness may span frames. No fallback circle is painted while
// waiting, and animation time does not advance before the surface exists.
for (var frame = 0; frame < 20 && surface.evaluate().isEmpty; frame++) {
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));
expect(tester.getRect(surface).center.dy, greaterThan(origin.center.dy));
await tester.pumpAndSettle();
// The shader owns the unified background; no opaque second capsule or
// independent shadow may divide its silhouette.
expect(tester.widget<PhysicalShape>(surface).color, Colors.transparent);
expect(tester.widget<PhysicalShape>(surface).elevation, 0);
await tester.pump(const Duration(seconds: 3));
await tester.pump(const Duration(milliseconds: 120));
expect(tester.getRect(surface).top, lessThan(67));
await tester.pumpAndSettle();
expect(find.text('Island preview'), findsNothing);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets('large-text Retry card stacks its action and invokes it once', (
tester,
) async {
tester.view.physicalSize = const Size(320, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final semantics = tester.ensureSemantics();
var retries = 0;
for (final brightness in Brightness.values) {
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(
platformBrightness: brightness,
accessibleNavigation: true,
disableAnimations: true,
textScaler: TextScaler.linear(2),
),
child: Scaffold(
body: Builder(
builder: (context) => Stack(
children: [
ReconnectCapsule(onSignIn: () {}),
Center(
child: TextButton(
onPressed: () => showBuzzToast(
context,
'Couldn’t save changes',
kind: ToastKind.error,
onRetry: () => retries++,
),
child: const Text('Show error'),
),
),
],
),
),
),
),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
platformBrightness: brightness,
accessibleNavigation: true,
disableAnimations: true,
textScaler: TextScaler.linear(2),
),
child: child!,
),
),
);
await tester.tap(find.text('Show error'));
await tester.pumpAndSettle();
final button = find.widgetWithText(FilledButton, 'Retry');
final shape = find
.ancestor(of: button, matching: find.byType(PhysicalShape))
.first;
final outer = tester.getRect(shape);
final inner = tester.getRect(button);
expect(
inner.top,
greaterThanOrEqualTo(
tester.getRect(find.text('Couldn’t save changes')).bottom,
),
);
expect(inner.left - outer.left, 12);
expect(outer.right - inner.right, 12);
expect(outer.bottom - inner.bottom, 12);
final style = tester.widget<FilledButton>(button).style!;
expect(style.backgroundColor!.resolve({}), Colors.white);
expect(style.foregroundColor!.resolve({}), Colors.black);
await tester.pump(const Duration(seconds: 20));
expect(button, findsOneWidget);
await tester.tap(button);
await tester.pump();
expect(button, findsNothing);
expect(find.text('Reconnect to Builderlab'), findsOneWidget);
await tester.tap(find.text('Show error'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 20));
expect(find.text('Reconnect to Builderlab'), findsNothing);
final dismissible = find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.onDismiss != null,
);
final node = tester.getSemantics(dismissible);
node.owner!.performAction(node.id, SemanticsAction.dismiss);
await tester.pumpAndSettle();
expect(button, findsNothing);
expect(find.text('Reconnect to Builderlab'), findsOneWidget);
expect(tester.takeException(), isNull);
}
expect(retries, 2);
semantics.dispose();
});
testWidgets(
'toast kinds request matching platform feedback',
(tester) async {
final nativeCalls = <String>[];
final impactCalls = <String>[];
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(const MethodChannel('buzz/haptics'), (
call,
) async {
nativeCalls.add(call.method);
return null;
});
messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
impactCalls.add(call.arguments as String);
}
return null;
});
addTearDown(() {
messenger.setMockMethodCallHandler(
const MethodChannel('buzz/haptics'),
null,
);
messenger.setMockMethodCallHandler(SystemChannels.platform, null);
});
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
return Column(
children: [
for (final kind in ToastKind.values)
TextButton(
onPressed: () =>
showBuzzToast(context, kind.name, kind: kind),
child: Text('Show ${kind.name}'),
),
],
);
},
),
),
),
);
for (final kind in ToastKind.values) {
await tester.tap(find.text('Show ${kind.name}'));
await tester.pumpAndSettle();
expect(find.text(kind.name), findsOneWidget);
}
if (Theme.of(tester.element(find.byType(Scaffold))).platform ==
TargetPlatform.iOS) {
expect(nativeCalls, ['success', 'error']);
expect(impactCalls, ['HapticFeedbackType.lightImpact']);
} else {
expect(nativeCalls, isEmpty);
expect(impactCalls, [
'HapticFeedbackType.lightImpact',
'HapticFeedbackType.mediumImpact',
'HapticFeedbackType.heavyImpact',
]);
}
await tester.pumpWidget(const SizedBox.shrink());
},
variant: TargetPlatformVariant({
TargetPlatform.iOS,
TargetPlatform.android,
}),
);
testWidgets('copy feedback floats above sheets, replaces, and exits upward', (
tester,
) async {
var hapticCount = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
expect(call.arguments, 'HapticFeedbackType.mediumImpact');
hapticCount++;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) {
return Stack(
children: [
ReconnectCapsule(onSignIn: () {}),
Center(
child: TextButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
builder: (context) => SizedBox(
height: 200,
child: TextButton(
onPressed: () =>
copyToClipboard(context, 'example'),
child: const Text('Copy'),
),
),
),
child: const Text('Open sheet'),
),
),
],
);
},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Reconnect to Builderlab'), findsOneWidget);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
await tester.tap(find.text('Copy'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 140));
expect(hapticCount, 1);
expect(find.text('Reconnect to Builderlab'), findsNothing);
final enteringY = tester.getTopLeft(find.text('Copied')).dy;
final textWidth = tester.getSize(find.text('Copied')).width;
final shapeFinder = find
.ancestor(of: find.text('Copied'), matching: find.byType(PhysicalShape))
.first;
final shape = tester.widget<PhysicalShape>(shapeFinder);
final shapeSize = tester.getSize(shapeFinder);
final revealingWidth = shape.clipper.getClip(shapeSize).getBounds().width;
expect(revealingWidth, lessThan(shapeSize.width));
await tester.pump(const Duration(milliseconds: 160));
final settledY = tester.getTopLeft(find.text('Copied')).dy;
expect((settledY - enteringY).abs(), lessThan(5));
expect(tester.getSize(find.text('Copied')).width, textWidth);
expect(
tester
.widget<PhysicalShape>(shapeFinder)
.clipper
.getClip(shapeSize)
.getBounds()
.width,
shapeSize.width,
);
expect(settledY, greaterThan(59));
expect(settledY, lessThan(120));
expect(tester.getSize(find.text('Copied')).width, lessThan(350));
expect(find.byType(SnackBar), findsNothing);
final toastMaterial = find
.ancestor(of: find.text('Copied'), matching: find.byType(PhysicalShape))
.first;
expect(
tester.widget<PhysicalShape>(toastMaterial).color,
lightColorScheme.inverseSurface,
);
// Feedback remains above the sheet, and a new copy restarts its lifetime.
await tester.pump(const Duration(seconds: 2));
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
expect(find.text('Copied'), findsOneWidget);
expect(hapticCount, 2);
expect(find.text('Reconnect to Builderlab'), findsNothing);
await tester.pump(const Duration(seconds: 1));
expect(find.text('Copied'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
await tester.pump(const Duration(milliseconds: 40));
final exitingShape = tester.widget<PhysicalShape>(shapeFinder);
expect(
exitingShape.clipper.getClip(shapeSize).getBounds().width,
lessThan(shapeSize.width * 0.9),
);
final opacity = tester.widget<Opacity>(
find
.ancestor(of: find.text('Copied'), matching: find.byType(Opacity))
.first,
);
expect(opacity.opacity, lessThan(0.8));
expect(tester.getSize(find.text('Copied')).width, textWidth);
expect(tester.getTopLeft(find.text('Copied')).dy, lessThan(settledY));
await tester.pump(const Duration(milliseconds: 220));
expect(find.text('Copied'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('Reconnect to Builderlab'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets(
'system dark mode, reduced motion, and large text are supported',
(tester) async {
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations: true,
textScaler: const TextScaler.linear(2),
),
child: child!,
),
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showBuzzToast(context, 'Channel name copied'),
child: const Text('Copy'),
),
),
),
),
);
await tester.tap(find.text('Copy'));
await tester.pump();
final text = find.text('Channel name copied');
final y = tester.getTopLeft(text).dy;
await tester.pump(const Duration(milliseconds: 150));
expect(tester.getTopLeft(text).dy, y);
final material = find
.ancestor(of: text, matching: find.byType(PhysicalShape))
.first;
expect(
tester.widget<PhysicalShape>(material).color,
darkColorScheme.surfaceContainer,
);
expect(tester.takeException(), isNull);
await tester.pump(const Duration(seconds: 3));
expect(text, findsNothing);
// Removing the app while a toast is alive must cancel its timer.
await tester.tap(find.text('Copy'));
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 4));
expect(tester.takeException(), isNull);
},
);
}