mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish mobile Huddles calls
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
+26
-7
@@ -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
|
||||
|
||||
@@ -70,7 +70,7 @@ import UserNotifications
|
||||
forPlugin: "BuzzConcentricSheetSurface"
|
||||
) {
|
||||
concentricSheetRegistrar.register(
|
||||
ConcentricSheetSurfaceFactory(),
|
||||
ConcentricSheetSurfaceFactory(messenger: messenger),
|
||||
withId: "buzz/concentric_sheet_surface"
|
||||
)
|
||||
concentricSheetSurfaceChannel = FlutterMethodChannel(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user