mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Harden mobile Huddle lifecycle
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
+4
-3
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user