From 8260b6ef4c055bf85016499f48ed2ae580ffc2e7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 22 Oct 2026 10:20:23 -0700 Subject: [PATCH] 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 Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/message_list.dart | 28 ++- .../channels/inline_agent_typing.dart | 153 ++++++------ .../channels/typing_avatar_cluster.dart | 129 ++++++++++ .../channels/typing_motion_preview.dart | 119 +++++++++ .../channels/channel_detail_page_test.dart | 24 +- .../channels/inline_agent_typing_test.dart | 233 ++++++++++++++++-- 7 files changed, 571 insertions(+), 116 deletions(-) create mode 100644 mobile/lib/features/channels/typing_avatar_cluster.dart create mode 100644 mobile/lib/features/channels/typing_motion_preview.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 0ad953a379..427cc27e34 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 35e903da52..75b3519e94 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -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, + ), ], ), ), diff --git a/mobile/lib/features/channels/inline_agent_typing.dart b/mobile/lib/features/channels/inline_agent_typing.dart index 712537e961..e8984883ad 100644 --- a/mobile/lib/features/channels/inline_agent_typing.dart +++ b/mobile/lib/features/channels/inline_agent_typing.dart @@ -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 entries; + final Map 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, - ), - ), - ), - ), - ), - ], + ), + ), ); } } diff --git a/mobile/lib/features/channels/typing_avatar_cluster.dart b/mobile/lib/features/channels/typing_avatar_cluster.dart new file mode 100644 index 0000000000..44c279c903 --- /dev/null +++ b/mobile/lib/features/channels/typing_avatar_cluster.dart @@ -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 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( + 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( + 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 { + _AvatarRectTween({required Rect end}) : super(begin: end, end: end); + + @override + Rect lerp(double t) => Rect.lerp(begin, end, t)!; +} diff --git a/mobile/lib/features/channels/typing_motion_preview.dart b/mobile/lib/features/channels/typing_motion_preview.dart new file mode 100644 index 0000000000..8272bf905e --- /dev/null +++ b/mobile/lib/features/channels/typing_motion_preview.dart @@ -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 = []; + 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…'), + ], + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 118e49bfe9..0c0f33bf3b 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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, ); }); diff --git a/mobile/test/features/channels/inline_agent_typing_test.dart b/mobile/test/features/channels/inline_agent_typing_test.dart index df29a9971d..05131c2bd9 100644 --- a/mobile/test/features/channels/inline_agent_typing_test.dart +++ b/mobile/test/features/channels/inline_agent_typing_test.dart @@ -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 build() => {agent: profile}; } +class DemoProfiles extends UserCacheNotifier { + @override + Map 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} : {}), - 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(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( + 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 = [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(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(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();