diff --git a/client/lib/features/events/data/services/firestore_event_service.dart b/client/lib/features/events/data/services/firestore_event_service.dart index c2b5cc498..b933d03c7 100644 --- a/client/lib/features/events/data/services/firestore_event_service.dart +++ b/client/lib/features/events/data/services/firestore_event_service.dart @@ -16,6 +16,17 @@ import 'package:rxdart/rxdart.dart'; class FirestoreEventService { static const events = 'events'; + // How far before an event's scheduled time it starts appearing in upcoming + // event queries — keeps recently-started events visible in listings. + static const Duration _upcomingEventsLookback = Duration(minutes: 15); + // Wider lookback used when querying across all communities, to surface + // events that started up to an hour ago. + static const Duration _allCommunitiesEventsLookback = Duration(hours: 1); + // Throttle on the event-participants Firestore stream to avoid processing + // a snapshot on every keystroke / write during busy periods. + static const Duration _participantStreamSampleTime = + Duration(milliseconds: 500); + // final time = await NTP.now(); // Future to mimic NTP.now() Future get currentTimeAsync => Future(() => clockService.now()); @@ -79,8 +90,9 @@ class FirestoreEventService { .where('isPublic', isEqualTo: true) .where( 'scheduledTime', - isGreaterThan: - Timestamp.fromDate(currentTime.subtract(Duration(minutes: 15))), + isGreaterThan: Timestamp.fromDate( + currentTime.subtract(_upcomingEventsLookback), + ), ) .orderBy('scheduledTime'); @@ -107,7 +119,7 @@ class FirestoreEventService { .where( 'scheduledTime', isGreaterThan: - Timestamp.fromDate(currentTime.subtract(Duration(minutes: 15))), + Timestamp.fromDate(currentTime.subtract(_upcomingEventsLookback)), ) .orderBy('scheduledTime'); @@ -142,8 +154,9 @@ class FirestoreEventService { .where('communityId', isEqualTo: communityId) .where( 'scheduledTime', - isGreaterThan: - Timestamp.fromDate(currentTime.subtract(Duration(hours: 1))), + isGreaterThan: Timestamp.fromDate( + currentTime.subtract(_allCommunitiesEventsLookback), + ), ) .where('isPublic', isEqualTo: true) .orderBy('scheduledTime') @@ -230,7 +243,7 @@ class FirestoreEventService { !snapshot.metadata.hasPendingWrites && !snapshot.metadata.isFromCache, ) - .sampleTime(Duration(milliseconds: 500)) + .sampleTime(_participantStreamSampleTime) .asyncMap((snapshot) => convertParticipantListAsync(snapshot)), ); } diff --git a/client/lib/features/events/features/event_page/data/providers/event_page_provider.dart b/client/lib/features/events/features/event_page/data/providers/event_page_provider.dart index 39cc5a28d..a1d8f53eb 100644 --- a/client/lib/features/events/features/event_page/data/providers/event_page_provider.dart +++ b/client/lib/features/events/features/event_page/data/providers/event_page_provider.dart @@ -18,7 +18,6 @@ import 'package:client/services.dart'; import 'package:data_models/analytics/analytics_entities.dart'; import 'package:data_models/events/event.dart'; import 'package:data_models/community/community_tag.dart'; -import 'package:client/core/localization/localization_helper.dart'; import '../../../../../../core/routing/locations.dart'; @@ -54,6 +53,10 @@ Future verifyAvailableForEvent(Event event) async { } class EventPageProvider with ChangeNotifier { + // Short delay to allow Firebase Auth state to propagate before attempting to + // register and enter the meeting with a newly created guest account. + static const Duration _guestAccountSetupDelay = Duration(seconds: 5); + final EventProvider eventProvider; final CommunityProvider communityProvider; final NavBarProvider navBarProvider; @@ -282,7 +285,7 @@ class EventPageProvider with ChangeNotifier { password: 'password', ); - await Future.delayed(Duration(seconds: 5)); + await Future.delayed(_guestAccountSetupDelay); // Register await joinEvent(showConfirm: false); @@ -337,6 +340,7 @@ class EventPageProvider with ChangeNotifier { cancelText: appLocalizationService.getLocalization().no, ).show(); if (cancelParticipation) { + if (!navigatorState.mounted) return; await alertOnError( navigatorState.context, () => firestoreEventService.removeParticipant( diff --git a/client/lib/features/events/features/event_page/presentation/views/event_page.dart b/client/lib/features/events/features/event_page/presentation/views/event_page.dart index 599c89ac0..12df67ef5 100644 --- a/client/lib/features/events/features/event_page/presentation/views/event_page.dart +++ b/client/lib/features/events/features/event_page/presentation/views/event_page.dart @@ -94,6 +94,10 @@ class EventPage extends StatefulWidget { } class EventPageState extends State implements EventPageView { + // How long after the scheduled start time the join-event graphic remains + // visible, giving participants a window to join a meeting already in progress. + static const int _kHoursAfterEventToShow = 2; + EventProvider get _eventProvider => EventProvider.watch(context); Event get event => _eventProvider.event; @@ -240,7 +244,8 @@ class EventPageState extends State implements EventPageView { final now = clockService.now(); final beforeMeetingCutoff = scheduled.subtract(Duration(minutes: kMinutesBeforeEventToJoin)); - final afterMeetingCutoff = scheduled.add(Duration(hours: 2)); + final afterMeetingCutoff = + scheduled.add(Duration(hours: _kHoursAfterEventToShow)); return now.isAfter(beforeMeetingCutoff) && now.isBefore(afterMeetingCutoff); } @@ -260,7 +265,7 @@ class EventPageState extends State implements EventPageView { Positioned.fill( child: ProxiedImage( null, - asset: AppAsset('media/background.gif'), + asset: AppAsset.backgroundGif(), fit: BoxFit.cover, loadingColor: Colors.transparent, ), diff --git a/client/lib/features/events/features/live_meeting/data/providers/live_meeting_provider.dart b/client/lib/features/events/features/live_meeting/data/providers/live_meeting_provider.dart index 8b9d6c6b5..4f7cd3a56 100644 --- a/client/lib/features/events/features/live_meeting/data/providers/live_meeting_provider.dart +++ b/client/lib/features/events/features/live_meeting/data/providers/live_meeting_provider.dart @@ -21,10 +21,10 @@ import 'package:client/app.dart'; import 'package:client/core/routing/locations.dart'; import 'package:client/core/utils/firestore_utils.dart'; import 'package:client/services.dart'; -import 'package:client/styles/styles.dart'; import 'package:client/core/data/providers/dialog_provider.dart'; import 'package:client/features/events/features/live_meeting/presentation/hostless_action_fallback_controller.dart'; import 'package:client/core/utils/platform_utils.dart'; +import 'package:data_models/analytics/analytics_entities.dart'; import 'package:data_models/cloud_functions/requests.dart'; import 'package:data_models/events/event.dart'; import 'package:data_models/events/event_proposal.dart'; @@ -83,7 +83,15 @@ class LiveMeetingProvider with ChangeNotifier { bool _leftMeeting = false; bool _userLeftBreakouts = false; - String? _activeBreakoutRoomId; + + /// The ID of the breakout room the user is currently transitioning into, or null if no + /// transition is in progress. We need to track this separately because there may be a delay + /// between when the user is sent to the breakout room and when they actually join the breakout + /// room stream. During this time we want to show the breakout room UI but we won't have a + /// breakout room stream to listen to yet. + /// Use [isInBreakoutTransition] to check whether a transition is in progress. + String? _inTransitionToBreakoutRoomId; + String? _cachedJoinInfoRoomId; String? _breakoutRoomOverride; /// Holds a reference to the current breakout room join info @@ -122,6 +130,9 @@ class LiveMeetingProvider with ChangeNotifier { Timer? _checkAssignToBreakoutsTimer; Timer? _presenceUpdater; + Timer? _transitionTimer; + int _transitionElapsedSeconds = 0; + DateTime? _transitionStartTime; HostlessActionFallbackController? _hostlessGoToBreakoutsFallbackController; HostlessActionFallbackController? _pendingBreakoutsFallbackController; @@ -150,6 +161,13 @@ class LiveMeetingProvider with ChangeNotifier { }); static const int _postEventEmailThresholdInMinutes = 5; + static const int _presenceHeartbeatIntervalSeconds = 5; + static const int _meetingStartTimerBufferMs = 100; + static const int _fallbackControllerBaseDelayMs = 5000; + static const int _hostlessFallbackJitterMs = 20000; + static const int _pendingBreakoutsFallbackJitterMs = 30000; + static const int _breakoutRoomTransitionHeartbeatSeconds = 5; + static const int _breakoutRoomTransitionTimeoutSeconds = 10; MeetingUiState get activeUiState { final showEnterMeeting = isInstant && !clickedEnterMeeting; @@ -204,11 +222,37 @@ class LiveMeetingProvider with ChangeNotifier { String? get breakoutRoomOverride => _breakoutRoomOverride; + /// Whether the user is currently transitioning into a breakout room. True from the moment the + /// user is assigned to a room until they have successfully joined the room stream. + bool get isInBreakoutTransition => _inTransitionToBreakoutRoomId != null; + String? get currentBreakoutRoomId { if (_userLeftBreakouts) return null; return breakoutRoomOverride ?? assignedBreakoutRoomId; } + String? get _presenceRoomId { + switch (activeUiState) { + case MeetingUiState.waitingRoom: + return breakoutsWaitingRoomId; + case MeetingUiState.breakoutRoom: + if (!isInBreakoutTransition) { + loggingService.log( + 'Heartbeat: user is in the breakout UI state but the breakout ' + 'room is not specified. This may occur if the breakout room is ' + 'removed or closed while someone is transitioning to it.', + ); + } + return _inTransitionToBreakoutRoomId; + + case MeetingUiState.liveStream: + case MeetingUiState.inMeeting: + case MeetingUiState.leftMeeting: + case MeetingUiState.enterMeetingPrescreen: + return null; + } + } + bool get isHost => eventProvider.event.creatorId == userService.currentUserId; bool get isMeetingStarted => @@ -367,16 +411,18 @@ class LiveMeetingProvider with ChangeNotifier { _updateTimersBeforeStart(); canAutoplayLookupFuture = _checkIfCanAutoplay(); - _presenceUpdater = Timer.periodic(Duration(seconds: 5), (_) { - if (_activeBreakoutRoomId != null && - eventProvider.selfParticipant?.currentBreakoutRoomId != - _activeBreakoutRoomId) { - firestoreLiveMeetingService.updateMeetingPresence( - event: eventProvider.event, - currentBreakoutRoomId: _activeBreakoutRoomId, - isPresent: true, - ); + _presenceUpdater = Timer.periodic( + Duration(seconds: _presenceHeartbeatIntervalSeconds), (_) { + if (activeUiState == MeetingUiState.leftMeeting || + activeUiState == MeetingUiState.enterMeetingPrescreen) { + return; } + + firestoreLiveMeetingService.updateMeetingPresence( + event: eventProvider.event, + currentBreakoutRoomId: _presenceRoomId, + isPresent: true, + ); }); } @@ -398,8 +444,9 @@ class LiveMeetingProvider with ChangeNotifier { final timeUntilScheduledStart = eventProvider.event.timeUntilScheduledStart(clockService.now()); if (!timeUntilScheduledStart.isNegative) { - _scheduledStartTimer = - Timer(timeUntilScheduledStart + Duration(milliseconds: 100), () { + _scheduledStartTimer = Timer( + timeUntilScheduledStart + + Duration(milliseconds: _meetingStartTimerBufferMs), () { notifyListeners(); }); } @@ -407,8 +454,9 @@ class LiveMeetingProvider with ChangeNotifier { eventProvider.event.timeUntilWaitingRoomFinished(clockService.now()); if (timeUntilWaitingRoomFinished != timeUntilScheduledStart && !timeUntilWaitingRoomFinished.isNegative) { - _meetingStartTimer = - Timer(timeUntilWaitingRoomFinished + Duration(milliseconds: 100), () { + _meetingStartTimer = Timer( + timeUntilWaitingRoomFinished + + Duration(milliseconds: _meetingStartTimerBufferMs), () { notifyListeners(); }); } @@ -430,7 +478,10 @@ class LiveMeetingProvider with ChangeNotifier { ); }, delay: timeUntilWaitingRoomFinished + - Duration(milliseconds: 5000 + random.nextInt(20000)), + Duration( + milliseconds: _fallbackControllerBaseDelayMs + + random.nextInt(_hostlessFallbackJitterMs), + ), checkIsActionCompleted: () async => liveMeeting?.currentBreakoutSession?.breakoutRoomStatus != null && liveMeeting?.currentBreakoutSession?.breakoutRoomStatus != @@ -448,6 +499,7 @@ class LiveMeetingProvider with ChangeNotifier { _assignedBreakoutRoomsStreamSubscription?.cancel(); _presenceUpdater?.cancel(); + _transitionTimer?.cancel(); _scheduledStartTimer?.cancel(); _meetingStartTimer?.cancel(); @@ -474,8 +526,10 @@ class LiveMeetingProvider with ChangeNotifier { _checkLoadBreakoutsStream(liveMeeting); - if (!breakoutsActive && !isNullOrEmpty(_activeBreakoutRoomId)) { + if (!breakoutsActive && isInBreakoutTransition) { leaveBreakoutRoom(); + // True immediately after calling leaveBreakoutRoom, so reset it here since + // the user is moving to another room rather than leaving breakouts entirely. _userLeftBreakouts = false; } @@ -578,7 +632,10 @@ class LiveMeetingProvider with ChangeNotifier { ); }, delay: timeUntilBreakouts + - Duration(milliseconds: 5000 + random.nextInt(30000)), + Duration( + milliseconds: _fallbackControllerBaseDelayMs + + random.nextInt(_pendingBreakoutsFallbackJitterMs), + ), checkIsActionCompleted: () async => liveMeeting?.currentBreakoutSession?.breakoutRoomStatus != BreakoutRoomStatus.pending, @@ -631,15 +688,76 @@ class LiveMeetingProvider with ChangeNotifier { .currentBreakoutSession?.breakoutRoomSessionId ?? '', ); - Navigator.of(context).pop(true); + if (context.mounted) Navigator.of(context).pop(true); }), ).show(); } } } + void _startBreakoutRoomTransitionTimer() { + _transitionTimer?.cancel(); + _transitionElapsedSeconds = 0; + _transitionStartTime = DateTime.now(); + _transitionTimer = Timer.periodic( + Duration(seconds: _breakoutRoomTransitionHeartbeatSeconds), (timer) { + if (!isInBreakoutTransition) { + timer.cancel(); + return; + } + + _transitionElapsedSeconds += _breakoutRoomTransitionHeartbeatSeconds; + loggingService.log( + 'Heartbeat: user is in breakout UI state but has not yet joined a room ' + 'after $_transitionElapsedSeconds seconds.', + ); + + if (_transitionElapsedSeconds >= _breakoutRoomTransitionTimeoutSeconds) { + timer.cancel(); + loggingService.log( + 'Breakout room transition timed out after ' + '$_breakoutRoomTransitionTimeoutSeconds seconds. ' + 'Canceling transition and returning to main meeting.', + ); + leaveBreakoutRoom(); + showToast( + appLocalizationService + .getLocalization() + .breakoutRoomTransitionTimeout, + ); + } + }); + } + + void _clearBreakoutRoomTransition() { + _inTransitionToBreakoutRoomId = null; + _transitionTimer?.cancel(); + _transitionTimer = null; + _transitionElapsedSeconds = 0; + _transitionStartTime = null; + } + + void clearBreakoutRoomTransition() { + final startTime = _transitionStartTime; + if (startTime != null) { + final elapsedMs = DateTime.now().difference(startTime).inMilliseconds; + loggingService.log( + 'Breakout room transition completed in ${elapsedMs}ms.', + ); + analytics.logEvent( + AnalyticsBreakoutRoomTransitionEvent( + communityId: eventProvider.communityId, + eventId: eventProvider.eventId, + durationMs: elapsedMs, + templateId: eventProvider.templateId, + ), + ); + } + _clearBreakoutRoomTransition(); + } + Future? getCurrentMeetingJoinInfo() { - if (_activeBreakoutRoomId == currentBreakoutRoomId && + if (_cachedJoinInfoRoomId == currentBreakoutRoomId && _activeRoomJoinInfoFuture != null) { return _activeRoomJoinInfoFuture; } @@ -657,7 +775,8 @@ class LiveMeetingProvider with ChangeNotifier { } Future getMeetingJoinInfo() { - _activeBreakoutRoomId = null; + _clearBreakoutRoomTransition(); + _cachedJoinInfoRoomId = null; _activeRoomJoinInfoFuture = null; _breakoutLiveMeetingStream?.dispose(); _breakoutLiveMeetingStream = null; @@ -785,7 +904,8 @@ class LiveMeetingProvider with ChangeNotifier { void leaveBreakoutRoom() { _userLeftBreakouts = true; - _activeBreakoutRoomId = null; + _clearBreakoutRoomTransition(); + _cachedJoinInfoRoomId = null; _breakoutRoomOverride = null; _activeRoomJoinInfoFuture = null; @@ -849,8 +969,10 @@ class LiveMeetingProvider with ChangeNotifier { Future getBreakoutRoomFuture({ required String roomId, }) async { - _activeBreakoutRoomId = roomId; + _inTransitionToBreakoutRoomId = roomId; + _cachedJoinInfoRoomId = roomId; _activeRoomJoinInfoFuture = null; + _startBreakoutRoomTransitionTimer(); _loadBreakoutLiveMeetingStream(roomId); @@ -866,11 +988,9 @@ class LiveMeetingProvider with ChangeNotifier { ), ); - await firestoreLiveMeetingService.updateMeetingPresence( - event: eventProvider.event, - isPresent: true, - currentBreakoutRoomId: _activeBreakoutRoomId, - ); + // Room membership is updated in ConferenceRoom.onConnected once Agora + // confirms the connection. The heartbeat timer continues updating + // mostRecentPresentTime in the meantime. return breakoutRoomJoinInfo; } diff --git a/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/views/breakout_rooms_dialog.dart b/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/views/breakout_rooms_dialog.dart index ad96561e3..1334eb864 100644 --- a/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/views/breakout_rooms_dialog.dart +++ b/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/views/breakout_rooms_dialog.dart @@ -1,7 +1,3 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:client/core/widgets/custom_loading_indicator.dart'; import 'package:flutter/material.dart'; import 'package:client/features/events/features/event_page/data/providers/event_provider.dart'; import 'package:client/features/events/features/live_meeting/data/providers/live_meeting_provider.dart'; @@ -13,19 +9,12 @@ import 'package:client/services.dart'; import 'package:client/styles/styles.dart'; import 'package:client/core/data/providers/dialog_provider.dart'; import 'package:client/core/widgets/height_constained_text.dart'; -import 'package:client/features/events/presentation/widgets/periodic_builder.dart'; import 'package:data_models/cloud_functions/requests.dart'; import 'package:data_models/events/event.dart'; import 'package:data_models/admin/plan_capability_list.dart'; import 'package:provider/provider.dart'; import 'package:client/core/localization/localization_helper.dart'; -enum _BreakoutRoomsDialogState { - start, - searchingForAvailable, - processingAssignment, -} - class BreakoutRoomsDialog extends StatefulWidget { final BuildContext outerContext; final bool canFetchCapabilities; @@ -40,7 +29,7 @@ class BreakoutRoomsDialog extends StatefulWidget { } @override - __BreakoutRoomsDialogState createState() => __BreakoutRoomsDialogState(); + State createState() => __BreakoutRoomsDialogState(); } class __BreakoutRoomsDialogState extends State { @@ -53,11 +42,6 @@ class __BreakoutRoomsDialogState extends State { Provider.of(widget.outerContext); late int _numPerRoom; - Timer? _presenceCheck; - final _presenceCheckStopwatch = Stopwatch(); - final _presenceCheckDuration = Duration(seconds: 45); - - final _BreakoutRoomsDialogState _state = _BreakoutRoomsDialogState.start; @override void initState() { @@ -70,13 +54,6 @@ class __BreakoutRoomsDialogState extends State { 5; } - @override - void dispose() { - _presenceCheck?.cancel(); - - super.dispose(); - } - Future _startBreakouts( BreakoutAssignmentMethod assignmentMethod, ) async { @@ -84,6 +61,7 @@ class __BreakoutRoomsDialogState extends State { numPerRoom: _numPerRoom, assignmentMethod: assignmentMethod, ); + if (!mounted) return; Navigator.of(context).pop(); } @@ -210,60 +188,32 @@ class __BreakoutRoomsDialogState extends State { final showSmartMatchOption = eventProvider.showSmartMatchingForBreakouts; return [ - if (_state == _BreakoutRoomsDialogState.start) ...[ - AnimatedBuilder( - animation: EventProvider.watch(widget.outerContext), - builder: (_, __) => _buildBreakoutChooser(), - ), - CustomStreamBuilder( - entryFrom: '__BreakoutRoomsDialogState._buildContent', - stream: widget.canFetchCapabilities - ? cloudFunctionsCommunityService - .getCommunityCapabilities( - GetCommunityCapabilitiesRequest( - communityId: _communityProvider.communityId, - ), - ) - .asStream() - : Future.value(null).asStream(), - builder: (context, caps) { - final hasSmartMatchingCapability = caps?.hasSmartMatching ?? false; - return Column( - children: [ - if (hasSmartMatchingCapability && showSmartMatchOption) - ..._buildSmartMatchingItems(context), - ..._buildRegularMatchingItems(context, eventProvider), - ], - ); - }, - ), - ] else ...[ - PeriodicBuilder( - period: Duration(seconds: 1), - builder: (_) { - final timeRemaining = max( - (_presenceCheckDuration - _presenceCheckStopwatch.elapsed) - .inSeconds, - 0, - ); - return Text( - 'Asking participants to join breakout rooms.\nBreakout rooms will start in...$timeRemaining', - textAlign: TextAlign.center, - style: TextStyle( - color: context.theme.colorScheme.primary, - fontSize: 16, - ), - ); - }, - ), - if (_state == _BreakoutRoomsDialogState.processingAssignment) ...[ - SizedBox(height: 24), - Container( - alignment: Alignment.center, - child: CustomLoadingIndicator(), - ), - ], - ], + AnimatedBuilder( + animation: EventProvider.watch(widget.outerContext), + builder: (_, __) => _buildBreakoutChooser(), + ), + CustomStreamBuilder( + entryFrom: '__BreakoutRoomsDialogState._buildContent', + stream: widget.canFetchCapabilities + ? cloudFunctionsCommunityService + .getCommunityCapabilities( + GetCommunityCapabilitiesRequest( + communityId: _communityProvider.communityId, + ), + ) + .asStream() + : Future.value(null).asStream(), + builder: (context, caps) { + final hasSmartMatchingCapability = caps?.hasSmartMatching ?? false; + return Column( + children: [ + if (hasSmartMatchingCapability && showSmartMatchOption) + ..._buildSmartMatchingItems(context), + ..._buildRegularMatchingItems(context, eventProvider), + ], + ); + }, + ), ]; } @@ -310,17 +260,16 @@ class __BreakoutRoomsDialogState extends State { SizedBox(height: 16), ], ), - if (_state != _BreakoutRoomsDialogState.searchingForAvailable) - Positioned.fill( - child: Align( - alignment: Alignment.topRight, - child: IconButton( - icon: Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - ), + Positioned.fill( + child: Align( + alignment: Alignment.topRight, + child: IconButton( + icon: Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, ), ), + ), ], ), ), diff --git a/client/lib/features/events/features/live_meeting/features/live_stream/presentation/widgets/url_video_widget.dart b/client/lib/features/events/features/live_meeting/features/live_stream/presentation/widgets/url_video_widget.dart index 2f1f62aed..41a142a97 100644 --- a/client/lib/features/events/features/live_meeting/features/live_stream/presentation/widgets/url_video_widget.dart +++ b/client/lib/features/events/features/live_meeting/features/live_stream/presentation/widgets/url_video_widget.dart @@ -53,10 +53,15 @@ class UrlVideoWidget extends StatefulHookWidget { }) : super(key: key ?? Key(playbackUrl)); @override - _UrlVideoWidgetState createState() => _UrlVideoWidgetState(); + State createState() => _UrlVideoWidgetState(); } class _UrlVideoWidgetState extends State { + // How often playhead position updates are forwarded to the callback. + static const Duration _playheadSampleInterval = Duration(seconds: 1); + // Minimum time between error-triggered refreshes, to avoid a rapid retry loop. + static const Duration _videoErrorRefreshCooldown = Duration(seconds: 5); + String _keyValue = uuid.v1(); Timer? _errorTimer; @@ -69,9 +74,9 @@ class _UrlVideoWidgetState extends State { // Replace '/upload' in the URL with '/upload/q_auto:good' for Cloudinary optimization, if not present if (!playbackUrl.contains('/upload/q_auto:good')) { - playbackUrl = playbackUrl.replaceFirst('/upload', '/upload/q_auto:good'); + playbackUrl = + playbackUrl.replaceFirst('/upload', '/upload/q_auto:good'); } - } final encodedLink = Uri.encodeQueryComponent(playbackUrl); String url = './stream/playback.html?url=$encodedLink' @@ -112,7 +117,7 @@ class _UrlVideoWidgetState extends State { // Listen to stream; forward maximum of one playhead update per second to callback useStreamListener( - stream: controller.stream.sampleTime(Duration(seconds: 1)), + stream: controller.stream.sampleTime(_playheadSampleInterval), function: (status) { final onPlayheadUpdate = widget.onPlayheadUpdate; if (onPlayheadUpdate != null) { @@ -125,12 +130,11 @@ class _UrlVideoWidgetState extends State { useEffect( () { final subscription = html.window.onMessage.listen((event) { - final messageObj = event.data; // Check if the messageObj is a Map and contains the 'source' key; // we have to do this because the messageObj is a native JS object and can be anything coming from the onMessage Stream - if(messageObj is Map && messageObj.containsKey('source')) { + if (messageObj is Map && messageObj.containsKey('source')) { if (messageObj['source'] == 'videojs') { final String messageType = messageObj['type']; final double currentTime = messageObj['currentTime']; @@ -146,11 +150,13 @@ class _UrlVideoWidgetState extends State { onEnded(); } if (messageType == 'video-error') { - loggingService.log('message error event received: ${event.data}'); - if (widget.refreshOnError && !(_errorTimer?.isActive ?? false)) { + loggingService + .log('message error event received: ${event.data}'); + if (widget.refreshOnError && + !(_errorTimer?.isActive ?? false)) { // Don't constantly restart due to an error. If another error occurs // during this window, then it will not refresh. - _errorTimer = Timer(Duration(seconds: 5), () {}); + _errorTimer = Timer(_videoErrorRefreshCooldown, () {}); setState(() => _keyValue = uuid.v1()); } @@ -162,7 +168,8 @@ class _UrlVideoWidgetState extends State { if (messageType == 'video-update') { loggingService .log('message update event received: ${event.data}'); - controller.add(UrlVideoPlayheadInfo(currentTime, videoDuration)); + controller + .add(UrlVideoPlayheadInfo(currentTime, videoDuration)); } } } @@ -191,7 +198,7 @@ class _UrlVideoInternal extends StatefulWidget { : super(key: key); @override - _UrlVideoInternalState createState() => _UrlVideoInternalState(); + State<_UrlVideoInternal> createState() => _UrlVideoInternalState(); } class _UrlVideoInternalState extends State<_UrlVideoInternal> { diff --git a/client/lib/features/events/features/live_meeting/features/meeting_agenda/data/providers/meeting_agenda_provider.dart b/client/lib/features/events/features/live_meeting/features/meeting_agenda/data/providers/meeting_agenda_provider.dart index f33dd1983..5a3258942 100644 --- a/client/lib/features/events/features/live_meeting/features/meeting_agenda/data/providers/meeting_agenda_provider.dart +++ b/client/lib/features/events/features/live_meeting/features/meeting_agenda/data/providers/meeting_agenda_provider.dart @@ -17,7 +17,6 @@ import 'package:data_models/events/event.dart'; import 'package:data_models/events/live_meetings/live_meeting.dart'; import 'package:data_models/templates/template.dart'; import 'package:provider/provider.dart'; -import 'package:client/core/localization/localization_helper.dart'; List defaultAgendaItems(String communityId) { final l10n = appLocalizationService.getLocalization(); @@ -56,6 +55,14 @@ class AgendaProviderParams { } class AgendaProvider with ChangeNotifier { + // Minimum time a participant must have spent on an agenda item before they + // can advance without a confirmation prompt. A shorter threshold is used for + // the start card since it has no real content. + static const Duration _startItemAdvanceConfirmationThreshold = + Duration(seconds: 15); + static const Duration _agendaItemAdvanceConfirmationThreshold = + Duration(seconds: 30); + final LiveMeetingProvider? liveMeetingProvider; AgendaProviderParams _params; @@ -589,8 +596,8 @@ class AgendaProvider with ChangeNotifier { final timeInState = timeInSection(currentAgendaItemId); final doubleCheckDuration = currentAgendaItemId == MeetingGuideCardStore.startAgendaItemId - ? Duration(seconds: 15) - : Duration(seconds: 30); + ? _startItemAdvanceConfirmationThreshold + : _agendaItemAdvanceConfirmationThreshold; final suppressWarning = currentAgendaItem?.type == AgendaItemType.poll || currentAgendaItem?.type == AgendaItemType.video; diff --git a/client/lib/features/events/features/live_meeting/features/meeting_guide/data/providers/meeting_guide_card_store.dart b/client/lib/features/events/features/live_meeting/features/meeting_guide/data/providers/meeting_guide_card_store.dart index 522c520c6..2a5669695 100644 --- a/client/lib/features/events/features/live_meeting/features/meeting_guide/data/providers/meeting_guide_card_store.dart +++ b/client/lib/features/events/features/live_meeting/features/meeting_guide/data/providers/meeting_guide_card_store.dart @@ -15,6 +15,9 @@ import 'package:provider/provider.dart'; class MeetingGuideCardStore with ChangeNotifier { static const String startAgendaItemId = 'start'; + // Delay before the guide card reflects a new agenda item, giving participants + // a brief countdown before the card transitions. + static const Duration _agendaItemTransitionDelay = Duration(seconds: 3); final CommunityProvider communityProvider; final LiveMeetingProvider liveMeetingProvider; @@ -179,7 +182,7 @@ class MeetingGuideCardStore with ChangeNotifier { // [_currentMeetingGuideAgendaItemId] to match. During this time a timer is shown counting // down to the new agenda item. _pendingMeetingGuideAgendaItemTimer?.cancel(); - _pendingMeetingGuideAgendaItemTimer = Timer(Duration(seconds: 3), () { + _pendingMeetingGuideAgendaItemTimer = Timer(_agendaItemTransitionDelay, () { _setCurrentMeetingGuideAgendaItemId(_agendaProviderCurrentItemId); liveMeetingProvider.setAudioTemporarilyDisabled( diff --git a/client/lib/features/events/features/live_meeting/features/meeting_guide/presentation/views/meeting_guide_card.dart b/client/lib/features/events/features/live_meeting/features/meeting_guide/presentation/views/meeting_guide_card.dart index 6d33e43ca..a2d662ba9 100644 --- a/client/lib/features/events/features/live_meeting/features/meeting_guide/presentation/views/meeting_guide_card.dart +++ b/client/lib/features/events/features/live_meeting/features/meeting_guide/presentation/views/meeting_guide_card.dart @@ -50,7 +50,7 @@ class MeetingGuideCard extends StatefulWidget { }) : super(key: key); @override - _MeetingGuideCardState createState() => _MeetingGuideCardState(); + State createState() => _MeetingGuideCardState(); } class _MeetingGuideCardState extends State { @@ -103,7 +103,7 @@ class MeetingGuideCardContent extends StatefulWidget { }) : super(key: key); @override - _MeetingGuideCardContentState createState() => + State createState() => _MeetingGuideCardContentState(); } diff --git a/client/lib/features/events/features/live_meeting/features/video/data/models/networking_status_model.dart b/client/lib/features/events/features/live_meeting/features/video/data/models/networking_status_model.dart index 919cae574..ce5f962b8 100644 --- a/client/lib/features/events/features/live_meeting/features/video/data/models/networking_status_model.dart +++ b/client/lib/features/events/features/live_meeting/features/video/data/models/networking_status_model.dart @@ -4,9 +4,13 @@ import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:client/services.dart'; class NetworkingStatusModel { + // Suppress low-network-quality warnings for this long after joining, to avoid + // false positives while the Agora connection is still ramping up. + static const Duration _lowNetworkQualityWarningDelay = Duration(minutes: 2); + /// Time when low network quality message can be shown. Onwards. final DateTime messageShowTimeThreshold = - clockService.now().add(Duration(minutes: 2)); + clockService.now().add(_lowNetworkQualityWarningDelay); bool isLowNetworkQuality = false; bool isLowNetworkQualityMessageDismissed = false; diff --git a/client/lib/features/events/features/live_meeting/features/video/data/providers/conference_room.dart b/client/lib/features/events/features/live_meeting/features/video/data/providers/conference_room.dart index 712c4edd3..c0c2936ca 100644 --- a/client/lib/features/events/features/live_meeting/features/video/data/providers/conference_room.dart +++ b/client/lib/features/events/features/live_meeting/features/video/data/providers/conference_room.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; -import 'package:beamer/beamer.dart'; import 'package:client/core/utils/media_device_service.dart'; import 'package:client/core/utils/navigation_utils.dart'; import 'package:client/core/utils/random_utils.dart'; @@ -22,12 +21,8 @@ import 'package:pedantic/pedantic.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:client/core/localization/localization_helper.dart'; import 'package:synchronized/synchronized.dart'; import 'package:universal_html/js_util.dart' as js_util; -import 'package:universal_html/html.dart' as html; - -import '../../../../../../../../core/routing/locations.dart'; import 'agora_room.dart'; class FakeParticipant extends AgoraParticipant { @@ -47,7 +42,6 @@ class FakeParticipant extends AgoraParticipant { @override void removeListener(VoidCallback listener) {} - @override List get audioTracks => []; @override @@ -59,10 +53,8 @@ class FakeParticipant extends AgoraParticipant { @override String get userId => id.toString(); - @override String get state => 'connected'; - @override List get videoTracks => []; } @@ -97,6 +89,26 @@ class VideoParticipant implements MeetingProviderParticipant { } class ConferenceRoom with ChangeNotifier { + static const Duration _mediaToggleLockTimeout = Duration(seconds: 4); + + // The dominant speaker pipeline has three timing constants: + // _dominantSpeakerInputDebounce collapses rapid bursts from Agora before any logic runs. + // _dominantSpeakerSilenceHoldDuration delays accepting a null (no active speaker) — + // a new speaker arriving within this window cancels the hold. + // _dominantSpeakerOutputDebounce settles any remaining chatter after the switchMap. + static const Duration _dominantSpeakerInputDebounce = + Duration(milliseconds: 500); + static const Duration _dominantSpeakerSilenceHoldDuration = + Duration(seconds: 3); + static const Duration _dominantSpeakerOutputDebounce = Duration(seconds: 1); + // If a user is confirmed as dominant speaker for this long, their raised hand + // is automatically lowered (they have the floor, no need to keep requesting it). + static const Duration _dominantSpeakerUnraiseHandDelay = Duration(seconds: 4); + static const Duration _participantInitializationDelay = Duration(seconds: 4); + // Random jitter added before calling checkReadyToAdvance after a disconnect, + // to avoid all remaining participants hitting Firestore simultaneously. + static const int _disconnectCheckReadyMaxJitterMs = 5000; + final LiveMeetingProvider liveMeetingProvider; final AgendaProvider agendaProvider; final CommunityProvider communityProvider; @@ -373,6 +385,7 @@ class ConferenceRoom with ChangeNotifier { if (updatedEnabledValue) { final permissionStatus = await Permission.camera.request(); if (permissionStatus.isDenied || permissionStatus.isPermanentlyDenied) { + if (!navigatorState.mounted) return; await showAlert( navigatorState.context, 'Error enabling camera. Please ensure you have granted permission.', @@ -392,7 +405,7 @@ class ConferenceRoom with ChangeNotifier { liveMeetingProvider.shouldStartLocalVideoOn = updatedEnabledValue; } }, - timeout: Duration(seconds: 4), + timeout: _mediaToggleLockTimeout, ); notifyListeners(); } @@ -406,6 +419,7 @@ class ConferenceRoom with ChangeNotifier { if (updatedEnabledValue) { final permissionStatus = await Permission.microphone.request(); if (permissionStatus.isDenied || permissionStatus.isPermanentlyDenied) { + if (!navigatorState.mounted) return; await showAlert( navigatorState.context, 'Error enabling microphone. Please ensure you have granted permission.', @@ -444,7 +458,7 @@ class ConferenceRoom with ChangeNotifier { liveMeetingProvider.shouldStartLocalAudioOn = updatedEnabledValue; } }, - timeout: Duration(seconds: 4), + timeout: _mediaToggleLockTimeout, ); notifyListeners(); @@ -468,21 +482,20 @@ class ConferenceRoom with ChangeNotifier { _debouncedDominantSpeakerStream = BehaviorSubjectWrapper( room.dominantSpeakerStream .distinct() - .debounceTime(Duration(milliseconds: 500)) + .debounceTime(_dominantSpeakerInputDebounce) .switchMap((id) { if (id == null) { - // If it is null then wait a few seconds to make sure there arent other changes before switching over to no active speaker - return Rx.timer(null, Duration(seconds: 3)); + return Rx.timer(null, _dominantSpeakerSilenceHoldDuration); } return Stream.value(id); // Immediately emit new speaker ID - }).debounceTime(Duration(seconds: 1)), + }).debounceTime(_dominantSpeakerOutputDebounce), ); _debouncedDominantSpeakerSubscription = _debouncedDominantSpeakerStream!.listen((_) => notifyListeners()); _unraiseHandSubscription = _debouncedDominantSpeakerStream! .distinct() - .debounceTime(Duration(seconds: 4)) + .debounceTime(_dominantSpeakerUnraiseHandDelay) .distinct() .listen((dominantSpeaker) { final dismissRaisedHand = @@ -504,7 +517,21 @@ class ConferenceRoom with ChangeNotifier { }); _updateLiveMeetingParticipants(); - print('updated live meeting participants'); + Debug.log( + 'ConferenceRoom._onConnected => updated live meeting participants', + ); + + // Update room membership now that Agora has confirmed connection + unawaited( + firestoreLiveMeetingService.updateMeetingPresence( + event: liveMeetingProvider.eventProvider.event, + isPresent: true, + currentBreakoutRoomId: liveMeetingProvider.currentBreakoutRoomId, + ), + ); + + liveMeetingProvider.clearBreakoutRoomTransition(); + notifyListeners(); _completer.complete(room); if (liveMeetingProvider.audioDefaultOn && @@ -522,12 +549,14 @@ class ConferenceRoom with ChangeNotifier { cancelText: appLocalizationService.getLocalization().cancel, ).show(); if (enableAudioVideo) { + if (!navigatorState.mounted) return; if (!(_room?.localParticipant?.audioTrackEnabled ?? false)) { await AudioVideoErrorDialog.showOnError( navigatorState.context, () => toggleAudioEnabled(setEnabled: true), ); } + if (!navigatorState.mounted) return; if (!(_room?.localParticipant?.videoTrackEnabled ?? false)) { await AudioVideoErrorDialog.showOnError( navigatorState.context, @@ -550,7 +579,7 @@ class ConferenceRoom with ChangeNotifier { // Add timers for newly connected users for (final participant in participants) { participantInitializationTimers[participant.userId] ??= - Timer(Duration(seconds: 4), () => notifyListeners()); + Timer(_participantInitializationDelay, () => notifyListeners()); } liveMeetingProvider.setMeetingProviderParticipants( @@ -585,8 +614,11 @@ class ConferenceRoom with ChangeNotifier { if (liveMeetingProvider.isInBreakout) { Future.delayed( - Duration(milliseconds: (5.0 * random.nextDouble() * 1000).round()), - () { + Duration( + milliseconds: + (_disconnectCheckReadyMaxJitterMs * random.nextDouble()) + .round(), + ), () { if (!_isDisposed) { agendaProvider.checkReadyToAdvance(); } diff --git a/client/lib/features/user/data/services/user_service.dart b/client/lib/features/user/data/services/user_service.dart index f05e576f2..2d1d853c0 100644 --- a/client/lib/features/user/data/services/user_service.dart +++ b/client/lib/features/user/data/services/user_service.dart @@ -24,6 +24,10 @@ enum SignInState { class UserService with ChangeNotifier { static bool usingEmulator = false; + // How long to wait for Firebase to automatically re-authenticate a returning + // user before falling back to anonymous sign-in. + static const Duration _returningUserSignInTimeout = Duration(seconds: 8); + final FirebaseAuth _firebaseAuth = FirebaseAuth.instance; // ignore: close_sinks @@ -76,7 +80,7 @@ class UserService with ChangeNotifier { /// /// We wait for a period and if we don't see them get signed in, we sign in anonymously. void _handleReturningUser() { - _returningUserTimer = Timer(Duration(seconds: 8), () { + _returningUserTimer = Timer(_returningUserSignInTimeout, () { final user = _firebaseAuth.currentUser; if (user != null) { // This path happens during hot reload mostly. diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index c7d69992a..2a73396d7 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -124,6 +124,7 @@ "emailAddressAlreadyInUse": "You already created an account tied to this email address. Use Sign in with Email and click Forgot Password if you don't know it. Or, Sign Up again using a different email.", "emailAddressAlreadyInUseLoginError": "This email is already in use. Try ", "endBreakoutRooms": "End Breakout Rooms", + "breakoutRoomTransitionTimeout": "Could not find that breakout room! This usually means a network error or that the host closed the room. Returning to the main meeting.", "enterValidName": "Please enter a valid name", "endTime": "End Time", "enterHeadline": "Enter Headline", diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index be6243fea..bafea315f 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -207,6 +207,7 @@ "emailAddressAlreadyInUse": "Ya ha creado una cuenta vinculada a esta dirección de email. Utilice Iniciar sesión con email y haga clic en Olvidé mi contraseña si no la conoce. O regístrese de nuevo con un email diferente.", "emailAddressAlreadyInUseLoginError": "Este correo electrónico ya está en uso. Inténtalo ", "endBreakoutRooms": "Finalizar salas de grupo", + "breakoutRoomTransitionTimeout": "¡No se pudo encontrar esa sala de grupo! Regresando a la reunión principal.", "endTime": "Hora de finalización", "enterHeadline": "Introducir título", "enterImageTitle": "Introducir título de la imagen", diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index dbf2a969e..4ce5f1c1e 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -233,6 +233,7 @@ "emailAddressAlreadyInUse": "您已经创建了一个与此电子邮件地址关联的帐户。请使用电子邮件登录,如果不知道密码请点击忘记密码。或者,使用其他电子邮件重新注册。", "emailAddressAlreadyInUseLoginError": "此邮箱已被使用。请尝试", "endBreakoutRooms": "结束分组讨论", + "breakoutRoomTransitionTimeout": "找不到该分组讨论室!返回主会议。", "endTime": "结束时间", "enterHeadline": "输入标题", "enterImageTitle": "输入图片标题", diff --git a/client/lib/l10n/app_zh_Hant_TW.arb b/client/lib/l10n/app_zh_Hant_TW.arb index b5df3805a..99c64e827 100644 --- a/client/lib/l10n/app_zh_Hant_TW.arb +++ b/client/lib/l10n/app_zh_Hant_TW.arb @@ -124,6 +124,7 @@ "emailAddressAlreadyInUse": "您已經創建了一個與此電子郵件地址關聯的帳戶。請使用電子郵件登入,如果不知道密碼請點擊忘記密碼。或者,使用其他電子郵件重新註冊。", "emailAddressAlreadyInUseLoginError": "此電子郵件已被使用。請嘗試 ", "endBreakoutRooms": "結束分組討論", + "breakoutRoomTransitionTimeout": "找不到該分組討論室!返回主會議。", "endTime": "結束時間", "enterHeadline": "輸入標題", "enterImageTitle": "輸入圖片標題", diff --git a/client/lib/styles/app_asset.dart b/client/lib/styles/app_asset.dart index f59f98b6c..49a30f66a 100644 --- a/client/lib/styles/app_asset.dart +++ b/client/lib/styles/app_asset.dart @@ -1,125 +1,143 @@ class AppAsset { - static const AppAsset kXWhitePng = AppAsset('media/xWhite.png'); - static const AppAsset kExclamationSvg = AppAsset('media/exclamation.svg'); - static const AppAsset kCheckCircleSvg = AppAsset('media/checkCircle.svg'); - static const AppAsset kXSvg = AppAsset('media/x.svg'); - static const AppAsset kCopySvg = AppAsset('media/copy.svg'); - static const AppAsset kPlusGuideSvg = AppAsset('media/plus-guide.svg'); - static const AppAsset kRefreshSvg = AppAsset('media/refresh.svg'); - static const AppAsset kSurveySvg = AppAsset('media/survey.svg'); - static const AppAsset kSurveyPng = AppAsset('media/survey.png'); - static const AppAsset kTextSvg = AppAsset('media/text.svg'); - static const AppAsset kTextPng = AppAsset('media/text.png'); - static const AppAsset kImageSvg = AppAsset('media/image.svg'); - static const AppAsset kImagePng = AppAsset('media/image.png'); - static const AppAsset kWordCloudSvg = AppAsset('media/word-cloud.svg'); - static const AppAsset kWordCloudPng = AppAsset('media/word-cloud.png'); + static const AppAsset kXWhitePng = AppAsset._raw('media/xWhite.png'); + static const AppAsset kExclamationSvg = + AppAsset._raw('media/exclamation.svg', true); + static const AppAsset kCheckCircleSvg = + AppAsset._raw('media/checkCircle.svg', true); + static const AppAsset kXSvg = AppAsset._raw('media/x.svg', true); + static const AppAsset kCopySvg = AppAsset._raw('media/copy.svg', true); + static const AppAsset kPlusGuideSvg = + AppAsset._raw('media/plus-guide.svg', true); + static const AppAsset kRefreshSvg = AppAsset._raw('media/refresh.svg', true); + static const AppAsset kSurveySvg = AppAsset._raw('media/survey.svg', true); + static const AppAsset kSurveyPng = AppAsset._raw('media/survey.png'); + static const AppAsset kTextSvg = AppAsset._raw('media/text.svg', true); + static const AppAsset kTextPng = AppAsset._raw('media/text.png'); + static const AppAsset kImageSvg = AppAsset._raw('media/image.svg', true); + static const AppAsset kImagePng = AppAsset._raw('media/image.png'); + static const AppAsset kWordCloudSvg = + AppAsset._raw('media/word-cloud.svg', true); + static const AppAsset kWordCloudPng = AppAsset._raw('media/word-cloud.png'); static const AppAsset kWordCloudEmptyPng = - AppAsset('media/wordcloud-empty.png'); + AppAsset._raw('media/wordcloud-empty.png'); static const AppAsset kWordCloudPlaceholderPng = - AppAsset('media/wordcloud-placeholder.png'); - static const AppAsset kThumbSvg = AppAsset('media/thumb.svg'); - static const AppAsset kThumbPng = AppAsset('media/thumb.png'); - static const AppAsset kMoveForwardPng = AppAsset('media/arrow_forward.png'); - static const AppAsset kMaximizePng = AppAsset('media/maximize.png'); - static const AppAsset kMinimizePng = AppAsset('media/minimize.png'); - static const AppAsset kWavingHand = AppAsset('media/waving_hand.png'); - static const AppAsset kLikeSelectedPng = AppAsset('media/like-selected.png'); - static const AppAsset kLikeNotSelectedPng = AppAsset('media/like.png'); + AppAsset._raw('media/wordcloud-placeholder.png'); + static const AppAsset kThumbSvg = AppAsset._raw('media/thumb.svg', true); + static const AppAsset kThumbPng = AppAsset._raw('media/thumb.png'); + static const AppAsset kMoveForwardPng = + AppAsset._raw('media/arrow_forward.png'); + static const AppAsset kMaximizePng = AppAsset._raw('media/maximize.png'); + static const AppAsset kMinimizePng = AppAsset._raw('media/minimize.png'); + static const AppAsset kWavingHand = AppAsset._raw('media/waving_hand.png'); + static const AppAsset kLikeSelectedPng = + AppAsset._raw('media/like-selected.png'); + static const AppAsset kLikeNotSelectedPng = AppAsset._raw('media/like.png'); static const AppAsset kDislikeSelectedPng = - AppAsset('media/dislike-selected.png'); - static const AppAsset kDislikeNotSelectedPng = AppAsset('media/dislike.png'); - static const AppAsset kArrowBackPng = AppAsset('media/arrow-back.png'); - static const AppAsset kMorePng = AppAsset('media/more-gray.png'); - static const AppAsset kHomePng = AppAsset('media/home.png'); - static const AppAsset kChatBubblePng = AppAsset('media/chatBubble.png'); - static const AppAsset kChatBubble2Png = AppAsset('media/chatBubble2.png'); - static const AppAsset kXPng = AppAsset('media/x.png'); + AppAsset._raw('media/dislike-selected.png'); + static const AppAsset kDislikeNotSelectedPng = + AppAsset._raw('media/dislike.png'); + static const AppAsset kArrowBackPng = AppAsset._raw('media/arrow-back.png'); + static const AppAsset kMorePng = AppAsset._raw('media/more-gray.png'); + static const AppAsset kHomePng = AppAsset._raw('media/home.png'); + static const AppAsset kChatBubblePng = AppAsset._raw('media/chatBubble.png'); + static const AppAsset kChatBubble2Png = + AppAsset._raw('media/chatBubble2.png'); + static const AppAsset kXPng = AppAsset._raw('media/x.png'); static const AppAsset kEmptyStateStatusPng = - AppAsset('media/empty-state-status.png'); - static const AppAsset kTrashPng = AppAsset('media/trash.png'); - static const AppAsset kRoleModPng = AppAsset('media/role-mod.png'); + AppAsset._raw('media/empty-state-status.png'); + static const AppAsset kTrashPng = AppAsset._raw('media/trash.png'); + static const AppAsset kRoleModPng = AppAsset._raw('media/role-mod.png'); static const AppAsset kRoleFacilitatorPng = - AppAsset('media/role-facilitator.png'); - static const AppAsset kRoleAdminPng = AppAsset('media/role-admin.png'); + AppAsset._raw('media/role-facilitator.png'); + static const AppAsset kRoleAdminPng = AppAsset._raw('media/role-admin.png'); static const AppAsset kRoleNonMemberPng = - AppAsset('media/role-nonmember.png'); - static const AppAsset kRoleMemberPng = AppAsset('media/role-member.png'); - static const AppAsset kTrashWhitePng = AppAsset('media/trash_white.png'); - static const AppAsset kCheckPng = AppAsset('media/check.png'); - static const AppAsset kStarPng = AppAsset('media/starIcon.png'); + AppAsset._raw('media/role-nonmember.png'); + static const AppAsset kRoleMemberPng = AppAsset._raw('media/role-member.png'); + static const AppAsset kTrashWhitePng = AppAsset._raw('media/trash_white.png'); + static const AppAsset kCheckPng = AppAsset._raw('media/check.png'); + static const AppAsset kStarPng = AppAsset._raw('media/starIcon.png'); static const AppAsset kEmojiCalendarPng = - AppAsset('media/emoji-calendar.png'); - static const AppAsset kEmojiSparklePng = AppAsset('media/emoji-sparkle.png'); + AppAsset._raw('media/emoji-calendar.png'); + static const AppAsset kEmojiSparklePng = + AppAsset._raw('media/emoji-sparkle.png'); static const AppAsset kEmojiMegaphonePng = - AppAsset('media/emoji-megaphone.png'); - static const AppAsset kEmojiNotepadPng = AppAsset('media/emoji-notepad.png'); + AppAsset._raw('media/emoji-megaphone.png'); + static const AppAsset kEmojiNotepadPng = + AppAsset._raw('media/emoji-notepad.png'); static const AppAsset kEmojiYellowHeartPng = - AppAsset('media/emoji-yellow-heart.png'); - static const AppAsset kEmojiPartyPng = AppAsset('media/emoji-party.png'); - static const AppAsset kFliterIcon = AppAsset('media/filter.png'); - static const AppAsset kSocialEmail2Png = AppAsset('media/social-email2.png'); + AppAsset._raw('media/emoji-yellow-heart.png'); + static const AppAsset kEmojiPartyPng = AppAsset._raw('media/emoji-party.png'); + static const AppAsset kFliterIcon = AppAsset._raw('media/filter.png'); + static const AppAsset kSocialEmail2Png = + AppAsset._raw('media/social-email2.png'); static const AppAsset kSocialFacebook2Png = - AppAsset('media/social-facebook2.png'); - static const AppAsset kSocialLinkedIn2Png = AppAsset('media/social-li2.png'); - static const AppAsset kSocialLink2Png = AppAsset('media/social-link2.png'); + AppAsset._raw('media/social-facebook2.png'); + static const AppAsset kSocialLinkedIn2Png = + AppAsset._raw('media/social-li2.png'); + static const AppAsset kSocialLink2Png = + AppAsset._raw('media/social-link2.png'); static const AppAsset kSocialTwitter2Png = - AppAsset('media/social-twitter2.png'); + AppAsset._raw('media/social-twitter2.png'); // Logo images - static const AppAsset kLogoIconPng = AppAsset('media/logo-icon.png'); - static const AppAsset kLogoPng = AppAsset('media/logo.png'); - static const AppAsset kLogoSvg = AppAsset('media/logo.svg'); + static const AppAsset kLogoIconPng = AppAsset._raw('media/logo-icon.png'); + static const AppAsset kLogoPng = AppAsset._raw('media/logo.png'); + static const AppAsset kLogoSvg = AppAsset._raw('media/logo.svg', true); static const AppAsset kSpokenCheckMark = - AppAsset('media/spoken_check_mark.png'); - static const AppAsset kEventsIcon = AppAsset('media/events_icon.png'); + AppAsset._raw('media/spoken_check_mark.png'); + static const AppAsset kEventsIcon = AppAsset._raw('media/events_icon.png'); static const AppAsset kStartEventCardImage = - AppAsset('media/start-event-card-image.png'); - static const AppAsset kCalendarGreyPng = AppAsset('media/calendar_grey.png'); - static const AppAsset kCalendarBluePng = AppAsset('media/calendar_blue.png'); + AppAsset._raw('media/start-event-card-image.png'); + static const AppAsset kCalendarGreyPng = + AppAsset._raw('media/calendar_grey.png'); + static const AppAsset kCalendarBluePng = + AppAsset._raw('media/calendar_blue.png'); static const AppAsset kChatBubbleGreyPng = - AppAsset('media/chat_bubble_grey.png'); + AppAsset._raw('media/chat_bubble_grey.png'); static const AppAsset kChatBubbleBluePng = - AppAsset('media/chat_bubble_blue.png'); + AppAsset._raw('media/chat_bubble_blue.png'); static const AppAsset kDocumentsGreyPng = - AppAsset('media/documents_grey.png'); + AppAsset._raw('media/documents_grey.png'); static const AppAsset kDocumentsBluePng = - AppAsset('media/documents_blue.png'); - static const AppAsset kAudioOffPng = AppAsset('media/audio-off.png'); + AppAsset._raw('media/documents_blue.png'); + static const AppAsset kAudioOffPng = AppAsset._raw('media/audio-off.png'); static const AppAsset kAudioOffPngWhite = - AppAsset('media/audio-off-white.png'); - static const AppAsset kAudioOnPngWhite = AppAsset('media/audio-on-white.png'); - static const AppAsset kCameraPng = AppAsset('media/camera.png'); - static const AppAsset kCameraOffPng = AppAsset('media/camera-off.png'); - static const AppAsset kAddPhotoSvg = AppAsset('media/add-photo.svg'); - static const AppAsset kFacebookPng = AppAsset('media/facebook.png'); - static const AppAsset kInstagramPng = AppAsset('media/instagram.png'); - static const AppAsset kLinkedinPng = AppAsset('media/linkedin.png'); - static const AppAsset kTwitterPng = AppAsset('media/twitterLogo.png'); - static const AppAsset kDeletePng = AppAsset('media/delete.png'); - static const AppAsset kGlobePng = AppAsset('media/globe.png'); - static const AppAsset kPlayScreenPng = AppAsset('media/play-screen.png'); - static const AppAsset kLockPng = AppAsset('media/lock.png'); - static const AppAsset kHostlessPng = AppAsset('media/hostless.png'); + AppAsset._raw('media/audio-off-white.png'); + static const AppAsset kAudioOnPngWhite = + AppAsset._raw('media/audio-on-white.png'); + static const AppAsset kCameraPng = AppAsset._raw('media/camera.png'); + static const AppAsset kCameraOffPng = AppAsset._raw('media/camera-off.png'); + static const AppAsset kAddPhotoSvg = + AppAsset._raw('media/add-photo.svg', true); + static const AppAsset kFacebookPng = AppAsset._raw('media/facebook.png'); + static const AppAsset kInstagramPng = AppAsset._raw('media/instagram.png'); + static const AppAsset kLinkedinPng = AppAsset._raw('media/linkedin.png'); + static const AppAsset kTwitterPng = AppAsset._raw('media/twitterLogo.png'); + static const AppAsset kDeletePng = AppAsset._raw('media/delete.png'); + static const AppAsset kGlobePng = AppAsset._raw('media/globe.png'); + static const AppAsset kPlayScreenPng = AppAsset._raw('media/play-screen.png'); + static const AppAsset kLockPng = AppAsset._raw('media/lock.png'); + static const AppAsset kHostlessPng = AppAsset._raw('media/hostless.png'); static const AppAsset kCheckCircleGreen = - AppAsset('media/checkCircle-green.png'); + AppAsset._raw('media/checkCircle-green.png'); static const AppAsset kCheckCircleGray = - AppAsset('media/checkCircle-gray.png'); - static const AppAsset kEditPng = AppAsset('media/edit.png'); - static const AppAsset kGearPng = AppAsset('media/gear.png'); - static const AppAsset kHandRaise = AppAsset('media/hand-raise.png'); - static const AppAsset kSpeaking = AppAsset('media/speaking.png'); - static const AppAsset kUpNext = AppAsset('media/up-next.png'); - static const AppAsset kPlusPng = AppAsset('media/plus.png'); + AppAsset._raw('media/checkCircle-gray.png'); + static const AppAsset kEditPng = AppAsset._raw('media/edit.png'); + static const AppAsset kGearPng = AppAsset._raw('media/gear.png'); + static const AppAsset kHandRaise = AppAsset._raw('media/hand-raise.png'); + static const AppAsset kSpeaking = AppAsset._raw('media/speaking.png'); + static const AppAsset kUpNext = AppAsset._raw('media/up-next.png'); + static const AppAsset kPlusPng = AppAsset._raw('media/plus.png'); static const AppAsset kUserSuggestionAgendaCover = - AppAsset('media/user-suggestion-agenda-cover.png'); - static const AppAsset kAirplaneWhite = AppAsset('media/airplane-white.png'); + AppAsset._raw('media/user-suggestion-agenda-cover.png'); + static const AppAsset kAirplaneWhite = + AppAsset._raw('media/airplane-white.png'); static const AppAsset kSmileyWithPlusPng = - AppAsset('media/smiley-with-plus.png'); + AppAsset._raw('media/smiley-with-plus.png'); - static const AppAsset kCongratulations = AppAsset('media/congrats.png'); + static const AppAsset kCongratulations = AppAsset._raw('media/congrats.png'); final String path; final bool isSvg; @@ -127,6 +145,8 @@ class AppAsset { @Deprecated('Use dedicated constructor') const AppAsset(this.path) : isSvg = false; + const AppAsset._raw(this.path, [this.isSvg = false]); + const AppAsset.clock([this.isSvg = false]) : path = isSvg ? 'media/clock.svg' : 'media/clock.png'; const AppAsset.raisedHand([this.isSvg = false]) @@ -171,4 +191,7 @@ class AppAsset { : path = isSvg ? 'media/maximize.svg' : 'media/maximize-blue.png'; const AppAsset.needHelpWhite([this.isSvg = false]) : path = isSvg ? 'media/needHelp.svg' : 'media/need_help.png'; + const AppAsset.backgroundGif() + : path = 'media/background.gif', + isSvg = false; } diff --git a/client/pubspec.lock b/client/pubspec.lock index 3b4d40a91..266c876f6 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -235,7 +235,7 @@ packages: source: hosted version: "4.10.0" collection: - dependency: "direct overridden" + dependency: "direct main" description: name: collection sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf @@ -354,7 +354,7 @@ packages: source: hosted version: "3.0.13" enum_to_string: - dependency: transitive + dependency: "direct main" description: name: enum_to_string sha256: bd9e83a33b754cb43a75b36a9af2a0b92a757bfd9847d2621ca0b1bed45f8e7a diff --git a/client/pubspec.yaml b/client/pubspec.yaml index b5adb4324..efaba8d9a 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -81,6 +81,8 @@ dependencies: logger: ^2.1.0 synchronized: ^3.1.0 clock: ^1.1.1 + collection: ^1.19.0 + enum_to_string: ^2.0.1 metadata_fetch: ^0.4.1 file_picker: ^5.3.1 rainbow_color: ^2.0.1 diff --git a/data_models/lib/analytics/analytics_entities.dart b/data_models/lib/analytics/analytics_entities.dart index 9a329be1b..071e04ca0 100644 --- a/data_models/lib/analytics/analytics_entities.dart +++ b/data_models/lib/analytics/analytics_entities.dart @@ -742,6 +742,47 @@ class AnalyticsRsvpEventEvent implements AnalyticsEvent { } } +@JsonSerializable() +class AnalyticsBreakoutRoomTransitionEvent implements AnalyticsEvent { + @override + String getEventType() { + return 'Breakout Room Transition'; + } + + final String communityId; + final String eventId; + + /// Duration of the breakout room transition in milliseconds. + final int durationMs; + final String? templateId; + + AnalyticsBreakoutRoomTransitionEvent({ + required this.communityId, + required this.eventId, + required this.durationMs, + this.templateId, + }); + + @override + Map toJson() => + _$AnalyticsBreakoutRoomTransitionEventToJson(this); + + @override + String getEventCategory() { + return AnalyticsEvent.eventCategory; + } + + @override + String? getEventName() { + return eventId; + } + + @override + num? getMetricValue() { + return durationMs; + } +} + @JsonSerializable() class AnalyticsDonateEvent implements AnalyticsEvent { @override diff --git a/data_models/lib/analytics/analytics_entities.g.dart b/data_models/lib/analytics/analytics_entities.g.dart index 785ba95c4..7283cbdc7 100644 --- a/data_models/lib/analytics/analytics_entities.g.dart +++ b/data_models/lib/analytics/analytics_entities.g.dart @@ -322,6 +322,24 @@ Map _$AnalyticsRsvpEventEventToJson( 'templateId': instance.templateId, }; +AnalyticsBreakoutRoomTransitionEvent + _$AnalyticsBreakoutRoomTransitionEventFromJson(Map json) => + AnalyticsBreakoutRoomTransitionEvent( + communityId: json['communityId'] as String, + eventId: json['eventId'] as String, + durationMs: json['durationMs'] as int, + templateId: json['templateId'] as String?, + ); + +Map _$AnalyticsBreakoutRoomTransitionEventToJson( + AnalyticsBreakoutRoomTransitionEvent instance) => + { + 'communityId': instance.communityId, + 'eventId': instance.eventId, + 'durationMs': instance.durationMs, + 'templateId': instance.templateId, + }; + AnalyticsDonateEvent _$AnalyticsDonateEventFromJson( Map json) => AnalyticsDonateEvent( diff --git a/firebase/functions/lib/events/live_meetings/update_presence_status.dart b/firebase/functions/lib/events/live_meetings/update_presence_status.dart index 8170ed801..e4280923d 100644 --- a/firebase/functions/lib/events/live_meetings/update_presence_status.dart +++ b/firebase/functions/lib/events/live_meetings/update_presence_status.dart @@ -91,7 +91,7 @@ class UpdatePresenceStatus implements CloudFunction { participant .copyWith( isPresent: false, - currentBreakoutRoomId: '', + currentBreakoutRoomId: null, ) .toJson() ..[Participant.kFieldLastUpdatedTime] =