From e2fdb791c06168c8a7c567bb87b793accb0d5a01 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:53:57 -0800 Subject: [PATCH] mobile: pop the first-agent card up after three seconds on Home The card appeared the instant Home loaded. It now waits three seconds on Home, then fades and rises from the agent slot using the popover entrance timing. Leaving Home restarts the wait, and with reduced motion it appears without animation after the same wait. Co-authored-by: Carl Co-Authored-By: Claude Opus 5.5 Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../channels/default_agent_launcher.dart | 2 + .../shared/widgets/anchored_popover_menu.dart | 12 +-- mobile/lib/shared/widgets/buzz_callout.dart | 48 +++++++++++- .../channels/first_agent_callout_test.dart | 73 +++++++++++++++++++ .../shared/widgets/buzz_callout_test.dart | 4 +- 5 files changed, 129 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/default_agent_launcher.dart b/mobile/lib/features/channels/default_agent_launcher.dart index 200669111f..9a0bc56c19 100644 --- a/mobile/lib/features/channels/default_agent_launcher.dart +++ b/mobile/lib/features/channels/default_agent_launcher.dart @@ -147,6 +147,8 @@ class DefaultAgentLauncher extends HookConsumerWidget { return BuzzCallout.card( message: 'Create your first agent', visible: showCallout, + // Give people a moment with Home before inviting them. + delay: const Duration(seconds: 3), onTap: open, onDismiss: () => dismissFirstAgentCallout(ref), artwork: [ diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 60fb9d55e7..55abd5615b 100644 --- a/mobile/lib/shared/widgets/anchored_popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -5,9 +5,11 @@ import 'package:flutter/material.dart'; import '../theme/theme.dart'; -const _popoverEnterDuration = Duration(milliseconds: 150); +/// How app popovers enter: a quick fade while scaling up from slightly small. +const appPopoverEnterDuration = Duration(milliseconds: 150); +const appPopoverEnterCurve = Curves.easeOutCubic; +const appPopoverStartScale = 0.96; const _popoverExitDuration = Duration(milliseconds: 110); -const _popoverStartScale = 0.96; /// Elevation shared by anchored menus and composer popover surfaces. const appPopoverElevation = 8.0; @@ -142,7 +144,7 @@ class _AnchoredPopoverRoute extends PopupRoute { @override Duration get transitionDuration => - reducedMotion ? Duration.zero : _popoverEnterDuration; + reducedMotion ? Duration.zero : appPopoverEnterDuration; @override Duration get reverseTransitionDuration => @@ -155,10 +157,10 @@ class _AnchoredPopoverRoute extends PopupRoute { Animation secondaryAnimation, ) { final curvedAnimation = animation.drive( - CurveTween(curve: Curves.easeOutCubic), + CurveTween(curve: appPopoverEnterCurve), ); final scaleAnimation = Tween( - begin: _popoverStartScale, + begin: appPopoverStartScale, end: 1, ).animate(curvedAnimation); final transformOrigin = switch (alignment) { diff --git a/mobile/lib/shared/widgets/buzz_callout.dart b/mobile/lib/shared/widgets/buzz_callout.dart index c273151f89..3e40839e28 100644 --- a/mobile/lib/shared/widgets/buzz_callout.dart +++ b/mobile/lib/shared/widgets/buzz_callout.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -17,7 +18,8 @@ const _cardMaxWidth = Grid.xxxl * 6; /// /// 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. +/// this one. With a [delay], it waits that long on screen before popping up +/// from [child], and the wait restarts whenever it's covered. /// /// 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 @@ -30,6 +32,7 @@ class BuzzCallout extends HookWidget { required this.visible, required this.onDismiss, required this.child, + this.delay = Duration.zero, super.key, }) : onTap = null, artwork = const []; @@ -41,6 +44,7 @@ class BuzzCallout extends HookWidget { required VoidCallback this.onTap, required this.child, this.artwork = const [], + this.delay = Duration.zero, super.key, }); @@ -51,13 +55,30 @@ class BuzzCallout extends HookWidget { /// Up to six pictures scattered around the card's edges. final List artwork; + final Duration delay; final Widget child; @override Widget build(BuildContext context) { - final showing = visible && (ModalRoute.of(context)?.isCurrent ?? true); + final eligible = visible && (ModalRoute.of(context)?.isCurrent ?? true); + final waited = useState(false); + useEffect(() { + waited.value = false; + if (!eligible || delay == Duration.zero) return null; + return Timer(delay, () => waited.value = true).cancel; + }, [eligible, delay]); + final showing = eligible && (delay == Duration.zero || waited.value); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final entrance = useAnimationController(duration: appPopoverEnterDuration); final controller = useMemoized(OverlayPortalController.new); useEffect(() { + if (!showing) { + entrance.value = 0; + } else if (reducedMotion) { + entrance.value = 1; + } else { + unawaited(entrance.forward(from: 0)); + } // The controller can't change during a build, so it follows a frame // behind; the builder below hides the callout in the meantime. WidgetsBinding.instance.addPostFrameCallback((_) { @@ -76,7 +97,7 @@ class BuzzCallout extends HookWidget { Offset.zero & info.childSize, ); final padding = MediaQuery.paddingOf(context); - return _CalloutPlacement( + final placement = _CalloutPlacement( target: target, margin: EdgeInsets.only( left: padding.left + Grid.xs, @@ -95,6 +116,27 @@ class BuzzCallout extends HookWidget { onDismiss: onDismiss, ), ); + // Pops up from the target: a fade and a short rise while scaling up + // around the arrow's tip. + return AnimatedBuilder( + animation: entrance, + builder: (context, placement) { + final t = appPopoverEnterCurve.transform(entrance.value); + return Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * Grid.xxs), + child: Transform.scale( + scale: appPopoverStartScale + (1 - appPopoverStartScale) * t, + alignment: Alignment.topLeft, + origin: Offset(target.center.dx, target.top), + child: placement, + ), + ), + ); + }, + child: placement, + ); }, child: child, ); diff --git a/mobile/test/features/channels/first_agent_callout_test.dart b/mobile/test/features/channels/first_agent_callout_test.dart index 7a0070b06c..8e09bb62f1 100644 --- a/mobile/test/features/channels/first_agent_callout_test.dart +++ b/mobile/test/features/channels/first_agent_callout_test.dart @@ -18,6 +18,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../shared/me/me_sync_test.dart' show FakeRelay; const _card = 'Create your first agent'; +const _delay = Duration(seconds: 3); void main() { final nsec = nostr.Nip19.encode( @@ -44,6 +45,8 @@ void main() { bool remoteAgents = true, List owned = const [], String? initialBestie, + bool reducedMotion = false, + bool waitForCard = true, }) async { final relay = FakeRelay(); final scope = MeSync( @@ -68,6 +71,12 @@ void main() { ], child: MaterialApp( theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reducedMotion), + child: child!, + ), home: Scaffold( body: Center( child: TextButton( @@ -85,6 +94,10 @@ void main() { ), ); await tester.pumpAndSettle(); + if (waitForCard) { + await tester.pump(_delay); + await tester.pumpAndSettle(); + } } bool seen(String community) => @@ -229,7 +242,67 @@ void main() { expect(find.text(_card), findsNothing); navigator.pop(); await tester.pumpAndSettle(); + // Coming back restarts the wait. + expect(find.text(_card), findsNothing); + await tester.pump(_delay); + await tester.pumpAndSettle(); expect(find.text(_card), findsOneWidget); expect(seen('https://a.test'), isFalse); }); + + testWidgets('waits three seconds on Home, then pops up', (tester) async { + await pumpHome(tester, waitForCard: false); + await tester.pump(_delay - const Duration(milliseconds: 100)); + expect(find.text(_card), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text(_card), findsOneWidget); + // It starts faint and fades in. + expect(_opacity(tester), lessThan(1)); + await tester.pumpAndSettle(); + expect(_opacity(tester), 1); + }); + + testWidgets('with reduced motion it still waits, then appears at once', ( + tester, + ) async { + await pumpHome(tester, reducedMotion: true, waitForCard: false); + await tester.pump(_delay - const Duration(milliseconds: 100)); + expect(find.text(_card), findsNothing); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(find.text(_card), findsOneWidget); + expect(_opacity(tester), 1); + }); + + testWidgets('leaving Home during the wait keeps it from popping up', ( + tester, + ) async { + await pumpHome(tester, waitForCard: false); + await tester.pump(const Duration(seconds: 2)); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute(builder: (_) => const Text('Elsewhere')), + ), + ); + await tester.pumpAndSettle(); + await tester.pump(_delay); + await tester.pumpAndSettle(); + expect(find.text(_card), findsNothing); + navigator.pop(); + await tester.pumpAndSettle(); + // Back on Home, the full wait starts over. + await tester.pump(const Duration(seconds: 2)); + expect(find.text(_card), findsNothing); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text(_card), findsOneWidget); + }); } + +double _opacity(WidgetTester tester) => tester + .widget( + find.ancestor(of: find.text(_card), matching: find.byType(Opacity)).first, + ) + .opacity; diff --git a/mobile/test/shared/widgets/buzz_callout_test.dart b/mobile/test/shared/widgets/buzz_callout_test.dart index 8a4e290737..4da9cb0971 100644 --- a/mobile/test/shared/widgets/buzz_callout_test.dart +++ b/mobile/test/shared/widgets/buzz_callout_test.dart @@ -32,7 +32,7 @@ void main() { ), ), ); - await tester.pump(); + await tester.pumpAndSettle(); final tip = tester.getRect( find.ancestor( of: find.text('A tip long enough to need room'), @@ -94,7 +94,7 @@ void cardTests() { ), ), ); - await tester.pump(); + await tester.pumpAndSettle(); final card = tester.getRect( find .ancestor(of: find.text('Make one'), matching: find.byType(Stack))