diff --git a/mobile/lib/features/channels/default_agent_launcher.dart b/mobile/lib/features/channels/default_agent_launcher.dart index 6461c2c482..00600a81eb 100644 --- a/mobile/lib/features/channels/default_agent_launcher.dart +++ b/mobile/lib/features/channels/default_agent_launcher.dart @@ -7,6 +7,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/agents/owned_agent_directory.dart'; +import '../../shared/agents/remote_agents.dart'; import '../../shared/me/default_agent.dart'; import '../../shared/animated_avatar.dart'; import '../../shared/me/me_sync.dart'; @@ -17,6 +19,8 @@ import '../../shared/widgets/agent_navigation_icon.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/preset_agent_avatar.dart'; import '../../shared/widgets/navigation_button_surface.dart'; +import '../../shared/widgets/buzz_callout.dart'; +import 'first_agent_callout.dart'; import 'empty_bestie_avatar.dart'; import 'channel.dart'; import 'channel_detail_page.dart'; @@ -36,12 +40,33 @@ class DefaultAgentLauncher extends HookConsumerWidget { final profile = profiles[pubkey]; final opening = useState(false); final resolving = useState(false); + final remoteAgents = ref.watch(remoteAgentsEnabledProvider); + // Only someone with no agents at all is invited to make a first one. + // Cheap checks come first, so the rest are watched only when relevant. + final showCallout = + remoteAgents && + pubkey == null && + !preference.isLoading && + !preference.hasError && + !ref.watch(firstAgentCalloutSeenProvider) && + ref + .watch(ownedAgentDirectoryProvider) + .unwrapPrevious() + .value + ?.isEmpty == + true; useEffect(() { if (pubkey != null) { ref.read(userCacheProvider.notifier).preload([pubkey]); } + // Having a Bestie, however it was chosen, retires the tip for good. + if (pubkey != null && + remoteAgents && + !ref.read(firstAgentCalloutSeenProvider)) { + dismissFirstAgentCallout(ref); + } return null; - }, [pubkey]); + }, [pubkey, remoteAgents]); final label = pubkey != null ? 'Chat with ${profile?.label ?? 'your Bestie'}' : preference.hasError @@ -61,6 +86,7 @@ class DefaultAgentLauncher extends HookConsumerWidget { return; } if (pubkey == null) { + if (remoteAgents) dismissFirstAgentCallout(ref); onChooseAgent(); return; } @@ -118,36 +144,41 @@ class DefaultAgentLauncher extends HookConsumerWidget { } } - return NavigationButtonSurface( - child: SizedBox.square( - dimension: 56, - child: IconButton( - tooltip: label, - padding: EdgeInsets.zero, - onPressed: opening.value || preference.isLoading ? null : open, - icon: ExcludeSemantics( - child: resolving.value - ? const SizedBox.square( - dimension: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : pubkey == null - ? preference.isLoading || preference.hasError - ? const AgentNavigationIcon(size: 24) - : const EmptyBestieAvatar() - : usesInsetAgentArtwork(profile?.avatarUrl) - ? PresetAgentAvatar( - imageUrl: profile!.avatarUrl!, - size: 47.88, - fallback: Center(child: Text(profile.initial)), - ) - : AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 18, - isAgent: true, - backgroundColor: context.controlSurface, - fallback: Center(child: Text(profile?.initial ?? 'A')), - ), + return BuzzCallout( + message: 'Create your first agent', + visible: showCallout, + onDismiss: () => dismissFirstAgentCallout(ref), + child: NavigationButtonSurface( + child: SizedBox.square( + dimension: 56, + child: IconButton( + tooltip: label, + padding: EdgeInsets.zero, + onPressed: opening.value || preference.isLoading ? null : open, + icon: ExcludeSemantics( + child: resolving.value + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : pubkey == null + ? preference.isLoading || preference.hasError + ? const AgentNavigationIcon(size: 24) + : const EmptyBestieAvatar() + : usesInsetAgentArtwork(profile?.avatarUrl) + ? PresetAgentAvatar( + imageUrl: profile!.avatarUrl!, + size: 47.88, + fallback: Center(child: Text(profile.initial)), + ) + : AvatarImage( + imageUrl: profile?.avatarUrl, + radius: 18, + isAgent: true, + backgroundColor: context.controlSurface, + fallback: Center(child: Text(profile?.initial ?? 'A')), + ), + ), ), ), ), diff --git a/mobile/lib/features/channels/first_agent_callout.dart b/mobile/lib/features/channels/first_agent_callout.dart new file mode 100644 index 0000000000..9d111d96f9 --- /dev/null +++ b/mobile/lib/features/channels/first_agent_callout.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/me/me_sync.dart'; +import '../../shared/theme/theme_provider.dart'; + +/// The first-agent tip is dismissed once per account and community. +String firstAgentCalloutKey(MeSync scope) => + 'buzz-first-agent-callout.v1:${Uri.encodeComponent(scope.community)}:' + '${scope.pubkey}'; + +/// Whether this account has already dismissed the tip in this community. +/// Without an identity there is nothing to scope it to, so it stays hidden. +final firstAgentCalloutSeenProvider = Provider.autoDispose((ref) { + final scope = ref.watch(meSyncProvider); + if (scope.config.nsec == null) return true; + return ref.watch(savedPrefsProvider).getBool(firstAgentCalloutKey(scope)) ?? + false; +}); + +/// Hides the tip for good. Preferences update in memory at once, so a failed +/// disk write only means the tip may return on the next launch. +void dismissFirstAgentCallout(WidgetRef ref) { + final scope = ref.read(meSyncProvider); + if (scope.config.nsec == null) return; + unawaited( + ref + .read(savedPrefsProvider) + .setBool(firstAgentCalloutKey(scope), true) + .catchError((Object _) => false), + ); + ref.invalidate(firstAgentCalloutSeenProvider); +} diff --git a/mobile/lib/shared/widgets/buzz_callout.dart b/mobile/lib/shared/widgets/buzz_callout.dart new file mode 100644 index 0000000000..3ba2e31e7a --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_callout.dart @@ -0,0 +1,229 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../theme/theme.dart'; +import 'anchored_popover_menu.dart'; + +const _arrowHeight = Grid.xxs; +const _arrowWidth = Grid.xs; +const _targetGap = Grid.half; + +/// A non-blocking tip 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. +class BuzzCallout extends HookWidget { + const BuzzCallout({ + required this.message, + required this.visible, + required this.onDismiss, + required this.child, + super.key, + }); + + final String message; + final bool visible; + final VoidCallback onDismiss; + final Widget child; + + @override + Widget build(BuildContext context) { + final showing = visible && (ModalRoute.of(context)?.isCurrent ?? true); + 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. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || showing == controller.isShowing) return; + showing ? controller.show() : controller.hide(); + }); + return null; + }, [showing]); + return OverlayPortal.overlayChildLayoutBuilder( + controller: controller, + overlayChildBuilder: (context, info) { + if (!showing) return const SizedBox.shrink(); + final target = MatrixUtils.transformRect( + info.childPaintTransform, + Offset.zero & info.childSize, + ); + final padding = MediaQuery.paddingOf(context); + return _CalloutPlacement( + target: target, + margin: EdgeInsets.only( + left: padding.left + Grid.xs, + right: padding.right + Grid.xs, + top: padding.top + Grid.xs, + ), + // Inverse colors set the tip 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: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onInverseSurface, + ), + ), + ), + ), + ), + ); + }, + child: child, + ); + } +} + +/// 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. +class _CalloutPlacement extends SingleChildRenderObjectWidget { + const _CalloutPlacement({ + required this.target, + required this.margin, + required this.color, + required this.shadowColor, + required super.child, + }); + + final Rect target; + final EdgeInsets margin; + final Color color; + final Color shadowColor; + + @override + _RenderCalloutPlacement createRenderObject(BuildContext context) => + _RenderCalloutPlacement(target, margin, color, shadowColor); + + @override + void updateRenderObject( + BuildContext context, + _RenderCalloutPlacement renderObject, + ) { + renderObject + ..target = target + ..margin = margin + ..color = color + ..shadowColor = shadowColor; + } +} + +class _RenderCalloutPlacement extends RenderShiftedBox { + _RenderCalloutPlacement( + this._target, + this._margin, + this._color, + this._shadowColor, + ) : super(null); + + Rect _target; + set target(Rect value) { + if (value == _target) return; + _target = value; + markNeedsLayout(); + } + + EdgeInsets _margin; + set margin(EdgeInsets value) { + if (value == _margin) return; + _margin = value; + markNeedsLayout(); + } + + Color _color; + set color(Color value) { + if (value == _color) return; + _color = value; + markNeedsPaint(); + } + + Color _shadowColor; + set shadowColor(Color value) { + if (value == _shadowColor) return; + _shadowColor = value; + markNeedsPaint(); + } + + Path _path = Path(); + + @override + void performLayout() { + size = constraints.biggest; + final child = this.child; + if (child == null) return; + final bottom = _target.top - _targetGap - _arrowHeight; + child.layout( + BoxConstraints( + maxWidth: math.max(0, size.width - _margin.horizontal), + maxHeight: math.max(0, bottom - _margin.top), + ), + parentUsesSize: true, + ); + final left = (_target.center.dx - child.size.width / 2) + .clamp( + _margin.left, + math.max(_margin.left, size.width - _margin.right - child.size.width), + ) + .toDouble(); + final offset = Offset(left, bottom - child.size.height); + (child.parentData! as BoxParentData).offset = offset; + + final bubble = offset & child.size; + final radius = math.min(Radii.popover, bubble.shortestSide / 2); + // Keep the arrow's base clear of the rounded corners. + final arrowX = _target.center.dx + .clamp( + bubble.left + radius + _arrowWidth / 2, + math.max( + bubble.left + radius + _arrowWidth / 2, + bubble.right - radius - _arrowWidth / 2, + ), + ) + .toDouble(); + // One outline, so the shadow doesn't cross where the arrow joins. + _path = Path.combine( + PathOperation.union, + Path() + ..addRRect(RRect.fromRectAndRadius(bubble, Radius.circular(radius))), + Path()..addPolygon([ + Offset(arrowX - _arrowWidth / 2, bubble.bottom - 1), + Offset(arrowX, bubble.bottom + _arrowHeight), + Offset(arrowX + _arrowWidth / 2, bubble.bottom - 1), + ], true), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final child = this.child; + if (child == null) return; + final path = _path.shift(offset); + final canvas = context.canvas; + canvas.drawShadow(path, _shadowColor, appPopoverElevation, _color.a < 1); + canvas.drawPath(path, Paint()..color = _color); + super.paint(context, offset); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) => + hitTestChildren(result, position: position); +} diff --git a/mobile/test/features/agents/agents_sheet_test.dart b/mobile/test/features/agents/agents_sheet_test.dart index 521b99af4c..044ff982b1 100644 --- a/mobile/test/features/agents/agents_sheet_test.dart +++ b/mobile/test/features/agents/agents_sheet_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:buzz/shared/agents/remote_agents.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/me/default_agent.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/default_agent_launcher.dart'; import 'package:buzz/features/agents/agents_page.dart'; @@ -11,6 +12,7 @@ import 'package:buzz/features/profile/user_profile_sheet.dart'; import 'package:flutter/material.dart'; import 'package:buzz/features/home/home_page.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'agents_harness.dart'; import '../../helpers/remote_agents_fixture.dart'; @@ -37,7 +39,12 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); final h = await AgentsHarness.create(); - await h.open(tester, directory: [existing, otherAgentKeys.public]); + final prefs = await SharedPreferences.getInstance(); + await h.open( + tester, + directory: [existing, otherAgentKeys.public], + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + ); expect(find.byKey(ValueKey(otherAgentKeys.public)), findsNothing); expect(find.byKey(ValueKey(oldAppAgentKeys.public)), findsNothing); if (!supportsRemoteAgents(h.community)) { @@ -270,6 +277,7 @@ void main() { await h.open( tester, directory: [existing], + overrides: [savedPrefsProvider.overrideWithValue(prefs)], home: HomePage( onAgents: showAgents, defaultAgentBuilder: (context) => DefaultAgentLauncher( diff --git a/mobile/test/features/channels/default_agent_launcher_test.dart b/mobile/test/features/channels/default_agent_launcher_test.dart index bf5904f375..7c9d8be6f8 100644 --- a/mobile/test/features/channels/default_agent_launcher_test.dart +++ b/mobile/test/features/channels/default_agent_launcher_test.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; import '../../shared/me/me_sync_test.dart' show FakeRelay; @@ -37,8 +38,11 @@ void main() { final pending = Completer>(); relayB.pendingRead = pending.future; var choices = 0; + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); Widget app(MeSync scope) => ProviderScope( overrides: [ + savedPrefsProvider.overrideWithValue(prefs), meSyncProvider.overrideWithValue(scope), relaySessionProvider.overrideWith(() => relayA), ], diff --git a/mobile/test/features/channels/first_agent_callout_test.dart b/mobile/test/features/channels/first_agent_callout_test.dart new file mode 100644 index 0000000000..92eaa7b1d7 --- /dev/null +++ b/mobile/test/features/channels/first_agent_callout_test.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:ui' show SemanticsAction; + +import 'package:buzz/features/channels/default_agent_launcher.dart'; +import 'package:buzz/features/channels/first_agent_callout.dart'; +import 'package:buzz/shared/agents/owned_agent_directory.dart'; +import 'package:buzz/shared/agents/remote_agents.dart'; +import 'package:buzz/shared/me/default_agent.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../shared/me/me_sync_test.dart' show FakeRelay; + +const _tip = 'Create your first agent'; + +void main() { + final nsec = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ); + late SharedPreferences prefs; + late StreamController bestie; + late int choices; + late int otherTaps; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + bestie = StreamController.broadcast(); + choices = 0; + otherTaps = 0; + }); + tearDown(() => bestie.close()); + + Future pumpHome( + WidgetTester tester, { + String community = 'https://a.test', + bool remoteAgents = true, + List owned = const [], + String? initialBestie, + }) async { + final relay = FakeRelay(); + final scope = MeSync( + relay, + RelayConfig(baseUrl: community, nsec: nsec), + prefs: prefs, + ); + await tester.pumpWidget( + ProviderScope( + // A new key per call rebuilds the providers, like a fresh launch. + key: UniqueKey(), + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + meSyncProvider.overrideWithValue(scope), + relaySessionProvider.overrideWith(() => relay), + remoteAgentsEnabledProvider.overrideWithValue(remoteAgents), + ownedAgentDirectoryProvider.overrideWith((ref) async => owned), + defaultAgentProvider.overrideWith((ref) async* { + yield initialBestie; + yield* bestie.stream; + }), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Center( + child: TextButton( + onPressed: () => otherTaps++, + child: const Text('Channel'), + ), + ), + bottomNavigationBar: Align( + alignment: Alignment.centerRight, + heightFactor: 1, + child: DefaultAgentLauncher(onChooseAgent: () => choices++), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + bool seen(String community) => + prefs.getBool( + firstAgentCalloutKey( + MeSync(FakeRelay(), RelayConfig(baseUrl: community, nsec: nsec)), + ), + ) == + true; + + testWidgets('shows above the empty Bestie slot without blocking Home', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpHome(tester); + expect(find.text(_tip), findsOneWidget); + expect( + tester.getBottomLeft(find.text(_tip)).dy, + lessThan(tester.getTopLeft(find.byTooltip('Bestie')).dy), + ); + expect( + tester.getSemantics(find.text(_tip)), + matchesSemantics( + label: _tip, + isLiveRegion: true, + hasDismissAction: true, + hasTapAction: true, + ), + ); + // The screen underneath still takes the tap that dismisses the tip. + await tester.tap(find.text('Channel')); + await tester.pumpAndSettle(); + expect(otherTaps, 1); + expect(find.text(_tip), findsNothing); + expect(seen('https://a.test'), isTrue); + semantics.dispose(); + }); + + testWidgets('activating the slot dismisses it and starts creation', ( + tester, + ) async { + await pumpHome(tester); + // Screen readers and keyboards activate the slot without a touch. + tester + .widget( + find.ancestor( + of: find.byTooltip('Bestie'), + matching: find.byType(IconButton), + ), + ) + .onPressed!(); + await tester.pumpAndSettle(); + expect(choices, 1); + expect(find.text(_tip), findsNothing); + expect(seen('https://a.test'), isTrue); + }); + + testWidgets('tapping the tip itself dismisses it', (tester) async { + await pumpHome(tester); + await tester.tap(find.text(_tip)); + await tester.pumpAndSettle(); + expect(find.text(_tip), findsNothing); + expect(choices, 0); + expect(seen('https://a.test'), isTrue); + }); + + testWidgets('a screen reader can dismiss it', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpHome(tester); + tester.semantics.performAction( + find.semantics.byLabel(_tip), + SemanticsAction.dismiss, + ); + await tester.pumpAndSettle(); + expect(find.text(_tip), findsNothing); + expect(seen('https://a.test'), isTrue); + semantics.dispose(); + }); + + testWidgets('a Bestie retires it, even if the Bestie is later cleared', ( + tester, + ) async { + await pumpHome(tester); + expect(find.text(_tip), findsOneWidget); + bestie.add('a' * 64); + await tester.pumpAndSettle(); + expect(find.text(_tip), findsNothing); + expect(seen('https://a.test'), isTrue); + bestie.add(null); + await tester.pumpAndSettle(); + expect(find.text(_tip), findsNothing); + }); + + testWidgets('once dismissed it never returns in that community', ( + tester, + ) async { + await pumpHome(tester); + await tester.tap(find.text('Channel')); + await tester.pumpAndSettle(); + await pumpHome(tester); + expect(find.text(_tip), findsNothing); + // Another community is its own first run. + await pumpHome(tester, community: 'https://b.test'); + expect(find.text(_tip), 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); + await pumpHome(tester, remoteAgents: false); + expect(find.text(_tip), findsNothing); + // None of these count as dismissing it. + await pumpHome(tester); + expect(find.text(_tip), findsOneWidget); + }); + + testWidgets('hides while anything covers Home, then returns', (tester) async { + await pumpHome(tester); + final navigator = tester.state(find.byType(Navigator)); + // A sheet leaves Home painted underneath, so only the route check hides + // the tip there. + unawaited( + showModalBottomSheet( + context: navigator.context, + builder: (_) => const Text('Sheet'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Channel'), findsOneWidget); + expect(find.text(_tip), findsNothing); + navigator.pop(); + await tester.pumpAndSettle(); + expect(find.text(_tip), findsOneWidget); + expect(seen('https://a.test'), isFalse); + }); +} diff --git a/mobile/test/shared/widgets/buzz_callout_test.dart b/mobile/test/shared/widgets/buzz_callout_test.dart new file mode 100644 index 0000000000..32eb145d25 --- /dev/null +++ b/mobile/test/shared/widgets/buzz_callout_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/buzz_callout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final (width, scale) in [(320.0, 1.0), (320.0, 3.0), (1024.0, 1.0)]) { + testWidgets( + 'stays on screen beside an edge target, $width wide at ${scale}x', + (tester) 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.dark(), + home: Scaffold( + body: Align( + alignment: Alignment.bottomRight, + child: BuzzCallout( + message: 'A tip long enough to need room', + visible: true, + onDismiss: () {}, + child: const SizedBox.square( + key: Key('target'), + dimension: 56, + ), + ), + ), + ), + ), + ); + await tester.pump(); + final tip = tester.getRect( + find.ancestor( + of: find.text('A tip long enough to need room'), + matching: find.byType(TapRegion), + ), + ); + final target = tester.getRect(find.byKey(const Key('target'))); + expect(tip.right, lessThanOrEqualTo(width - Grid.xs)); + expect(tip.left, greaterThanOrEqualTo(Grid.xs)); + expect(tip.bottom, lessThan(target.top)); + }, + ); + } +}