Update mobile toast animations (#8855)

Updates mobile toast animations and styling.



| Sign in | Retry | Copied |
| --- | --- | --- |
| ![Reconnect to
Builderlab](https://github.com/user-attachments/assets/5298b507-df5b-49da-a021-03f337e997fa)
| ![Save error with
Retry](https://github.com/user-attachments/assets/492c8929-4f91-4342-943b-a72b96547992)
| ![Copied
confirmation](https://github.com/user-attachments/assets/864fd629-7414-415e-81ef-5f7d3d5333a4)
|

---------

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 14:50:00 +00:00
committed by GitHub
parent 5f13984a19
commit cfcaef4e9f
59 changed files with 1479 additions and 341 deletions

View File

@@ -0,0 +1,401 @@
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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
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();
}
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('Retry capsule stays inset and invokes the action 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 - outer.top, 6);
expect(outer.right - inner.right, 6);
expect(outer.bottom - inner.bottom, 6);
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);
},
);
}

View File

@@ -0,0 +1,39 @@
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('settled island shader paints only the exact toast capsule', () async {
final program = await ui.FragmentProgram.fromAsset(
'shaders/toast_island.frag',
);
final shader = program.fragmentShader();
addTearDown(shader.dispose);
// Island bottom 48, toast top 68: the resting 20-point separation.
final uniforms = <double>[100, 30, 60, 18, 100, 98, 90, 30, 0, 0.25];
for (var i = 0; i < uniforms.length; i++) {
shader.setFloat(i, uniforms[i]);
}
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawRect(
const ui.Rect.fromLTWH(0, 0, 200, 160),
ui.Paint()..shader = shader,
);
final picture = recorder.endRecording();
final image = await picture.toImage(200, 160);
final pixels = (await image.toByteData())!;
int alpha(int x, int y) => pixels.getUint8((y * 200 + x) * 4 + 3);
// No synthetic hardware island, and no hump above the flat toast top.
expect(alpha(100, 30), 0);
for (var x = 50; x <= 150; x += 10) {
expect(alpha(x, 66), 0);
expect(alpha(x, 70), 255);
}
expect(alpha(100, 98), 255);
image.dispose();
picture.dispose();
});
}