Files
buzz/mobile/lib/features/channels/typing_avatar_cluster.dart
klopez4212 8260b6ef4c 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>
2026-10-22 10:20:23 -07:00

130 lines
4.3 KiB
Dart

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)!;
}