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 <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:
morgmart
2026-11-04 11:53:57 -08:00
parent f16600ea10
commit e2fdb791c0
5 changed files with 129 additions and 10 deletions

View File

@@ -147,6 +147,8 @@ class DefaultAgentLauncher extends HookConsumerWidget {
return BuzzCallout.card( return BuzzCallout.card(
message: 'Create your first agent', message: 'Create your first agent',
visible: showCallout, visible: showCallout,
// Give people a moment with Home before inviting them.
delay: const Duration(seconds: 3),
onTap: open, onTap: open,
onDismiss: () => dismissFirstAgentCallout(ref), onDismiss: () => dismissFirstAgentCallout(ref),
artwork: [ artwork: [

View File

@@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
import '../theme/theme.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 _popoverExitDuration = Duration(milliseconds: 110);
const _popoverStartScale = 0.96;
/// Elevation shared by anchored menus and composer popover surfaces. /// Elevation shared by anchored menus and composer popover surfaces.
const appPopoverElevation = 8.0; const appPopoverElevation = 8.0;
@@ -142,7 +144,7 @@ class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
@override @override
Duration get transitionDuration => Duration get transitionDuration =>
reducedMotion ? Duration.zero : _popoverEnterDuration; reducedMotion ? Duration.zero : appPopoverEnterDuration;
@override @override
Duration get reverseTransitionDuration => Duration get reverseTransitionDuration =>
@@ -155,10 +157,10 @@ class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
Animation<double> secondaryAnimation, Animation<double> secondaryAnimation,
) { ) {
final curvedAnimation = animation.drive( final curvedAnimation = animation.drive(
CurveTween(curve: Curves.easeOutCubic), CurveTween(curve: appPopoverEnterCurve),
); );
final scaleAnimation = Tween<double>( final scaleAnimation = Tween<double>(
begin: _popoverStartScale, begin: appPopoverStartScale,
end: 1, end: 1,
).animate(curvedAnimation); ).animate(curvedAnimation);
final transformOrigin = switch (alignment) { final transformOrigin = switch (alignment) {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; 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 /// It floats above everything else on the current route without a scrim, so
/// the screen underneath stays usable, and hides while another route covers /// 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, /// 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 /// calls [onDismiss]. [BuzzCallout.card] is the loud variant, a full-width
@@ -30,6 +32,7 @@ class BuzzCallout extends HookWidget {
required this.visible, required this.visible,
required this.onDismiss, required this.onDismiss,
required this.child, required this.child,
this.delay = Duration.zero,
super.key, super.key,
}) : onTap = null, }) : onTap = null,
artwork = const []; artwork = const [];
@@ -41,6 +44,7 @@ class BuzzCallout extends HookWidget {
required VoidCallback this.onTap, required VoidCallback this.onTap,
required this.child, required this.child,
this.artwork = const [], this.artwork = const [],
this.delay = Duration.zero,
super.key, super.key,
}); });
@@ -51,13 +55,30 @@ class BuzzCallout extends HookWidget {
/// Up to six pictures scattered around the card's edges. /// Up to six pictures scattered around the card's edges.
final List<Widget> artwork; final List<Widget> artwork;
final Duration delay;
final Widget child; final Widget child;
@override @override
Widget build(BuildContext context) { 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); final controller = useMemoized(OverlayPortalController.new);
useEffect(() { 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 // The controller can't change during a build, so it follows a frame
// behind; the builder below hides the callout in the meantime. // behind; the builder below hides the callout in the meantime.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -76,7 +97,7 @@ class BuzzCallout extends HookWidget {
Offset.zero & info.childSize, Offset.zero & info.childSize,
); );
final padding = MediaQuery.paddingOf(context); final padding = MediaQuery.paddingOf(context);
return _CalloutPlacement( final placement = _CalloutPlacement(
target: target, target: target,
margin: EdgeInsets.only( margin: EdgeInsets.only(
left: padding.left + Grid.xs, left: padding.left + Grid.xs,
@@ -95,6 +116,27 @@ class BuzzCallout extends HookWidget {
onDismiss: onDismiss, 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, child: child,
); );

View File

@@ -18,6 +18,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/me/me_sync_test.dart' show FakeRelay; import '../../shared/me/me_sync_test.dart' show FakeRelay;
const _card = 'Create your first agent'; const _card = 'Create your first agent';
const _delay = Duration(seconds: 3);
void main() { void main() {
final nsec = nostr.Nip19.encode( final nsec = nostr.Nip19.encode(
@@ -44,6 +45,8 @@ void main() {
bool remoteAgents = true, bool remoteAgents = true,
List<OwnedAgentDirectoryEntry> owned = const [], List<OwnedAgentDirectoryEntry> owned = const [],
String? initialBestie, String? initialBestie,
bool reducedMotion = false,
bool waitForCard = true,
}) async { }) async {
final relay = FakeRelay(); final relay = FakeRelay();
final scope = MeSync( final scope = MeSync(
@@ -68,6 +71,12 @@ void main() {
], ],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(), theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: reducedMotion),
child: child!,
),
home: Scaffold( home: Scaffold(
body: Center( body: Center(
child: TextButton( child: TextButton(
@@ -85,6 +94,10 @@ void main() {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
if (waitForCard) {
await tester.pump(_delay);
await tester.pumpAndSettle();
}
} }
bool seen(String community) => bool seen(String community) =>
@@ -229,7 +242,67 @@ void main() {
expect(find.text(_card), findsNothing); expect(find.text(_card), findsNothing);
navigator.pop(); navigator.pop();
await tester.pumpAndSettle(); 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(find.text(_card), findsOneWidget);
expect(seen('https://a.test'), isFalse); 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<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(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<Opacity>(
find.ancestor(of: find.text(_card), matching: find.byType(Opacity)).first,
)
.opacity;

View File

@@ -32,7 +32,7 @@ void main() {
), ),
), ),
); );
await tester.pump(); await tester.pumpAndSettle();
final tip = tester.getRect( final tip = tester.getRect(
find.ancestor( find.ancestor(
of: find.text('A tip long enough to need room'), 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( final card = tester.getRect(
find find
.ancestor(of: find.text('Make one'), matching: find.byType(Stack)) .ancestor(of: find.text('Make one'), matching: find.byType(Stack))