feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/app_list.dart';
|
||||
import 'package:buzz/shared/widgets/app_list_card.dart';
|
||||
|
||||
Widget _host(Widget child) => MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('AppListRow', () {
|
||||
testWidgets('centres the leading icon against a two-line row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const AppListRow(
|
||||
icon: Icons.dns,
|
||||
title: 'Connected to',
|
||||
subtitle: 'https://relay.example.com',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final row = tester.getRect(find.byType(AppListRow));
|
||||
final icon = tester.getRect(find.byType(Icon));
|
||||
expect(icon.center.dy, closeTo(row.center.dy, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('centres the trailing control against a two-line row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const AppListRow(
|
||||
icon: Icons.key,
|
||||
title: 'Identity',
|
||||
subtitle: 'deadbeef',
|
||||
trailing: Icon(Icons.copy, key: Key('trailing')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final row = tester.getRect(find.byType(AppListRow));
|
||||
final trailing = tester.getRect(find.byKey(const Key('trailing')));
|
||||
expect(trailing.center.dy, closeTo(row.center.dy, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('keeps the value flush against the trailing edge', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const AppListRow(
|
||||
icon: Icons.palette,
|
||||
title: 'Theme',
|
||||
value: 'Buzz',
|
||||
trailing: Icon(Icons.chevron_right, key: Key('chevron')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final value = tester.getRect(find.text('Buzz'));
|
||||
final chevron = tester.getRect(find.byKey(const Key('chevron')));
|
||||
// Only the row's own inter-widget gap sits between them — no flex slack.
|
||||
expect(chevron.left - value.right, closeTo(Grid.xxs, 0.5));
|
||||
});
|
||||
});
|
||||
|
||||
group('AppListCard', () {
|
||||
testWidgets('separates rows with dividers that clear the icon column', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const AppListCard(
|
||||
label: 'Style',
|
||||
children: [
|
||||
AppListRow(icon: Icons.sunny, title: 'Appearance'),
|
||||
AppListRow(icon: Icons.palette, title: 'Theme'),
|
||||
AppListRow(icon: Icons.water_drop, title: 'Accent color'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final dividers = find.byType(Divider);
|
||||
expect(dividers, findsNWidgets(2));
|
||||
|
||||
final divider = tester.widget<Divider>(dividers.first);
|
||||
final scheme = AppTheme.light().colorScheme;
|
||||
// Derived from the text color, not the scheme's border tokens — those sit
|
||||
// within a few levels of the card fill and vanish.
|
||||
expect(divider.color, scheme.onSurface.withValues(alpha: 0.12));
|
||||
|
||||
// The indent is painted inside the divider's box, so the line starts at
|
||||
// the box's left edge plus the indent — level with the row titles.
|
||||
final titleLeft = tester.getRect(find.text('Appearance')).left;
|
||||
final lineLeft = tester.getRect(dividers.first).left + divider.indent!;
|
||||
expect(lineLeft, closeTo(titleLeft, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('fills with the softest container step', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const AppListCard(children: [AppListRow(title: 'Remove community')]),
|
||||
),
|
||||
);
|
||||
|
||||
final material = tester.widget<Material>(
|
||||
find.descendant(
|
||||
of: find.byType(AppListCard),
|
||||
matching: find.byType(Material),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
material.color,
|
||||
AppTheme.light().colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
|
||||
'<text x="16" y="24" text-anchor="middle">🦝</text></svg>';
|
||||
|
||||
Widget subject(String? imageUrl) => MaterialApp(
|
||||
home: AvatarImage(
|
||||
imageUrl: imageUrl,
|
||||
radius: 16,
|
||||
fallback: const Text('R'),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('renders raccoon percent-encoded SVG data avatar', (
|
||||
tester,
|
||||
) async {
|
||||
const raccoonAvatar =
|
||||
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%3E%3Crect%20width%3D%22512%22%20height%3D%22512%22%20rx%3D%22256%22%20fill%3D%22%233399FF%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2256%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%22258%22%3E%F0%9F%A6%9D%3C%2Ftext%3E%3C%2Fsvg%3E';
|
||||
await tester.pumpWidget(subject(raccoonAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final emoji = tester.widget<Text>(find.text('🦝'));
|
||||
expect(emoji.style?.height, 1);
|
||||
expect(find.byType(SvgPicture), findsNothing);
|
||||
expect(find.text('R'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('renders base64 SVG data avatar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
subject('data:image/svg+xml;base64,${base64Encode(utf8.encode(svg))}'),
|
||||
);
|
||||
|
||||
expect(find.byType(SvgPicture), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('centers fallback when no avatar is configured', (tester) async {
|
||||
await tester.pumpWidget(subject(null));
|
||||
|
||||
final avatarCenter = tester.getCenter(find.byType(CircleAvatar));
|
||||
final fallbackCenter = tester.getCenter(find.text('R'));
|
||||
expect(fallbackCenter, avatarCenter);
|
||||
});
|
||||
|
||||
testWidgets('uses fallback for malformed image data', (tester) async {
|
||||
await tester.pumpWidget(subject('data:image/svg+xml;base64,%%%'));
|
||||
|
||||
expect(find.text('R'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reuses parsed raster bytes across parent rebuilds', (
|
||||
tester,
|
||||
) async {
|
||||
const png =
|
||||
'data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
await tester.pumpWidget(subject(png));
|
||||
final firstBytes = tester.widget<Image>(find.byType(Image)).image;
|
||||
|
||||
await tester.pumpWidget(subject(png));
|
||||
final rebuiltBytes = tester.widget<Image>(find.byType(Image)).image;
|
||||
|
||||
final firstMemory = firstBytes as MemoryImage;
|
||||
final rebuiltMemory = rebuiltBytes as MemoryImage;
|
||||
expect(rebuiltMemory.bytes, same(firstMemory.bytes));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/shared/widgets/bee_refresh_indicator.dart';
|
||||
import 'package:buzz/shared/widgets/flapping_bee.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows the bee while pulling to refresh', (tester) async {
|
||||
const contentKey = ValueKey('loading-content');
|
||||
var refreshes = 0;
|
||||
final refreshCompleter = Completer<void>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: BeeRefreshIndicator(
|
||||
onRefresh: () {
|
||||
refreshes++;
|
||||
return refreshCompleter.future;
|
||||
},
|
||||
child: ListView(
|
||||
children: const [SizedBox(key: contentKey, height: 800)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final listFinder = find.byType(ListView);
|
||||
final restingTop = tester.getTopLeft(listFinder).dy;
|
||||
final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
|
||||
await tester.timedDrag(
|
||||
listFinder,
|
||||
const Offset(0, 320),
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final beeFinder = find.byType(FlappingBee);
|
||||
final loadingTop = tester.getTopLeft(listFinder).dy;
|
||||
final loadingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
|
||||
final gapTransform = tester.widget<Transform>(
|
||||
find.byKey(const ValueKey('bee-refresh-retained-gap')),
|
||||
);
|
||||
expect(beeFinder, findsOneWidget);
|
||||
expect(refreshes, 1);
|
||||
expect(gapTransform.transform.getTranslation().y, closeTo(72, 1));
|
||||
expect(loadingTop - restingTop, closeTo(72, 1));
|
||||
final loadingBeeRect = tester.getRect(beeFinder);
|
||||
final loadingGap = loadingContentTop - restingContentTop;
|
||||
expect(
|
||||
loadingBeeRect.center.dy,
|
||||
closeTo(
|
||||
restingContentTop +
|
||||
(loadingGap - loadingBeeRect.height) * 0.75 +
|
||||
loadingBeeRect.height / 2,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
refreshCompleter.complete();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
|
||||
final closingTop = tester.getTopLeft(listFinder).dy;
|
||||
expect(closingTop, greaterThan(restingTop));
|
||||
expect(closingTop, lessThan(loadingTop));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(listFinder).dy, closeTo(restingTop, 1));
|
||||
expect(beeFinder, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tracks each stage of an active pull', (tester) async {
|
||||
tester.view.physicalSize = const Size(420, 912);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const contentKey = ValueKey('pull-content');
|
||||
final refreshCompleter = Completer<void>();
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: BeeRefreshIndicator(
|
||||
onRefresh: () => refreshCompleter.future,
|
||||
child: ListView(
|
||||
children: const [SizedBox(key: contentKey, height: 800)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(ListView)),
|
||||
pointer: 1,
|
||||
);
|
||||
await gesture.moveBy(const Offset(0, 12));
|
||||
await tester.pump();
|
||||
|
||||
final beeFinder = find.byType(FlappingBee);
|
||||
expect(beeFinder, findsNothing);
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(contentKey)).dy,
|
||||
greaterThan(restingContentTop),
|
||||
);
|
||||
|
||||
await gesture.moveBy(const Offset(0, 44));
|
||||
await tester.pump();
|
||||
|
||||
final earlyTop = tester.getTopLeft(beeFinder).dy;
|
||||
final partialOpacity = tester.widget<Opacity>(
|
||||
find.byKey(const ValueKey('bee-refresh-opacity')),
|
||||
);
|
||||
expect(partialOpacity.opacity, greaterThan(0));
|
||||
expect(partialOpacity.opacity, lessThan(1));
|
||||
final partialScale = tester
|
||||
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
|
||||
.transform
|
||||
.storage[0];
|
||||
expect(partialScale, greaterThan(0.6));
|
||||
expect(partialScale, lessThan(1));
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(hapticCalls, isEmpty);
|
||||
|
||||
await gesture.moveBy(const Offset(0, 120));
|
||||
await tester.pump();
|
||||
|
||||
final pulledContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
|
||||
expect(tester.getTopLeft(beeFinder).dy, greaterThan(earlyTop));
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
|
||||
final pulledBeeRect = tester.getRect(beeFinder);
|
||||
final pulledGap = pulledContentTop - restingContentTop;
|
||||
expect(
|
||||
pulledBeeRect.center.dy,
|
||||
closeTo(
|
||||
restingContentTop +
|
||||
(pulledGap - pulledBeeRect.height) * 0.75 +
|
||||
pulledBeeRect.height / 2,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
await gesture.moveBy(const Offset(0, 120));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
|
||||
.transform
|
||||
.storage[0],
|
||||
closeTo(1, 0.001),
|
||||
);
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
await gesture.moveBy(
|
||||
const Offset(0, 120),
|
||||
timeStamp: const Duration(milliseconds: 350),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final pupilProgress = tester.widget<FlappingBee>(beeFinder).eyeProgress;
|
||||
expect(pupilProgress, greaterThan(0));
|
||||
expect(pupilProgress, lessThan(0.5));
|
||||
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
|
||||
expect(hapticCalls, hasLength(1));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await gesture.moveBy(
|
||||
const Offset(0, 240),
|
||||
timeStamp: const Duration(milliseconds: 750),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(hapticCalls, hasLength(2));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.heavyImpact');
|
||||
expect(
|
||||
tester
|
||||
.widget<Transform>(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji-offset')),
|
||||
)
|
||||
.transform
|
||||
.storage[12],
|
||||
2,
|
||||
);
|
||||
final initialShake = tester
|
||||
.widget<Transform>(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
|
||||
)
|
||||
.transform
|
||||
.storage[12];
|
||||
await tester.pump(const Duration(milliseconds: 35));
|
||||
final movedShake = tester
|
||||
.widget<Transform>(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
|
||||
)
|
||||
.transform
|
||||
.storage[12];
|
||||
expect(movedShake, isNot(closeTo(initialShake, 0.01)));
|
||||
expect(movedShake.abs(), lessThanOrEqualTo(0.75));
|
||||
await tester.pump(const Duration(milliseconds: 105));
|
||||
expect(hapticCalls, hasLength(3));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
|
||||
final secondGesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(ListView)),
|
||||
pointer: 2,
|
||||
);
|
||||
await secondGesture.moveBy(
|
||||
const Offset(0, 40),
|
||||
timeStamp: const Duration(milliseconds: 800),
|
||||
);
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(hapticCalls, hasLength(3));
|
||||
|
||||
await secondGesture.moveBy(
|
||||
const Offset(0, 80),
|
||||
timeStamp: const Duration(milliseconds: 900),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(hapticCalls, hasLength(3));
|
||||
|
||||
await secondGesture.up();
|
||||
await tester.pump();
|
||||
final hapticsAfterRelease = hapticCalls.length;
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
|
||||
expect(hapticCalls, hasLength(hapticsAfterRelease));
|
||||
|
||||
refreshCompleter.complete();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('keeps expressive eyes hidden for a quick refresh flick', (
|
||||
tester,
|
||||
) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
final refreshCompleter = Completer<void>();
|
||||
var refreshes = 0;
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: BeeRefreshIndicator(
|
||||
onRefresh: () {
|
||||
refreshes++;
|
||||
return refreshCompleter.future;
|
||||
},
|
||||
child: ListView(children: const [SizedBox(height: 800)]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(ListView)),
|
||||
);
|
||||
final beeFinder = find.byType(FlappingBee);
|
||||
for (var step = 0; step < 10; step++) {
|
||||
await gesture.moveBy(
|
||||
const Offset(0, 50),
|
||||
timeStamp: Duration(milliseconds: (step + 1) * 10),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
if (beeFinder.evaluate().isNotEmpty) {
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(
|
||||
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
|
||||
findsNothing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(refreshes, 1);
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
|
||||
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
|
||||
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
|
||||
|
||||
refreshCompleter.complete();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('keeps the bee static when motion is disabled', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(disableAnimations: true),
|
||||
child: WidgetHelpers.testable(
|
||||
child: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(disableAnimations: true),
|
||||
child: BeeRefreshIndicator(
|
||||
onRefresh: () async {},
|
||||
child: ListView(children: const [SizedBox(height: 800)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.timedDrag(
|
||||
find.byType(ListView),
|
||||
const Offset(0, 160),
|
||||
const Duration(milliseconds: 400),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final bee = tester.widget<FlappingBee>(find.byType(FlappingBee));
|
||||
expect(bee.flapAmount, 0);
|
||||
});
|
||||
|
||||
testWidgets('provides elastic always-scrollable physics', (tester) async {
|
||||
late ScrollPhysics physics;
|
||||
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: BeeRefreshIndicator(
|
||||
onRefresh: () async {},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
physics = ScrollConfiguration.of(
|
||||
context,
|
||||
).getScrollPhysics(context);
|
||||
return ListView(children: const [SizedBox(height: 20)]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(physics, isA<BouncingScrollPhysics>());
|
||||
expect(physics.parent, isA<AlwaysScrollableScrollPhysics>());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
|
||||
|
||||
Widget _testable({required bool disableAnimations}) {
|
||||
return ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData().copyWith(
|
||||
disableAnimations: disableAnimations,
|
||||
),
|
||||
child: const Scaffold(
|
||||
body: BuzzLoadingIndicator(semanticLabel: 'Loading photos'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('animates the shared arc spinner', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
|
||||
await tester.pumpWidget(_testable(disableAnimations: false));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
|
||||
final spinner = tester.widget<RotationTransition>(
|
||||
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
|
||||
);
|
||||
expect(spinner.turns.value, greaterThan(0));
|
||||
expect(find.bySemanticsLabel('Loading photos'), findsOneWidget);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('holds a static pose when reduced motion is enabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_testable(disableAnimations: true));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
|
||||
final spinner = tester.widget<RotationTransition>(
|
||||
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
|
||||
);
|
||||
expect(spinner.turns.value, 0);
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/filter_chip_bar.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('selected chip resolves the accent (scheme.primary) as fill', (
|
||||
tester,
|
||||
) async {
|
||||
final accented = applyAccent(darkColorScheme, 0); // Blue accent
|
||||
final accent = accented.primary;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark(colorScheme: accented),
|
||||
home: Scaffold(
|
||||
body: FilterChipBar<int>(
|
||||
selected: 0,
|
||||
onSelected: (_) {},
|
||||
items: const [
|
||||
FilterChipItem(id: 0, label: 'Everyone'),
|
||||
FilterChipItem(id: 1, label: 'Following'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The chip's container color comes from ChipThemeData.color resolved for
|
||||
// the selected state. Resolve it the same way the chip does and assert
|
||||
// it's the accent.
|
||||
final chipTheme = ChipTheme.of(
|
||||
tester.element(find.widgetWithText(RawChip, 'Everyone')),
|
||||
);
|
||||
final resolved = chipTheme.color?.resolve({WidgetState.selected});
|
||||
expect(resolved, accent);
|
||||
final selectedLabel = tester.widget<Text>(find.text('Everyone'));
|
||||
final unselectedLabel = tester.widget<Text>(find.text('Following'));
|
||||
expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
|
||||
expect(selectedLabel.style?.height, filterChipTextStyle.height);
|
||||
expect(selectedLabel.style?.fontFamily, 'Inter');
|
||||
expect(selectedLabel.style?.fontWeight, FontWeight.w500);
|
||||
expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
|
||||
expect(unselectedLabel.style?.height, filterChipTextStyle.height);
|
||||
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
|
||||
final chip = tester.widget<FilterChip>(
|
||||
find.widgetWithText(FilterChip, 'Everyone'),
|
||||
);
|
||||
final shape = chip.shape! as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.lg));
|
||||
expect(chip.labelPadding, EdgeInsets.zero);
|
||||
});
|
||||
|
||||
testWidgets('search labels are vertically centered in their chips', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 390,
|
||||
child: FilterChipBar<int>(
|
||||
expandItems: true,
|
||||
visualDensity: const VisualDensity(horizontal: -2),
|
||||
chipVerticalPadding: Grid.xxs,
|
||||
barVerticalPadding: Grid.twelve,
|
||||
selected: 0,
|
||||
onSelected: (_) {},
|
||||
items: const [
|
||||
FilterChipItem(id: 0, label: 'All'),
|
||||
FilterChipItem(id: 1, label: 'Messages'),
|
||||
FilterChipItem(id: 2, label: 'Channels'),
|
||||
FilterChipItem(id: 3, label: 'People'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (final label in ['All', 'Messages', 'Channels', 'People']) {
|
||||
final text = find.text(label);
|
||||
final chip = find.ancestor(of: text, matching: find.byType(RawChip));
|
||||
expect(chip, findsOneWidget);
|
||||
expect(
|
||||
tester.getCenter(text).dy,
|
||||
closeTo(tester.getCenter(chip).dy, 0.01),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('expanded chips preserve large accessible text scaling', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
|
||||
child: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 240,
|
||||
child: FilterChipBar<int>(
|
||||
expandItems: true,
|
||||
selected: 0,
|
||||
onSelected: (_) {},
|
||||
items: const [
|
||||
FilterChipItem(id: 0, label: 'All'),
|
||||
FilterChipItem(id: 1, label: 'Messages'),
|
||||
FilterChipItem(id: 2, label: 'Channels'),
|
||||
FilterChipItem(id: 3, label: 'People'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(FilterChipBar<int>),
|
||||
matching: find.byType(FittedBox),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.text('Messages')).height,
|
||||
greaterThanOrEqualTo(30),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('title row and reported height grow with accessible text', (
|
||||
tester,
|
||||
) async {
|
||||
const titleStyle = TextStyle(fontSize: 22, height: 1.3);
|
||||
late double reportedHeight;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
reportedHeight = frostedAppBarHeight(
|
||||
context,
|
||||
titleStyle: titleStyle,
|
||||
);
|
||||
return const Stack(
|
||||
children: [
|
||||
FrostedAppBar(title: Text('Search'), titleStyle: titleStyle),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final clip = find.descendant(
|
||||
of: find.byType(FrostedAppBar),
|
||||
matching: find.byType(ClipRect),
|
||||
);
|
||||
expect(reportedHeight, greaterThan(48));
|
||||
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
|
||||
expect(tester.getSize(find.text('Search')).height, greaterThan(48));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('custom multi-line title height matches reported height', (
|
||||
tester,
|
||||
) async {
|
||||
const titleContentHeight = 64.0;
|
||||
late double reportedHeight;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
reportedHeight = frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: titleContentHeight,
|
||||
);
|
||||
return const Stack(
|
||||
children: [
|
||||
FrostedAppBar(
|
||||
titleContentHeight: titleContentHeight,
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Text('Title'), Text('Subtitle')],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final clip = find.descendant(
|
||||
of: find.byType(FrostedAppBar),
|
||||
matching: find.byType(ClipRect),
|
||||
);
|
||||
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
|
||||
expect(reportedHeight, closeTo(titleContentHeight + Grid.xs + 1, 0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A focused field inside a `Scaffold` body, which is the only arrangement
|
||||
/// either message list ever runs in.
|
||||
Widget _testable({
|
||||
required FocusNode focusNode,
|
||||
VoidCallback? onUserScrollStart,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
TextField(focusNode: focusNode),
|
||||
Expanded(
|
||||
child: KeyboardDismissOnDrag(
|
||||
onUserScrollStart: onUserScrollStart,
|
||||
child: ListView(
|
||||
children: [
|
||||
for (var i = 0; i < 40; i++)
|
||||
SizedBox(height: 60, child: Text('row $i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Raise the keyboard the way the platform does — on the view, not via a
|
||||
/// synthetic `MediaQuery` wrapper. It has to travel the real path, because
|
||||
/// `Scaffold` strips the bottom view inset from its body once it has resized to
|
||||
/// account for it, and that stripping is the whole reason this widget broke.
|
||||
void _raiseKeyboard(WidgetTester tester) {
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
Future<void> _dragDown(
|
||||
WidgetTester tester,
|
||||
double distance, {
|
||||
String on = 'row 3',
|
||||
}) async {
|
||||
final gesture = await tester.startGesture(tester.getCenter(find.text(on)));
|
||||
// Several small moves, the way a real finger reports — the accumulator has to
|
||||
// add them up rather than needing one big delta.
|
||||
final step = distance / 6;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await gesture.moveBy(Offset(0, step));
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('KeyboardDismissOnDrag', () {
|
||||
testWidgets('a deliberate downward drag drops focus', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
_raiseKeyboard(tester);
|
||||
await tester.pumpWidget(_testable(focusNode: focusNode));
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
|
||||
await _dragDown(tester, keyboardDismissDragThreshold * 2);
|
||||
|
||||
// At rest the list can't scroll down, so this gesture is pure overscroll
|
||||
// — the case that reports `OverscrollNotification` rather than
|
||||
// `ScrollUpdateNotification`, and the one a focused composer is usually
|
||||
// sitting in.
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('an ordinary downward scroll dismisses too', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
_raiseKeyboard(tester);
|
||||
await tester.pumpWidget(_testable(focusNode: focusNode));
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
// Move off the resting edge so dragging down actually moves the position,
|
||||
// covering the `ScrollUpdateNotification` path rather than overscroll.
|
||||
await tester.drag(find.text('row 3'), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
|
||||
await _dragDown(tester, keyboardDismissDragThreshold * 2, on: 'row 10');
|
||||
|
||||
expect(focusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a short scroll leaves the keyboard alone', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
_raiseKeyboard(tester);
|
||||
await tester.pumpWidget(_testable(focusNode: focusNode));
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await _dragDown(tester, keyboardDismissDragThreshold / 2);
|
||||
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('reports a user-started scroll', (tester) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
var userScrollStarts = 0;
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
focusNode: focusNode,
|
||||
onUserScrollStart: () => userScrollStarts += 1,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.drag(find.text('row 3'), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(userScrollStarts, 1);
|
||||
});
|
||||
|
||||
testWidgets('an upward drag never dismisses, however far it goes', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
_raiseKeyboard(tester);
|
||||
await tester.pumpWidget(_testable(focusNode: focusNode));
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await _dragDown(tester, -keyboardDismissDragThreshold * 4);
|
||||
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('with the keyboard already down, focus is left as it is', (
|
||||
tester,
|
||||
) async {
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
// No _raiseKeyboard: the keyboard is down.
|
||||
await tester.pumpWidget(_testable(focusNode: focusNode));
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
await _dragDown(tester, keyboardDismissDragThreshold * 2);
|
||||
|
||||
// Nothing to dismiss, so don't yank focus out of whatever holds it.
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
|
||||
|
||||
void main() {
|
||||
group('avatarBadgeMaskPath', () {
|
||||
const size = 128.0;
|
||||
const geometry = AvatarBadgeMaskGeometry.badge;
|
||||
|
||||
test('keeps the avatar but cuts the notch out of it', () {
|
||||
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
|
||||
|
||||
expect(path.contains(const Offset(size / 2, size / 2)), isTrue);
|
||||
expect(path.contains(geometry.cutoutCenter(size)), isFalse);
|
||||
// The far edge is untouched — the notch only bites the bottom-right.
|
||||
expect(path.contains(const Offset(4, size / 2)), isTrue);
|
||||
});
|
||||
|
||||
test('stays inside the avatar circle on the unnotched sides', () {
|
||||
// Checked with contains() rather than getBounds(), which reports
|
||||
// control-point bounds and so overshoots the near-full-circle arc.
|
||||
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
|
||||
|
||||
expect(path.contains(const Offset(-1, size / 2)), isFalse);
|
||||
expect(path.contains(const Offset(size / 2, -1)), isFalse);
|
||||
expect(path.contains(const Offset(2, 2)), isFalse);
|
||||
});
|
||||
|
||||
test('fillets the notch rather than subtracting a bare circle', () {
|
||||
// A plain difference meets the cutout at two cusps. The fillets round
|
||||
// those off, which necessarily removes extra material just outside the
|
||||
// cutout circle — so the masked path must be a strict subset there.
|
||||
final masked = avatarBadgeMaskPath(size: size, geometry: geometry);
|
||||
final cutoutCenter = geometry.cutoutCenter(size);
|
||||
final cutoutRadius = geometry.cutoutRadius(size);
|
||||
final bare = Path.combine(
|
||||
PathOperation.difference,
|
||||
Path()..addOval(
|
||||
Rect.fromCircle(
|
||||
center: const Offset(size / 2, size / 2),
|
||||
radius: size / 2,
|
||||
),
|
||||
),
|
||||
Path()..addOval(
|
||||
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
|
||||
),
|
||||
);
|
||||
|
||||
// Sample the ring just outside the cutout, where the fillets live.
|
||||
var filleted = 0;
|
||||
for (var step = 0; step < 360; step++) {
|
||||
final angle = step * math.pi / 180;
|
||||
final point =
|
||||
cutoutCenter +
|
||||
Offset(math.cos(angle), math.sin(angle)) * (cutoutRadius + 2);
|
||||
if (bare.contains(point) && !masked.contains(point)) filleted++;
|
||||
// The fillet only adds material to the cut, never puts it back.
|
||||
expect(
|
||||
masked.contains(point) && !bare.contains(point),
|
||||
isFalse,
|
||||
reason: 'mask should not extend beyond the plain difference',
|
||||
);
|
||||
}
|
||||
expect(filleted, greaterThan(0));
|
||||
});
|
||||
|
||||
test('falls back to a plain circle when the notch misses the avatar', () {
|
||||
// A notch tucked at the centre never crosses the avatar's edge, so there
|
||||
// is no crossing to fillet and nothing should be cut.
|
||||
const detached = AvatarBadgeMaskGeometry(
|
||||
cutoutCenterRatio: 0.5,
|
||||
cutoutRadiusRatio: 0.05,
|
||||
badgeSizeRatio: 0.08,
|
||||
curve: AvatarBadgeCurve.standard,
|
||||
);
|
||||
final path = avatarBadgeMaskPath(size: size, geometry: detached);
|
||||
|
||||
expect(path.contains(detached.cutoutCenter(size)), isTrue);
|
||||
expect(path.getBounds().size, const Size(size, size));
|
||||
});
|
||||
|
||||
test(
|
||||
'scales geometry with the avatar, matching desktop at its own size',
|
||||
() {
|
||||
// Desktop's sidebar avatar is 32px with the cutout at (28, 28) r 7.5.
|
||||
const presence = AvatarBadgeMaskGeometry.presenceDot;
|
||||
|
||||
expect(presence.cutoutCenter(32), const Offset(28, 28));
|
||||
expect(presence.cutoutRadius(32), 7.5);
|
||||
expect(presence.badgeSize(32), 14);
|
||||
// Twice the avatar, twice the notch.
|
||||
expect(presence.cutoutCenter(64), const Offset(56, 56));
|
||||
expect(presence.cutoutRadius(64), 15);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('MaskedAvatarBadge', () {
|
||||
Widget harness({Widget? badge}) => MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Center(
|
||||
child: MaskedAvatarBadge(
|
||||
size: 128,
|
||||
geometry: AvatarBadgeMaskGeometry.badge,
|
||||
avatar: const ColoredBox(color: Color(0xFF123456)),
|
||||
badge: badge,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('masks the avatar and centres the badge in the notch', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
harness(badge: const ColoredBox(color: Color(0xFFABCDEF))),
|
||||
);
|
||||
|
||||
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
|
||||
expect(clip.clipper, isA<AvatarBadgeMaskClipper>());
|
||||
|
||||
const geometry = AvatarBadgeMaskGeometry.badge;
|
||||
final frame = tester.getRect(find.byType(MaskedAvatarBadge));
|
||||
final badgeRect = tester.getRect(find.byType(ColoredBox).last);
|
||||
|
||||
expect(badgeRect.width, closeTo(geometry.badgeSize(128), 0.01));
|
||||
expect(
|
||||
badgeRect.center - frame.topLeft,
|
||||
within(distance: 0.01, from: geometry.cutoutCenter(128)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('leaves the avatar whole when there is no badge', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(harness());
|
||||
|
||||
expect(find.byType(ClipPath), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byType(MaskedAvatarBadge)),
|
||||
const Size(128, 128),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/message_author_meta.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('keeps the separator and timestamp next to a short name', (
|
||||
tester,
|
||||
) async {
|
||||
const displayNameKey = Key('author-display-name');
|
||||
const timestampKey = Key('author-timestamp');
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const Scaffold(
|
||||
body: SizedBox(
|
||||
width: 300,
|
||||
child: MessageAuthorMeta(
|
||||
displayName: 'Alice',
|
||||
timestamp: '2m',
|
||||
displayNameKey: displayNameKey,
|
||||
timestampKey: timestampKey,
|
||||
nameColor: Colors.black,
|
||||
metadataColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final displayNameRect = tester.getRect(find.byKey(displayNameKey));
|
||||
final separatorRect = tester.getRect(find.text('·'));
|
||||
final timestampRect = tester.getRect(find.byKey(timestampKey));
|
||||
|
||||
expect(separatorRect.left - displayNameRect.right, Grid.half);
|
||||
expect(timestampRect.left - separatorRect.right, Grid.half);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('reallocates unused metadata width to the display name', (
|
||||
tester,
|
||||
) async {
|
||||
const displayNameKey = Key('author-display-name');
|
||||
const timestampKey = Key('author-timestamp');
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const Scaffold(
|
||||
body: SizedBox(
|
||||
width: 300,
|
||||
child: MessageAuthorMeta(
|
||||
displayName: 'A display name that needs the available width',
|
||||
username: 'al',
|
||||
timestamp: '2m',
|
||||
displayNameKey: displayNameKey,
|
||||
timestampKey: timestampKey,
|
||||
nameColor: Colors.black,
|
||||
metadataColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = find.byType(MessageAuthorMeta);
|
||||
final displayName = find.byKey(displayNameKey);
|
||||
final timestamp = find.byKey(timestampKey);
|
||||
|
||||
expect(
|
||||
tester.getSize(displayName).width,
|
||||
greaterThan(tester.getSize(row).width / 2),
|
||||
);
|
||||
expect(
|
||||
tester.getTopRight(timestamp).dx,
|
||||
closeTo(tester.getTopRight(row).dx, 0.01),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('constrains long metadata at large accessible text sizes', (
|
||||
tester,
|
||||
) async {
|
||||
const timestampKey = Key('author-timestamp');
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const MediaQuery(
|
||||
data: MediaQueryData(textScaler: TextScaler.linear(2)),
|
||||
child: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 220,
|
||||
child: MessageAuthorMeta(
|
||||
displayName: 'A very long display name',
|
||||
username: 'a-very-long-username',
|
||||
timestamp: 'Mar 15, 2025',
|
||||
timestampKey: timestampKey,
|
||||
nameColor: Colors.black,
|
||||
metadataColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final timestamp = tester.widget<Text>(find.byKey(timestampKey));
|
||||
expect(timestamp.maxLines, 1);
|
||||
expect(timestamp.overflow, TextOverflow.ellipsis);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shared gradient fades from transparent to the page surface', (
|
||||
tester,
|
||||
) async {
|
||||
LinearGradient? gradient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
gradient = mobileTabFooterBackdropGradient(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(gradient?.stops, [0, 0.18, 0.38, 0.6, 0.8, 1]);
|
||||
expect(gradient?.colors.first.a, 0);
|
||||
expect(gradient?.colors[1].a, closeTo(0.03, 0.01));
|
||||
expect(gradient?.colors[3].a, closeTo(0.34, 0.01));
|
||||
expect(gradient?.colors.last.a, 1);
|
||||
});
|
||||
|
||||
testWidgets('uses a fixed 180px footer backdrop height', (tester) async {
|
||||
double? height;
|
||||
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
height = mobileTabFooterBackdropHeight(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(height, 180);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/modal_presentation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'keeps an opaque Flutter surface when iOS native support is unavailable',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const ConcentricSheetSurface(
|
||||
enabled: true,
|
||||
color: Colors.red,
|
||||
child: SizedBox(height: 80, child: Text('Sheet body')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Material && widget.color == Colors.red,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'replaces the Flutter fallback when native support is available',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
surfaceChannel,
|
||||
(call) async => call.method == 'isSupported' ? true : null,
|
||||
);
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const ConcentricSheetSurface(
|
||||
enabled: true,
|
||||
color: Colors.red,
|
||||
child: SizedBox(height: 80, child: Text('Sheet body')),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(UiKitView), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Material && widget.color == Colors.red,
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
} finally {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
surfaceChannel,
|
||||
null,
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'non-iOS sheets use Flutter drag handle and shared close control',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const Text('Sheet body'),
|
||||
),
|
||||
child: const Text('Open sheet'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final closeButton = find.byTooltip('Close sheet');
|
||||
expect(closeButton, findsOneWidget);
|
||||
expect(tester.getSize(closeButton), const Size.square(44));
|
||||
final closeGutter = find.ancestor(
|
||||
of: closeButton,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Padding &&
|
||||
widget.padding ==
|
||||
const EdgeInsets.only(
|
||||
top: Grid.xxs,
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
bottom: Grid.xs,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(closeGutter, findsOneWidget);
|
||||
final gutterRect = tester.getRect(closeGutter);
|
||||
final closeRect = tester.getRect(closeButton);
|
||||
expect(closeRect.top - gutterRect.top, Grid.gutter);
|
||||
expect(gutterRect.right - closeRect.right, Grid.gutter);
|
||||
expect(
|
||||
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('Sheet body'), findsOneWidget);
|
||||
|
||||
await tester.tap(closeButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sheet body'), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('iOS paints the drag handle inside the concentric surface', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const Text('Sheet body'),
|
||||
),
|
||||
child: const Text('Open sheet'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
|
||||
isFalse,
|
||||
);
|
||||
final internalHandle = find.byKey(
|
||||
const ValueKey('buzz-sheet-drag-handle'),
|
||||
);
|
||||
expect(internalHandle, findsOneWidget);
|
||||
expect(tester.getSize(internalHandle), const Size(32, 4));
|
||||
expect(find.bySemanticsLabel('Drag handle'), findsOneWidget);
|
||||
expect(find.byTooltip('Close sheet'), findsOneWidget);
|
||||
expect(find.text('Sheet body'), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('iOS compact sheets can omit X but retain the inside handle', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
showCloseButton: false,
|
||||
builder: (_) => const Text('Compact sheet body'),
|
||||
),
|
||||
child: const Text('Open compact sheet'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open compact sheet'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byTooltip('Close sheet'), findsNothing);
|
||||
expect(find.text('Compact sheet body'), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildShimmerTestable({
|
||||
bool disableAnimations = false,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: SkeletonShimmer(
|
||||
enabled: enabled,
|
||||
child: const SkeletonBar(width: 120, height: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildRevealTestable({
|
||||
required ValueNotifier<bool> loading,
|
||||
bool disableAnimations = false,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: loading,
|
||||
builder: (context, isLoading, _) => SkeletonReveal(
|
||||
loading: isLoading,
|
||||
skeleton: const SkeletonBar(width: 120, height: 16),
|
||||
content: const Text('Loaded content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double layerOpacity(WidgetTester tester, String key) =>
|
||||
tester.widget<Opacity>(find.byKey(Key(key))).opacity;
|
||||
|
||||
bool contentFocusExcluded(WidgetTester tester) => tester
|
||||
.widget<ExcludeFocus>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('skeleton-reveal-content')),
|
||||
matching: find.byType(ExcludeFocus),
|
||||
),
|
||||
)
|
||||
.excluding;
|
||||
|
||||
testWidgets('sweeps a highlight across skeleton elements while loading', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildShimmerTestable());
|
||||
|
||||
final shimmer = find.byType(SkeletonShimmer);
|
||||
expect(find.byType(SkeletonBar), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
find
|
||||
.descendant(of: shimmer, matching: find.byType(RepaintBoundary))
|
||||
.first,
|
||||
);
|
||||
final beforeBytes = await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
return image.toByteData();
|
||||
});
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1000));
|
||||
final afterBytes = await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
return image.toByteData();
|
||||
});
|
||||
|
||||
expect(
|
||||
beforeBytes!.buffer.asUint8List(),
|
||||
isNot(equals(afterBytes!.buffer.asUint8List())),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps skeleton elements static for reduced motion', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildShimmerTestable(disableAnimations: true));
|
||||
|
||||
final shimmer = find.byType(SkeletonShimmer);
|
||||
expect(find.byType(SkeletonBar), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps skeleton elements static when shimmer is disabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(buildShimmerTestable(enabled: false));
|
||||
|
||||
final shimmer = find.byType(SkeletonShimmer);
|
||||
expect(find.byType(SkeletonBar), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('reveals content in the same slot with a 400ms cross-fade', (
|
||||
tester,
|
||||
) async {
|
||||
final loading = ValueNotifier(true);
|
||||
await tester.pumpWidget(buildRevealTestable(loading: loading));
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
|
||||
expect(contentFocusExcluded(tester), isTrue);
|
||||
|
||||
loading.value = false;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
layerOpacity(tester, 'skeleton-reveal-placeholder'),
|
||||
closeTo(0.5, 0.01),
|
||||
);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), closeTo(0.5, 0.01));
|
||||
expect(contentFocusExcluded(tester), isTrue);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
|
||||
expect(contentFocusExcluded(tester), isFalse);
|
||||
expect(find.text('Loaded content'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('resets to the skeleton without animating backwards', (
|
||||
tester,
|
||||
) async {
|
||||
final loading = ValueNotifier(false);
|
||||
await tester.pumpWidget(buildRevealTestable(loading: loading));
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
|
||||
|
||||
loading.value = true;
|
||||
await tester.pump();
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
|
||||
});
|
||||
|
||||
testWidgets('reveals immediately for reduced motion', (tester) async {
|
||||
final loading = ValueNotifier(true);
|
||||
await tester.pumpWidget(
|
||||
buildRevealTestable(loading: loading, disableAnimations: true),
|
||||
);
|
||||
final reveal = find.byType(SkeletonReveal);
|
||||
expect(
|
||||
find.descendant(of: reveal, matching: find.byType(ShaderMask)),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
loading.value = false;
|
||||
await tester.pump();
|
||||
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
|
||||
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user