feat(mobile): group typing avatars into one animated indicator (#8543)

## Summary
Replace stacked mobile typing rows with one animated avatar cluster and
one typing bubble. People and agents share diagonal, triangle, and
four-corner layouts, with a circular +N badge for overflow and
reduced-motion support.

Includes an opt-in local demo (`BUZZ_TYPING_PREVIEW=true`) with mixed
human/agent avatars and count controls; it never publishes typing
events.

### Related issue
Follow-up to #8505. No duplicate found in the typing-avatar PR search.

### Testing
- 3,736 mobile tests plus four unconfigured-push tests passed; analysis
and file-size checks passed.
- Installed and iterated on a physical iPhone with the existing
profile/community preserved; owner reviewed the motion and badge sizing.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-22 10:20:23 -07:00
committed by GitHub
parent 2d289f720d
commit 8260b6ef4c
7 changed files with 571 additions and 116 deletions

View File

@@ -57,6 +57,7 @@ import 'channel_sections/channel_sections_provider.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'inline_agent_typing.dart';
import 'typing_motion_preview.dart';
import 'channel_ids_by_name_provider.dart';
import 'channels_provider.dart';
import 'unread_badge/observed_unread_event.dart';

View File

@@ -798,7 +798,8 @@ class _MessageList extends HookConsumerWidget {
},
);
if (entries.isEmpty && displayedTyping.isNotEmpty) {
if (entries.isEmpty &&
(displayedTyping.isNotEmpty || TypingMotionPreview.enabled)) {
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
@@ -808,10 +809,12 @@ class _MessageList extends HookConsumerWidget {
Grid.gutter,
composerBottomInset,
),
child: InlineAgentTyping(
channelId: channelId,
entries: displayedTyping,
),
child: TypingMotionPreview.enabled
? TypingMotionPreview(channelId: channelId)
: InlineAgentTyping(
channelId: channelId,
entries: displayedTyping,
),
),
);
}
@@ -1024,11 +1027,16 @@ class _MessageList extends HookConsumerWidget {
restoreComposerFocus: restoreComposerFocus,
),
],
if (index == 0 && displayedTyping.isNotEmpty)
InlineAgentTyping(
channelId: channelId,
entries: displayedTyping,
),
if (index == 0 &&
(displayedTyping.isNotEmpty ||
TypingMotionPreview.enabled))
if (TypingMotionPreview.enabled)
TypingMotionPreview(channelId: channelId)
else
InlineAgentTyping(
channelId: channelId,
entries: displayedTyping,
),
],
),
),

View File

@@ -1,111 +1,108 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import 'agent_typing_bubble.dart';
import 'channel_identity_names_provider.dart';
import 'channel_typing_provider.dart';
import 'agent_typing_bubble.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import 'typing_avatar_cluster.dart';
/// Ephemeral typing message rows, using desktop's three-dot work signal.
/// One pending reply, shared by everyone currently typing.
class InlineAgentTyping extends ConsumerWidget {
const InlineAgentTyping({
super.key,
required this.channelId,
required this.entries,
this.previewAvatars = const {},
});
final String channelId;
final List<TypingEntry> entries;
final Map<String, Widget> previewAvatars;
@override
Widget build(BuildContext context, WidgetRef ref) {
final keys = entries.map((entry) => entry.pubkey.toLowerCase()).toSet();
// Bound layout, profile lookups, live regions, and animation controllers
// even when the relay supplies a large burst of concurrent typers.
final visibleKeys = keys.take(3).toList();
final remaining = keys.length - visibleKeys.length;
if (keys.isEmpty) return const SizedBox.shrink();
// Bound profile resolution and avatar construction independently of fanout.
final visibleKeys = keys.take(keys.length > 4 ? 3 : 4).toList();
final profiles = ref.watch(userCacheProvider);
final bots = ref.watch(agentMentionPubkeysProvider(channelId));
final labels = watchChannelIdentityLabels(ref, channelId, visibleKeys);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final key in visibleKeys)
Semantics(
key: ValueKey('inline-agent-typing-$key'),
liveRegion: true,
label: '${labels[key]} is typing',
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.only(top: Grid.xs, bottom: Grid.xxs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
AvatarImage(
imageUrl:
(profiles[key] ??
ref.read(userCacheProvider.notifier).get(key))
?.avatarUrl,
radius: messageAvatarSize / 2,
isAgent:
bots.contains(key) ||
(profiles[key]?.isAgent ?? false),
backgroundColor: context.controlSurface,
fallback: Text(
profiles[key]?.initial ??
key.substring(0, 1).toUpperCase(),
),
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: Grid.xs),
child: Text(
labels[key] ?? 'Assistant',
style: messageUsernameTextStyle.copyWith(
color: context.colors.onSurface,
fontSize: 13.1,
height: 17 / 13.1,
),
),
final labels = watchChannelIdentityLabels(
ref,
channelId,
visibleKeys.where((key) => !previewAvatars.containsKey(key)),
);
final name = previewAvatars.containsKey(visibleKeys.first)
? 'Demo agent'
: labels[visibleKeys.first] ?? 'Someone';
final title = keys.length == 1
? name
: '$name and ${keys.length - 1} ${keys.length == 2 ? 'other' : 'others'}';
return Semantics(
liveRegion: true,
label: '$title ${keys.length == 1 ? 'is' : 'are'} typing',
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.only(top: Grid.xs, bottom: Grid.xxs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TypingAvatarCluster(
count: keys.length,
avatars: {
for (final key in visibleKeys)
key:
previewAvatars[key] ??
AvatarImage(
key: ValueKey('inline-agent-typing-$key'),
imageUrl:
(profiles[key] ??
ref
.read(userCacheProvider.notifier)
.get(key))
?.avatarUrl,
radius: messageAvatarSize / 2,
isAgent:
bots.contains(key) ||
(profiles[key]?.isAgent ?? false),
backgroundColor: context.controlSurface,
fallback: Text(
profiles[key]?.initial ??
key.substring(0, 1).toUpperCase(),
),
const SizedBox(height: Grid.xxs),
const AgentTypingBubble(),
],
),
},
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: Grid.xs),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageUsernameTextStyle.copyWith(
color: context.colors.onSurface,
fontSize: 13.1,
height: 17 / 13.1,
),
),
),
const SizedBox(height: Grid.xxs),
const AgentTypingBubble(),
],
),
),
),
],
),
if (remaining > 0)
Semantics(
liveRegion: true,
label:
'$remaining ${remaining == 1 ? 'other person is' : 'other people are'} typing',
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap + Grid.xs,
top: Grid.xs,
bottom: Grid.xxs,
),
child: Text(
'and $remaining ${remaining == 1 ? 'other' : 'others'}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
/// Fixed message-avatar footprint with at most four animated occupants.
class TypingAvatarCluster extends StatelessWidget {
const TypingAvatarCluster({
super.key,
required this.count,
required this.avatars,
});
final int count;
final Map<String, Widget> avatars;
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: Duration(milliseconds: count == 3 || count == 4 ? 420 : 360);
final size = switch (count) {
1 => messageAvatarSize,
2 => 28.0,
3 => 26.0,
_ => 24.0,
};
final edge = messageAvatarSize - size;
const badgeSize = 26.0;
final positions = switch (count) {
1 => [Offset.zero],
2 => [Offset.zero, Offset(edge, edge)],
3 => [Offset(edge / 2, 0), Offset(0, edge), Offset(edge, edge)],
_ => [Offset.zero, Offset(edge, 0), Offset(0, edge), Offset(edge, edge)],
};
final children = avatars.entries.take(count > 4 ? 3 : 4).toList();
return SizedBox(
key: const ValueKey('typing-avatar-cluster'),
width: messageAvatarSize,
height: messageAvatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < children.length; i++)
TweenAnimationBuilder<Rect>(
key: ValueKey(children[i].key),
tween: _AvatarRectTween(
end: Rect.fromLTWH(
positions[i].dx,
positions[i].dy,
size,
size,
),
),
duration: duration,
curve: count == 2 ? Curves.easeOutBack : Curves.easeOutCubic,
builder: (context, rect, child) =>
Positioned.fromRect(rect: rect, child: child!),
child: _arrival(
duration: count == 1 ? Duration.zero : duration,
offset: Offset(
positions[i].dx < edge / 2 ? -12 : 12,
positions[i].dy < edge / 2 ? -8 : 8,
),
child: FittedBox(child: children[i].value),
),
),
if (count > 4)
Positioned(
left: messageAvatarSize - badgeSize,
top: messageAvatarSize - badgeSize,
width: badgeSize,
height: badgeSize,
child: _arrival(
duration: duration,
offset: const Offset(12, 8),
child: Container(
key: const ValueKey('typing-avatar-overflow'),
decoration: BoxDecoration(
color: context.colors.secondaryContainer,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(2),
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'+${count - 3}',
style: context.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 12,
color: context.colors.onSecondaryContainer,
),
),
),
),
),
),
),
],
),
);
}
Widget _arrival({
required Duration duration,
required Offset offset,
required Widget child,
}) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: duration,
curve: count == 2 ? Curves.easeOutBack : Curves.easeOutCubic,
builder: (context, value, child) => Opacity(
opacity: value.clamp(0.0, 1.0),
child: Transform.translate(
offset: offset * (1 - value),
child: Transform.scale(scale: 0.95 + 0.05 * value, child: child),
),
),
child: child,
);
}
}
/// Interpolate directly between layouts, keeping the first frame stationary.
class _AvatarRectTween extends Tween<Rect> {
_AvatarRectTween({required Rect end}) : super(begin: end, end: end);
@override
Rect lerp(double t) => Rect.lerp(begin, end, t)!;
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import 'channel_typing_provider.dart';
import 'inline_agent_typing.dart';
/// Install-only motion playground. Never publishes typing events.
class TypingMotionPreview extends HookConsumerWidget {
const TypingMotionPreview({super.key, required this.channelId});
final String channelId;
static const enabled = bool.fromEnvironment('BUZZ_TYPING_PREVIEW');
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = useState(1);
final playing = useState(true);
final reduced = MediaQuery.disableAnimationsOf(context);
final profiles = ref.watch(userCacheProvider);
final bots = ref.watch(agentMentionPubkeysProvider(channelId));
final agentKeys = {
for (final profile in profiles.values)
if (profile.isAgent || bots.contains(profile.pubkey)) profile.pubkey,
...bots,
}.take(8).toList();
const demoKeys = ['typing-preview-agent-1', 'typing-preview-agent-2'];
agentKeys.insertAll(0, demoKeys);
final people = profiles.keys
.where(
(key) => !bots.contains(key) && !(profiles[key]?.isAgent ?? false),
)
.take(8)
.toList();
// Deliberately alternate shapes, rather than letting cache insertion order
// fill every demo slot with people before an agent can appear.
final keys = <String>[];
for (
var i = 0;
keys.length < 8 && (i < people.length || i < agentKeys.length);
i++
) {
if (i < people.length) keys.add(people[i]);
if (keys.length < 8 && i < agentKeys.length) keys.add(agentKeys[i]);
}
useEffect(() {
if (!playing.value || reduced || keys.isEmpty) return null;
final timer = Timer.periodic(const Duration(seconds: 2), (_) {
count.value = count.value >= keys.length ? 1 : count.value + 1;
});
return timer.cancel;
}, [playing.value, reduced, keys.length]);
final shown = count.value.clamp(1, keys.isEmpty ? 1 : keys.length);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'Typing preview · $shown · demo agents',
style: context.textTheme.labelSmall,
),
),
IconButton(
tooltip: playing.value ? 'Pause preview' : 'Play preview',
onPressed: () => playing.value = !playing.value,
icon: Icon(playing.value ? Icons.pause : Icons.play_arrow),
),
IconButton(
tooltip: 'Fewer people',
onPressed: () {
playing.value = false;
count.value = shown > 1 ? shown - 1 : 1;
},
icon: const Icon(Icons.remove),
),
IconButton(
tooltip: 'More people',
onPressed: () {
playing.value = false;
count.value = shown < keys.length ? shown + 1 : shown;
},
icon: const Icon(Icons.add),
),
],
),
if (keys.isNotEmpty)
InlineAgentTyping(
channelId: channelId,
previewAvatars: {
for (final key in demoKeys)
key: AvatarImage(
imageUrl: null,
radius: messageAvatarSize / 2,
isAgent: true,
backgroundColor: context.colors.primaryContainer,
fallback: Icon(
Icons.smart_toy_rounded,
color: context.colors.onPrimaryContainer,
size: 24,
),
),
},
entries: [
for (final key in keys.take(shown))
TypingEntry(pubkey: key, expiresAtMs: 0),
],
)
else
const Text('Waiting for community profiles…'),
],
);
}
}

View File

@@ -11171,13 +11171,18 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget);
expect(
find.byKey(const ValueKey('agent-typing-dot-2')),
findsNWidgets(2),
find.byKey(const ValueKey('inline-agent-typing-alice')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('inline-agent-typing-bob')),
findsOneWidget,
);
});
testWidgets('shows inline dots for all three typers', (tester) async {
testWidgets('shares one bubble among three typers', (tester) async {
await tester.pumpWidget(
_buildTestable(
messages: [],
@@ -11205,9 +11210,18 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget);
expect(
find.byKey(const ValueKey('agent-typing-dot-2')),
findsNWidgets(3),
find.byKey(const ValueKey('inline-agent-typing-alice')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('inline-agent-typing-bob')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('inline-agent-typing-carol')),
findsOneWidget,
);
});

View File

@@ -3,6 +3,7 @@ import 'package:buzz/features/channels/agent_typing_bubble.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/features/channels/inline_agent_typing.dart';
import 'package:buzz/features/channels/typing_motion_preview.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
@@ -21,7 +22,17 @@ class Profiles extends UserCacheNotifier {
Map<String, UserProfile> build() => {agent: profile};
}
class DemoProfiles extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => {
for (var i = 0; i < 8; i++)
'person-$i': UserProfile(pubkey: 'person-$i', displayName: 'Person $i'),
agent: profile,
};
}
Widget surface({
bool preview = false,
bool reduced = false,
bool typing = true,
bool bot = true,
@@ -32,7 +43,7 @@ Widget surface({
agentMentionPubkeysProvider(
'chat',
).overrideWithValue(bot ? {agent} : <String>{}),
userCacheProvider.overrideWith(Profiles.new),
userCacheProvider.overrideWith(preview ? DemoProfiles.new : Profiles.new),
channelIdentityNamesProvider('chat').overrideWithValue(
const IdentityNameSources(profiles: {agent: profile}).scope([agent]),
),
@@ -45,17 +56,200 @@ Widget surface({
textScaler: TextScaler.linear(textScale),
),
child: Scaffold(
body: InlineAgentTyping(
channelId: 'chat',
entries:
entries ??
[if (typing) const TypingEntry(pubkey: agent, expiresAtMs: 8000)],
),
body: preview
? const TypingMotionPreview(channelId: 'chat')
: InlineAgentTyping(
channelId: 'chat',
entries:
entries ??
[
if (typing)
const TypingEntry(pubkey: agent, expiresAtMs: 8000),
],
),
),
),
),
);
void main() {
testWidgets('one to two keeps the gentle straight repositioning', (
tester,
) async {
final entries = [
for (final key in [agent, 'second'])
TypingEntry(pubkey: key, expiresAtMs: 8000),
];
final first = find.byKey(const ValueKey('inline-agent-typing-$agent'));
await tester.pumpWidget(surface(entries: entries.take(1).toList()));
final start = tester.getCenter(first);
await tester.pumpWidget(surface(entries: entries));
await tester.pump(const Duration(milliseconds: 100));
final during = tester.getCenter(first);
await tester.pump(const Duration(milliseconds: 600));
final end = tester.getCenter(first);
final travel = end - start;
final offset = during - start;
expect(
(travel.dx * offset.dy - travel.dy * offset.dx).abs() / travel.distance,
lessThan(0.01),
);
});
testWidgets('demo still shows a squircle when agent discovery is empty', (
tester,
) async {
await tester.pumpWidget(surface(preview: true, bot: false));
await tester.tap(find.byTooltip('More people'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 700));
final avatars = tester
.widgetList<AvatarImage>(find.byType(AvatarImage))
.toList();
expect(avatars, hasLength(2));
expect(avatars[0].isAgent, isFalse);
expect(avatars[1].isAgent, isTrue);
expect(find.textContaining('demo agents'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('first avatar is immediately still and visible', (tester) async {
await tester.pumpWidget(surface());
final avatar = find.byType(AvatarImage);
final start = tester.getRect(avatar);
await tester.pump(const Duration(milliseconds: 100));
expect(tester.getRect(avatar), start);
final opacity = tester.widget<Opacity>(
find.ancestor(of: avatar, matching: find.byType(Opacity)).first,
);
expect(opacity.opacity, 1);
});
testWidgets(
'third arrival moves existing avatars directly without overshoot',
(tester) async {
final entries = [
for (final key in [agent, 'second', 'third'])
TypingEntry(pubkey: key, expiresAtMs: 8000),
];
final first = find.byKey(const ValueKey('inline-agent-typing-$agent'));
await tester.pumpWidget(surface(entries: entries.take(2).toList()));
await tester.pump(const Duration(milliseconds: 700));
final start = tester.getCenter(first);
await tester.pumpWidget(surface(entries: entries));
await tester.pump(const Duration(milliseconds: 100));
final during = tester.getCenter(first);
final samples = <Offset>[during];
for (var frame = 0; frame < 8; frame++) {
await tester.pump(const Duration(milliseconds: 40));
samples.add(tester.getCenter(first));
}
await tester.pump(const Duration(milliseconds: 280));
final end = tester.getCenter(first);
final direction = end - start;
var previousProgress = 0.0;
for (final sample in samples) {
final delta = sample - start;
final progress =
(delta.dx * direction.dx + delta.dy * direction.dy) /
direction.distanceSquared;
expect(progress, inInclusiveRange(previousProgress - 0.001, 1.001));
previousProgress = progress;
}
final travel = end - start;
final offset = during - start;
final perpendicularDistance =
(travel.dx * offset.dy - travel.dy * offset.dx).abs() /
travel.distance;
expect(perpendicularDistance, lessThan(0.01));
expect(find.byType(AgentTypingBubble), findsOneWidget);
},
);
testWidgets(
'demo includes an agent alongside a person even after eight cached people',
(tester) async {
await tester.pumpWidget(surface(preview: true));
await tester.tap(find.byTooltip('More people'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 700));
final avatars = tester
.widgetList<AvatarImage>(find.byType(AvatarImage))
.toList();
expect(avatars, hasLength(2));
expect(avatars[0].isAgent, isFalse);
expect(avatars[1].isAgent, isTrue);
expect(find.byIcon(Icons.smart_toy_rounded), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
testWidgets('overflow stays circular even when replacing an agent', (
tester,
) async {
final entries = [
for (final key in ['first', 'second', 'third', agent, 'fifth'])
TypingEntry(pubkey: key, expiresAtMs: 8000),
];
await tester.pumpWidget(surface(entries: entries.take(4).toList()));
await tester.pump(const Duration(milliseconds: 700));
final avatarRect = tester.getRect(
find.byKey(const ValueKey('inline-agent-typing-$agent')),
);
await tester.pumpWidget(surface(entries: entries));
await tester.pump(const Duration(milliseconds: 700));
final badge = find.byKey(const ValueKey('typing-avatar-overflow'));
final badgeRect = tester.getRect(badge);
expect(badgeRect.width, greaterThan(avatarRect.width));
expect(badgeRect.size, const Size(26, 26));
expect(badgeRect.bottomRight, avatarRect.bottomRight);
final decoration =
tester.widget<Container>(badge).decoration! as BoxDecoration;
expect(decoration.shape, BoxShape.circle);
expect(decoration.borderRadius, isNull);
expect(decoration.border, isNull);
expect(find.text('+2'), findsOneWidget);
});
testWidgets('avatar layouts stay in one footprint through joins and leaves', (
tester,
) async {
final entries = List.generate(
5,
(i) => TypingEntry(
pubkey: i.toRadixString(16).padLeft(64, '0'),
expiresAtMs: 8000,
),
);
for (final count in [1, 2, 3, 4, 5, 4, 3, 2, 1]) {
await tester.pumpWidget(surface(entries: entries.take(count).toList()));
await tester.pump(const Duration(milliseconds: 700));
final cluster = tester.getRect(
find.byKey(const ValueKey('typing-avatar-cluster')),
);
expect(cluster.size, const Size(messageAvatarSize, messageAvatarSize));
final avatars = find.byType(AvatarImage);
expect(avatars, findsNWidgets(count > 4 ? 3 : count));
expect(find.byType(AgentTypingBubble), findsOneWidget);
final rects = [
for (var i = 0; i < (count > 4 ? 3 : count); i++)
tester.getRect(avatars.at(i)),
];
for (final rect in rects) {
expect(cluster.contains(rect.center), isTrue);
}
if (count == 2) {
expect(rects[0].left, lessThan(rects[1].left));
expect(rects[0].top, lessThan(rects[1].top));
}
if (count == 3) {
expect(rects[0].center.dx, cluster.center.dx);
expect(rects[1].top, rects[2].top);
expect(rects[0].top, lessThan(rects[1].top));
}
if (count == 4) {
expect(rects[0].top, rects[1].top);
expect(rects[2].top, rects[3].top);
expect(rects[0].left, rects[2].left);
expect(rects[1].left, rects[3].left);
}
if (count == 5) expect(find.text('+2'), findsOneWidget);
expect(tester.takeException(), isNull);
}
});
testWidgets(
'message rail, author and dots replace status text without a timestamp',
(tester) async {
@@ -129,43 +323,36 @@ void main() {
textScale: 2,
),
);
await tester.pump(const Duration(milliseconds: 240));
expect(find.byType(AvatarImage), findsNWidgets(3));
expect(find.byType(AgentTypingBubble), findsNWidgets(3));
await tester.pump(const Duration(milliseconds: 700));
expect(find.byType(AvatarImage), findsNWidgets(count == 4 ? 4 : 3));
expect(find.byType(AgentTypingBubble), findsOneWidget);
// Each repeating ticker schedules one transient callback for the next
// frame: this measures active work, not just visible dot widgets.
expect(tester.binding.transientCallbackCount, 3);
expect(tester.binding.transientCallbackCount, 1);
for (final entry in entries.take(3)) {
expect(
find.byKey(ValueKey('inline-agent-typing-${entry.pubkey}')),
findsOneWidget,
);
}
if (count > 3) {
if (count > 4) {
final remainder = count - 3;
expect(find.text('+$remainder'), findsOneWidget);
expect(
find.text('and $remainder ${remainder == 1 ? 'other' : 'others'}'),
find.bySemanticsLabel(RegExp('.*and ${count - 1} others are typing')),
findsOneWidget,
);
expect(
find.bySemanticsLabel(
'$remainder ${remainder == 1 ? 'other person is' : 'other people are'} typing',
),
findsOneWidget,
);
} else {
expect(find.textContaining('and '), findsNothing);
}
expect(tester.takeException(), isNull);
}
await tester.pumpWidget(surface(entries: entries.take(1).toList()));
await tester.pump(const Duration(milliseconds: 240));
await tester.pump(const Duration(milliseconds: 700));
expect(find.byType(AvatarImage), findsOneWidget);
expect(find.textContaining('and '), findsNothing);
expect(tester.binding.transientCallbackCount, 1);
await tester.pumpWidget(surface(entries: entries, reduced: true));
await tester.pumpAndSettle();
expect(find.byType(AgentTypingBubble), findsNWidgets(3));
expect(find.byType(AgentTypingBubble), findsOneWidget);
expect(tester.binding.transientCallbackCount, 0);
await tester.pumpWidget(surface(typing: false, reduced: true));
await tester.pumpAndSettle();