Harden mobile Huddle lifecycle

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-16 17:10:01 +01:00
parent a415c86c7a
commit 9941f6ee1a
12 changed files with 435 additions and 282 deletions
+4 -3
View File
@@ -3,7 +3,7 @@
This foreground-only product slice preserves the existing Desktop/relay
contract. Android and iOS clients can start a human Huddle, open a recent
Desktop-started Huddle card, join with the microphone on, hear multiple remote
participants, send microphone audio, mute, leave, or (as creator) end the room.
participants, send microphone audio, mute, and leave.
The parent channel carries creator-signed kind `48100` start and `48103` end
events. The private `stream` backing channel uses kind `9007`, `ttl=3600`, and
@@ -13,8 +13,9 @@ rule: it counts non-`bot` backing-channel members before disconnecting, submits
kind `9022` when another human remains, and otherwise publishes kind `48103`
and archives with kind `9002`. A failed count safely assumes another human is
present so a transient relay error cannot end the Huddle. Creator-only explicit
“End for everyone” remains a separate action. On relaunch, parent event history
reconstructs the visible card without silently reopening a microphone.
end support remains in the controller, but the current mobile UI does not
foreground a separate “End for everyone” control. On relaunch, parent event
history reconstructs the visible card without silently reopening a microphone.
## Control plane
@@ -69,6 +69,8 @@ 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_call_participants.dart';
part 'channel_detail_page/huddle_call_controls.dart';
part 'channel_detail_page/huddle_drawer.dart';
part 'channel_detail_page/message_bubble.dart';
part 'channel_detail_page/banners.dart';
@@ -0,0 +1,106 @@
part of '../channel_detail_page.dart';
class _HuddleCallControls extends StatelessWidget {
const _HuddleCallControls({
required this.isMuted,
required this.isSpeakerEnabled,
required this.onToggleMute,
required this.onToggleSpeaker,
});
final bool isMuted;
final bool isSpeakerEnabled;
final VoidCallback onToggleMute;
final VoidCallback onToggleSpeaker;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.xxs),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_HuddleRoundControl(
key: const ValueKey('huddle-speaker-toggle'),
tooltip: isSpeakerEnabled ? 'Use earpiece' : 'Use speaker',
icon: LucideIcons.volume2,
foregroundColor: isSpeakerEnabled
? context.colors.onPrimary
: context.colors.onSurface,
backgroundColor: isSpeakerEnabled
? context.colors.primary
: context.colors.surfaceContainerHighest,
dimension: 72,
toggled: isSpeakerEnabled,
onPressed: onToggleSpeaker,
),
const SizedBox(width: Grid.sm),
_HuddleRoundControl(
key: const ValueKey('huddle-mute-toggle'),
tooltip: isMuted ? 'Unmute' : 'Mute',
icon: isMuted ? LucideIcons.micOff : LucideIcons.mic,
foregroundColor: isMuted
? context.colors.onSurface
: context.colors.onPrimary,
backgroundColor: isMuted
? context.colors.surfaceContainerHighest
: context.colors.primary,
dimension: 72,
toggled: isMuted,
onPressed: onToggleMute,
),
],
),
);
}
}
class _HuddleRoundControl extends StatelessWidget {
const _HuddleRoundControl({
super.key,
required this.tooltip,
required this.icon,
required this.foregroundColor,
required this.backgroundColor,
required this.onPressed,
this.dimension = 64,
this.showTooltip = true,
this.toggled,
});
final String tooltip;
final IconData icon;
final Color foregroundColor;
final Color backgroundColor;
final VoidCallback? onPressed;
final double dimension;
final bool showTooltip;
final bool? toggled;
@override
Widget build(BuildContext context) {
return Semantics(
label: tooltip,
button: true,
enabled: onPressed != null,
toggled: toggled,
onTap: onPressed,
child: ExcludeSemantics(
child: SizedBox.square(
dimension: dimension,
child: IconButton(
tooltip: showTooltip ? tooltip : null,
onPressed: onPressed,
style: IconButton.styleFrom(
foregroundColor: foregroundColor,
backgroundColor: backgroundColor,
disabledForegroundColor: foregroundColor.withValues(alpha: 0.5),
disabledBackgroundColor: backgroundColor.withValues(alpha: 0.5),
),
icon: Icon(icon, size: 28),
),
),
),
);
}
}
@@ -0,0 +1,162 @@
part of '../channel_detail_page.dart';
class _HuddleCallParticipants extends StatelessWidget {
const _HuddleCallParticipants({
required this.connected,
required this.error,
required this.profiles,
required this.fallbackLabels,
required this.remotePubkeys,
required this.localPubkey,
required this.activeSpeakerPubkeys,
required this.retryTooltip,
required this.retryIcon,
required this.onRetry,
});
final bool connected;
final String? error;
final Map<String, UserProfile> profiles;
final Map<String, String> fallbackLabels;
final List<String> remotePubkeys;
final String? localPubkey;
final Set<String> activeSpeakerPubkeys;
final String retryTooltip;
final IconData retryIcon;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
if (error case final message?) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.triangleAlert,
size: 32,
color: context.colors.error,
),
const SizedBox(height: Grid.xxs),
Text(
message,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
),
const SizedBox(height: Grid.xs),
IconButton.filledTonal(
key: const ValueKey('huddle-retry'),
tooltip: retryTooltip,
onPressed: onRetry,
icon: Icon(retryIcon),
),
],
),
),
);
}
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final hasRemoteParticipants = connected && remotePubkeys.isNotEmpty;
final movementDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 260);
final entryDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 180);
return Stack(
key: const ValueKey('huddle-participant-stage'),
fit: StackFit.expand,
children: [
Positioned.fill(
child: TweenAnimationBuilder<double>(
key: const ValueKey('huddle-local-participant-motion'),
duration: movementDuration,
curve: Curves.easeInOutCubic,
tween: Tween(
begin: hasRemoteParticipants ? 1 : 0,
end: hasRemoteParticipants ? 1 : 0,
),
builder: (context, value, child) => FractionallySizedBox(
heightFactor: 0.5,
alignment: Alignment.lerp(
Alignment.center,
Alignment.bottomCenter,
value,
)!,
child: Align(
key: const ValueKey('huddle-local-participant'),
alignment: Alignment.lerp(
Alignment.center,
const Alignment(0, -0.35),
value,
)!,
child: child,
),
),
child: _HuddleCallAvatar(
pubkey: localPubkey ?? '',
profile: localPubkey == null ? null : profiles[localPubkey],
fallbackLabel: null,
active:
localPubkey != null &&
activeSpeakerPubkeys.contains(localPubkey),
isSelf: true,
),
),
),
Positioned.fill(
child: FractionallySizedBox(
heightFactor: 0.5,
alignment: Alignment.topCenter,
child: Align(
key: const ValueKey('huddle-remote-participant-group'),
alignment: const Alignment(0, 0.35),
child: connected
? SingleChildScrollView(
key: const ValueKey('huddle-remote-participants'),
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
child: Wrap(
alignment: WrapAlignment.center,
spacing: Grid.md,
runSpacing: Grid.xs,
children: [
for (final pubkey in remotePubkeys)
SizedBox(
key: ValueKey('huddle-participant-entry-$pubkey'),
width: _huddleAvatarFrameSize,
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),
),
),
),
],
),
)
: const BuzzLoadingIndicator(size: 40),
),
),
),
],
);
}
}
@@ -4,12 +4,6 @@ const _mobileHuddleDrawerBaseHeight = 80.0;
const _mobileHuddleDrawerRadius = 24.0;
const _mobileHuddleDrawerMotion = Duration(milliseconds: 260);
const _mobileHuddleDrawerCurve = Cubic(0.32, 0.72, 0, 1);
const _lightHuddleDrawerSurface = Color(0xFF000000);
const _lightHuddleControlSurface = Color(0xFF333333);
const _lightHuddleForeground = Color(0xFFFAFAFA);
const _darkHuddleDrawerSurface = Color(0xFF363A4F);
const _darkHuddleControlSurface = Color(0xFF494D64);
const _darkHuddleForeground = Color(0xFFCAD3F5);
/// Lifts the mobile app above a persistent Huddle control drawer when the
/// full-screen call has been minimized.
@@ -65,9 +59,9 @@ class MobileHuddleShell extends ConsumerWidget {
),
),
boxShadow: drawerOpen
? const [
? [
BoxShadow(
color: Color(0x70000000),
color: context.colors.shadow.withValues(alpha: 0.44),
blurRadius: 24,
spreadRadius: -12,
offset: Offset(0, 10),
@@ -226,6 +220,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
backgroundColor: session.isSpeakerEnabled
? foreground
: controlSurface,
toggled: session.isSpeakerEnabled,
onPressed: () => unawaited(
sessionController.setSpeakerEnabled(
!session.isSpeakerEnabled,
@@ -246,6 +241,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
backgroundColor: session.isMuted
? controlSurface
: foreground,
toggled: session.isMuted,
onPressed: () => unawaited(
sessionController.setMuted(!session.isMuted),
),
@@ -272,16 +268,10 @@ class _MobileHuddleDrawer extends ConsumerWidget {
}
Color _huddleDrawerSurface(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark
? _darkHuddleDrawerSurface
: _lightHuddleDrawerSurface;
context.appColors.huddleDrawerSurface;
Color _huddleDrawerControlSurface(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark
? _darkHuddleControlSurface
: _lightHuddleControlSurface;
context.appColors.huddleControlSurface;
Color _huddleDrawerForeground(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark
? _darkHuddleForeground
: _lightHuddleForeground;
context.appColors.onHuddleDrawer;
@@ -690,267 +690,6 @@ class _HuddleCallHeader extends StatelessWidget {
}
}
class _HuddleCallParticipants extends StatelessWidget {
const _HuddleCallParticipants({
required this.connected,
required this.error,
required this.profiles,
required this.fallbackLabels,
required this.remotePubkeys,
required this.localPubkey,
required this.activeSpeakerPubkeys,
required this.retryTooltip,
required this.retryIcon,
required this.onRetry,
});
final bool connected;
final String? error;
final Map<String, UserProfile> profiles;
final Map<String, String> fallbackLabels;
final List<String> remotePubkeys;
final String? localPubkey;
final Set<String> activeSpeakerPubkeys;
final String retryTooltip;
final IconData retryIcon;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
if (error case final message?) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.triangleAlert,
size: 32,
color: context.colors.error,
),
const SizedBox(height: Grid.xxs),
Text(
message,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
),
const SizedBox(height: Grid.xs),
IconButton.filledTonal(
key: const ValueKey('huddle-retry'),
tooltip: retryTooltip,
onPressed: onRetry,
icon: Icon(retryIcon),
),
],
),
),
);
}
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final hasRemoteParticipants = connected && remotePubkeys.isNotEmpty;
final movementDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 260);
final entryDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 180);
return Stack(
key: const ValueKey('huddle-participant-stage'),
fit: StackFit.expand,
children: [
Positioned.fill(
child: TweenAnimationBuilder<double>(
key: const ValueKey('huddle-local-participant-motion'),
duration: movementDuration,
curve: Curves.easeInOutCubic,
tween: Tween(
begin: hasRemoteParticipants ? 1 : 0,
end: hasRemoteParticipants ? 1 : 0,
),
builder: (context, value, child) => FractionallySizedBox(
heightFactor: 0.5,
alignment: Alignment.lerp(
Alignment.center,
Alignment.bottomCenter,
value,
)!,
child: Align(
key: const ValueKey('huddle-local-participant'),
alignment: Alignment.lerp(
Alignment.center,
const Alignment(0, -0.35),
value,
)!,
child: child,
),
),
child: _HuddleCallAvatar(
pubkey: localPubkey ?? '',
profile: localPubkey == null ? null : profiles[localPubkey],
fallbackLabel: null,
active:
localPubkey != null &&
activeSpeakerPubkeys.contains(localPubkey),
isSelf: true,
),
),
),
Positioned.fill(
child: FractionallySizedBox(
heightFactor: 0.5,
alignment: Alignment.topCenter,
child: Align(
key: const ValueKey('huddle-remote-participant-group'),
alignment: const Alignment(0, 0.35),
child: connected
? SingleChildScrollView(
key: const ValueKey('huddle-remote-participants'),
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
child: Wrap(
alignment: WrapAlignment.center,
spacing: Grid.md,
runSpacing: Grid.xs,
children: [
for (final pubkey in remotePubkeys)
SizedBox(
key: ValueKey('huddle-participant-entry-$pubkey'),
width: _huddleAvatarFrameSize,
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),
),
),
),
],
),
)
: const BuzzLoadingIndicator(size: 40),
),
),
),
],
);
}
}
class _HuddleCallControls extends StatelessWidget {
const _HuddleCallControls({
required this.isMuted,
required this.isSpeakerEnabled,
required this.onToggleMute,
required this.onToggleSpeaker,
});
final bool isMuted;
final bool isSpeakerEnabled;
final VoidCallback onToggleMute;
final VoidCallback onToggleSpeaker;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.xxs),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_HuddleRoundControl(
key: const ValueKey('huddle-speaker-toggle'),
tooltip: isSpeakerEnabled ? 'Use earpiece' : 'Use speaker',
icon: LucideIcons.volume2,
foregroundColor: isSpeakerEnabled
? context.colors.onPrimary
: context.colors.onSurface,
backgroundColor: isSpeakerEnabled
? context.colors.primary
: context.colors.surfaceContainerHighest,
dimension: 72,
onPressed: onToggleSpeaker,
),
const SizedBox(width: Grid.sm),
_HuddleRoundControl(
key: const ValueKey('huddle-mute-toggle'),
tooltip: isMuted ? 'Unmute' : 'Mute',
icon: isMuted ? LucideIcons.micOff : LucideIcons.mic,
foregroundColor: isMuted
? context.colors.onSurface
: context.colors.onPrimary,
backgroundColor: isMuted
? context.colors.surfaceContainerHighest
: context.colors.primary,
dimension: 72,
onPressed: onToggleMute,
),
],
),
);
}
}
class _HuddleRoundControl extends StatelessWidget {
const _HuddleRoundControl({
super.key,
required this.tooltip,
required this.icon,
required this.foregroundColor,
required this.backgroundColor,
required this.onPressed,
this.dimension = 64,
this.showTooltip = true,
});
final String tooltip;
final IconData icon;
final Color foregroundColor;
final Color backgroundColor;
final VoidCallback? onPressed;
final double dimension;
final bool showTooltip;
@override
Widget build(BuildContext context) {
return Semantics(
label: tooltip,
button: true,
enabled: onPressed != null,
onTap: onPressed,
child: ExcludeSemantics(
child: SizedBox.square(
dimension: dimension,
child: IconButton(
tooltip: showTooltip ? tooltip : null,
onPressed: onPressed,
style: IconButton.styleFrom(
foregroundColor: foregroundColor,
backgroundColor: backgroundColor,
disabledForegroundColor: foregroundColor.withValues(alpha: 0.5),
disabledBackgroundColor: backgroundColor.withValues(alpha: 0.5),
),
icon: Icon(icon, size: 28),
),
),
),
);
}
}
_HuddleInvite? _activeHuddleStart(List<NostrEvent> events) {
final endedAt = <String, int>{};
for (final event in events) {
@@ -167,7 +167,7 @@ final class MobileHuddleController extends Notifier<bool> {
final session = ref.read(huddleSessionProvider);
final parentChannelId = session.parentChannelId;
final backingChannelId = session.ephemeralChannelId;
final humanCount = backingChannelId == null
final humanCount = !session.wasAdmitted || backingChannelId == null
? null
: ref
.read(huddleHumanCountProvider)(backingChannelId)
@@ -31,6 +31,9 @@ final class HuddleSessionState {
final String? startedEventId;
final String? currentPubkey;
final bool isCreator;
/// Whether the audio relay admitted this identity during this session.
final bool wasAdmitted;
final bool isMuted;
final bool isSpeakerEnabled;
final int participantCount;
@@ -49,6 +52,7 @@ final class HuddleSessionState {
this.startedEventId,
this.currentPubkey,
this.isCreator = false,
this.wasAdmitted = false,
this.isMuted = false,
this.isSpeakerEnabled = false,
this.participantCount = 0,
@@ -64,6 +68,7 @@ final class HuddleSessionState {
static const idle = HuddleSessionState(phase: HuddleSessionPhase.idle);
bool get isConnected => phase == HuddleSessionPhase.connected;
bool get isInSession => switch (phase) {
HuddleSessionPhase.checkingSupport ||
HuddleSessionPhase.requestingPermission ||
@@ -83,6 +88,7 @@ final class HuddleSessionState {
Object? startedEventId = _notProvided,
Object? currentPubkey = _notProvided,
bool? isCreator,
bool? wasAdmitted,
bool? isMuted,
bool? isSpeakerEnabled,
int? participantCount,
@@ -108,6 +114,7 @@ final class HuddleSessionState {
? this.currentPubkey
: currentPubkey as String?,
isCreator: isCreator ?? this.isCreator,
wasAdmitted: wasAdmitted ?? this.wasAdmitted,
isMuted: isMuted ?? this.isMuted,
isSpeakerEnabled: isSpeakerEnabled ?? this.isSpeakerEnabled,
participantCount: participantCount ?? this.participantCount,
@@ -274,6 +281,7 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
: HuddleSessionPhase.connected,
isMuted: media.state.isMuted,
isSpeakerEnabled: media.state.isSpeakerEnabled,
wasAdmitted: true,
participantCount: transport.state.peers.length,
participantPubkeys: _participantPubkeys(transport.state),
reconnectAttempt: 0,
@@ -397,6 +405,7 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
phase: media.state.isInterrupted
? HuddleSessionPhase.interrupted
: HuddleSessionPhase.connected,
wasAdmitted: true,
participantCount: transportState.peers.length,
participantPubkeys: _participantPubkeys(transportState),
reconnectAttempt: 0,
+23
View File
@@ -5,6 +5,9 @@ class AppColors extends ThemeExtension<AppColors> {
final Color success;
final Color warning;
final Color accent;
final Color huddleDrawerSurface;
final Color huddleControlSurface;
final Color onHuddleDrawer;
/// Gradient for the app's top section, non-null only under the Buzz themes.
/// Carried on the theme rather than read from a provider so any surface can
@@ -16,6 +19,9 @@ class AppColors extends ThemeExtension<AppColors> {
required this.success,
required this.warning,
required this.accent,
required this.huddleDrawerSurface,
required this.huddleControlSurface,
required this.onHuddleDrawer,
this.topSectionGradient,
});
@@ -24,11 +30,17 @@ class AppColors extends ThemeExtension<AppColors> {
Color? success,
Color? warning,
Color? accent,
Color? huddleDrawerSurface,
Color? huddleControlSurface,
Color? onHuddleDrawer,
Gradient? topSectionGradient,
}) => AppColors(
success: success ?? this.success,
warning: warning ?? this.warning,
accent: accent ?? this.accent,
huddleDrawerSurface: huddleDrawerSurface ?? this.huddleDrawerSurface,
huddleControlSurface: huddleControlSurface ?? this.huddleControlSurface,
onHuddleDrawer: onHuddleDrawer ?? this.onHuddleDrawer,
topSectionGradient: topSectionGradient ?? this.topSectionGradient,
);
@@ -39,6 +51,17 @@ class AppColors extends ThemeExtension<AppColors> {
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
accent: Color.lerp(accent, other.accent, t)!,
huddleDrawerSurface: Color.lerp(
huddleDrawerSurface,
other.huddleDrawerSurface,
t,
)!,
huddleControlSurface: Color.lerp(
huddleControlSurface,
other.huddleControlSurface,
t,
)!,
onHuddleDrawer: Color.lerp(onHuddleDrawer, other.onHuddleDrawer, t)!,
topSectionGradient: Gradient.lerp(
topSectionGradient,
other.topSectionGradient,
+6
View File
@@ -33,6 +33,9 @@ class AppTheme {
success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal
warning: const Color(0xFFDF8E1D), // Latte Yellow
accent: scheme.tertiary,
huddleDrawerSurface: const Color(0xFF000000),
huddleControlSurface: const Color(0xFF333333),
onHuddleDrawer: const Color(0xFFFAFAFA),
topSectionGradient: topSectionGradient,
);
@@ -56,6 +59,9 @@ class AppTheme {
), // Catppuccin Macchiato Green — universal
warning: const Color(0xFFEED49F), // Macchiato Yellow
accent: scheme.tertiary,
huddleDrawerSurface: scheme.primaryContainer,
huddleControlSurface: scheme.secondaryContainer,
onHuddleDrawer: scheme.onPrimaryContainer,
topSectionGradient: topSectionGradient,
);
@@ -3374,6 +3374,48 @@ void main() {
expect(find.byIcon(LucideIcons.headphoneOff), findsNothing);
});
testWidgets('failed admission cannot publish Huddle leave lifecycle', (
tester,
) async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final relaySession = _ReconnectingRelaySession();
await tester.pumpWidget(
_buildTestable(
messages: [
_huddleMsg(
id: 'unavailable-huddle',
kind: EventKind.huddleStarted,
pubkey: 'desktop',
createdAt: now,
),
],
users: const {
'desktop': UserProfile(pubkey: 'desktop'),
'self': UserProfile(pubkey: 'self'),
},
relayConfigNotifier: _HuddleRelayConfigNotifier(),
relaySessionNotifier: relaySession,
huddleCurrentPubkey: 'self',
huddleMediaFactory: _HuddleTestMedia.new,
huddleTransportFactory: (_) => _HuddleTestTransport(
connectError: const HuddleTransportError(
code: HuddleTransportErrorCode.relayRejected,
message: 'not a member',
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('huddle-leave')));
await tester.pumpAndSettle();
expect(relaySession.publishedKinds, isEmpty);
});
testWidgets(
'opens the sparse full-screen call with avatar and audio controls',
(tester) async {
@@ -3567,6 +3609,20 @@ void main() {
matching: find.byType(IconButton),
),
);
expect(
tester
.widget<Semantics>(
find
.descendant(
of: find.byKey(const ValueKey('huddle-speaker-toggle')),
matching: find.byType(Semantics),
)
.first,
)
.properties
.toggled,
isFalse,
);
final inactiveSpeakerFill = inactiveSpeakerButton.style?.backgroundColor
?.resolve(const <WidgetState>{});
await tester.tap(find.byKey(const ValueKey('huddle-speaker-toggle')));
@@ -3590,6 +3646,20 @@ void main() {
),
isNot(inactiveSpeakerFill),
);
expect(
tester
.widget<Semantics>(
find
.descendant(
of: find.byKey(const ValueKey('huddle-speaker-toggle')),
matching: find.byType(Semantics),
)
.first,
)
.properties
.toggled,
isTrue,
);
expect(
find.descendant(
@@ -3618,6 +3688,20 @@ void main() {
),
findsOneWidget,
);
expect(
tester
.widget<Semantics>(
find
.descendant(
of: find.byKey(const ValueKey('huddle-mute-toggle')),
matching: find.byType(Semantics),
)
.first,
)
.properties
.toggled,
isTrue,
);
await tester.tap(find.byKey(const ValueKey('huddle-minimize')));
await tester.pumpAndSettle();
@@ -161,8 +161,39 @@ void main() {
final state = container.read(huddleSessionProvider);
expect(state.phase, HuddleSessionPhase.failed);
expect(state.error, contains('Microphone permission'));
expect(state.wasAdmitted, isFalse);
expect(transport.connectCalls, 0);
});
test(
'retains admission evidence after an established transport fails',
() async {
final media = _FakeMedia();
final transport = _FakeTransport();
final container = ProviderContainer(
overrides: [
huddleMediaFactoryProvider.overrideWithValue(() => media),
huddleTransportFactoryProvider.overrideWithValue((_) => transport),
huddleReconnectDelaysProvider.overrideWithValue(const []),
],
);
addTearDown(container.dispose);
await container
.read(huddleSessionProvider.notifier)
.join(_parameters(), currentPubkey: 'mobile');
expect(container.read(huddleSessionProvider).wasAdmitted, isTrue);
transport.emitUnexpectedFailure();
await _waitUntil(
() =>
container.read(huddleSessionProvider).phase ==
HuddleSessionPhase.failed,
);
expect(container.read(huddleSessionProvider).wasAdmitted, isTrue);
},
);
}
HuddleConnectionParameters _parameters() => HuddleConnectionParameters(