mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Fill mobile loading views across the screen and use known image, video, voice-note, and file shapes in message placeholders. Freeze each loading cycle’s layout so arriving metadata cannot reshuffle the shimmer just before content appears; preserve cached Home and forum content during reconnects. Validation: mobile analysis and full test suite passed (2,752 passed, 4 skipped), plus 3 unconfigured-push tests and file-size checks. Installed and launched the loading changes on Pixel 10 before the latest main merge. Full `just ci` was stopped during desktop checks due to low disk space. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
273 lines
8.8 KiB
Dart
273 lines
8.8 KiB
Dart
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() {
|
|
for (final reducedMotion in [false, true]) {
|
|
testWidgets(
|
|
'holds the placeholder through metadata and reveal updates (reduced motion: $reducedMotion)',
|
|
(tester) async {
|
|
final state = ValueNotifier((loading: true, media: false));
|
|
addTearDown(state.dispose);
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: MediaQuery(
|
|
data: MediaQueryData(disableAnimations: reducedMotion),
|
|
child: Scaffold(
|
|
body: ValueListenableBuilder(
|
|
valueListenable: state,
|
|
builder: (context, value, _) => SkeletonReveal(
|
|
loading: value.loading,
|
|
skeleton: Align(
|
|
alignment: Alignment.topLeft,
|
|
child: SkeletonBar(
|
|
key: ValueKey(
|
|
value.media ? 'media-shape' : 'text-shape',
|
|
),
|
|
width: 200,
|
|
height: value.media ? 240 : 16,
|
|
),
|
|
),
|
|
content: const Text('Actual content'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
state.value = (loading: true, media: true);
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey('text-shape')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('media-shape')), findsNothing);
|
|
state.value = (loading: false, media: true);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(find.byKey(const ValueKey('text-shape')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('media-shape')), findsNothing);
|
|
// A new loading cycle may use the now-known media layout, even if it
|
|
// interrupts an in-progress reveal.
|
|
state.value = (loading: true, media: true);
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey('media-shape')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('text-shape')), findsNothing);
|
|
state.value = (loading: false, media: false);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 450));
|
|
expect(find.byKey(const ValueKey('media-shape')), findsOneWidget);
|
|
expect(
|
|
tester
|
|
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
|
|
.opacity,
|
|
1,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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,
|
|
ValueChanged<bool>? onReadyChanged,
|
|
}) {
|
|
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,
|
|
onReadyChanged: onReadyChanged,
|
|
skeleton: const SkeletonBar(width: 120, height: 16),
|
|
content: const Text('Loaded content'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
testWidgets('reports ready only after the loaded reveal finishes', (
|
|
tester,
|
|
) async {
|
|
final loading = ValueNotifier(true);
|
|
addTearDown(loading.dispose);
|
|
final readiness = <bool>[];
|
|
await tester.pumpWidget(
|
|
buildRevealTestable(loading: loading, onReadyChanged: readiness.add),
|
|
);
|
|
expect(readiness.last, false);
|
|
loading.value = false;
|
|
await tester.pump();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(readiness.last, false);
|
|
await tester.pump(const Duration(milliseconds: 220));
|
|
expect(readiness.last, true);
|
|
loading.value = true;
|
|
await tester.pump();
|
|
expect(readiness.last, false);
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|