From d0c6a15445345e90b737b693451201548ff8d5c3 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:46:37 -0800 Subject: [PATCH] mobile: make the first-agent callout a bold card The callout over the empty Bestie slot becomes a full-width card with agent pictures scattered around a centered "Create your first agent". Tapping the card starts creation; a close button dismisses it for good. Other taps on Home now leave it up. The card is a new BuzzCallout.card variant: phone-width on wider screens, and it grows to fit large text below its close button. 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 | 12 +- .../channels/empty_bestie_avatar.dart | 11 +- mobile/lib/shared/widgets/buzz_callout.dart | 223 +++++++++++++++--- .../channels/first_agent_callout_test.dart | 62 +++-- .../shared/widgets/buzz_callout_test.dart | 120 ++++++++++ 5 files changed, 367 insertions(+), 61 deletions(-) diff --git a/mobile/lib/features/channels/default_agent_launcher.dart b/mobile/lib/features/channels/default_agent_launcher.dart index 00600a81eb..200669111f 100644 --- a/mobile/lib/features/channels/default_agent_launcher.dart +++ b/mobile/lib/features/channels/default_agent_launcher.dart @@ -144,10 +144,20 @@ class DefaultAgentLauncher extends HookConsumerWidget { } } - return BuzzCallout( + return BuzzCallout.card( message: 'Create your first agent', visible: showCallout, + onTap: open, onDismiss: () => dismissFirstAgentCallout(ref), + artwork: [ + for (final character in emptyBestieCharacters.take(6)) + AvatarImageContent( + imageUrl: character.url, + preferThumbnail: true, + fit: BoxFit.contain, + fallback: const AgentNavigationIcon(size: 24), + ), + ], child: NavigationButtonSurface( child: SizedBox.square( dimension: 56, diff --git a/mobile/lib/features/channels/empty_bestie_avatar.dart b/mobile/lib/features/channels/empty_bestie_avatar.dart index daca2a0d93..6a0f3f5e4a 100644 --- a/mobile/lib/features/channels/empty_bestie_avatar.dart +++ b/mobile/lib/features/channels/empty_bestie_avatar.dart @@ -7,7 +7,8 @@ import '../../shared/agents/agent_avatar_catalog.dart'; import '../../shared/widgets/agent_navigation_icon.dart'; import '../../shared/widgets/avatar_image.dart'; -final _characters = [ +/// The agent pictures new people are invited with, in the order shown. +final emptyBestieCharacters = [ for (var i = 0; i < AgentAvatarPack.gloopies.indices.length; i++) for (final pack in [ AgentAvatarPack.fuzzies, @@ -33,16 +34,18 @@ class EmptyBestieAvatar extends HookWidget { useEffect(() { if (reducedMotion || !visible) return null; final timer = Timer.periodic(const Duration(seconds: 5), (_) { - index.value = (index.value + 1) % _characters.length; + index.value = (index.value + 1) % emptyBestieCharacters.length; }); return timer.cancel; }, [reducedMotion, visible]); - final character = _characters[index.value]; + final character = emptyBestieCharacters[index.value]; useEffect(() { var cancelled = false; if (visible && !reducedMotion) { final nextPoster = - _characters[(index.value + 1) % _characters.length].url; + emptyBestieCharacters[(index.value + 1) % + emptyBestieCharacters.length] + .url; // precacheImage reads inherited configuration; do so after hook setup. WidgetsBinding.instance.addPostFrameCallback((_) { if (cancelled || !context.mounted) return; diff --git a/mobile/lib/shared/widgets/buzz_callout.dart b/mobile/lib/shared/widgets/buzz_callout.dart index 3ba2e31e7a..4c8d4d11c6 100644 --- a/mobile/lib/shared/widgets/buzz_callout.dart +++ b/mobile/lib/shared/widgets/buzz_callout.dart @@ -4,19 +4,26 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import '../theme/buzz_icons.dart'; import '../theme/theme.dart'; import 'anchored_popover_menu.dart'; const _arrowHeight = Grid.xxs; const _arrowWidth = Grid.xs; const _targetGap = Grid.half; +const _cardMaxWidth = Grid.xxxl * 6; -/// A non-blocking tip that points down at [child] while [visible]. +/// A non-blocking callout 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. +/// 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. +/// +/// 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 +/// card with [artwork] scattered around a centered message: tapping it calls +/// [onTap], and only its close button calls [onDismiss]. Screen readers can +/// dismiss either one. class BuzzCallout extends HookWidget { const BuzzCallout({ required this.message, @@ -24,11 +31,26 @@ class BuzzCallout extends HookWidget { required this.onDismiss, required this.child, super.key, + }) : onTap = null, + artwork = const []; + + const BuzzCallout.card({ + required this.message, + required this.visible, + required this.onDismiss, + required VoidCallback this.onTap, + required this.child, + this.artwork = const [], + super.key, }); final String message; final bool visible; final VoidCallback onDismiss; + final VoidCallback? onTap; + + /// Up to six pictures scattered around the card's edges. + final List artwork; final Widget child; @override @@ -37,13 +59,14 @@ class BuzzCallout extends HookWidget { 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. + // behind; the builder below hides the callout in the meantime. WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || showing == controller.isShowing) return; showing ? controller.show() : controller.hide(); }); return null; }, [showing]); + final onTap = this.onTap; return OverlayPortal.overlayChildLayoutBuilder( controller: controller, overlayChildBuilder: (context, info) { @@ -60,32 +83,18 @@ class BuzzCallout extends HookWidget { right: padding.right + Grid.xs, top: padding.top + Grid.xs, ), - // Inverse colors set the tip apart from menus and the page. + fullWidth: onTap != null, + // Inverse colors set the callout 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: onTap == null + ? _Tip(message: message, onDismiss: onDismiss) + : _Card( + message: message, + artwork: artwork, + onTap: onTap, + onDismiss: onDismiss, ), - child: Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onInverseSurface, - ), - ), - ), - ), - ), ); }, child: child, @@ -93,6 +102,143 @@ class BuzzCallout extends HookWidget { } } +class _Tip extends StatelessWidget { + const _Tip({required this.message, required this.onDismiss}); + + final String message; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + return 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, + ), + ), + ), + ), + ); + } +} + +/// Where the card's artwork sits, after desktop's empty Agents tile: each +/// picture hangs partly off an edge, tilted, clear of the close button. The +/// message sits on top where they meet. +const _scatter = [ + (Alignment(-1.12, -1.3), -0.21), + (Alignment(-0.42, -1.45), 0.12), + (Alignment(-0.78, 1.4), -0.17), + (Alignment(0.12, 1.5), 0.08), + (Alignment(0.45, -1.4), -0.1), + (Alignment(1.12, 1.25), 0.21), +]; + +class _Card extends StatelessWidget { + const _Card({ + required this.message, + required this.artwork, + required this.onTap, + required this.onDismiss, + }); + + final String message; + final List artwork; + final VoidCallback onTap; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + liveRegion: true, + button: true, + onTap: onTap, + onDismiss: onDismiss, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: onTap, + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.popover), + child: Stack( + children: [ + for (final (index, picture) + in artwork.take(_scatter.length).indexed) + Positioned.fill( + child: Align( + alignment: _scatter[index].$1, + child: Transform.rotate( + angle: _scatter[index].$2, + child: SizedBox.square( + dimension: Grid.xxxl, + child: ExcludeSemantics(child: picture), + ), + ), + ), + ), + // The message sets the card's height, keeping a close-button + // row above it at any text size. + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxl, + ), + child: Center( + heightFactor: 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.full), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.titleMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ), + ), + ), + ), + Positioned( + top: Grid.half, + right: Grid.half, + child: IconButton( + tooltip: 'Dismiss', + onPressed: onDismiss, + color: context.colors.onInverseSurface, + icon: const Icon(BuzzIcons.x), + ), + ), + ], + ), + ), + ), + ); + } +} + /// 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. @@ -100,6 +246,7 @@ class _CalloutPlacement extends SingleChildRenderObjectWidget { const _CalloutPlacement({ required this.target, required this.margin, + required this.fullWidth, required this.color, required this.shadowColor, required super.child, @@ -107,12 +254,13 @@ class _CalloutPlacement extends SingleChildRenderObjectWidget { final Rect target; final EdgeInsets margin; + final bool fullWidth; final Color color; final Color shadowColor; @override _RenderCalloutPlacement createRenderObject(BuildContext context) => - _RenderCalloutPlacement(target, margin, color, shadowColor); + _RenderCalloutPlacement(target, margin, fullWidth, color, shadowColor); @override void updateRenderObject( @@ -122,6 +270,7 @@ class _CalloutPlacement extends SingleChildRenderObjectWidget { renderObject ..target = target ..margin = margin + ..fullWidth = fullWidth ..color = color ..shadowColor = shadowColor; } @@ -131,6 +280,7 @@ class _RenderCalloutPlacement extends RenderShiftedBox { _RenderCalloutPlacement( this._target, this._margin, + this._fullWidth, this._color, this._shadowColor, ) : super(null); @@ -149,6 +299,13 @@ class _RenderCalloutPlacement extends RenderShiftedBox { markNeedsLayout(); } + bool _fullWidth; + set fullWidth(bool value) { + if (value == _fullWidth) return; + _fullWidth = value; + markNeedsLayout(); + } + Color _color; set color(Color value) { if (value == _color) return; @@ -171,9 +328,13 @@ class _RenderCalloutPlacement extends RenderShiftedBox { final child = this.child; if (child == null) return; final bottom = _target.top - _targetGap - _arrowHeight; + final room = math.max(0.0, size.width - _margin.horizontal); + // A card spans a phone; on wider screens it stays phone-sized. + final width = _fullWidth ? math.min(room, _cardMaxWidth) : room; child.layout( BoxConstraints( - maxWidth: math.max(0, size.width - _margin.horizontal), + minWidth: _fullWidth ? width : 0, + maxWidth: width, maxHeight: math.max(0, bottom - _margin.top), ), parentUsesSize: true, diff --git a/mobile/test/features/channels/first_agent_callout_test.dart b/mobile/test/features/channels/first_agent_callout_test.dart index 92eaa7b1d7..7a0070b06c 100644 --- a/mobile/test/features/channels/first_agent_callout_test.dart +++ b/mobile/test/features/channels/first_agent_callout_test.dart @@ -17,7 +17,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../shared/me/me_sync_test.dart' show FakeRelay; -const _tip = 'Create your first agent'; +const _card = 'Create your first agent'; void main() { final nsec = nostr.Nip19.encode( @@ -100,26 +100,27 @@ void main() { ) async { final semantics = tester.ensureSemantics(); await pumpHome(tester); - expect(find.text(_tip), findsOneWidget); + expect(find.text(_card), findsOneWidget); expect( - tester.getBottomLeft(find.text(_tip)).dy, + tester.getBottomLeft(find.text(_card)).dy, lessThan(tester.getTopLeft(find.byTooltip('Bestie')).dy), ); expect( - tester.getSemantics(find.text(_tip)), + tester.getSemantics(find.text(_card)), matchesSemantics( - label: _tip, + label: _card, isLiveRegion: true, + isButton: true, hasDismissAction: true, hasTapAction: true, ), ); - // The screen underneath still takes the tap that dismisses the tip. + // The screen underneath still takes taps, and they leave the card up. await tester.tap(find.text('Channel')); await tester.pumpAndSettle(); expect(otherTaps, 1); - expect(find.text(_tip), findsNothing); - expect(seen('https://a.test'), isTrue); + expect(find.text(_card), findsOneWidget); + expect(seen('https://a.test'), isFalse); semantics.dispose(); }); @@ -138,16 +139,27 @@ void main() { .onPressed!(); await tester.pumpAndSettle(); expect(choices, 1); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); expect(seen('https://a.test'), isTrue); }); - testWidgets('tapping the tip itself dismisses it', (tester) async { + testWidgets('tapping the card starts creation and retires it', ( + tester, + ) async { await pumpHome(tester); - await tester.tap(find.text(_tip)); + await tester.tap(find.text(_card)); + await tester.pumpAndSettle(); + expect(choices, 1); + expect(find.text(_card), findsNothing); + expect(seen('https://a.test'), isTrue); + }); + + testWidgets('the close button dismisses it without creating', (tester) async { + await pumpHome(tester); + await tester.tap(find.byTooltip('Dismiss')); await tester.pumpAndSettle(); - expect(find.text(_tip), findsNothing); expect(choices, 0); + expect(find.text(_card), findsNothing); expect(seen('https://a.test'), isTrue); }); @@ -155,11 +167,11 @@ void main() { final semantics = tester.ensureSemantics(); await pumpHome(tester); tester.semantics.performAction( - find.semantics.byLabel(_tip), + find.semantics.byLabel(_card), SemanticsAction.dismiss, ); await tester.pumpAndSettle(); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); expect(seen('https://a.test'), isTrue); semantics.dispose(); }); @@ -168,37 +180,37 @@ void main() { tester, ) async { await pumpHome(tester); - expect(find.text(_tip), findsOneWidget); + expect(find.text(_card), findsOneWidget); bestie.add('a' * 64); await tester.pumpAndSettle(); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); expect(seen('https://a.test'), isTrue); bestie.add(null); await tester.pumpAndSettle(); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); }); testWidgets('once dismissed it never returns in that community', ( tester, ) async { await pumpHome(tester); - await tester.tap(find.text('Channel')); + await tester.tap(find.byTooltip('Dismiss')); await tester.pumpAndSettle(); await pumpHome(tester); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); // Another community is its own first run. await pumpHome(tester, community: 'https://b.test'); - expect(find.text(_tip), findsOneWidget); + expect(find.text(_card), 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); + expect(find.text(_card), findsNothing); await pumpHome(tester, remoteAgents: false); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); // None of these count as dismissing it. await pumpHome(tester); - expect(find.text(_tip), findsOneWidget); + expect(find.text(_card), findsOneWidget); }); testWidgets('hides while anything covers Home, then returns', (tester) async { @@ -214,10 +226,10 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Channel'), findsOneWidget); - expect(find.text(_tip), findsNothing); + expect(find.text(_card), findsNothing); navigator.pop(); await tester.pumpAndSettle(); - expect(find.text(_tip), findsOneWidget); + expect(find.text(_card), 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 index 32eb145d25..8a4e290737 100644 --- a/mobile/test/shared/widgets/buzz_callout_test.dart +++ b/mobile/test/shared/widgets/buzz_callout_test.dart @@ -46,4 +46,124 @@ void main() { }, ); } + cardTests(); +} + +void cardTests() { + Future pumpCard( + WidgetTester tester, { + required double width, + double scale = 1, + VoidCallback? onTap, + VoidCallback? onDismiss, + VoidCallback? onPage, + }) 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.light(), + home: Scaffold( + body: Stack( + children: [ + Center( + child: TextButton( + onPressed: onPage ?? () {}, + child: const Text('Page'), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: BuzzCallout.card( + message: 'Make one', + visible: true, + onTap: onTap ?? () {}, + onDismiss: onDismiss ?? () {}, + artwork: const [Text('Art')], + child: const SizedBox.square( + key: Key('target'), + dimension: 56, + ), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + final card = tester.getRect( + find + .ancestor(of: find.text('Make one'), matching: find.byType(Stack)) + .first, + ); + expect( + card.bottom, + lessThan(tester.getRect(find.byKey(const Key('target'))).top), + ); + return card; + } + + testWidgets('a card spans a phone between the margins', (tester) async { + final card = await pumpCard(tester, width: 320); + expect(card.left, Grid.xs); + expect(card.right, 320 - Grid.xs); + }); + + testWidgets('a card grows to fit large text below its close button', ( + tester, + ) async { + final card = await pumpCard(tester, width: 320, scale: 3); + final message = tester.getRect(find.text('Make one')); + final close = tester.getRect(find.byTooltip('Dismiss')); + expect(card.contains(message.topLeft), isTrue); + expect(card.contains(message.bottomRight), isTrue); + expect(message.top, greaterThanOrEqualTo(close.bottom)); + }); + + testWidgets('a card stays phone-sized on a wide screen', (tester) async { + final card = await pumpCard(tester, width: 1024); + expect(card.width, Grid.xxxl * 6); + expect(card.right, lessThanOrEqualTo(1024 - Grid.xs)); + }); + + testWidgets('a card opens on tap and closes only from its close button', ( + tester, + ) async { + var taps = 0; + var dismissals = 0; + var pageTaps = 0; + await pumpCard( + tester, + width: 390, + onTap: () => taps++, + onDismiss: () => dismissals++, + onPage: () => pageTaps++, + ); + expect(find.text('Art'), findsOneWidget); + final semantics = tester.ensureSemantics(); + // The close button stays its own control inside the tappable card. + expect( + tester.getSemantics(find.byTooltip('Dismiss')), + matchesSemantics( + tooltip: 'Dismiss', + isButton: true, + hasTapAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasFocusAction: true, + ), + ); + semantics.dispose(); + await tester.tap(find.text('Page')); + expect((pageTaps, taps, dismissals), (1, 0, 0)); + await tester.tap(find.text('Make one')); + expect((pageTaps, taps, dismissals), (1, 1, 0)); + await tester.tap(find.byTooltip('Dismiss')); + expect((pageTaps, taps, dismissals), (1, 1, 1)); + }); }