Polish Huddle participant interactions (#6312)

## Summary
- arrange Huddle participants in a responsive, equal-weight cluster with
spring enter/exit motion and a `+N` overflow
- spotlight tapped participants over a blurred call surface, with a
roster for hidden participants and no self-avatar action
- add selection haptics across full-screen and drawer controls,
including both end-call buttons
<img width="1080" height="2424" alt="Screenshot_20260819-151448"
src="https://github.com/user-attachments/assets/00b7fdca-2304-4788-9952-e07224798513"
/>
<img width="1080" height="2424" alt="Screenshot_20260819-151422"
src="https://github.com/user-attachments/assets/a0cfc861-0519-44ff-bb56-4c983ed6344c"
/>

## Validation
- `just mobile-check`
- focused participant, drawer-control, and full-screen end-call widget
tests
- Huddle-focused widget suite (15 tests)
- full mobile Flutter suite (1,538 tests)

## Dependency
Built on #6056 and contains only the follow-up interaction work. Merge
after #6056 lands.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-23 00:06:36 +01:00
committed by GitHub
parent 4baccd5394
commit e236329413
9 changed files with 1160 additions and 214 deletions

View File

@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:math' show cos, max, min, pi;
import 'dart:ui' show ImageFilter, lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -79,7 +81,9 @@ part 'channel_detail_page/message_list.dart';
part 'channel_detail_page/system_rows.dart';
part 'channel_detail_page/huddle_sheet.dart';
part 'channel_detail_page/huddle_call_avatar.dart';
part 'channel_detail_page/huddle_participant_cluster.dart';
part 'channel_detail_page/huddle_call_participants.dart';
part 'channel_detail_page/huddle_participant_overlay.dart';
part 'channel_detail_page/huddle_call_controls.dart';
part 'channel_detail_page/huddle_drawer.dart';
part 'channel_detail_page/huddle_reactions.dart';

View File

@@ -7,6 +7,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
required this.fallbackLabel,
required this.active,
required this.speakerLevel,
required this.onTap,
this.isSelf = false,
this.frameSize = _huddleAvatarFrameSize,
});
@@ -16,6 +17,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
final String? fallbackLabel;
final bool active;
final double speakerLevel;
final VoidCallback? onTap;
final bool isSelf;
final double frameSize;
@@ -56,31 +58,28 @@ class _HuddleCallAvatar extends HookConsumerWidget {
originRegistry.register(pubkey, originKey);
return () => originRegistry.unregister(pubkey, originKey);
}, [originRegistry, pubkey, originKey]);
final showsLabel = useState(false);
final profileName = profile?.displayName?.trim();
final directoryName = fallbackLabel?.trim();
final label = isSelf
? 'You'
: (profileName?.isNotEmpty == true ? profileName : null) ??
(directoryName?.isNotEmpty == true ? directoryName : null) ??
(pubkey.isEmpty ? 'Participant' : shortPubkey(pubkey));
void toggleLabel() => showsLabel.value = !showsLabel.value;
final label = _huddleParticipantLabel(
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabel,
isSelf: isSelf,
);
return SizedBox(
width: frameSize,
child: Semantics(
label: active ? '$label, speaking' : label,
hint: showsLabel.value
? 'Tap to hide participant name'
: 'Tap to show participant name',
button: true,
onTap: toggleLabel,
hint: onTap == null ? null : 'Tap to focus participant',
button: onTap != null,
onTap: onTap,
excludeSemantics: true,
child: GestureDetector(
key: ValueKey('huddle-participant-avatar-$pubkey'),
behavior: HitTestBehavior.opaque,
behavior: onTap == null
? HitTestBehavior.deferToChild
: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: toggleLabel,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
@@ -136,50 +135,6 @@ class _HuddleCallAvatar extends HookConsumerWidget {
),
),
),
AnimatedSwitcher(
key: ValueKey('huddle-participant-label-reveal-$pubkey'),
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.topCenter,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
),
),
child: showsLabel.value
? Padding(
key: ValueKey('huddle-participant-label-$pubkey'),
padding: const EdgeInsets.only(top: Grid.half),
child: SizedBox(
width: frameSize,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: active ? FontWeight.w600 : null,
),
),
),
)
: SizedBox(
key: ValueKey('huddle-participant-label-empty-$pubkey'),
),
),
],
),
),
@@ -187,3 +142,17 @@ class _HuddleCallAvatar extends HookConsumerWidget {
);
}
}
String _huddleParticipantLabel({
required String pubkey,
required UserProfile? profile,
required String? fallbackLabel,
required bool isSelf,
}) {
if (isSelf) return 'You';
final profileName = profile?.displayName?.trim();
final directoryName = fallbackLabel?.trim();
return (profileName?.isNotEmpty == true ? profileName : null) ??
(directoryName?.isNotEmpty == true ? directoryName : null) ??
(pubkey.isEmpty ? 'Participant' : shortPubkey(pubkey));
}

View File

@@ -35,6 +35,7 @@ class _HuddleCallControls extends StatelessWidget {
: context.colors.surfaceContainerHighest,
dimension: 80,
toggled: isSpeakerEnabled,
useHapticFeedback: true,
onPressed: onToggleSpeaker,
),
const SizedBox(width: Grid.sm),
@@ -50,6 +51,7 @@ class _HuddleCallControls extends StatelessWidget {
: context.colors.primary,
dimension: 80,
toggled: isMuted,
useHapticFeedback: true,
onPressed: onToggleMute,
),
const SizedBox(width: Grid.sm),
@@ -60,6 +62,7 @@ class _HuddleCallControls extends StatelessWidget {
foregroundColor: context.colors.onSurface,
backgroundColor: context.colors.surfaceContainerHighest,
dimension: 80,
useHapticFeedback: true,
onPressed: onReact,
),
],
@@ -79,6 +82,7 @@ class _HuddleRoundControl extends StatelessWidget {
this.dimension = 64,
this.showTooltip = true,
this.toggled,
this.useHapticFeedback = false,
});
final String tooltip;
@@ -89,21 +93,31 @@ class _HuddleRoundControl extends StatelessWidget {
final double dimension;
final bool showTooltip;
final bool? toggled;
final bool useHapticFeedback;
@override
Widget build(BuildContext context) {
final effectiveOnPressed = onPressed == null
? null
: () {
if (useHapticFeedback) {
unawaited(HapticFeedback.selectionClick());
}
onPressed!();
};
return Semantics(
label: tooltip,
button: true,
enabled: onPressed != null,
toggled: toggled,
onTap: onPressed,
onTap: effectiveOnPressed,
child: ExcludeSemantics(
child: SizedBox.square(
dimension: dimension,
child: IconButton(
tooltip: showTooltip ? tooltip : null,
onPressed: onPressed,
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
foregroundColor: foregroundColor,
backgroundColor: backgroundColor,

View File

@@ -13,6 +13,8 @@ class _HuddleCallParticipants extends StatelessWidget {
required this.retryTooltip,
required this.retryIcon,
required this.onRetry,
required this.onParticipantTap,
required this.onOverflowTap,
});
final bool connected;
@@ -26,6 +28,8 @@ class _HuddleCallParticipants extends StatelessWidget {
final String retryTooltip;
final IconData retryIcon;
final VoidCallback onRetry;
final ValueChanged<String> onParticipantTap;
final VoidCallback onOverflowTap;
@override
Widget build(BuildContext context) {
@@ -74,10 +78,13 @@ class _HuddleCallParticipants extends StatelessWidget {
final localHeightFactor = hasDenseRemoteRoster ? 0.42 : 0.5;
final movementDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 260);
: const Duration(milliseconds: 360);
final entryDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 180);
: const Duration(milliseconds: 420);
final exitDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 360);
return Stack(
key: const ValueKey('huddle-participant-stage'),
@@ -87,7 +94,7 @@ class _HuddleCallParticipants extends StatelessWidget {
child: TweenAnimationBuilder<double>(
key: const ValueKey('huddle-local-participant-motion'),
duration: movementDuration,
curve: Curves.easeInOutCubic,
curve: Curves.easeOutBack,
tween: Tween(
begin: hasRemoteParticipants ? 1 : 0,
end: hasRemoteParticipants ? 1 : 0,
@@ -120,6 +127,7 @@ class _HuddleCallParticipants extends StatelessWidget {
? 0
: speakerLevels[localPubkey] ?? 0,
isSelf: true,
onTap: null,
),
),
),
@@ -131,54 +139,17 @@ class _HuddleCallParticipants extends StatelessWidget {
child: Align(
key: const ValueKey('huddle-remote-participant-group'),
alignment: const Alignment(0, 0.35),
child: LayoutBuilder(
builder: (context, constraints) {
final layout = _HuddleParticipantLayout.fit(
availableSize: constraints.biggest,
participantCount: remotePubkeys.length,
dense: hasDenseRemoteRoster,
);
return TweenAnimationBuilder<double>(
key: const ValueKey('huddle-remote-participants'),
duration: movementDuration,
curve: Curves.easeInOutCubic,
tween: Tween(end: layout.frameSize),
builder: (context, frameSize, _) => Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
spacing: layout.spacing,
runSpacing: layout.runSpacing,
children: [
for (final pubkey in remotePubkeys)
SizedBox(
key: ValueKey('huddle-participant-entry-$pubkey'),
width: frameSize,
height: frameSize + _huddleParticipantLabelSpace,
child: TweenAnimationBuilder<double>(
duration: entryDuration,
curve: Curves.easeOutCubic,
tween: Tween(begin: 0, end: 1),
builder: (context, value, child) => Opacity(
opacity: value,
child: Transform.scale(
scale: 0.95 + value * 0.05,
child: child,
),
),
child: _HuddleCallAvatar(
pubkey: pubkey,
profile: profiles[pubkey],
fallbackLabel: fallbackLabels[pubkey],
active: activeSpeakerPubkeys.contains(pubkey),
speakerLevel: speakerLevels[pubkey] ?? 0,
frameSize: frameSize,
),
),
),
],
),
);
},
child: _HuddleParticipantCluster(
pubkeys: remotePubkeys,
profiles: profiles,
fallbackLabels: fallbackLabels,
activeSpeakerPubkeys: activeSpeakerPubkeys,
speakerLevels: speakerLevels,
movementDuration: movementDuration,
entryDuration: entryDuration,
exitDuration: exitDuration,
onParticipantTap: onParticipantTap,
onOverflowTap: onOverflowTap,
),
),
),
@@ -226,59 +197,3 @@ class _HuddleLoadingBee extends HookWidget {
);
}
}
class _HuddleParticipantLayout {
const _HuddleParticipantLayout({
required this.frameSize,
required this.spacing,
required this.runSpacing,
});
final double frameSize;
final double spacing;
final double runSpacing;
static _HuddleParticipantLayout fit({
required Size availableSize,
required int participantCount,
required bool dense,
}) {
const spacing = Grid.xs;
const runSpacing = Grid.xxs;
final maximumFrameSize = dense
? _huddleDenseAvatarFrameSize
: _huddleAvatarFrameSize;
if (participantCount <= 0 || availableSize.isEmpty) {
return _HuddleParticipantLayout(
frameSize: maximumFrameSize,
spacing: spacing,
runSpacing: runSpacing,
);
}
var bestFrameSize = _huddleMinimumAvatarFrameSize;
for (var columns = 1; columns <= participantCount; columns++) {
final rows = (participantCount + columns - 1) ~/ columns;
final widthForAvatars = availableSize.width - spacing * (columns - 1);
final heightForAvatars =
availableSize.height -
runSpacing * (rows - 1) -
_huddleParticipantLabelSpace * rows;
if (widthForAvatars <= 0 || heightForAvatars <= 0) continue;
final widthBound = widthForAvatars / columns;
final heightBound = heightForAvatars / rows;
final candidate = min(maximumFrameSize, min(widthBound, heightBound));
if (candidate > bestFrameSize) bestFrameSize = candidate;
}
return _HuddleParticipantLayout(
frameSize: bestFrameSize.clamp(
_huddleMinimumAvatarFrameSize,
maximumFrameSize,
),
spacing: spacing,
runSpacing: runSpacing,
);
}
}

View File

@@ -218,6 +218,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
icon: LucideIcons.chevronUp,
foregroundColor: foreground,
backgroundColor: controlSurface,
useHapticFeedback: true,
onPressed: restoreFullScreen,
),
const SizedBox(width: Grid.twelve),
@@ -235,6 +236,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
? foreground
: controlSurface,
toggled: session.isSpeakerEnabled,
useHapticFeedback: true,
onPressed: () => unawaited(
sessionController.setSpeakerEnabled(
!session.isSpeakerEnabled,
@@ -256,6 +258,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
? controlSurface
: foreground,
toggled: session.isMuted,
useHapticFeedback: true,
onPressed: () => unawaited(
sessionController.setMuted(!session.isMuted),
),
@@ -269,6 +272,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
icon: LucideIcons.phoneOff,
foregroundColor: context.colors.error,
backgroundColor: controlSurface,
useHapticFeedback: true,
onPressed: leave,
),
],

View File

@@ -0,0 +1,407 @@
part of '../channel_detail_page.dart';
const _huddleClusterDesignSize = Size(360, 340);
const _huddleClusterVisibleParticipantCount = 10;
const _huddleClusterFrameScale =
_huddleAvatarFrameSize / (_huddleAvatarRadius * 2);
const _huddleParticipantPresenceScale = 0.72;
const _huddleClusterSlots = <_HuddleClusterSlot>[
// A: lightly emphasized center participant.
_HuddleClusterSlot(left: 130, top: 120, avatarDiameter: 100),
// B-F: the original lower/right sequence.
_HuddleClusterSlot(left: 226.2, top: 203.3, avatarDiameter: 88),
_HuddleClusterSlot(left: 157.2, top: 228.5, avatarDiameter: 64),
_HuddleClusterSlot(left: 255.3, top: 135.2, avatarDiameter: 64),
_HuddleClusterSlot(left: 223.8, top: 108.8, avatarDiameter: 40),
_HuddleClusterSlot(left: 103.2, top: 236, avatarDiameter: 48),
// G-J: the mirrored upper/left sequence.
_HuddleClusterSlot(left: 45.8, top: 48.7, avatarDiameter: 88),
_HuddleClusterSlot(left: 138.8, top: 47.5, avatarDiameter: 64),
_HuddleClusterSlot(left: 40.7, top: 140.8, avatarDiameter: 64),
_HuddleClusterSlot(left: 96.2, top: 191.2, avatarDiameter: 40),
];
const _huddleClusterOverflowSlot = _HuddleClusterSlot(
left: 208.8,
top: 56,
avatarDiameter: 48,
);
final _huddleClusterAnchor = _huddleClusterCentroid([
..._huddleClusterSlots,
_huddleClusterOverflowSlot,
]);
class _HuddleParticipantCluster extends HookWidget {
const _HuddleParticipantCluster({
required this.pubkeys,
required this.profiles,
required this.fallbackLabels,
required this.activeSpeakerPubkeys,
required this.speakerLevels,
required this.movementDuration,
required this.entryDuration,
required this.exitDuration,
required this.onParticipantTap,
required this.onOverflowTap,
});
final List<String> pubkeys;
final Map<String, UserProfile> profiles;
final Map<String, String> fallbackLabels;
final Set<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final Duration movementDuration;
final Duration entryDuration;
final Duration exitDuration;
final ValueChanged<String> onParticipantTap;
final VoidCallback onOverflowTap;
@override
Widget build(BuildContext context) {
final visiblePubkeys = pubkeys
.take(_huddleClusterVisibleParticipantCount)
.toList(growable: false);
final visiblePubkeySet = visiblePubkeys.toSet();
final renderedPubkeys = useState<List<String>>(visiblePubkeys);
final removalTimers = useMemoized(() => <String, Timer>{});
final lastSlots = useMemoized(() => <String, _HuddleClusterSlot>{});
useEffect(() {
for (final pubkey in visiblePubkeys) {
removalTimers.remove(pubkey)?.cancel();
}
final nextRenderedPubkeys = [...renderedPubkeys.value];
for (final pubkey in visiblePubkeys) {
if (!nextRenderedPubkeys.contains(pubkey)) {
nextRenderedPubkeys.add(pubkey);
}
}
if (!listEquals(renderedPubkeys.value, nextRenderedPubkeys)) {
renderedPubkeys.value = List.unmodifiable(nextRenderedPubkeys);
}
for (final pubkey in renderedPubkeys.value) {
if (visiblePubkeySet.contains(pubkey) ||
removalTimers.containsKey(pubkey)) {
continue;
}
removalTimers[pubkey] = Timer(exitDuration, () {
removalTimers.remove(pubkey);
lastSlots.remove(pubkey);
renderedPubkeys.value = List.unmodifiable(
renderedPubkeys.value.where((value) => value != pubkey),
);
});
}
return null;
}, [Object.hashAll(visiblePubkeys), exitDuration]);
useEffect(
() => () {
for (final timer in removalTimers.values) {
timer.cancel();
}
removalTimers.clear();
},
const [],
);
final overflowCount =
pubkeys.length - _huddleClusterVisibleParticipantCount;
final layout = _huddleClusterLayout(
participantCount: visiblePubkeys.length,
showsOverflow: overflowCount > 0,
);
for (final (index, pubkey) in visiblePubkeys.indexed) {
lastSlots[pubkey] = layout.participantSlots[index];
}
final orderedRenderedPubkeys = [
for (final pubkey in renderedPubkeys.value)
if (!visiblePubkeySet.contains(pubkey)) pubkey,
for (final pubkey in visiblePubkeys)
if (renderedPubkeys.value.contains(pubkey)) pubkey,
];
return FittedBox(
key: const ValueKey('huddle-participant-cluster'),
fit: BoxFit.contain,
child: SizedBox(
width: _huddleClusterDesignSize.width,
height: _huddleClusterDesignSize.height,
child: Stack(
clipBehavior: Clip.none,
children: [
for (final pubkey in orderedRenderedPubkeys)
_HuddleAnimatedParticipant(
key: ValueKey('huddle-participant-entry-$pubkey'),
pubkey: pubkey,
profile: profiles[pubkey],
fallbackLabel: fallbackLabels[pubkey],
active: activeSpeakerPubkeys.contains(pubkey),
speakerLevel: speakerLevels[pubkey] ?? 0,
slot: lastSlots[pubkey]!,
present: visiblePubkeySet.contains(pubkey),
movementDuration: movementDuration,
entryDuration: entryDuration,
exitDuration: exitDuration,
onTap: visiblePubkeySet.contains(pubkey)
? () {
unawaited(HapticFeedback.selectionClick());
onParticipantTap(pubkey);
}
: null,
),
if (overflowCount > 0)
_HuddleParticipantOverflow(
count: overflowCount,
slot: layout.overflowSlot!,
entryDuration: entryDuration,
onTap: onOverflowTap,
),
],
),
),
);
}
}
class _HuddleAnimatedParticipant extends StatelessWidget {
const _HuddleAnimatedParticipant({
super.key,
required this.pubkey,
required this.profile,
required this.fallbackLabel,
required this.active,
required this.speakerLevel,
required this.slot,
required this.present,
required this.movementDuration,
required this.entryDuration,
required this.exitDuration,
required this.onTap,
});
final String pubkey;
final UserProfile? profile;
final String? fallbackLabel;
final bool active;
final double speakerLevel;
final _HuddleClusterSlot slot;
final bool present;
final Duration movementDuration;
final Duration entryDuration;
final Duration exitDuration;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final frameSize = slot.avatarDiameter * _huddleClusterFrameScale;
final frameInset = (frameSize - slot.avatarDiameter) / 2;
final targetOffset = Offset(slot.left - frameInset, slot.top - frameInset);
return Positioned(
left: 0,
top: 0,
width: frameSize,
height: frameSize + _huddleParticipantLabelSpace,
child: TweenAnimationBuilder<Offset>(
key: ValueKey('huddle-participant-motion-$pubkey'),
duration: movementDuration,
curve: Curves.easeOutBack,
tween: Tween(begin: targetOffset, end: targetOffset),
builder: (context, offset, child) => Transform.translate(
key: ValueKey('huddle-participant-translation-$pubkey'),
offset: offset,
child: child,
),
child: TweenAnimationBuilder<double>(
key: ValueKey('huddle-participant-entry-motion-$pubkey'),
duration: present ? entryDuration : exitDuration,
curve: present
? Curves.easeOutBack
: const _HuddleExitPresenceCurve(),
tween: Tween(begin: present ? 0 : 1, end: present ? 1 : 0),
builder: (context, value, child) => Opacity(
opacity: value.clamp(0, 1),
child: Transform.scale(
key: ValueKey('huddle-participant-entry-scale-$pubkey'),
scale:
_huddleParticipantPresenceScale +
value * (1 - _huddleParticipantPresenceScale),
child: child,
),
),
child: _HuddleCallAvatar(
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabel,
active: active,
speakerLevel: speakerLevel,
frameSize: frameSize,
onTap: onTap,
),
),
),
);
}
}
class _HuddleParticipantOverflow extends StatelessWidget {
const _HuddleParticipantOverflow({
required this.count,
required this.slot,
required this.entryDuration,
required this.onTap,
});
final int count;
final _HuddleClusterSlot slot;
final Duration entryDuration;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Positioned(
left: slot.left,
top: slot.top,
width: slot.avatarDiameter,
height: slot.avatarDiameter,
child: TweenAnimationBuilder<double>(
duration: entryDuration,
curve: Curves.easeOutBack,
tween: Tween(begin: 0, end: 1),
builder: (context, value, child) => Opacity(
opacity: value.clamp(0, 1),
child: Transform.scale(scale: 0.95 + value * 0.05, child: child),
),
child: Semantics(
label: '$count more participants',
hint: 'Tap to show participant list',
button: true,
onTap: onTap,
child: ExcludeSemantics(
child: GestureDetector(
key: const ValueKey('huddle-participant-overflow'),
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.surfaceContainerHighest,
),
child: Center(
child: AnimatedSwitcher(
duration: entryDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: Text(
'+$count',
key: ValueKey('huddle-participant-overflow-count-$count'),
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
),
),
);
}
}
class _HuddleClusterSlot {
const _HuddleClusterSlot({
required this.left,
required this.top,
required this.avatarDiameter,
});
final double left;
final double top;
final double avatarDiameter;
_HuddleClusterSlot translate(Offset offset) => _HuddleClusterSlot(
left: left + offset.dx,
top: top + offset.dy,
avatarDiameter: avatarDiameter,
);
}
_HuddleClusterLayout _huddleClusterLayout({
required int participantCount,
required bool showsOverflow,
}) {
final participantSlots = _huddleClusterSlots
.take(participantCount)
.toList(growable: false);
final occupiedSlots = [
...participantSlots,
if (showsOverflow) _huddleClusterOverflowSlot,
];
if (occupiedSlots.isEmpty) {
return const _HuddleClusterLayout(participantSlots: []);
}
final translation =
_huddleClusterAnchor - _huddleClusterCentroid(occupiedSlots);
return _HuddleClusterLayout(
participantSlots: [
for (final slot in participantSlots) slot.translate(translation),
],
overflowSlot: showsOverflow
? _huddleClusterOverflowSlot.translate(translation)
: null,
);
}
Offset _huddleClusterCentroid(Iterable<_HuddleClusterSlot> slots) {
var x = 0.0;
var y = 0.0;
var count = 0;
for (final slot in slots) {
x += slot.left + slot.avatarDiameter / 2;
y += slot.top + slot.avatarDiameter / 2;
count++;
}
return count == 0 ? Offset.zero : Offset(x / count, y / count);
}
class _HuddleClusterLayout {
const _HuddleClusterLayout({
required this.participantSlots,
this.overflowSlot,
});
final List<_HuddleClusterSlot> participantSlots;
final _HuddleClusterSlot? overflowSlot;
}
class _HuddleExitPresenceCurve extends Curve {
const _HuddleExitPresenceCurve();
@override
double transformInternal(double t) {
const overshootEnd = 0.3;
const overshootPeak = overshootEnd / 2;
const overshootAmount = 0.25;
if (t < overshootPeak) {
return -overshootAmount *
Curves.easeOutCubic.transform(t / overshootPeak);
}
if (t < overshootEnd) {
return -overshootAmount *
(1 -
Curves.easeInOutCubic.transform(
(t - overshootPeak) / overshootPeak,
));
}
return Curves.easeInOutCubic.transform(
(t - overshootEnd) / (1 - overshootEnd),
);
}
}

View File

@@ -0,0 +1,372 @@
part of '../channel_detail_page.dart';
const _huddleParticipantOverlayDuration = Duration(milliseconds: 320);
const _huddleParticipantSpotlightRadius = 68.0;
void _showHuddleParticipantSpotlight({
required BuildContext context,
required String pubkey,
required bool isSelf,
}) {
_showHuddleParticipantOverlay(
context: context,
barrierLabel: 'Dismiss participant',
childBuilder: (dialogContext) => _HuddleParticipantSpotlight(
pubkey: pubkey,
isSelf: isSelf,
onDismiss: () => Navigator.of(dialogContext).pop(),
),
);
}
void _showHuddleParticipantRoster({required BuildContext context}) {
_showHuddleParticipantOverlay(
context: context,
barrierLabel: 'Dismiss participant list',
childBuilder: (_) => const _HuddleParticipantRoster(),
);
}
void _showHuddleParticipantOverlay({
required BuildContext context,
required String barrierLabel,
required WidgetBuilder childBuilder,
}) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
unawaited(
showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: barrierLabel,
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: _huddleParticipantOverlayDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_HuddleParticipantOverlay(
animation: animation,
child: childBuilder(dialogContext),
),
),
);
}
class _HuddleParticipantOverlay extends StatelessWidget {
const _HuddleParticipantOverlay({
required this.animation,
required this.child,
});
final Animation<double> animation;
final Widget child;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final blurProgress = const Interval(
0,
0.30,
curve: Curves.easeOutCubic,
).transform(animation.value);
final sigma = 20 * blurProgress;
return BackdropFilter(
key: const ValueKey('huddle-participant-modal-backdrop'),
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: ColoredBox(
color: context.colors.inverseSurface.withValues(
alpha: 0.10 * blurProgress,
),
),
);
},
),
),
Positioned.fill(
child: GestureDetector(
key: const ValueKey('huddle-participant-modal-dismiss-region'),
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
),
),
SafeArea(
child: Center(
child: AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final appearance = Curves.easeOutCubic.transform(
animation.value,
);
final scale = Curves.easeOutBack.transform(animation.value);
return Opacity(
opacity: appearance,
child: Transform.scale(
scale: lerpDouble(0.92, 1, scale)!,
child: child,
),
);
},
),
),
),
],
);
}
}
class _HuddleParticipantSpotlight extends ConsumerWidget {
const _HuddleParticipantSpotlight({
required this.pubkey,
required this.isSelf,
required this.onDismiss,
});
final String pubkey;
final bool isSelf;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context, WidgetRef ref) {
final participantPresent = ref.watch(
huddleSessionProvider.select(
(session) => isSelf || session.participantPubkeys.contains(pubkey),
),
);
if (!participantPresent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) onDismiss();
});
return const SizedBox.shrink();
}
final profile = ref.watch(
userCacheProvider.select((profiles) => profiles[pubkey]),
);
final fallbackLabel = ref.watch(
agentDirectoryDisplayNamesProvider.select((labels) => labels[pubkey]),
);
final active = ref.watch(
huddleSessionProvider.select(
(session) => session.activeSpeakerPubkeys.contains(pubkey),
),
);
final label = _huddleParticipantLabel(
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabel,
isSelf: isSelf,
);
return Semantics(
label: active ? '$label, speaking' : label,
hint: 'Tap to close participant',
button: true,
excludeSemantics: true,
child: GestureDetector(
key: const ValueKey('huddle-participant-spotlight'),
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: onDismiss,
child: Padding(
padding: const EdgeInsets.all(Grid.gutter),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
key: ValueKey('huddle-participant-spotlight-avatar-$pubkey'),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
padding: EdgeInsets.all(active ? Grid.xxs : Grid.half),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primary.withValues(
alpha: active ? 0.18 : 0.08,
),
),
child: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: _huddleParticipantSpotlightRadius,
backgroundColor: context.colors.primaryContainer,
fallback: Icon(
LucideIcons.userRound,
size: 56,
color: context.colors.onPrimaryContainer,
),
),
),
const SizedBox(height: Grid.twelve),
Material(
color: context.colors.surface.withValues(alpha: 0.92),
shape: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
label,
key: const ValueKey('huddle-participant-spotlight-name'),
textAlign: TextAlign.center,
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
),
);
}
}
class _HuddleParticipantRoster extends ConsumerWidget {
const _HuddleParticipantRoster();
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(huddleSessionProvider);
final localPubkey = session.currentPubkey?.toLowerCase();
final pubkeys = session.participantPubkeys
.where((pubkey) => pubkey != localPubkey)
.skip(_huddleClusterVisibleParticipantCount)
.toList(growable: false);
if (pubkeys.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).pop();
});
return const SizedBox.shrink();
}
final profiles = ref.watch(userCacheProvider);
final fallbackLabels = ref.watch(agentDirectoryDisplayNamesProvider);
final activeSpeakerPubkeys = ref.watch(
huddleSessionProvider.select((session) => session.activeSpeakerPubkeys),
);
final maxListHeight = min(MediaQuery.sizeOf(context).height * 0.55, 416.0);
final listHeight = min(pubkeys.length * 64.0, maxListHeight);
return Semantics(
label: '${pubkeys.length} more participants',
container: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Material(
key: const ValueKey('huddle-participant-roster'),
color: context.colors.surface,
surfaceTintColor: Colors.transparent,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(Radii.dialog),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${pubkeys.length} more ${pubkeys.length == 1 ? 'person' : 'people'}',
textAlign: TextAlign.center,
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.twelve),
SizedBox(
height: listHeight,
child: ListView.separated(
key: const ValueKey('huddle-participant-roster-list'),
padding: EdgeInsets.zero,
itemCount: pubkeys.length,
separatorBuilder: (_, _) => Divider(
height: Grid.xxs,
color: context.colors.outlineVariant.withValues(
alpha: 0.5,
),
),
itemBuilder: (context, index) {
final pubkey = pubkeys[index];
final profile = profiles[pubkey];
final active = activeSpeakerPubkeys.contains(pubkey);
final label = _huddleParticipantLabel(
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabels[pubkey],
isSelf: false,
);
return Semantics(
label: active ? '$label, speaking' : label,
excludeSemantics: true,
child: SizedBox(
key: ValueKey(
'huddle-participant-roster-row-$pubkey',
),
height: 56,
child: Row(
children: [
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 22,
backgroundColor:
context.colors.primaryContainer,
fallback: Icon(
LucideIcons.userRound,
size: 22,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge
?.copyWith(
color: context.colors.onSurface,
fontWeight: active
? FontWeight.w600
: null,
),
),
),
],
),
),
);
},
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -4,8 +4,6 @@ const _huddleLifetime = Duration(hours: 1);
const _huddleAvatarRadius = 52.0;
const _huddleAvatarFrameSize = 128.0;
const _huddleSpeakingRingSize = 112.0;
const _huddleDenseAvatarFrameSize = 104.0;
const _huddleMinimumAvatarFrameSize = 48.0;
const _huddleParticipantLabelSpace = 28.0;
const _huddleDenseParticipantThreshold = 6;
@@ -656,6 +654,16 @@ class _MobileHuddleCallPage extends ConsumerWidget {
retryTooltip: retryTooltip,
retryIcon: retryIcon,
onRetry: onRetry,
onParticipantTap: (pubkey) {
final isSelf = pubkey == localPubkey || pubkey.isEmpty;
_showHuddleParticipantSpotlight(
context: context,
pubkey: pubkey,
isSelf: isSelf,
);
},
onOverflowTap: () =>
_showHuddleParticipantRoster(context: context),
),
),
if (connected)
@@ -742,7 +750,10 @@ class _HuddleCallHeader extends StatelessWidget {
IconButton(
key: const ValueKey('huddle-minimize'),
tooltip: 'Minimize',
onPressed: onMinimize,
onPressed: () {
unawaited(HapticFeedback.selectionClick());
onMinimize();
},
icon: const Icon(LucideIcons.chevronDown, size: 32),
),
const Spacer(),
@@ -752,6 +763,7 @@ class _HuddleCallHeader extends StatelessWidget {
icon: LucideIcons.phoneOff,
foregroundColor: context.colors.error,
backgroundColor: context.colors.surfaceContainerHighest,
useHapticFeedback: true,
onPressed: isLeaving ? null : onLeave,
),
],

View File

@@ -4715,8 +4715,29 @@ void main() {
3: HuddlePeer(pubkey: 'agent', peerIndex: 3, epoch: 0),
},
);
final users = _FakeUserCacheNotifier(const {
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'),
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
});
final navigator = _RecordingNavigatorObserver();
String? leftChannelId;
final hapticCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await tester.pumpWidget(
_buildTestable(
@@ -4728,11 +4749,7 @@ void main() {
createdAt: now,
),
],
users: const {
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'),
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
},
userCacheNotifier: users,
huddleMembers: [
ChannelMember(
pubkey: 'agent',
@@ -4851,47 +4868,103 @@ void main() {
find.byKey(const ValueKey('huddle-participant-avatar-desktop')),
);
await tester.pump();
final milesLabel = find.byKey(
const ValueKey('huddle-participant-label-desktop'),
);
final milesReveal = find
.ancestor(of: milesLabel, matching: find.byType(FadeTransition))
.first;
expect(tester.widget<FadeTransition>(milesReveal).opacity.value, 0);
await tester.pump(const Duration(milliseconds: 90));
expect(hapticCalls, hasLength(1));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
tester.widget<FadeTransition>(milesReveal).opacity.value,
allOf(greaterThan(0), lessThan(1)),
find.byKey(const ValueKey('huddle-participant-modal-backdrop')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('huddle-participant-spotlight')),
findsOneWidget,
);
await tester.pumpAndSettle();
expect(find.text('Miles'), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == 'Miles',
),
findsNWidgets(2),
);
transport.emitRemoteAudio();
await tester.pump();
expect(
find.byWidgetPredicate(
(widget) =>
widget is Semantics &&
widget.properties.label == 'Miles, speaking',
),
findsNWidgets(2),
);
users.replace(
const UserProfile(pubkey: 'desktop', displayName: 'Miles Davis'),
);
await tester.pump();
expect(find.text('Miles'), findsNothing);
expect(find.text('Miles Davis'), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) =>
widget is Semantics &&
widget.properties.label == 'Miles Davis, speaking',
),
findsNWidgets(2),
);
users.replace(
const UserProfile(pubkey: 'desktop', displayName: 'Miles'),
);
await tester.pump();
expect(find.text('Pollen'), findsNothing);
expect(find.byKey(const ValueKey('huddle-leave')), findsOneWidget);
expect(
find.byKey(const ValueKey('huddle-participant-label-desktop')),
findsNothing,
);
transport.emitPeerLeave(1);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('huddle-participant-spotlight')),
findsNothing,
);
transport.emitPeerJoin(
const HuddlePeer(pubkey: 'desktop', peerIndex: 1, epoch: 1),
);
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('huddle-participant-avatar-agent')),
);
await tester.pump();
await tester.pumpAndSettle();
expect(hapticCalls, hasLength(2));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(find.text('Pollen'), findsOneWidget);
await tester.tapAt(const Offset(8, 8));
await tester.pumpAndSettle();
expect(find.text('Pollen'), findsNothing);
await tester.tap(
find.byKey(const ValueKey('huddle-participant-avatar-self')),
);
await tester.pumpAndSettle();
expect(find.text('You'), findsOneWidget);
final selfLabel = find.byKey(
const ValueKey('huddle-participant-label-self'),
);
final selfAvatar = find.byKey(
const ValueKey('huddle-speaking-ring-self'),
);
expect(find.text('You'), findsNothing);
expect(
tester.getCenter(selfLabel).dx,
closeTo(tester.getCenter(selfAvatar).dx, 0.01),
find.byKey(
const ValueKey('huddle-participant-spotlight-avatar-self'),
),
findsNothing,
);
expect(
tester.getTopLeft(selfLabel).dy,
greaterThanOrEqualTo(tester.getBottomLeft(selfAvatar).dy),
expect(hapticCalls, hasLength(2));
final selfSemantics = tester.widget<Semantics>(
find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == 'You',
),
);
expect(selfSemantics.properties.button, isFalse);
expect(selfSemantics.properties.onTap, isNull);
expect(selfSemantics.properties.hint, isNull);
hapticCalls.clear();
expect(find.text('Connected'), findsNothing);
expect(find.text('Waiting for remote audio'), findsNothing);
expect(find.text('Microphone muted'), findsNothing);
@@ -4998,6 +5071,8 @@ void main() {
?.resolve(const <WidgetState>{});
await tester.tap(find.byKey(const ValueKey('huddle-speaker-toggle')));
await tester.pump();
expect(hapticCalls, hasLength(1));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
find.descendant(
of: find.byKey(const ValueKey('huddle-speaker-toggle')),
@@ -5052,6 +5127,8 @@ void main() {
);
await tester.tap(find.byKey(const ValueKey('huddle-mute-toggle')));
await tester.pump();
expect(hapticCalls, hasLength(2));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
find.descendant(
of: find.byKey(const ValueKey('huddle-mute-toggle')),
@@ -5092,6 +5169,8 @@ void main() {
);
await tester.tap(find.byKey(const ValueKey('huddle-emoji-reactions')));
await tester.pump(const Duration(milliseconds: 500));
expect(hapticCalls, hasLength(3));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(find.byType(EmojiPickerSheet), findsOneWidget);
tester
.widget<EmojiPickerSheet>(find.byType(EmojiPickerSheet))
@@ -5102,6 +5181,8 @@ void main() {
await tester.tap(find.byKey(const ValueKey('huddle-minimize')));
await tester.pumpAndSettle();
expect(hapticCalls, hasLength(4));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(find.widgetWithText(FilledButton, 'Open'), findsOneWidget);
expect(
tester
@@ -5157,10 +5238,19 @@ void main() {
);
expect(find.bySemanticsLabel('Unmute'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('huddle-drawer-speaker-toggle')),
);
await tester.pump();
expect(hapticCalls, hasLength(5));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
await tester.tap(
find.byKey(const ValueKey('huddle-drawer-mute-toggle')),
);
await tester.pump();
expect(hapticCalls, hasLength(6));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
tester
.widget<AnimatedPositioned>(
@@ -5204,6 +5294,8 @@ void main() {
}
await tester.tap(find.byKey(const ValueKey('huddle-drawer-expand')));
await tester.pumpAndSettle();
expect(hapticCalls, hasLength(7));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(find.byKey(const ValueKey('huddle-minimize')), findsOneWidget);
expect(
tester
@@ -5218,8 +5310,12 @@ void main() {
await tester.tap(find.byKey(const ValueKey('huddle-minimize')));
await tester.pumpAndSettle();
expect(hapticCalls, hasLength(8));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
await tester.tap(find.byKey(const ValueKey('huddle-drawer-leave')));
await tester.pump();
expect(hapticCalls, hasLength(9));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
tester
.widget<AnimatedPositioned>(
@@ -5243,7 +5339,7 @@ void main() {
);
testWidgets(
'fits a dense Huddle roster without scrolling and shrinks avatars',
'caps a dense Huddle roster at ten avatars with an overflow count',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
@@ -5314,12 +5410,60 @@ void main() {
),
findsNothing,
);
for (final pubkey in remotePubkeys) {
for (final pubkey in remotePubkeys.take(10)) {
expect(
find.byKey(ValueKey('huddle-participant-entry-$pubkey')),
find.byKey(ValueKey('huddle-participant-avatar-$pubkey')),
findsOneWidget,
);
}
for (final pubkey in remotePubkeys.skip(10)) {
expect(
find.byKey(ValueKey('huddle-participant-avatar-$pubkey')),
findsNothing,
);
}
expect(
find.byKey(const ValueKey('huddle-participant-overflow')),
findsOneWidget,
);
expect(find.text('+14'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('huddle-participant-overflow')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('huddle-participant-roster')),
findsOneWidget,
);
expect(find.text('14 more people'), findsOneWidget);
expect(
find.byKey(const ValueKey('huddle-participant-roster-row-guest-10')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('huddle-participant-roster-row-guest-0')),
findsNothing,
);
await tester.scrollUntilVisible(
find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')),
200,
scrollable: find.descendant(
of: find.byKey(const ValueKey('huddle-participant-roster-list')),
matching: find.byType(Scrollable),
),
);
expect(
find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')),
findsOneWidget,
);
await tester.tapAt(const Offset(8, 8));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('huddle-participant-roster')),
findsNothing,
);
final firstRemoteRing = find.byKey(
const ValueKey('huddle-speaking-ring-guest-0'),
@@ -5467,13 +5611,12 @@ void main() {
},
);
testWidgets('does not add backing-channel members after relay admission', (
tester,
) async {
const staleMemberPubkey =
testWidgets('springs admitted participants in and out', (tester) async {
const addedMemberPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final membersNotifier = _MutableHuddleMembersNotifier(const []);
final transport = _HuddleTestTransport();
await tester.pumpWidget(
_buildTestable(
@@ -5488,9 +5631,9 @@ void main() {
users: const {
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
staleMemberPubkey: UserProfile(
pubkey: staleMemberPubkey,
displayName: 'Stale member',
addedMemberPubkey: UserProfile(
pubkey: addedMemberPubkey,
displayName: 'Added member',
),
},
huddleMembersNotifier: membersNotifier,
@@ -5498,33 +5641,107 @@ void main() {
relaySessionNotifier: _ReconnectingRelaySession(),
huddleCurrentPubkey: 'self',
huddleMediaFactory: _HuddleTestMedia.new,
huddleTransportFactory: (_) => _HuddleTestTransport(),
huddleTransportFactory: (_) => transport,
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pumpAndSettle();
final desktopAvatar = find.byKey(
const ValueKey('huddle-speaking-ring-desktop'),
);
final initialDesktopCenter = tester.getCenter(desktopAvatar);
membersNotifier.replace([
ChannelMember(
pubkey: staleMemberPubkey,
pubkey: addedMemberPubkey,
role: 'member',
joinedAt: DateTime(2025),
),
]);
await tester.pump();
await tester.pump();
expect(
find.byKey(
const ValueKey('huddle-participant-avatar-$staleMemberPubkey'),
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
),
findsNothing,
);
transport.emitPeerJoin(
const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0),
);
await tester.pump();
final addedMemberScale = find.byKey(
const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'),
);
expect(
tester.widget<Transform>(addedMemberScale).transform.storage[0],
closeTo(0.72, 0.01),
);
await tester.pump(const Duration(milliseconds: 120));
final movingDesktopCenter = tester.getCenter(desktopAvatar);
expect(
tester.widget<Transform>(addedMemberScale).transform.storage[0],
greaterThan(0.72),
);
expect(
(movingDesktopCenter - initialDesktopCenter).distance,
greaterThan(1),
);
expect(
find.byKey(
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('huddle-participant-avatar-desktop')),
findsOneWidget,
);
await tester.pumpAndSettle();
final settledDesktopCenter = tester.getCenter(desktopAvatar);
expect(
(settledDesktopCenter - movingDesktopCenter).distance,
greaterThan(1),
);
expect(
(settledDesktopCenter - initialDesktopCenter).distance,
greaterThan(1),
);
expect(
tester.widget<Transform>(addedMemberScale).transform.storage[0],
closeTo(1, 0.01),
);
transport.emitPeerLeave(3);
await tester.pump();
expect(
find.byKey(
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 54));
expect(
tester.widget<Transform>(addedMemberScale).transform.storage[0],
greaterThan(1.05),
);
await tester.pump(const Duration(milliseconds: 140));
expect(
tester.widget<Transform>(addedMemberScale).transform.storage[0],
lessThan(1),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
),
findsNothing,
);
});
testWidgets('top-right call end leaves audio and the backing channel', (
@@ -5534,6 +5751,22 @@ void main() {
final media = _HuddleTestMedia();
final transport = _HuddleTestTransport();
String? leftChannelId;
final hapticCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await tester.pumpWidget(
_buildTestable(
@@ -5575,6 +5808,8 @@ void main() {
await tester.tap(hangup);
await tester.pump();
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick');
for (var attempt = 0; attempt < 100 && leftChannelId == null; attempt++) {
await tester.pump();
}
@@ -12512,6 +12747,20 @@ final class _HuddleTestTransport implements HuddleTransportClient {
);
}
void emitPeerLeave(int peerIndex) {
final peer = _peers.remove(peerIndex);
if (peer == null) return;
_state = HuddleTransportState(
phase: HuddleTransportPhase.connected,
localPeerIndex: _state.localPeerIndex,
peers: _peers,
);
_states.add(_state);
_peerEvents.add(
HuddlePeerEvent(type: HuddlePeerEventType.left, peer: peer),
);
}
void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) {
_remoteFrames.add(
HuddleRemoteAudioFrame(