Polish mobile Huddles calls

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-17 08:32:01 +01:00
parent 9941f6ee1a
commit e395e3320f
21 changed files with 1355 additions and 177 deletions
+26 -7
View File
@@ -9,10 +9,11 @@ The parent channel carries creator-signed kind `48100` start and `48103` end
events. The private `stream` backing channel uses kind `9007`, `ttl=3600`, and
is hidden from the ordinary channel list only after a matching start event
links it from an accessible parent. The top-right hang-up follows Desktop's
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
rule: it fetches the current kind `39002` backing-channel snapshot and counts
non-`bot` 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 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.
@@ -34,6 +35,17 @@ history reconstructs the visible card without silently reopening a microphone.
The main Nostr socket is intentionally not shared: it accepts Nostr JSON arrays
and drops binary frames, while Huddle control messages are JSON objects.
Emoji reactions remain on the shared Nostr control plane. Mobile publishes the
same ephemeral kind `24810` event as Desktop, scoped to the backing channel with
`h`, `reaction`, and `sender_name` tags (plus the optional NIP-30 `emoji` tag for
custom emoji). While a Huddle session is active, Mobile also subscribes to that
same backing-channel event stream and bursts reactions from other identities;
the sender's relay echo is ignored because its burst already plays locally.
When the sender's full-screen participant avatar is visible, that avatar is the
burst origin so authorship is spatially clear; minimized calls fall back to the
available Huddle surface. Reaction events never enter the ordinary channel
timeline.
## Media plane: protocol v2
Audio is 48 kHz, mono Opus in 20 ms (960-sample) frames.
@@ -55,6 +67,13 @@ level dBov `i8` in `-127...0`, and flags `u8` where bit 0 marks DTX. Unknown
flag bits are ignored. Frames are capped at 4096 bytes before the relay peer
prefix.
The dBov header also drives the participant speaking treatment. Values above
the existing `-55 dBov` activity threshold are normalized for a 50 ms visual
update cadence and continuously scale a 7%-opacity primary-color halo from
just beyond the avatar to 2.55 times its base diameter. Level increases use a
responsive eased attack while decreases and silence use a softer eased release;
silence begins that release after the existing 600 ms hold.
## Native boundary
`buzz/huddle_media` owns microphone permission and the foreground voice audio
@@ -85,9 +104,9 @@ cannot accumulate unbounded latency.
This is deliberately not Desktop parity. The app must remain in the foreground;
pausing/detaching leaves the room. Mobile agent controls, mobile-originated
STT/TTS, recordings, reactions, background ringing/calling, and advanced device
controls remain outside this slice. Agent-authenticated Opus published by a
Desktop Huddle uses the same remote playback path as any other participant.
STT/TTS, recordings, background ringing/calling, and advanced device controls
remain outside this slice. Agent-authenticated Opus published by a Desktop
Huddle uses the same remote playback path as any other participant.
Physical iOS acceptance must separately verify permission, receiver/speaker and
connected-headset routing, interruption recovery, and two-way audio with a
+1 -1
View File
@@ -70,7 +70,7 @@ import UserNotifications
forPlugin: "BuzzConcentricSheetSurface"
) {
concentricSheetRegistrar.register(
ConcentricSheetSurfaceFactory(),
ConcentricSheetSurfaceFactory(messenger: messenger),
withId: "buzz/concentric_sheet_surface"
)
concentricSheetSurfaceChannel = FlutterMethodChannel(
+71 -4
View File
@@ -2,6 +2,13 @@ import Flutter
import UIKit
final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
@@ -11,24 +18,48 @@ final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
ConcentricSheetSurfacePlatformView(frame: frame, arguments: args)
ConcentricSheetSurfacePlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
arguments: args
)
}
}
final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
private let rootView: UIView
private let surfaceView: UIView
private let channel: FlutterMethodChannel
init(frame: CGRect, arguments args: Any?) {
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
arguments args: Any?
) {
let arguments = args as? [String: Any]
let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFF_FFFF
let backdropColorValue = (arguments?["backdropColor"] as? NSNumber)?.uint32Value
let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24
let corners = arguments?["corners"] as? String ?? "all"
surfaceView = UIView(frame: frame)
rootView = UIView(frame: frame)
rootView.isOpaque = backdropColorValue != nil
rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear
surfaceView = UIView(frame: rootView.bounds)
surfaceView.isOpaque = true
surfaceView.backgroundColor = Self.color(from: colorValue)
surfaceView.clipsToBounds = true
surfaceView.layer.cornerCurve = .continuous
surfaceView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
rootView.addSubview(surfaceView)
channel = FlutterMethodChannel(
name: "buzz/concentric_sheet_surface/\(viewId)",
binaryMessenger: messenger
)
if #available(iOS 26.0, *) {
let radius = UICornerRadius.containerConcentric(minimum: minimumRadius)
@@ -51,10 +82,46 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
}
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "updateColors" else {
result(FlutterMethodNotImplemented)
return
}
guard
let arguments = call.arguments as? [String: Any],
let colorValue = arguments["color"] as? NSNumber
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected a surface color.",
details: nil
)
)
return
}
self?.updateColors(
colorValue: colorValue.uint32Value,
backdropColorValue: (arguments["backdropColor"] as? NSNumber)?.uint32Value
)
result(nil)
}
}
deinit {
channel.setMethodCallHandler(nil)
}
func view() -> UIView {
surfaceView
rootView
}
private func updateColors(colorValue: UInt32, backdropColorValue: UInt32?) {
surfaceView.backgroundColor = Self.color(from: colorValue)
rootView.isOpaque = backdropColorValue != nil
rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear
}
private static func color(from value: UInt32) -> UIColor {
@@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:math' show min;
import 'dart:math' show cos, min, pi;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -10,6 +10,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/emoji/emoji_burst.dart';
import '../../shared/huddle/huddle.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
@@ -19,6 +20,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/concentric_sheet_surface.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/flapping_bee.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import '../../shared/widgets/message_author_meta.dart';
@@ -47,6 +49,7 @@ import 'date_formatters.dart';
import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'emoji_picker.dart';
import 'ime_metrics_settle_observer.dart';
import 'latest_message_button.dart';
import 'mobile_huddle_controller.dart';
@@ -59,6 +62,7 @@ import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
import 'reaction_row.dart';
import 'recent_emoji_provider.dart';
import 'send_message_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'small_avatar.dart';
@@ -72,6 +76,7 @@ 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/huddle_reactions.dart';
part 'channel_detail_page/message_bubble.dart';
part 'channel_detail_page/banners.dart';
part 'channel_detail_page/app_bar.dart';
@@ -1,23 +1,61 @@
part of '../channel_detail_page.dart';
class _HuddleCallAvatar extends HookWidget {
class _HuddleCallAvatar extends HookConsumerWidget {
const _HuddleCallAvatar({
required this.pubkey,
required this.profile,
required this.fallbackLabel,
required this.active,
required this.speakerLevel,
this.isSelf = false,
this.frameSize = _huddleAvatarFrameSize,
});
final String pubkey;
final UserProfile? profile;
final String? fallbackLabel;
final bool active;
final double speakerLevel;
final bool isSelf;
final double frameSize;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final scale = frameSize / _huddleAvatarFrameSize;
final avatarRadius = _huddleAvatarRadius * scale;
final speakingRingSize = _huddleSpeakingRingSize * scale;
final fallbackIconSize = 44 * scale;
final normalizedSpeakerLevel = speakerLevel.clamp(0.0, 1.0).toDouble();
final haloLevel = active ? 0.08 + normalizedSpeakerLevel * 0.92 : 0.0;
final haloController = useAnimationController(
initialValue: reducedMotion ? haloLevel : 0,
);
final animatedHaloLevel = useAnimation(haloController);
useEffect(() {
haloController.stop();
if (reducedMotion) {
haloController.value = haloLevel;
return null;
}
final isRising = haloLevel > haloController.value;
haloController.animateTo(
haloLevel,
duration: Duration(milliseconds: isRising ? 140 : 220),
curve: isRising ? Curves.easeOutCubic : Curves.easeInOutCubic,
);
return null;
}, [haloController, haloLevel, reducedMotion]);
final originKey = useMemoized(
() => GlobalKey(debugLabel: 'huddle-reaction-origin-$pubkey'),
[pubkey],
);
final originRegistry = ref.read(_huddleAvatarOriginRegistryProvider);
useEffect(() {
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();
@@ -29,7 +67,7 @@ class _HuddleCallAvatar extends HookWidget {
void toggleLabel() => showsLabel.value = !showsLabel.value;
return SizedBox(
width: _huddleAvatarFrameSize,
width: frameSize,
child: Semantics(
label: active ? '$label, speaking' : label,
hint: showsLabel.value
@@ -47,49 +85,56 @@ class _HuddleCallAvatar extends HookWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TweenAnimationBuilder<double>(
SizedBox.square(
key: ValueKey('huddle-speaking-ring-$pubkey'),
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
tween: Tween(end: active ? 1 : 0),
builder: (context, value, child) => SizedBox.square(
dimension: _huddleAvatarFrameSize,
dimension: frameSize,
child: SizedBox.square(
key: originKey,
dimension: frameSize,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Opacity(
opacity: value,
AnimatedOpacity(
key: ValueKey('huddle-speaking-halo-opacity-$pubkey'),
duration: reducedMotion
? Duration.zero
: Duration(milliseconds: active ? 140 : 220),
curve: active
? Curves.easeOutCubic
: Curves.easeInOutCubic,
opacity: active ? 1 : 0,
child: Transform.scale(
scale: 1 + value * 0.08,
key: ValueKey('huddle-speaking-halo-scale-$pubkey'),
scale: reducedMotion
? (active ? 1.15 : 1)
: 1 + animatedHaloLevel * 1.55,
child: Container(
width: _huddleSpeakingRingSize,
height: _huddleSpeakingRingSize,
key: ValueKey('huddle-speaking-halo-$pubkey'),
width: speakingRingSize,
height: speakingRingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: context.colors.primary,
width: 4,
color: context.colors.primary.withValues(
alpha: 0.07,
),
),
),
),
),
child!,
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: avatarRadius,
backgroundColor: context.colors.primaryContainer,
fallback: Icon(
LucideIcons.userRound,
size: fallbackIconSize,
color: context.colors.onPrimaryContainer,
),
),
],
),
),
child: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: _huddleAvatarRadius,
backgroundColor: context.colors.primaryContainer,
fallback: Icon(
LucideIcons.userRound,
size: 44,
color: context.colors.onPrimaryContainer,
),
),
),
AnimatedSwitcher(
key: ValueKey('huddle-participant-label-reveal-$pubkey'),
@@ -118,7 +163,7 @@ class _HuddleCallAvatar extends HookWidget {
key: ValueKey('huddle-participant-label-$pubkey'),
padding: const EdgeInsets.only(top: Grid.half),
child: SizedBox(
width: _huddleAvatarFrameSize,
width: frameSize,
child: Text(
label,
maxLines: 1,
@@ -6,16 +6,19 @@ class _HuddleCallControls extends StatelessWidget {
required this.isSpeakerEnabled,
required this.onToggleMute,
required this.onToggleSpeaker,
required this.onReact,
});
final bool isMuted;
final bool isSpeakerEnabled;
final VoidCallback onToggleMute;
final VoidCallback onToggleSpeaker;
final VoidCallback onReact;
@override
Widget build(BuildContext context) {
return Padding(
key: const ValueKey('huddle-call-controls'),
padding: const EdgeInsets.only(top: Grid.xxs),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -30,7 +33,7 @@ class _HuddleCallControls extends StatelessWidget {
backgroundColor: isSpeakerEnabled
? context.colors.primary
: context.colors.surfaceContainerHighest,
dimension: 72,
dimension: 80,
toggled: isSpeakerEnabled,
onPressed: onToggleSpeaker,
),
@@ -45,10 +48,20 @@ class _HuddleCallControls extends StatelessWidget {
backgroundColor: isMuted
? context.colors.surfaceContainerHighest
: context.colors.primary,
dimension: 72,
dimension: 80,
toggled: isMuted,
onPressed: onToggleMute,
),
const SizedBox(width: Grid.sm),
_HuddleRoundControl(
key: const ValueKey('huddle-emoji-reactions'),
tooltip: 'Emoji reactions',
icon: LucideIcons.smilePlus,
foregroundColor: context.colors.onSurface,
backgroundColor: context.colors.surfaceContainerHighest,
dimension: 80,
onPressed: onReact,
),
],
),
);
@@ -9,6 +9,7 @@ class _HuddleCallParticipants extends StatelessWidget {
required this.remotePubkeys,
required this.localPubkey,
required this.activeSpeakerPubkeys,
required this.speakerLevels,
required this.retryTooltip,
required this.retryIcon,
required this.onRetry,
@@ -21,6 +22,7 @@ class _HuddleCallParticipants extends StatelessWidget {
final List<String> remotePubkeys;
final String? localPubkey;
final Set<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final String retryTooltip;
final IconData retryIcon;
final VoidCallback onRetry;
@@ -60,8 +62,16 @@ class _HuddleCallParticipants extends StatelessWidget {
);
}
if (!connected) {
return const Center(child: _HuddleLoadingBee());
}
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final hasRemoteParticipants = connected && remotePubkeys.isNotEmpty;
final hasRemoteParticipants = remotePubkeys.isNotEmpty;
final hasDenseRemoteRoster =
remotePubkeys.length > _huddleDenseParticipantThreshold;
final remoteHeightFactor = hasDenseRemoteRoster ? 0.58 : 0.5;
final localHeightFactor = hasDenseRemoteRoster ? 0.42 : 0.5;
final movementDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 260);
@@ -83,7 +93,7 @@ class _HuddleCallParticipants extends StatelessWidget {
end: hasRemoteParticipants ? 1 : 0,
),
builder: (context, value, child) => FractionallySizedBox(
heightFactor: 0.5,
heightFactor: localHeightFactor,
alignment: Alignment.lerp(
Alignment.center,
Alignment.bottomCenter,
@@ -106,53 +116,70 @@ class _HuddleCallParticipants extends StatelessWidget {
active:
localPubkey != null &&
activeSpeakerPubkeys.contains(localPubkey),
speakerLevel: localPubkey == null
? 0
: speakerLevels[localPubkey] ?? 0,
isSelf: true,
),
),
),
Positioned.fill(
child: FractionallySizedBox(
heightFactor: 0.5,
key: const ValueKey('huddle-remote-participant-region'),
heightFactor: remoteHeightFactor,
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),
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,
),
),
],
),
)
: const BuzzLoadingIndicator(size: 40),
),
],
),
);
},
),
),
),
),
@@ -160,3 +187,98 @@ class _HuddleCallParticipants extends StatelessWidget {
);
}
}
class _HuddleLoadingBee extends HookWidget {
const _HuddleLoadingBee();
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final flapController = useAnimationController(
duration: const Duration(milliseconds: 480),
);
final flapProgress = useAnimation(flapController);
useEffect(() {
if (reducedMotion) {
flapController
..stop()
..reset();
} else {
flapController.repeat();
}
return null;
}, [flapController, reducedMotion]);
final flapAmount = reducedMotion
? 0.0
: 0.5 - (0.5 * cos(flapProgress * 4 * pi));
return Semantics(
label: 'Joining Huddle',
liveRegion: true,
child: ExcludeSemantics(
child: FlappingBee(
key: const ValueKey('huddle-loading-bee'),
width: 60,
color: context.colors.primary,
flapAmount: flapAmount,
),
),
);
}
}
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,
);
}
}
@@ -7,7 +7,7 @@ const _mobileHuddleDrawerCurve = Cubic(0.32, 0.72, 0, 1);
/// Lifts the mobile app above a persistent Huddle control drawer when the
/// full-screen call has been minimized.
class MobileHuddleShell extends ConsumerWidget {
class MobileHuddleShell extends HookConsumerWidget {
const MobileHuddleShell({
super.key,
required this.navigatorKey,
@@ -21,6 +21,15 @@ class MobileHuddleShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final presentation = ref.watch(mobileHuddlePresentationProvider);
final session = ref.watch(huddleSessionProvider);
final relayStatus = ref.watch(relaySessionProvider).status;
_useIncomingHuddleReactions(
context: context,
ref: ref,
inSession: session.isInSession,
channelId: session.ephemeralChannelId,
currentPubkey: session.currentPubkey,
relayStatus: relayStatus,
);
final drawerOpen =
presentation == MobileHuddlePresentation.drawer && session.isInSession;
final reducedMotion = MediaQuery.disableAnimationsOf(context);
@@ -76,6 +85,7 @@ class MobileHuddleShell extends ConsumerWidget {
),
enabled: drawerOpen,
color: context.colors.surface,
backdropColor: surface,
corners: ConcentricSurfaceCorners.bottom,
padding: EdgeInsets.zero,
providesSheetSurface: false,
@@ -188,76 +198,80 @@ class _MobileHuddleDrawer extends ConsumerWidget {
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
key: const ValueKey('huddle-drawer-controls'),
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
key: const ValueKey('huddle-drawer-primary-controls'),
mainAxisSize: MainAxisSize.min,
children: [
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-expand'),
tooltip: 'Open Huddle',
showTooltip: false,
icon: LucideIcons.chevronUp,
foregroundColor: foreground,
backgroundColor: controlSurface,
onPressed: restoreFullScreen,
),
const SizedBox(width: Grid.twelve),
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-speaker-toggle'),
tooltip: session.isSpeakerEnabled
? 'Use earpiece'
: 'Use speaker',
showTooltip: false,
icon: LucideIcons.volume2,
foregroundColor: session.isSpeakerEnabled
? drawerSurface
: foreground,
backgroundColor: session.isSpeakerEnabled
? foreground
: controlSurface,
toggled: session.isSpeakerEnabled,
onPressed: () => unawaited(
sessionController.setSpeakerEnabled(
!session.isSpeakerEnabled,
child: Transform.translate(
key: const ValueKey('huddle-drawer-control-offset'),
offset: const Offset(0, -8),
child: Row(
key: const ValueKey('huddle-drawer-controls'),
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
key: const ValueKey('huddle-drawer-primary-controls'),
mainAxisSize: MainAxisSize.min,
children: [
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-expand'),
tooltip: 'Open Huddle',
showTooltip: false,
icon: LucideIcons.chevronUp,
foregroundColor: foreground,
backgroundColor: controlSurface,
onPressed: restoreFullScreen,
),
const SizedBox(width: Grid.twelve),
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-speaker-toggle'),
tooltip: session.isSpeakerEnabled
? 'Use earpiece'
: 'Use speaker',
showTooltip: false,
icon: LucideIcons.volume2,
foregroundColor: session.isSpeakerEnabled
? drawerSurface
: foreground,
backgroundColor: session.isSpeakerEnabled
? foreground
: controlSurface,
toggled: session.isSpeakerEnabled,
onPressed: () => unawaited(
sessionController.setSpeakerEnabled(
!session.isSpeakerEnabled,
),
),
),
),
const SizedBox(width: Grid.twelve),
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-mute-toggle'),
tooltip: session.isMuted ? 'Unmute' : 'Mute',
showTooltip: false,
icon: session.isMuted
? LucideIcons.micOff
: LucideIcons.mic,
foregroundColor: session.isMuted
? foreground
: drawerSurface,
backgroundColor: session.isMuted
? controlSurface
: foreground,
toggled: session.isMuted,
onPressed: () => unawaited(
sessionController.setMuted(!session.isMuted),
const SizedBox(width: Grid.twelve),
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-mute-toggle'),
tooltip: session.isMuted ? 'Unmute' : 'Mute',
showTooltip: false,
icon: session.isMuted
? LucideIcons.micOff
: LucideIcons.mic,
foregroundColor: session.isMuted
? foreground
: drawerSurface,
backgroundColor: session.isMuted
? controlSurface
: foreground,
toggled: session.isMuted,
onPressed: () => unawaited(
sessionController.setMuted(!session.isMuted),
),
),
),
],
),
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-leave'),
tooltip: 'Leave Huddle',
showTooltip: false,
icon: LucideIcons.phoneOff,
foregroundColor: context.colors.error,
backgroundColor: controlSurface,
onPressed: leave,
),
],
],
),
_HuddleRoundControl(
key: const ValueKey('huddle-drawer-leave'),
tooltip: 'Leave Huddle',
showTooltip: false,
icon: LucideIcons.phoneOff,
foregroundColor: context.colors.error,
backgroundColor: controlSurface,
onPressed: leave,
),
],
),
),
),
),
@@ -0,0 +1,117 @@
part of '../channel_detail_page.dart';
final _huddleAvatarOriginRegistryProvider = Provider(
(_) => _HuddleAvatarOriginRegistry(),
);
class _HuddleAvatarOriginRegistry {
final Map<String, GlobalKey> _keys = {};
void register(String pubkey, GlobalKey key) {
_keys[pubkey.toLowerCase()] = key;
}
void unregister(String pubkey, GlobalKey key) {
final normalized = pubkey.toLowerCase();
if (identical(_keys[normalized], key)) _keys.remove(normalized);
}
Offset? originFor(String? pubkey) {
if (pubkey == null) return null;
final context = _keys[pubkey.toLowerCase()]?.currentContext;
final box = context?.findRenderObject();
if (box is! RenderBox || !box.hasSize) return null;
return box.localToGlobal(box.size.center(Offset.zero));
}
}
void _burstHuddleReaction({
required WidgetRef ref,
required BuildContext fallbackContext,
required String emoji,
required String? senderPubkey,
}) {
if (MediaQuery.maybeDisableAnimationsOf(fallbackContext) ?? false) return;
final origin = ref
.read(_huddleAvatarOriginRegistryProvider)
.originFor(senderPubkey);
if (origin == null) {
burstEmojiFromContext(ref, fallbackContext, emoji, requirePositive: false);
return;
}
ref.read(emojiBurstControllerProvider).burst(emoji, origin);
}
void _useIncomingHuddleReactions({
required BuildContext context,
required WidgetRef ref,
required bool inSession,
required String? channelId,
required String? currentPubkey,
required SessionStatus relayStatus,
}) {
useEffect(() {
if (!inSession ||
channelId == null ||
relayStatus != SessionStatus.connected) {
return null;
}
final relaySession = ref.read(relaySessionProvider.notifier);
final seenEventIds = <String>{};
var disposed = false;
VoidCallback? unsubscribe;
Future.microtask(() async {
try {
final cleanup = await relaySession.subscribe(
NostrFilter(
kinds: const [EventKind.huddleReaction],
tags: {
'#h': [channelId],
},
limit: 1000,
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
(event) {
if (disposed || !seenEventIds.add(event.id)) return;
if (event.pubkey.toLowerCase() == currentPubkey?.toLowerCase()) {
return;
}
final emoji = _huddleReactionEmoji(event);
if (emoji == null || !context.mounted) return;
_burstHuddleReaction(
ref: ref,
fallbackContext: context,
emoji: emoji,
senderPubkey: event.pubkey,
);
},
);
if (disposed) {
cleanup();
} else {
unsubscribe = cleanup;
}
} catch (error) {
if (!disposed) {
debugPrint('[MobileHuddle] reaction subscription failed: $error');
}
}
});
return () {
disposed = true;
unsubscribe?.call();
};
}, [inSession, channelId, currentPubkey, relayStatus]);
}
String? _huddleReactionEmoji(NostrEvent event) {
if (event.kind != EventKind.huddleReaction) return null;
for (final tag in event.tags) {
if (tag.length < 2 || tag.first != 'reaction') continue;
final emoji = tag[1].trim();
if (emoji.isNotEmpty) return emoji;
}
final emoji = event.content.trim();
return emoji.isEmpty ? null : emoji;
}
@@ -4,6 +4,10 @@ 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;
final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose
.family<_HuddleParticipantProfileUpdates, int, String>(
@@ -554,6 +558,10 @@ class _MobileHuddleCallPage extends ConsumerWidget {
);
final profiles = ref.watch(userCacheProvider);
final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider);
final reactionSenderName = _huddleReactionSenderName(
localPubkey: localPubkey,
profile: localPubkey == null ? null : profiles[localPubkey],
);
return PopScope<void>(
onPopInvokedWithResult: (didPop, _) {
@@ -605,6 +613,7 @@ class _MobileHuddleCallPage extends ConsumerWidget {
remotePubkeys: remotePubkeys,
localPubkey: localPubkey,
activeSpeakerPubkeys: session.activeSpeakerPubkeys,
speakerLevels: session.speakerLevels,
retryTooltip: unavailable
? 'Start a new Huddle'
: 'Try again',
@@ -640,6 +649,35 @@ class _MobileHuddleCallPage extends ConsumerWidget {
!session.isSpeakerEnabled,
),
),
onReact: () => showEmojiPicker(
context: context,
onSelect: (emoji) {
ref.read(recentEmojiProvider.notifier).record(emoji);
_burstHuddleReaction(
ref: ref,
fallbackContext: context,
emoji: emoji,
senderPubkey: localPubkey,
);
unawaited(
ref
.read(channelActionsProvider)
.sendHuddleReaction(
channelId: invite.ephemeralChannelId,
emoji: emoji,
senderName: reactionSenderName,
)
.catchError((Object error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Reaction failed'),
),
);
}),
);
},
),
)
else
const SizedBox(height: Grid.xxl),
@@ -652,6 +690,18 @@ class _MobileHuddleCallPage extends ConsumerWidget {
}
}
String _huddleReactionSenderName({
required String? localPubkey,
required UserProfile? profile,
}) {
final displayName = profile?.displayName?.trim();
if (displayName?.isNotEmpty == true) return displayName!;
if (localPubkey?.isNotEmpty == true) {
return 'Participant ${shortPubkey(localPubkey!)}';
}
return 'Someone';
}
class _HuddleCallHeader extends StatelessWidget {
const _HuddleCallHeader({
required this.isLeaving,
@@ -1,5 +1,13 @@
part of 'channel_management_provider.dart';
const _huddleReactionNameMax = 48;
String _clampHuddleReactionName(String name) {
final trimmed = name.trim();
if (trimmed.length <= _huddleReactionNameMax) return trimmed;
return '${trimmed.substring(0, _huddleReactionNameMax - 1).trimRight()}';
}
/// Canonical Huddle lifecycle operations built on the shared channel actions.
extension HuddleChannelActions on ChannelActions {
/// Creates the private, one-hour stream used only by Huddle media.
@@ -54,4 +62,30 @@ extension HuddleChannelActions on ChannelActions {
],
);
}
/// Sends an ephemeral emoji burst to the active Huddle backing channel.
Future<void> sendHuddleReaction({
required String channelId,
required String emoji,
required String senderName,
}) async {
final normalizedEmoji = emoji.trim();
if (normalizedEmoji.isEmpty) return;
final shortcode = normalizeShortcode(normalizedEmoji);
final emojiUrl = reactionEmojiUrl(
normalizedEmoji,
_ref.read(customEmojiListProvider),
);
await _signedEventRelay.submit(
kind: EventKind.huddleReaction,
content: normalizedEmoji,
tags: [
['h', channelId],
['reaction', normalizedEmoji],
['sender_name', _clampHuddleReactionName(senderName)],
if (shortcode != null && emojiUrl != null)
['emoji', shortcode, emojiUrl],
],
);
}
}
@@ -50,19 +50,23 @@ final mobileHuddlePresentationProvider =
/// ending the Huddle on transient relay failure.
final huddleHumanCountProvider = Provider<HuddleHumanCountLoader>((ref) {
return (channelId) async {
final membersProvider = channelMembersProvider(channelId);
final cachedMembers = ref.read(membersProvider).value;
if (cachedMembers != null) {
return cachedMembers.where((member) => !member.isBot).length;
}
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
throw StateError('Relay is unavailable for Huddle member lookup.');
}
final members = await ref.read(membersProvider.future);
// Desktop always queries the relay's current kind:39002 snapshot here.
// The UI-facing provider intentionally preserves cached members through
// reconnects, but that cache can lag a leave event and must not decide
// whether the room ends for everyone.
final events = await ref
.read(relaySessionProvider.notifier)
.fetchHistory(NostrFilters.channelMembers(channelId));
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
throw StateError('Relay disconnected during Huddle member lookup.');
}
return members.where((member) => !member.isBot).length;
if (events.isEmpty) return 0;
return membersFromEvent(
events.first,
).where((member) => member.role != 'bot').length;
};
});
+6
View File
@@ -155,6 +155,7 @@ class _Particle {
class EmojiBurstController extends ChangeNotifier {
final List<_Particle> _particles = [];
final Random _random;
Offset? _lastBurstOrigin;
/// Set when a spawn arrives so the overlay knows to start its ticker.
VoidCallback? onSpawn;
@@ -163,12 +164,16 @@ class EmojiBurstController extends ChangeNotifier {
bool get hasParticles => _particles.isNotEmpty;
@visibleForTesting
Offset? get debugLastBurstOrigin => _lastBurstOrigin;
/// Spawn a burst of [emoji] centred on [origin] (global coordinates).
/// Mirrors desktop's `spawnPickerEmojiBurst`.
void burst(String emoji, Offset origin) {
final trimmed = emoji.trim();
if (trimmed.isEmpty) return;
if (_particles.length + _particlesPerBurst > _maxActiveParticles) return;
_lastBurstOrigin = origin;
for (var i = 0; i < _particlesPerBurst; i += 1) {
final horizontalDrift = (_random.nextDouble() - 0.5) * 4.4;
@@ -211,6 +216,7 @@ class EmojiBurstController extends ChangeNotifier {
}
void clear() {
_lastBurstOrigin = null;
if (_particles.isEmpty) return;
_particles.clear();
notifyListeners();
+58 -10
View File
@@ -39,6 +39,7 @@ final class HuddleSessionState {
final int participantCount;
final List<String> participantPubkeys;
final Set<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final int reconnectAttempt;
final int receivedFrameCount;
final int sentFrameCount;
@@ -58,6 +59,7 @@ final class HuddleSessionState {
this.participantCount = 0,
this.participantPubkeys = const [],
this.activeSpeakerPubkeys = const {},
this.speakerLevels = const {},
this.reconnectAttempt = 0,
this.receivedFrameCount = 0,
this.sentFrameCount = 0,
@@ -94,6 +96,7 @@ final class HuddleSessionState {
int? participantCount,
List<String>? participantPubkeys,
Set<String>? activeSpeakerPubkeys,
Map<String, double>? speakerLevels,
int? reconnectAttempt,
int? receivedFrameCount,
int? sentFrameCount,
@@ -120,6 +123,7 @@ final class HuddleSessionState {
participantCount: participantCount ?? this.participantCount,
participantPubkeys: participantPubkeys ?? this.participantPubkeys,
activeSpeakerPubkeys: activeSpeakerPubkeys ?? this.activeSpeakerPubkeys,
speakerLevels: speakerLevels ?? this.speakerLevels,
reconnectAttempt: reconnectAttempt ?? this.reconnectAttempt,
receivedFrameCount: receivedFrameCount ?? this.receivedFrameCount,
sentFrameCount: sentFrameCount ?? this.sentFrameCount,
@@ -173,6 +177,8 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
var _sentFrames = 0;
Future<void> _playbackTail = Future<void>.value();
final Map<String, Timer> _speakerTimers = {};
final Map<String, double> _pendingSpeakerLevels = {};
Timer? _speakerLevelFlushTimer;
Timer? _reconnectTimer;
var _reconnectAttempt = 0;
var _reconnectInFlight = false;
@@ -366,7 +372,11 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
if (!frame.header.isDtx && frame.header.levelDbov >= -55) {
final currentPubkey = state.currentPubkey;
if (currentPubkey != null) {
_recordSpeakerPubkey(currentPubkey, generation);
_recordSpeakerPubkey(
currentPubkey,
frame.header.levelDbov,
generation,
);
}
}
_sentFrames += 1;
@@ -447,32 +457,64 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
if (frame.header.isDtx || frame.header.levelDbov < -55) return;
final pubkey = transport.state.peers[frame.peerIndex]?.pubkey.toLowerCase();
if (pubkey == null) return;
_recordSpeakerPubkey(pubkey, generation);
_recordSpeakerPubkey(pubkey, frame.header.levelDbov, generation);
}
void _recordSpeakerPubkey(String pubkey, int generation) {
final speakers = {...state.activeSpeakerPubkeys, pubkey};
if (!state.activeSpeakerPubkeys.contains(pubkey)) {
state = state.copyWith(activeSpeakerPubkeys: Set.unmodifiable(speakers));
void _recordSpeakerPubkey(String pubkey, int levelDbov, int generation) {
final normalized = pubkey.toLowerCase();
final wasActive = state.activeSpeakerPubkeys.contains(normalized);
final level = _speakerLevelFromDbov(levelDbov);
if (!wasActive) {
final speakers = {...state.activeSpeakerPubkeys, normalized};
final levels = {...state.speakerLevels, normalized: level};
state = state.copyWith(
activeSpeakerPubkeys: Set.unmodifiable(speakers),
speakerLevels: Map.unmodifiable(levels),
);
} else {
_pendingSpeakerLevels[normalized] = level;
_scheduleSpeakerLevelFlush(generation);
}
_speakerTimers.remove(pubkey)?.cancel();
_speakerTimers[pubkey] = Timer(const Duration(milliseconds: 600), () {
_speakerTimers.remove(normalized)?.cancel();
_speakerTimers[normalized] = Timer(const Duration(milliseconds: 600), () {
if (!_isCurrent(generation)) return;
_clearSpeaker(pubkey);
_clearSpeaker(normalized);
});
}
void _clearSpeaker(String pubkey) {
final normalized = pubkey.toLowerCase();
_speakerTimers.remove(normalized)?.cancel();
if (!state.activeSpeakerPubkeys.contains(normalized)) return;
_pendingSpeakerLevels.remove(normalized);
if (!state.activeSpeakerPubkeys.contains(normalized) &&
!state.speakerLevels.containsKey(normalized)) {
return;
}
final levels = Map<String, double>.from(state.speakerLevels)
..remove(normalized);
state = state.copyWith(
activeSpeakerPubkeys: Set.unmodifiable(
state.activeSpeakerPubkeys.where((value) => value != normalized),
),
speakerLevels: Map.unmodifiable(levels),
);
}
void _scheduleSpeakerLevelFlush(int generation) {
if (_speakerLevelFlushTimer != null) return;
_speakerLevelFlushTimer = Timer(const Duration(milliseconds: 50), () {
_speakerLevelFlushTimer = null;
if (!_isCurrent(generation)) {
_pendingSpeakerLevels.clear();
return;
}
if (_pendingSpeakerLevels.isEmpty) return;
final levels = {...state.speakerLevels, ..._pendingSpeakerLevels};
_pendingSpeakerLevels.clear();
state = state.copyWith(speakerLevels: Map.unmodifiable(levels));
});
}
void _scheduleReconnect(
HuddleTransportClient transport,
int generation,
@@ -551,6 +593,9 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
timer.cancel();
}
_speakerTimers.clear();
_speakerLevelFlushTimer?.cancel();
_speakerLevelFlushTimer = null;
_pendingSpeakerLevels.clear();
final transport = _transport;
final media = _media;
_transport = null;
@@ -605,3 +650,6 @@ final class HuddleSessionNotifier extends Notifier<HuddleSessionState> {
_ => 'Unable to join the Huddle.',
};
}
double _speakerLevelFromDbov(int levelDbov) =>
((levelDbov + 55) / 55).clamp(0.0, 1.0).toDouble();
+4 -1
View File
@@ -60,7 +60,10 @@ class AppTheme {
warning: const Color(0xFFEED49F), // Macchiato Yellow
accent: scheme.tertiary,
huddleDrawerSurface: scheme.primaryContainer,
huddleControlSurface: scheme.secondaryContainer,
huddleControlSurface: Color.alphaBlend(
scheme.onPrimaryContainer.withValues(alpha: 0.18),
scheme.primaryContainer,
),
onHuddleDrawer: scheme.onPrimaryContainer,
topSectionGradient: topSectionGradient,
);
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -15,6 +17,7 @@ class ConcentricSheetSurface extends HookWidget {
required this.child,
required this.enabled,
this.color,
this.backdropColor,
this.corners = ConcentricSurfaceCorners.all,
this.padding = const EdgeInsets.only(
left: Grid.xxs,
@@ -28,6 +31,7 @@ class ConcentricSheetSurface extends HookWidget {
final Widget child;
final bool enabled;
final Color? color;
final Color? backdropColor;
final ConcentricSurfaceCorners corners;
final EdgeInsetsGeometry padding;
final bool providesSheetSurface;
@@ -61,6 +65,23 @@ class ConcentricSheetSurface extends HookWidget {
}
}
Future<void> _updateNativeSurfaceColors({
required MethodChannel channel,
required Color surfaceColor,
required Color? backdropColor,
}) async {
try {
await channel.invokeMethod<void>('updateColors', <String, Object?>{
'color': surfaceColor.toARGB32(),
'backdropColor': backdropColor?.toARGB32(),
});
} on MissingPluginException {
// The platform view may have been disposed while its theme was changing.
} on PlatformException {
// The native surface is optional; retain its last successfully sent color.
}
}
@override
Widget build(BuildContext context) {
final shouldCheckNativeSurface =
@@ -72,12 +93,38 @@ class ConcentricSheetSurface extends HookWidget {
[shouldCheckNativeSurface],
);
final nativeSurfaceSupported = useFuture(supportFuture).data ?? false;
final surfaceColor = color ?? context.colors.surface;
final nativeSurfaceChannel = useState<MethodChannel?>(null);
useEffect(
() {
final channel = nativeSurfaceChannel.value;
if (!shouldCheckNativeSurface ||
!nativeSurfaceSupported ||
channel == null) {
return null;
}
unawaited(
_updateNativeSurfaceColors(
channel: channel,
surfaceColor: surfaceColor,
backdropColor: backdropColor,
),
);
return null;
},
[
shouldCheckNativeSurface,
nativeSurfaceSupported,
nativeSurfaceChannel.value,
surfaceColor,
backdropColor,
],
);
if (!shouldCheckNativeSurface) {
return _ConcentricSheetSurfaceScope(providesSurface: false, child: child);
}
final surfaceColor = color ?? context.colors.surface;
final fallbackBorderRadius = _borderRadius(Radii.dialog);
return Padding(
@@ -90,8 +137,15 @@ class ConcentricSheetSurface extends HookWidget {
child: UiKitView(
viewType: 'buzz/concentric_sheet_surface',
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
onPlatformViewCreated: (viewId) {
nativeSurfaceChannel.value = MethodChannel(
'buzz/concentric_sheet_surface/$viewId',
);
},
creationParams: <String, Object>{
'color': surfaceColor.toARGB32(),
if (backdropColor case final color?)
'backdropColor': color.toARGB32(),
'minimumRadius': Radii.dialog,
'corners': corners.name,
},
@@ -35,12 +35,14 @@ import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/profile/user_profile_sheet.dart';
import 'package:buzz/shared/emoji/emoji_burst.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/huddle/huddle.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:buzz/shared/widgets/flapping_bee.dart';
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
@@ -3416,6 +3418,65 @@ void main() {
expect(relaySession.publishedKinds, isEmpty);
});
testWidgets('shows the flapping bee instead of an avatar while joining', (
tester,
) async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final connectGate = Completer<void>();
final transport = _HuddleTestTransport(connectGate: connectGate.future);
await tester.pumpWidget(
_buildTestable(
messages: [
_huddleMsg(
id: 'loading-call-layout',
kind: EventKind.huddleStarted,
pubkey: 'desktop',
createdAt: now,
),
],
users: const {
'desktop': UserProfile(pubkey: 'desktop'),
'self': UserProfile(pubkey: 'self'),
},
relayConfigNotifier: _HuddleRelayConfigNotifier(),
huddleCurrentPubkey: 'self',
huddleMediaFactory: _HuddleTestMedia.new,
huddleTransportFactory: (_) => transport,
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pump();
await tester.pump();
final loadingBee = find.byKey(const ValueKey('huddle-loading-bee'));
expect(loadingBee, findsOneWidget);
expect(find.byType(FlappingBee), findsOneWidget);
expect(tester.widget<FlappingBee>(loadingBee).width, 60);
expect(find.bySemanticsLabel('Joining Huddle'), findsOneWidget);
expect(
find.byKey(const ValueKey('huddle-participant-avatar-self')),
findsNothing,
);
final initialFlap = tester.widget<FlappingBee>(loadingBee).flapAmount;
await tester.pump(const Duration(milliseconds: 120));
expect(
tester.widget<FlappingBee>(loadingBee).flapAmount,
isNot(initialFlap),
);
connectGate.complete();
await tester.pumpAndSettle();
expect(loadingBee, findsNothing);
expect(
find.byKey(const ValueKey('huddle-participant-avatar-self')),
findsOneWidget,
);
});
testWidgets(
'opens the sparse full-screen call with avatar and audio controls',
(tester) async {
@@ -3475,11 +3536,40 @@ void main() {
);
expect(
tester.getSize(find.byKey(const ValueKey('huddle-speaker-toggle'))),
const Size.square(72),
const Size.square(80),
);
expect(
tester.getSize(find.byKey(const ValueKey('huddle-mute-toggle'))),
const Size.square(72),
const Size.square(80),
);
expect(
tester.getSize(find.byKey(const ValueKey('huddle-emoji-reactions'))),
const Size.square(80),
);
expect(
(tester
.widget<Padding>(
find.byKey(const ValueKey('huddle-call-controls')),
)
.padding
as EdgeInsets)
.bottom,
0,
);
final speakerCenter = tester.getCenter(
find.byKey(const ValueKey('huddle-speaker-toggle')),
);
final muteCenter = tester.getCenter(
find.byKey(const ValueKey('huddle-mute-toggle')),
);
final emojiCenter = tester.getCenter(
find.byKey(const ValueKey('huddle-emoji-reactions')),
);
expect(speakerCenter.dy, closeTo(muteCenter.dy, 0.01));
expect(emojiCenter.dy, closeTo(muteCenter.dy, 0.01));
expect(
(speakerCenter.dx + emojiCenter.dx) / 2,
closeTo(muteCenter.dx, 0.01),
);
expect(find.text('Miles'), findsNothing);
expect(find.text('Pollen'), findsNothing);
@@ -3580,11 +3670,60 @@ void main() {
transport.emitRemoteAudio();
await tester.pump();
await tester.pump();
expect(
find.bySemanticsLabel(RegExp(r'Miles, speaking')),
findsOneWidget,
);
final speakingHalo = tester.widget<Container>(
find.byKey(const ValueKey('huddle-speaking-halo-desktop')),
);
final speakingHaloDecoration =
speakingHalo.decoration! as BoxDecoration;
expect(speakingHaloDecoration.border, isNull);
expect(speakingHaloDecoration.color?.a, closeTo(0.07, 0.001));
await tester.pump(const Duration(milliseconds: 70));
final mediumScaleMidTransition = tester
.widget<Transform>(
find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')),
)
.transform
.storage
.first;
expect(mediumScaleMidTransition, greaterThan(1));
expect(mediumScaleMidTransition, lessThan(1.772));
await tester.pump(const Duration(milliseconds: 70));
final mediumSpeakingScale = tester
.widget<Transform>(
find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')),
)
.transform
.storage
.first;
expect(mediumSpeakingScale, closeTo(1.772, 0.01));
transport.emitRemoteAudio(levelDbov: -10, sequence: 2);
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(const Duration(milliseconds: 70));
final loudScaleMidTransition = tester
.widget<Transform>(
find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')),
)
.transform
.storage
.first;
expect(loudScaleMidTransition, greaterThan(mediumSpeakingScale));
expect(loudScaleMidTransition, lessThan(2.291));
await tester.pump(const Duration(milliseconds: 70));
final loudSpeakingScale = tester
.widget<Transform>(
find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')),
)
.transform
.storage
.first;
expect(loudSpeakingScale, closeTo(2.291, 0.01));
expect(loudSpeakingScale, greaterThan(mediumSpeakingScale));
expect(loudSpeakingScale, lessThanOrEqualTo(2.55));
expect(
find.descendant(
@@ -3703,6 +3842,32 @@ void main() {
isTrue,
);
final emojiIcon = find.descendant(
of: find.byKey(const ValueKey('huddle-emoji-reactions')),
matching: find.byIcon(LucideIcons.smilePlus),
);
expect(emojiIcon, findsOneWidget);
expect(tester.widget<Icon>(emojiIcon).size, 28);
expect(find.bySemanticsLabel('Emoji reactions'), findsOneWidget);
final huddleContainer = ProviderScope.containerOf(
tester.element(find.byType(MobileHuddleShell)),
);
final localBurstController = huddleContainer.read(
emojiBurstControllerProvider,
)..clear();
final localAvatarCenter = tester.getCenter(
find.byKey(const ValueKey('huddle-speaking-ring-self')),
);
await tester.tap(find.byKey(const ValueKey('huddle-emoji-reactions')));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(EmojiPickerSheet), findsOneWidget);
tester
.widget<EmojiPickerSheet>(find.byType(EmojiPickerSheet))
.onSelect('🎉');
await tester.pump();
expect(localBurstController.debugLastBurstOrigin, localAvatarCenter);
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.byKey(const ValueKey('huddle-minimize')));
await tester.pumpAndSettle();
expect(find.widgetWithText(FilledButton, 'Open'), findsOneWidget);
@@ -3778,6 +3943,10 @@ void main() {
final drawerRect = tester.getRect(
find.byKey(const ValueKey('mobile-huddle-drawer')),
);
final drawerOffset = tester.widget<Transform>(
find.byKey(const ValueKey('huddle-drawer-control-offset')),
);
expect(drawerOffset.transform.storage[13], -8);
final primaryControlsRect = tester.getRect(
find.byKey(const ValueKey('huddle-drawer-primary-controls')),
);
@@ -3798,7 +3967,7 @@ void main() {
]) {
expect(
tester.getCenter(find.byKey(key)).dy,
closeTo(drawerControlCenter, 0.01),
closeTo(drawerControlCenter - 8, 0.01),
);
}
await tester.tap(find.byKey(const ValueKey('huddle-drawer-expand')));
@@ -3841,6 +4010,157 @@ void main() {
},
);
testWidgets(
'fits a dense Huddle roster without scrolling and shrinks avatars',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// The relay caps a room at 25 peers, so 24 remotes plus the local
// participant exercises the densest supported call.
final remotePubkeys = List.generate(24, (index) => 'guest-$index');
final transport = _HuddleTestTransport(
peers: const {2: HuddlePeer(pubkey: 'self', peerIndex: 2)},
);
await tester.pumpWidget(
_buildTestable(
messages: [
_huddleMsg(
id: 'dense-call-layout',
kind: EventKind.huddleStarted,
pubkey: 'self',
createdAt: now,
),
],
users: {
'self': const UserProfile(pubkey: 'self', displayName: 'Self'),
for (final pubkey in remotePubkeys)
pubkey: UserProfile(pubkey: pubkey, displayName: pubkey),
},
huddleMembers: [
for (final pubkey in remotePubkeys)
ChannelMember(
pubkey: pubkey,
role: 'member',
joinedAt: DateTime(2025),
),
],
relayConfigNotifier: _HuddleRelayConfigNotifier(),
huddleCurrentPubkey: 'self',
huddleMediaFactory: _HuddleTestMedia.new,
huddleTransportFactory: (_) => transport,
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pumpAndSettle();
final remoteRegion = find.byKey(
const ValueKey('huddle-remote-participant-region'),
);
expect(
tester.widget<FractionallySizedBox>(remoteRegion).heightFactor,
0.58,
);
expect(
find.descendant(
of: remoteRegion,
matching: find.byType(SingleChildScrollView),
),
findsNothing,
);
for (final pubkey in remotePubkeys) {
expect(
find.byKey(ValueKey('huddle-participant-entry-$pubkey')),
findsOneWidget,
);
}
final firstRemoteRing = find.byKey(
const ValueKey('huddle-speaking-ring-guest-0'),
);
final firstRemoteAvatar = find
.descendant(
of: firstRemoteRing,
matching: find.byType(CircleAvatar),
)
.first;
expect(tester.getSize(firstRemoteAvatar).width, lessThan(104));
expect(tester.takeException(), isNull);
},
);
testWidgets(
'bursts remote Huddle reactions and ignores the local relay echo',
(tester) async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final relaySession = _HuddleReactionRelaySession();
await tester.pumpWidget(
_buildTestable(
messages: [
_huddleMsg(
id: 'reaction-call',
kind: EventKind.huddleStarted,
pubkey: 'desktop',
createdAt: now,
),
],
users: const {
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
},
relayConfigNotifier: _HuddleRelayConfigNotifier(),
relaySessionNotifier: relaySession,
huddleCurrentPubkey: 'self',
huddleMediaFactory: _HuddleTestMedia.new,
huddleTransportFactory: (_) => _HuddleTestTransport(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pumpAndSettle();
for (
var attempt = 0;
attempt < 20 && relaySession.reactionFilter == null;
attempt++
) {
await tester.pump();
}
expect(
relaySession.reactionFilter?.kinds,
contains(EventKind.huddleReaction),
);
expect(relaySession.reactionFilter?.tags['#h'], [_huddleChannelId]);
expect(relaySession.reactionFilter?.since, isNotNull);
final container = ProviderScope.containerOf(
tester.element(find.byType(MobileHuddleShell)),
);
final burstController = container.read(emojiBurstControllerProvider);
expect(burstController.hasParticles, isFalse);
final remoteAvatarCenter = tester.getCenter(
find.byKey(const ValueKey('huddle-speaking-ring-desktop')),
);
relaySession.emitReaction(pubkey: 'self', emoji: '🎉');
await tester.pump();
expect(burstController.hasParticles, isFalse);
relaySession.emitReaction(pubkey: 'desktop', emoji: '🎉');
await tester.pump();
expect(burstController.hasParticles, isTrue);
expect(burstController.debugLastBurstOrigin, remoteAvatarCenter);
},
);
testWidgets(
'centers a solo participant and moves them when another person joins',
(tester) async {
@@ -8279,6 +8599,63 @@ class _ReconnectingRelaySession extends RelaySessionNotifier {
}
}
class _HuddleReactionRelaySession extends RelaySessionNotifier {
NostrFilter? reactionFilter;
void Function(NostrEvent)? _reactionListener;
var _nextEventId = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async => event;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
if (filter.kinds.contains(EventKind.huddleReaction)) {
reactionFilter = filter;
_reactionListener = onEvent;
return () {
if (identical(_reactionListener, onEvent)) {
_reactionListener = null;
}
};
}
return () {};
}
void emitReaction({required String pubkey, required String emoji}) {
_reactionListener?.call(
NostrEvent(
id: 'huddle-reaction-${_nextEventId++}',
pubkey: pubkey,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
kind: EventKind.huddleReaction,
tags: [
['h', _huddleChannelId],
['reaction', emoji],
['sender_name', 'Remote'],
],
content: emoji,
sig: 'sig',
),
);
}
}
class _FakeTypingNotifier extends ChannelTypingNotifier {
final List<TypingEntry> _entries;
_FakeTypingNotifier(this._entries, {String channelId = _channelId})
@@ -8530,6 +8907,7 @@ final class _HuddleTestMedia implements HuddleMedia {
final class _HuddleTestTransport implements HuddleTransportClient {
_HuddleTestTransport({
this.connectError,
this.connectGate,
this.peers = const {
1: HuddlePeer(pubkey: 'desktop', peerIndex: 1),
2: HuddlePeer(pubkey: 'self', peerIndex: 2),
@@ -8537,6 +8915,7 @@ final class _HuddleTestTransport implements HuddleTransportClient {
});
final HuddleTransportError? connectError;
final Future<void>? connectGate;
final Map<int, HuddlePeer> peers;
final _states = StreamController<HuddleTransportState>.broadcast(sync: true);
final _remoteFrames = StreamController<HuddleRemoteAudioFrame>.broadcast(
@@ -8546,14 +8925,14 @@ final class _HuddleTestTransport implements HuddleTransportClient {
final _issues = StreamController<HuddleTransportError>.broadcast(sync: true);
HuddleTransportState _state = HuddleTransportState.idle();
void emitRemoteAudio() {
void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) {
_remoteFrames.add(
HuddleRemoteAudioFrame(
peerIndex: 1,
header: const HuddleAudioHeader(
sequence: 1,
header: HuddleAudioHeader(
sequence: sequence,
timestamp48k: 960,
levelDbov: -30,
levelDbov: levelDbov,
flags: 0,
),
opusPayload: Uint8List.fromList([1, 2, 3]),
@@ -8578,6 +8957,7 @@ final class _HuddleTestTransport implements HuddleTransportClient {
@override
Future<void> connect() async {
if (connectGate case final gate?) await gate;
if (connectError case final error?) throw error;
_state = HuddleTransportState(
phase: HuddleTransportPhase.connected,
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/mobile_huddle_controller.dart';
import 'package:buzz/shared/relay/relay.dart';
/// Tests for [channelDetailsFromEvent].
@@ -274,6 +275,90 @@ void main() {
expect(started.id, start.id);
},
);
test(
'publishes ephemeral Huddle reactions on the backing channel',
() async {
final keys = nostr.Keys.generate();
final session = _RecordingPublishRelaySession();
final actionsProvider = Provider<ChannelActions>((ref) {
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: keys.nsec,
),
currentPubkey: keys.public,
);
});
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue(keys.public),
],
);
addTearDown(container.dispose);
await container
.read(actionsProvider)
.sendHuddleReaction(
channelId: _channelId,
emoji: ' 🎉 ',
senderName: 'Self',
);
expect(session.publishedEvents, hasLength(1));
final reaction = session.publishedEvents.single;
expect(reaction.kind, EventKind.huddleReaction);
expect(reaction.content, '🎉');
expect(reaction.tags, [
['h', _channelId],
['reaction', '🎉'],
['sender_name', 'Self'],
]);
},
);
test('last-human count ignores a stale cached roster', () async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
channelMembersProvider(_channelId).overrideWith(
(ref) async => [
ChannelMember(
pubkey: _memberPubkey,
role: 'admin',
joinedAt: DateTime(2025),
),
ChannelMember(
pubkey:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
role: 'member',
joinedAt: DateTime(2025),
),
],
),
],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
session.connect();
expect(
await container.read(channelMembersProvider(_channelId).future),
hasLength(2),
);
final humanCount = await container.read(huddleHumanCountProvider)(
_channelId,
);
expect(humanCount, 1);
expect(session.historyQueryCount, 1);
});
});
group('channelMembersProvider', () {
@@ -60,6 +60,10 @@ void main() {
container.read(huddleSessionProvider).activeSpeakerPubkeys,
contains('desktop'),
);
expect(
container.read(huddleSessionProvider).speakerLevels['desktop'],
closeTo(25 / 55, 0.001),
);
media.emitLocal(
HuddleLocalAudioFrame(
@@ -76,6 +80,10 @@ void main() {
expect(transport.sentFrames.single.opusPayload, [4, 5]);
expect(container.read(huddleSessionProvider).sentFrameCount, 1);
expect(container.read(huddleSessionProvider).isMuted, isFalse);
expect(
container.read(huddleSessionProvider).speakerLevels['mobile'],
closeTo(35 / 55, 0.001),
);
await controller.setMuted(true);
media.emitLocal(
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/app_colors.dart';
import 'package:buzz/shared/theme/app_theme.dart';
void main() {
@@ -24,4 +25,16 @@ void main() {
expect(side.color, Colors.black.withValues(alpha: 0.04));
expect(side.width, 1);
});
test('keeps inactive Huddle controls distinct in dark mode', () {
final colors = AppTheme.dark().extension<AppColors>()!;
expect(colors.huddleControlSurface, isNot(colors.huddleDrawerSurface));
expect(
(colors.huddleControlSurface.computeLuminance() -
colors.huddleDrawerSurface.computeLuminance())
.abs(),
greaterThan(0.02),
);
});
}
@@ -96,6 +96,7 @@ void main() {
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
backdropColor: Colors.black,
corners: ConcentricSurfaceCorners.bottom,
padding: EdgeInsets.zero,
providesSheetSurface: false,
@@ -107,6 +108,10 @@ void main() {
final nativeSurface = tester.widget<UiKitView>(find.byType(UiKitView));
expect(nativeSurface.creationParams, containsPair('corners', 'bottom'));
expect(
nativeSurface.creationParams,
containsPair('backdropColor', Colors.black.toARGB32()),
);
final contentClip = tester.widget<ClipRSuperellipse>(
find.byKey(const ValueKey('concentric-sheet-content-clip')),
);
@@ -124,6 +129,92 @@ void main() {
},
);
testWidgets('native surface colors follow live theme changes', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const supportChannel = MethodChannel('buzz/concentric_sheet_surface');
const viewChannel = MethodChannel('buzz/concentric_sheet_surface/42');
final colorUpdates = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
supportChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
viewChannel,
(call) async {
colorUpdates.add(call);
return null;
},
);
Widget themedSurface(ThemeData theme) => MaterialApp(
theme: theme,
home: Builder(
builder: (context) => ConcentricSheetSurface(
enabled: true,
color: context.colors.surface,
backdropColor: context.appColors.huddleDrawerSurface,
corners: ConcentricSurfaceCorners.bottom,
padding: EdgeInsets.zero,
providesSheetSurface: false,
child: const SizedBox(height: 80, child: Text('App surface')),
),
),
);
try {
final darkTheme = AppTheme.dark();
await tester.pumpWidget(themedSurface(darkTheme));
await tester.pump();
tester.widget<UiKitView>(find.byType(UiKitView)).onPlatformViewCreated!(
42,
);
await tester.pump();
expect(colorUpdates, hasLength(1));
expect(colorUpdates.single.method, 'updateColors');
expect(
colorUpdates.single.arguments,
containsPair('color', darkTheme.colorScheme.surface.toARGB32()),
);
expect(
colorUpdates.single.arguments,
containsPair(
'backdropColor',
darkTheme.extension<AppColors>()!.huddleDrawerSurface.toARGB32(),
),
);
final lightTheme = AppTheme.light();
await tester.pumpWidget(themedSurface(lightTheme));
await tester.pumpAndSettle();
expect(colorUpdates.length, greaterThanOrEqualTo(2));
expect(
colorUpdates.last.arguments,
containsPair('color', lightTheme.colorScheme.surface.toARGB32()),
);
expect(
colorUpdates.last.arguments,
containsPair(
'backdropColor',
lightTheme.extension<AppColors>()!.huddleDrawerSurface.toARGB32(),
),
);
} finally {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
supportChannel,
null,
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
viewChannel,
null,
);
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('native titled sheets leave the concentric surface unobscured', (
tester,
) async {