mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
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:
@@ -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: [
|
||||
|
||||
@@ -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<T> extends PopupRoute<T> {
|
||||
|
||||
@override
|
||||
Duration get transitionDuration =>
|
||||
reducedMotion ? Duration.zero : _popoverEnterDuration;
|
||||
reducedMotion ? Duration.zero : appPopoverEnterDuration;
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration =>
|
||||
@@ -155,10 +157,10 @@ class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
final curvedAnimation = animation.drive(
|
||||
CurveTween(curve: Curves.easeOutCubic),
|
||||
CurveTween(curve: appPopoverEnterCurve),
|
||||
);
|
||||
final scaleAnimation = Tween<double>(
|
||||
begin: _popoverStartScale,
|
||||
begin: appPopoverStartScale,
|
||||
end: 1,
|
||||
).animate(curvedAnimation);
|
||||
final transformOrigin = switch (alignment) {
|
||||
|
||||
@@ -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<Widget> 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,
|
||||
);
|
||||
|
||||
@@ -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<OwnedAgentDirectoryEntry> 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<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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user