mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
129
mobile/lib/features/channels/typing_avatar_cluster.dart
Normal file
129
mobile/lib/features/channels/typing_avatar_cluster.dart
Normal 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)!;
|
||||
}
|
||||
119
mobile/lib/features/channels/typing_motion_preview.dart
Normal file
119
mobile/lib/features/channels/typing_motion_preview.dart
Normal 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…'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user