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))