mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user