mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
mobile: make the first-agent callout a bold card
The callout over the empty Bestie slot becomes a full-width card with agent pictures scattered around a centered "Create your first agent". Tapping the card starts creation; a close button dismisses it for good. Other taps on Home now leave it up. The card is a new BuzzCallout.card variant: phone-width on wider screens, and it grows to fit large text below its close button. Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -144,10 +144,20 @@ class DefaultAgentLauncher extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return BuzzCallout(
|
||||
return BuzzCallout.card(
|
||||
message: 'Create your first agent',
|
||||
visible: showCallout,
|
||||
onTap: open,
|
||||
onDismiss: () => dismissFirstAgentCallout(ref),
|
||||
artwork: [
|
||||
for (final character in emptyBestieCharacters.take(6))
|
||||
AvatarImageContent(
|
||||
imageUrl: character.url,
|
||||
preferThumbnail: true,
|
||||
fit: BoxFit.contain,
|
||||
fallback: const AgentNavigationIcon(size: 24),
|
||||
),
|
||||
],
|
||||
child: NavigationButtonSurface(
|
||||
child: SizedBox.square(
|
||||
dimension: 56,
|
||||
|
||||
@@ -7,7 +7,8 @@ import '../../shared/agents/agent_avatar_catalog.dart';
|
||||
import '../../shared/widgets/agent_navigation_icon.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
|
||||
final _characters = [
|
||||
/// The agent pictures new people are invited with, in the order shown.
|
||||
final emptyBestieCharacters = [
|
||||
for (var i = 0; i < AgentAvatarPack.gloopies.indices.length; i++)
|
||||
for (final pack in [
|
||||
AgentAvatarPack.fuzzies,
|
||||
@@ -33,16 +34,18 @@ class EmptyBestieAvatar extends HookWidget {
|
||||
useEffect(() {
|
||||
if (reducedMotion || !visible) return null;
|
||||
final timer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
index.value = (index.value + 1) % _characters.length;
|
||||
index.value = (index.value + 1) % emptyBestieCharacters.length;
|
||||
});
|
||||
return timer.cancel;
|
||||
}, [reducedMotion, visible]);
|
||||
final character = _characters[index.value];
|
||||
final character = emptyBestieCharacters[index.value];
|
||||
useEffect(() {
|
||||
var cancelled = false;
|
||||
if (visible && !reducedMotion) {
|
||||
final nextPoster =
|
||||
_characters[(index.value + 1) % _characters.length].url;
|
||||
emptyBestieCharacters[(index.value + 1) %
|
||||
emptyBestieCharacters.length]
|
||||
.url;
|
||||
// precacheImage reads inherited configuration; do so after hook setup.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (cancelled || !context.mounted) return;
|
||||
|
||||
@@ -4,19 +4,26 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import '../theme/buzz_icons.dart';
|
||||
import '../theme/theme.dart';
|
||||
import 'anchored_popover_menu.dart';
|
||||
|
||||
const _arrowHeight = Grid.xxs;
|
||||
const _arrowWidth = Grid.xs;
|
||||
const _targetGap = Grid.half;
|
||||
const _cardMaxWidth = Grid.xxxl * 6;
|
||||
|
||||
/// A non-blocking tip that points down at [child] while [visible].
|
||||
/// A non-blocking callout that points down at [child] while [visible].
|
||||
///
|
||||
/// The tip floats above everything else on the current route without a scrim,
|
||||
/// so the screen underneath stays usable. Any tap, on the tip or elsewhere,
|
||||
/// calls [onDismiss]; so does a screen reader's dismiss action. The tip hides
|
||||
/// while another route covers this one.
|
||||
/// It floats above everything else on the current route without a scrim, so
|
||||
/// the screen underneath stays usable, and hides while another route covers
|
||||
/// this one.
|
||||
///
|
||||
/// The default tip is a short message: any tap, on the tip or elsewhere,
|
||||
/// calls [onDismiss]. [BuzzCallout.card] is the loud variant, a full-width
|
||||
/// card with [artwork] scattered around a centered message: tapping it calls
|
||||
/// [onTap], and only its close button calls [onDismiss]. Screen readers can
|
||||
/// dismiss either one.
|
||||
class BuzzCallout extends HookWidget {
|
||||
const BuzzCallout({
|
||||
required this.message,
|
||||
@@ -24,11 +31,26 @@ class BuzzCallout extends HookWidget {
|
||||
required this.onDismiss,
|
||||
required this.child,
|
||||
super.key,
|
||||
}) : onTap = null,
|
||||
artwork = const [];
|
||||
|
||||
const BuzzCallout.card({
|
||||
required this.message,
|
||||
required this.visible,
|
||||
required this.onDismiss,
|
||||
required VoidCallback this.onTap,
|
||||
required this.child,
|
||||
this.artwork = const [],
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final bool visible;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Up to six pictures scattered around the card's edges.
|
||||
final List<Widget> artwork;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -37,13 +59,14 @@ class BuzzCallout extends HookWidget {
|
||||
final controller = useMemoized(OverlayPortalController.new);
|
||||
useEffect(() {
|
||||
// The controller can't change during a build, so it follows a frame
|
||||
// behind; the builder below hides the tip in the meantime.
|
||||
// behind; the builder below hides the callout in the meantime.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted || showing == controller.isShowing) return;
|
||||
showing ? controller.show() : controller.hide();
|
||||
});
|
||||
return null;
|
||||
}, [showing]);
|
||||
final onTap = this.onTap;
|
||||
return OverlayPortal.overlayChildLayoutBuilder(
|
||||
controller: controller,
|
||||
overlayChildBuilder: (context, info) {
|
||||
@@ -60,32 +83,18 @@ class BuzzCallout extends HookWidget {
|
||||
right: padding.right + Grid.xs,
|
||||
top: padding.top + Grid.xs,
|
||||
),
|
||||
// Inverse colors set the tip apart from menus and the page.
|
||||
fullWidth: onTap != null,
|
||||
// Inverse colors set the callout apart from menus and the page.
|
||||
color: context.colors.inverseSurface,
|
||||
shadowColor: appPopoverShadowColor(context),
|
||||
child: TapRegion(
|
||||
onTapInside: (_) => onDismiss(),
|
||||
onTapOutside: (_) => onDismiss(),
|
||||
child: Semantics(
|
||||
container: true,
|
||||
liveRegion: true,
|
||||
onDismiss: onDismiss,
|
||||
onTap: onDismiss,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.twelve,
|
||||
child: onTap == null
|
||||
? _Tip(message: message, onDismiss: onDismiss)
|
||||
: _Card(
|
||||
message: message,
|
||||
artwork: artwork,
|
||||
onTap: onTap,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
@@ -93,6 +102,143 @@ class BuzzCallout extends HookWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Tip extends StatelessWidget {
|
||||
const _Tip({required this.message, required this.onDismiss});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TapRegion(
|
||||
onTapInside: (_) => onDismiss(),
|
||||
onTapOutside: (_) => onDismiss(),
|
||||
child: Semantics(
|
||||
container: true,
|
||||
liveRegion: true,
|
||||
onDismiss: onDismiss,
|
||||
onTap: onDismiss,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the card's artwork sits, after desktop's empty Agents tile: each
|
||||
/// picture hangs partly off an edge, tilted, clear of the close button. The
|
||||
/// message sits on top where they meet.
|
||||
const _scatter = [
|
||||
(Alignment(-1.12, -1.3), -0.21),
|
||||
(Alignment(-0.42, -1.45), 0.12),
|
||||
(Alignment(-0.78, 1.4), -0.17),
|
||||
(Alignment(0.12, 1.5), 0.08),
|
||||
(Alignment(0.45, -1.4), -0.1),
|
||||
(Alignment(1.12, 1.25), 0.21),
|
||||
];
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({
|
||||
required this.message,
|
||||
required this.artwork,
|
||||
required this.onTap,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final List<Widget> artwork;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
container: true,
|
||||
liveRegion: true,
|
||||
button: true,
|
||||
onTap: onTap,
|
||||
onDismiss: onDismiss,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
onTap: onTap,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.popover),
|
||||
child: Stack(
|
||||
children: [
|
||||
for (final (index, picture)
|
||||
in artwork.take(_scatter.length).indexed)
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: _scatter[index].$1,
|
||||
child: Transform.rotate(
|
||||
angle: _scatter[index].$2,
|
||||
child: SizedBox.square(
|
||||
dimension: Grid.xxxl,
|
||||
child: ExcludeSemantics(child: picture),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The message sets the card's height, keeping a close-button
|
||||
// row above it at any text size.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxl,
|
||||
),
|
||||
child: Center(
|
||||
heightFactor: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: Grid.half,
|
||||
right: Grid.half,
|
||||
child: IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
onPressed: onDismiss,
|
||||
color: context.colors.onInverseSurface,
|
||||
icon: const Icon(BuzzIcons.x),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the overlay, placing its child above [target] inside [margin] and
|
||||
/// painting one surface for the bubble and its arrow. Only the bubble is
|
||||
/// hit-testable, so the rest of the screen keeps receiving touches.
|
||||
@@ -100,6 +246,7 @@ class _CalloutPlacement extends SingleChildRenderObjectWidget {
|
||||
const _CalloutPlacement({
|
||||
required this.target,
|
||||
required this.margin,
|
||||
required this.fullWidth,
|
||||
required this.color,
|
||||
required this.shadowColor,
|
||||
required super.child,
|
||||
@@ -107,12 +254,13 @@ class _CalloutPlacement extends SingleChildRenderObjectWidget {
|
||||
|
||||
final Rect target;
|
||||
final EdgeInsets margin;
|
||||
final bool fullWidth;
|
||||
final Color color;
|
||||
final Color shadowColor;
|
||||
|
||||
@override
|
||||
_RenderCalloutPlacement createRenderObject(BuildContext context) =>
|
||||
_RenderCalloutPlacement(target, margin, color, shadowColor);
|
||||
_RenderCalloutPlacement(target, margin, fullWidth, color, shadowColor);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
@@ -122,6 +270,7 @@ class _CalloutPlacement extends SingleChildRenderObjectWidget {
|
||||
renderObject
|
||||
..target = target
|
||||
..margin = margin
|
||||
..fullWidth = fullWidth
|
||||
..color = color
|
||||
..shadowColor = shadowColor;
|
||||
}
|
||||
@@ -131,6 +280,7 @@ class _RenderCalloutPlacement extends RenderShiftedBox {
|
||||
_RenderCalloutPlacement(
|
||||
this._target,
|
||||
this._margin,
|
||||
this._fullWidth,
|
||||
this._color,
|
||||
this._shadowColor,
|
||||
) : super(null);
|
||||
@@ -149,6 +299,13 @@ class _RenderCalloutPlacement extends RenderShiftedBox {
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
bool _fullWidth;
|
||||
set fullWidth(bool value) {
|
||||
if (value == _fullWidth) return;
|
||||
_fullWidth = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
Color _color;
|
||||
set color(Color value) {
|
||||
if (value == _color) return;
|
||||
@@ -171,9 +328,13 @@ class _RenderCalloutPlacement extends RenderShiftedBox {
|
||||
final child = this.child;
|
||||
if (child == null) return;
|
||||
final bottom = _target.top - _targetGap - _arrowHeight;
|
||||
final room = math.max(0.0, size.width - _margin.horizontal);
|
||||
// A card spans a phone; on wider screens it stays phone-sized.
|
||||
final width = _fullWidth ? math.min(room, _cardMaxWidth) : room;
|
||||
child.layout(
|
||||
BoxConstraints(
|
||||
maxWidth: math.max(0, size.width - _margin.horizontal),
|
||||
minWidth: _fullWidth ? width : 0,
|
||||
maxWidth: width,
|
||||
maxHeight: math.max(0, bottom - _margin.top),
|
||||
),
|
||||
parentUsesSize: true,
|
||||
|
||||
@@ -17,7 +17,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../shared/me/me_sync_test.dart' show FakeRelay;
|
||||
|
||||
const _tip = 'Create your first agent';
|
||||
const _card = 'Create your first agent';
|
||||
|
||||
void main() {
|
||||
final nsec = nostr.Nip19.encode(
|
||||
@@ -100,26 +100,27 @@ void main() {
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
await pumpHome(tester);
|
||||
expect(find.text(_tip), findsOneWidget);
|
||||
expect(find.text(_card), findsOneWidget);
|
||||
expect(
|
||||
tester.getBottomLeft(find.text(_tip)).dy,
|
||||
tester.getBottomLeft(find.text(_card)).dy,
|
||||
lessThan(tester.getTopLeft(find.byTooltip('Bestie')).dy),
|
||||
);
|
||||
expect(
|
||||
tester.getSemantics(find.text(_tip)),
|
||||
tester.getSemantics(find.text(_card)),
|
||||
matchesSemantics(
|
||||
label: _tip,
|
||||
label: _card,
|
||||
isLiveRegion: true,
|
||||
isButton: true,
|
||||
hasDismissAction: true,
|
||||
hasTapAction: true,
|
||||
),
|
||||
);
|
||||
// The screen underneath still takes the tap that dismisses the tip.
|
||||
// The screen underneath still takes taps, and they leave the card up.
|
||||
await tester.tap(find.text('Channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(otherTaps, 1);
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(seen('https://a.test'), isTrue);
|
||||
expect(find.text(_card), findsOneWidget);
|
||||
expect(seen('https://a.test'), isFalse);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
@@ -138,16 +139,27 @@ void main() {
|
||||
.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
expect(choices, 1);
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
expect(seen('https://a.test'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tapping the tip itself dismisses it', (tester) async {
|
||||
testWidgets('tapping the card starts creation and retires it', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester);
|
||||
await tester.tap(find.text(_tip));
|
||||
await tester.tap(find.text(_card));
|
||||
await tester.pumpAndSettle();
|
||||
expect(choices, 1);
|
||||
expect(find.text(_card), findsNothing);
|
||||
expect(seen('https://a.test'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the close button dismisses it without creating', (tester) async {
|
||||
await pumpHome(tester);
|
||||
await tester.tap(find.byTooltip('Dismiss'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(choices, 0);
|
||||
expect(find.text(_card), findsNothing);
|
||||
expect(seen('https://a.test'), isTrue);
|
||||
});
|
||||
|
||||
@@ -155,11 +167,11 @@ void main() {
|
||||
final semantics = tester.ensureSemantics();
|
||||
await pumpHome(tester);
|
||||
tester.semantics.performAction(
|
||||
find.semantics.byLabel(_tip),
|
||||
find.semantics.byLabel(_card),
|
||||
SemanticsAction.dismiss,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
expect(seen('https://a.test'), isTrue);
|
||||
semantics.dispose();
|
||||
});
|
||||
@@ -168,37 +180,37 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester);
|
||||
expect(find.text(_tip), findsOneWidget);
|
||||
expect(find.text(_card), findsOneWidget);
|
||||
bestie.add('a' * 64);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
expect(seen('https://a.test'), isTrue);
|
||||
bestie.add(null);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('once dismissed it never returns in that community', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHome(tester);
|
||||
await tester.tap(find.text('Channel'));
|
||||
await tester.tap(find.byTooltip('Dismiss'));
|
||||
await tester.pumpAndSettle();
|
||||
await pumpHome(tester);
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
// Another community is its own first run.
|
||||
await pumpHome(tester, community: 'https://b.test');
|
||||
expect(find.text(_tip), findsOneWidget);
|
||||
expect(find.text(_card), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stays hidden for anyone who already has agents', (tester) async {
|
||||
await pumpHome(tester, owned: [OwnedAgentDirectoryEntry(pubkey: 'b' * 64)]);
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
await pumpHome(tester, remoteAgents: false);
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
// None of these count as dismissing it.
|
||||
await pumpHome(tester);
|
||||
expect(find.text(_tip), findsOneWidget);
|
||||
expect(find.text(_card), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides while anything covers Home, then returns', (tester) async {
|
||||
@@ -214,10 +226,10 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Channel'), findsOneWidget);
|
||||
expect(find.text(_tip), findsNothing);
|
||||
expect(find.text(_card), findsNothing);
|
||||
navigator.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(_tip), findsOneWidget);
|
||||
expect(find.text(_card), findsOneWidget);
|
||||
expect(seen('https://a.test'), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,4 +46,124 @@ void main() {
|
||||
},
|
||||
);
|
||||
}
|
||||
cardTests();
|
||||
}
|
||||
|
||||
void cardTests() {
|
||||
Future<Rect> pumpCard(
|
||||
WidgetTester tester, {
|
||||
required double width,
|
||||
double scale = 1,
|
||||
VoidCallback? onTap,
|
||||
VoidCallback? onDismiss,
|
||||
VoidCallback? onPage,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = scale;
|
||||
addTearDown(tester.view.reset);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: onPage ?? () {},
|
||||
child: const Text('Page'),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: BuzzCallout.card(
|
||||
message: 'Make one',
|
||||
visible: true,
|
||||
onTap: onTap ?? () {},
|
||||
onDismiss: onDismiss ?? () {},
|
||||
artwork: const [Text('Art')],
|
||||
child: const SizedBox.square(
|
||||
key: Key('target'),
|
||||
dimension: 56,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final card = tester.getRect(
|
||||
find
|
||||
.ancestor(of: find.text('Make one'), matching: find.byType(Stack))
|
||||
.first,
|
||||
);
|
||||
expect(
|
||||
card.bottom,
|
||||
lessThan(tester.getRect(find.byKey(const Key('target'))).top),
|
||||
);
|
||||
return card;
|
||||
}
|
||||
|
||||
testWidgets('a card spans a phone between the margins', (tester) async {
|
||||
final card = await pumpCard(tester, width: 320);
|
||||
expect(card.left, Grid.xs);
|
||||
expect(card.right, 320 - Grid.xs);
|
||||
});
|
||||
|
||||
testWidgets('a card grows to fit large text below its close button', (
|
||||
tester,
|
||||
) async {
|
||||
final card = await pumpCard(tester, width: 320, scale: 3);
|
||||
final message = tester.getRect(find.text('Make one'));
|
||||
final close = tester.getRect(find.byTooltip('Dismiss'));
|
||||
expect(card.contains(message.topLeft), isTrue);
|
||||
expect(card.contains(message.bottomRight), isTrue);
|
||||
expect(message.top, greaterThanOrEqualTo(close.bottom));
|
||||
});
|
||||
|
||||
testWidgets('a card stays phone-sized on a wide screen', (tester) async {
|
||||
final card = await pumpCard(tester, width: 1024);
|
||||
expect(card.width, Grid.xxxl * 6);
|
||||
expect(card.right, lessThanOrEqualTo(1024 - Grid.xs));
|
||||
});
|
||||
|
||||
testWidgets('a card opens on tap and closes only from its close button', (
|
||||
tester,
|
||||
) async {
|
||||
var taps = 0;
|
||||
var dismissals = 0;
|
||||
var pageTaps = 0;
|
||||
await pumpCard(
|
||||
tester,
|
||||
width: 390,
|
||||
onTap: () => taps++,
|
||||
onDismiss: () => dismissals++,
|
||||
onPage: () => pageTaps++,
|
||||
);
|
||||
expect(find.text('Art'), findsOneWidget);
|
||||
final semantics = tester.ensureSemantics();
|
||||
// The close button stays its own control inside the tappable card.
|
||||
expect(
|
||||
tester.getSemantics(find.byTooltip('Dismiss')),
|
||||
matchesSemantics(
|
||||
tooltip: 'Dismiss',
|
||||
isButton: true,
|
||||
hasTapAction: true,
|
||||
hasEnabledState: true,
|
||||
isEnabled: true,
|
||||
isFocusable: true,
|
||||
hasFocusAction: true,
|
||||
),
|
||||
);
|
||||
semantics.dispose();
|
||||
await tester.tap(find.text('Page'));
|
||||
expect((pageTaps, taps, dismissals), (1, 0, 0));
|
||||
await tester.tap(find.text('Make one'));
|
||||
expect((pageTaps, taps, dismissals), (1, 1, 0));
|
||||
await tester.tap(find.byTooltip('Dismiss'));
|
||||
expect((pageTaps, taps, dismissals), (1, 1, 1));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user