mobile: point new users at the empty Bestie slot

People landing on Home for the first time had no cue that the slot at
the bottom is where they make an agent. A one-time "Create your first
agent" callout now points at the empty Bestie slot when remote agents
are on, no Bestie is set, and the account owns no agents yet.

It never blocks Home: any tap (on the tip, the slot, or anywhere else),
a screen reader dismiss, or ending up with a Bestie retires it for good,
remembered per account and community. It only shows while Home is the
current route.

The callout is a new reusable BuzzCallout in the mobile design system,
drawn as one inverse-surface bubble with an arrow that stays on screen
and keeps pointing at its target at any width or text size.

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-03 17:15:31 -08:00
parent 865b150371
commit f648e800a7
7 changed files with 610 additions and 32 deletions

View File

@@ -7,6 +7,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/agents/owned_agent_directory.dart';
import '../../shared/agents/remote_agents.dart';
import '../../shared/me/default_agent.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/me/me_sync.dart';
@@ -17,6 +19,8 @@ import '../../shared/widgets/agent_navigation_icon.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/preset_agent_avatar.dart';
import '../../shared/widgets/navigation_button_surface.dart';
import '../../shared/widgets/buzz_callout.dart';
import 'first_agent_callout.dart';
import 'empty_bestie_avatar.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
@@ -36,12 +40,33 @@ class DefaultAgentLauncher extends HookConsumerWidget {
final profile = profiles[pubkey];
final opening = useState(false);
final resolving = useState(false);
final remoteAgents = ref.watch(remoteAgentsEnabledProvider);
// Only someone with no agents at all is invited to make a first one.
// Cheap checks come first, so the rest are watched only when relevant.
final showCallout =
remoteAgents &&
pubkey == null &&
!preference.isLoading &&
!preference.hasError &&
!ref.watch(firstAgentCalloutSeenProvider) &&
ref
.watch(ownedAgentDirectoryProvider)
.unwrapPrevious()
.value
?.isEmpty ==
true;
useEffect(() {
if (pubkey != null) {
ref.read(userCacheProvider.notifier).preload([pubkey]);
}
// Having a Bestie, however it was chosen, retires the tip for good.
if (pubkey != null &&
remoteAgents &&
!ref.read(firstAgentCalloutSeenProvider)) {
dismissFirstAgentCallout(ref);
}
return null;
}, [pubkey]);
}, [pubkey, remoteAgents]);
final label = pubkey != null
? 'Chat with ${profile?.label ?? 'your Bestie'}'
: preference.hasError
@@ -61,6 +86,7 @@ class DefaultAgentLauncher extends HookConsumerWidget {
return;
}
if (pubkey == null) {
if (remoteAgents) dismissFirstAgentCallout(ref);
onChooseAgent();
return;
}
@@ -118,36 +144,41 @@ class DefaultAgentLauncher extends HookConsumerWidget {
}
}
return NavigationButtonSurface(
child: SizedBox.square(
dimension: 56,
child: IconButton(
tooltip: label,
padding: EdgeInsets.zero,
onPressed: opening.value || preference.isLoading ? null : open,
icon: ExcludeSemantics(
child: resolving.value
? const SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: pubkey == null
? preference.isLoading || preference.hasError
? const AgentNavigationIcon(size: 24)
: const EmptyBestieAvatar()
: usesInsetAgentArtwork(profile?.avatarUrl)
? PresetAgentAvatar(
imageUrl: profile!.avatarUrl!,
size: 47.88,
fallback: Center(child: Text(profile.initial)),
)
: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
isAgent: true,
backgroundColor: context.controlSurface,
fallback: Center(child: Text(profile?.initial ?? 'A')),
),
return BuzzCallout(
message: 'Create your first agent',
visible: showCallout,
onDismiss: () => dismissFirstAgentCallout(ref),
child: NavigationButtonSurface(
child: SizedBox.square(
dimension: 56,
child: IconButton(
tooltip: label,
padding: EdgeInsets.zero,
onPressed: opening.value || preference.isLoading ? null : open,
icon: ExcludeSemantics(
child: resolving.value
? const SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: pubkey == null
? preference.isLoading || preference.hasError
? const AgentNavigationIcon(size: 24)
: const EmptyBestieAvatar()
: usesInsetAgentArtwork(profile?.avatarUrl)
? PresetAgentAvatar(
imageUrl: profile!.avatarUrl!,
size: 47.88,
fallback: Center(child: Text(profile.initial)),
)
: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
isAgent: true,
backgroundColor: context.controlSurface,
fallback: Center(child: Text(profile?.initial ?? 'A')),
),
),
),
),
),

View File

@@ -0,0 +1,34 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/me/me_sync.dart';
import '../../shared/theme/theme_provider.dart';
/// The first-agent tip is dismissed once per account and community.
String firstAgentCalloutKey(MeSync scope) =>
'buzz-first-agent-callout.v1:${Uri.encodeComponent(scope.community)}:'
'${scope.pubkey}';
/// Whether this account has already dismissed the tip in this community.
/// Without an identity there is nothing to scope it to, so it stays hidden.
final firstAgentCalloutSeenProvider = Provider.autoDispose<bool>((ref) {
final scope = ref.watch(meSyncProvider);
if (scope.config.nsec == null) return true;
return ref.watch(savedPrefsProvider).getBool(firstAgentCalloutKey(scope)) ??
false;
});
/// Hides the tip for good. Preferences update in memory at once, so a failed
/// disk write only means the tip may return on the next launch.
void dismissFirstAgentCallout(WidgetRef ref) {
final scope = ref.read(meSyncProvider);
if (scope.config.nsec == null) return;
unawaited(
ref
.read(savedPrefsProvider)
.setBool(firstAgentCalloutKey(scope), true)
.catchError((Object _) => false),
);
ref.invalidate(firstAgentCalloutSeenProvider);
}

View File

@@ -0,0 +1,229 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import 'anchored_popover_menu.dart';
const _arrowHeight = Grid.xxs;
const _arrowWidth = Grid.xs;
const _targetGap = Grid.half;
/// A non-blocking tip 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.
class BuzzCallout extends HookWidget {
const BuzzCallout({
required this.message,
required this.visible,
required this.onDismiss,
required this.child,
super.key,
});
final String message;
final bool visible;
final VoidCallback onDismiss;
final Widget child;
@override
Widget build(BuildContext context) {
final showing = visible && (ModalRoute.of(context)?.isCurrent ?? true);
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.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || showing == controller.isShowing) return;
showing ? controller.show() : controller.hide();
});
return null;
}, [showing]);
return OverlayPortal.overlayChildLayoutBuilder(
controller: controller,
overlayChildBuilder: (context, info) {
if (!showing) return const SizedBox.shrink();
final target = MatrixUtils.transformRect(
info.childPaintTransform,
Offset.zero & info.childSize,
);
final padding = MediaQuery.paddingOf(context);
return _CalloutPlacement(
target: target,
margin: EdgeInsets.only(
left: padding.left + Grid.xs,
right: padding.right + Grid.xs,
top: padding.top + Grid.xs,
),
// Inverse colors set the tip 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: Text(
message,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onInverseSurface,
),
),
),
),
),
);
},
child: child,
);
}
}
/// 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.
class _CalloutPlacement extends SingleChildRenderObjectWidget {
const _CalloutPlacement({
required this.target,
required this.margin,
required this.color,
required this.shadowColor,
required super.child,
});
final Rect target;
final EdgeInsets margin;
final Color color;
final Color shadowColor;
@override
_RenderCalloutPlacement createRenderObject(BuildContext context) =>
_RenderCalloutPlacement(target, margin, color, shadowColor);
@override
void updateRenderObject(
BuildContext context,
_RenderCalloutPlacement renderObject,
) {
renderObject
..target = target
..margin = margin
..color = color
..shadowColor = shadowColor;
}
}
class _RenderCalloutPlacement extends RenderShiftedBox {
_RenderCalloutPlacement(
this._target,
this._margin,
this._color,
this._shadowColor,
) : super(null);
Rect _target;
set target(Rect value) {
if (value == _target) return;
_target = value;
markNeedsLayout();
}
EdgeInsets _margin;
set margin(EdgeInsets value) {
if (value == _margin) return;
_margin = value;
markNeedsLayout();
}
Color _color;
set color(Color value) {
if (value == _color) return;
_color = value;
markNeedsPaint();
}
Color _shadowColor;
set shadowColor(Color value) {
if (value == _shadowColor) return;
_shadowColor = value;
markNeedsPaint();
}
Path _path = Path();
@override
void performLayout() {
size = constraints.biggest;
final child = this.child;
if (child == null) return;
final bottom = _target.top - _targetGap - _arrowHeight;
child.layout(
BoxConstraints(
maxWidth: math.max(0, size.width - _margin.horizontal),
maxHeight: math.max(0, bottom - _margin.top),
),
parentUsesSize: true,
);
final left = (_target.center.dx - child.size.width / 2)
.clamp(
_margin.left,
math.max(_margin.left, size.width - _margin.right - child.size.width),
)
.toDouble();
final offset = Offset(left, bottom - child.size.height);
(child.parentData! as BoxParentData).offset = offset;
final bubble = offset & child.size;
final radius = math.min(Radii.popover, bubble.shortestSide / 2);
// Keep the arrow's base clear of the rounded corners.
final arrowX = _target.center.dx
.clamp(
bubble.left + radius + _arrowWidth / 2,
math.max(
bubble.left + radius + _arrowWidth / 2,
bubble.right - radius - _arrowWidth / 2,
),
)
.toDouble();
// One outline, so the shadow doesn't cross where the arrow joins.
_path = Path.combine(
PathOperation.union,
Path()
..addRRect(RRect.fromRectAndRadius(bubble, Radius.circular(radius))),
Path()..addPolygon([
Offset(arrowX - _arrowWidth / 2, bubble.bottom - 1),
Offset(arrowX, bubble.bottom + _arrowHeight),
Offset(arrowX + _arrowWidth / 2, bubble.bottom - 1),
], true),
);
}
@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child == null) return;
final path = _path.shift(offset);
final canvas = context.canvas;
canvas.drawShadow(path, _shadowColor, appPopoverElevation, _color.a < 1);
canvas.drawPath(path, Paint()..color = _color);
super.paint(context, offset);
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) =>
hitTestChildren(result, position: position);
}

View File

@@ -3,6 +3,7 @@ import 'package:buzz/shared/widgets/app_list_card.dart';
import 'package:buzz/shared/agents/remote_agents.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/me/default_agent.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/default_agent_launcher.dart';
import 'package:buzz/features/agents/agents_page.dart';
@@ -11,6 +12,7 @@ import 'package:buzz/features/profile/user_profile_sheet.dart';
import 'package:flutter/material.dart';
import 'package:buzz/features/home/home_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'agents_harness.dart';
import '../../helpers/remote_agents_fixture.dart';
@@ -37,7 +39,12 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final h = await AgentsHarness.create();
await h.open(tester, directory: [existing, otherAgentKeys.public]);
final prefs = await SharedPreferences.getInstance();
await h.open(
tester,
directory: [existing, otherAgentKeys.public],
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
expect(find.byKey(ValueKey(otherAgentKeys.public)), findsNothing);
expect(find.byKey(ValueKey(oldAppAgentKeys.public)), findsNothing);
if (!supportsRemoteAgents(h.community)) {
@@ -270,6 +277,7 @@ void main() {
await h.open(
tester,
directory: [existing],
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
home: HomePage(
onAgents: showAgents,
defaultAgentBuilder: (context) => DefaultAgentLauncher(

View File

@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/me/me_sync_test.dart' show FakeRelay;
@@ -37,8 +38,11 @@ void main() {
final pending = Completer<List<NostrEvent>>();
relayB.pendingRead = pending.future;
var choices = 0;
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
Widget app(MeSync scope) => ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
meSyncProvider.overrideWithValue(scope),
relaySessionProvider.overrideWith(() => relayA),
],

View File

@@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:ui' show SemanticsAction;
import 'package:buzz/features/channels/default_agent_launcher.dart';
import 'package:buzz/features/channels/first_agent_callout.dart';
import 'package:buzz/shared/agents/owned_agent_directory.dart';
import 'package:buzz/shared/agents/remote_agents.dart';
import 'package:buzz/shared/me/default_agent.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/me/me_sync_test.dart' show FakeRelay;
const _tip = 'Create your first agent';
void main() {
final nsec = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
);
late SharedPreferences prefs;
late StreamController<String?> bestie;
late int choices;
late int otherTaps;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
bestie = StreamController<String?>.broadcast();
choices = 0;
otherTaps = 0;
});
tearDown(() => bestie.close());
Future<void> pumpHome(
WidgetTester tester, {
String community = 'https://a.test',
bool remoteAgents = true,
List<OwnedAgentDirectoryEntry> owned = const [],
String? initialBestie,
}) async {
final relay = FakeRelay();
final scope = MeSync(
relay,
RelayConfig(baseUrl: community, nsec: nsec),
prefs: prefs,
);
await tester.pumpWidget(
ProviderScope(
// A new key per call rebuilds the providers, like a fresh launch.
key: UniqueKey(),
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
meSyncProvider.overrideWithValue(scope),
relaySessionProvider.overrideWith(() => relay),
remoteAgentsEnabledProvider.overrideWithValue(remoteAgents),
ownedAgentDirectoryProvider.overrideWith((ref) async => owned),
defaultAgentProvider.overrideWith((ref) async* {
yield initialBestie;
yield* bestie.stream;
}),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Center(
child: TextButton(
onPressed: () => otherTaps++,
child: const Text('Channel'),
),
),
bottomNavigationBar: Align(
alignment: Alignment.centerRight,
heightFactor: 1,
child: DefaultAgentLauncher(onChooseAgent: () => choices++),
),
),
),
),
);
await tester.pumpAndSettle();
}
bool seen(String community) =>
prefs.getBool(
firstAgentCalloutKey(
MeSync(FakeRelay(), RelayConfig(baseUrl: community, nsec: nsec)),
),
) ==
true;
testWidgets('shows above the empty Bestie slot without blocking Home', (
tester,
) async {
final semantics = tester.ensureSemantics();
await pumpHome(tester);
expect(find.text(_tip), findsOneWidget);
expect(
tester.getBottomLeft(find.text(_tip)).dy,
lessThan(tester.getTopLeft(find.byTooltip('Bestie')).dy),
);
expect(
tester.getSemantics(find.text(_tip)),
matchesSemantics(
label: _tip,
isLiveRegion: true,
hasDismissAction: true,
hasTapAction: true,
),
);
// The screen underneath still takes the tap that dismisses the tip.
await tester.tap(find.text('Channel'));
await tester.pumpAndSettle();
expect(otherTaps, 1);
expect(find.text(_tip), findsNothing);
expect(seen('https://a.test'), isTrue);
semantics.dispose();
});
testWidgets('activating the slot dismisses it and starts creation', (
tester,
) async {
await pumpHome(tester);
// Screen readers and keyboards activate the slot without a touch.
tester
.widget<IconButton>(
find.ancestor(
of: find.byTooltip('Bestie'),
matching: find.byType(IconButton),
),
)
.onPressed!();
await tester.pumpAndSettle();
expect(choices, 1);
expect(find.text(_tip), findsNothing);
expect(seen('https://a.test'), isTrue);
});
testWidgets('tapping the tip itself dismisses it', (tester) async {
await pumpHome(tester);
await tester.tap(find.text(_tip));
await tester.pumpAndSettle();
expect(find.text(_tip), findsNothing);
expect(choices, 0);
expect(seen('https://a.test'), isTrue);
});
testWidgets('a screen reader can dismiss it', (tester) async {
final semantics = tester.ensureSemantics();
await pumpHome(tester);
tester.semantics.performAction(
find.semantics.byLabel(_tip),
SemanticsAction.dismiss,
);
await tester.pumpAndSettle();
expect(find.text(_tip), findsNothing);
expect(seen('https://a.test'), isTrue);
semantics.dispose();
});
testWidgets('a Bestie retires it, even if the Bestie is later cleared', (
tester,
) async {
await pumpHome(tester);
expect(find.text(_tip), findsOneWidget);
bestie.add('a' * 64);
await tester.pumpAndSettle();
expect(find.text(_tip), findsNothing);
expect(seen('https://a.test'), isTrue);
bestie.add(null);
await tester.pumpAndSettle();
expect(find.text(_tip), findsNothing);
});
testWidgets('once dismissed it never returns in that community', (
tester,
) async {
await pumpHome(tester);
await tester.tap(find.text('Channel'));
await tester.pumpAndSettle();
await pumpHome(tester);
expect(find.text(_tip), findsNothing);
// Another community is its own first run.
await pumpHome(tester, community: 'https://b.test');
expect(find.text(_tip), 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);
await pumpHome(tester, remoteAgents: false);
expect(find.text(_tip), findsNothing);
// None of these count as dismissing it.
await pumpHome(tester);
expect(find.text(_tip), findsOneWidget);
});
testWidgets('hides while anything covers Home, then returns', (tester) async {
await pumpHome(tester);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
// A sheet leaves Home painted underneath, so only the route check hides
// the tip there.
unawaited(
showModalBottomSheet<void>(
context: navigator.context,
builder: (_) => const Text('Sheet'),
),
);
await tester.pumpAndSettle();
expect(find.text('Channel'), findsOneWidget);
expect(find.text(_tip), findsNothing);
navigator.pop();
await tester.pumpAndSettle();
expect(find.text(_tip), findsOneWidget);
expect(seen('https://a.test'), isFalse);
});
}

View File

@@ -0,0 +1,49 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_callout.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
for (final (width, scale) in [(320.0, 1.0), (320.0, 3.0), (1024.0, 1.0)]) {
testWidgets(
'stays on screen beside an edge target, $width wide at ${scale}x',
(tester) 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.dark(),
home: Scaffold(
body: Align(
alignment: Alignment.bottomRight,
child: BuzzCallout(
message: 'A tip long enough to need room',
visible: true,
onDismiss: () {},
child: const SizedBox.square(
key: Key('target'),
dimension: 56,
),
),
),
),
),
);
await tester.pump();
final tip = tester.getRect(
find.ancestor(
of: find.text('A tip long enough to need room'),
matching: find.byType(TapRegion),
),
);
final target = tester.getRect(find.byKey(const Key('target')));
expect(tip.right, lessThanOrEqualTo(width - Grid.xs));
expect(tip.left, greaterThanOrEqualTo(Grid.xs));
expect(tip.bottom, lessThan(target.top));
},
);
}
}