diff --git a/mobile/HUDDLES.md b/mobile/HUDDLES.md index c0373e808..fb716ced4 100644 --- a/mobile/HUDDLES.md +++ b/mobile/HUDDLES.md @@ -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 diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 4e05ae89c..63d4274af 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart new file mode 100644 index 000000000..b6f08a660 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.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), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart new file mode 100644 index 000000000..3cb73da08 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart @@ -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 profiles; + final Map fallbackLabels; + final List remotePubkeys; + final String? localPubkey; + final Set 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( + 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( + 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), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart b/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart index 4328570b8..9569ad40e 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart @@ -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; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index c8cb39cc3..a3c8ff2e5 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -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 profiles; - final Map fallbackLabels; - final List remotePubkeys; - final String? localPubkey; - final Set 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( - 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( - 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 events) { final endedAt = {}; for (final event in events) { diff --git a/mobile/lib/features/channels/mobile_huddle_controller.dart b/mobile/lib/features/channels/mobile_huddle_controller.dart index e06d26a50..e815cf41f 100644 --- a/mobile/lib/features/channels/mobile_huddle_controller.dart +++ b/mobile/lib/features/channels/mobile_huddle_controller.dart @@ -167,7 +167,7 @@ final class MobileHuddleController extends Notifier { 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) diff --git a/mobile/lib/shared/huddle/huddle_session.dart b/mobile/lib/shared/huddle/huddle_session.dart index 499e2b230..839dba7b4 100644 --- a/mobile/lib/shared/huddle/huddle_session.dart +++ b/mobile/lib/shared/huddle/huddle_session.dart @@ -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 { : 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 { phase: media.state.isInterrupted ? HuddleSessionPhase.interrupted : HuddleSessionPhase.connected, + wasAdmitted: true, participantCount: transportState.peers.length, participantPubkeys: _participantPubkeys(transportState), reconnectAttempt: 0, diff --git a/mobile/lib/shared/theme/app_colors.dart b/mobile/lib/shared/theme/app_colors.dart index 2a937ec6b..bb56234bc 100644 --- a/mobile/lib/shared/theme/app_colors.dart +++ b/mobile/lib/shared/theme/app_colors.dart @@ -5,6 +5,9 @@ class AppColors extends ThemeExtension { 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 { 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 { 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 { 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, diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 140735706..7a6abd259 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -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, ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 73ef3ad55..2f0dd7c01 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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( + 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 {}); await tester.tap(find.byKey(const ValueKey('huddle-speaker-toggle'))); @@ -3590,6 +3646,20 @@ void main() { ), isNot(inactiveSpeakerFill), ); + expect( + tester + .widget( + 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( + 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(); diff --git a/mobile/test/shared/huddle/huddle_session_test.dart b/mobile/test/shared/huddle/huddle_session_test.dart index 080da4d88..6b8656b25 100644 --- a/mobile/test/shared/huddle/huddle_session_test.dart +++ b/mobile/test/shared/huddle/huddle_session_test.dart @@ -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(