From 5b56ab3e3134e226724b37796d553f39ce8bcdbe Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:52:08 -0400 Subject: [PATCH 01/39] Unify build into build-all.sh and register as npm run build. data_models/build.sh + firebase/functions/build.sh -> build-all.sh Expanded script to cover all three packages (client, data_models, firebase/functions) and use the FVM-pinned Flutter/Dart SDK instead of global binaries, fixing kernel binary format mismatch errors when the global SDK version differs from the repo pin. --- build-all.sh | 37 +++++++++++++++++++++++++++++++++++++ package.json | 3 ++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100755 build-all.sh diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 000000000..3bba24a06 --- /dev/null +++ b/build-all.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" + +CLIENT_DIR="$REPO_ROOT/client" +DATA_MODELS_DIR="$REPO_ROOT/data_models" +FUNCTIONS_DIR="$REPO_ROOT/firebase/functions" + +FVM_FLUTTER_BIN="$CLIENT_DIR/.fvm/flutter_sdk/bin/flutter" +FVM_DART_BIN="$CLIENT_DIR/.fvm/flutter_sdk/bin/dart" + +if [ -x "$FVM_FLUTTER_BIN" ] && [ -x "$FVM_DART_BIN" ]; then + FLUTTER_CMD="$FVM_FLUTTER_BIN" + DART_CMD="$FVM_DART_BIN" +else + FLUTTER_CMD="flutter" + DART_CMD="dart" +fi + +flutter_version="$($FLUTTER_CMD --version 2>/dev/null || true)" +flutter_version="${flutter_version%%$'\n'*}" +printf '\n[build-all] Using Flutter: %s\n' "$flutter_version" +printf '[build-all] Using Dart: %s\n\n' "$($DART_CMD --version 2>&1)" + +cd "$CLIENT_DIR" +"$FLUTTER_CMD" pub get + +cd "$DATA_MODELS_DIR" +"$FLUTTER_CMD" pub get +"$DART_CMD" run build_runner build --delete-conflicting-outputs + +cd "$FUNCTIONS_DIR" +npm install +"$FLUTTER_CMD" pub get +"$DART_CMD" run build_runner build --output=build \ No newline at end of file diff --git a/package.json b/package.json index ca2c67436..b571aa26d 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "frankly-dev-tools", "private": true, "scripts": { - "dev": "./run-dev.sh" + "dev": "./run-dev.sh", + "build": "./build-all.sh" } } From 554f56cb04a7b40f5dab2a9a993e9a715f96cd43 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:00:09 -0400 Subject: [PATCH 02/39] use FVM-pinned SDK in run-dev.sh; invalidate build stamps on SDK change Resolves kernel binary format mismatch errors caused by global Flutter/Dart differing from the repo-pinned version. All flutter/dart invocations now prefer client/.fvm/flutter_sdk/bin, with fallback to global. Also clears build stamps when the active SDK fingerprint changes between runs. --- run-dev.sh | 56 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/run-dev.sh b/run-dev.sh index b9de3d8c1..7b724e2c6 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -8,11 +8,23 @@ CLIENT_DIR="$REPO_ROOT/client" DATA_MODELS_DIR="$REPO_ROOT/data_models" FUNCTIONS_DIR="$REPO_ROOT/firebase/functions" +FVM_FLUTTER_BIN="$CLIENT_DIR/.fvm/flutter_sdk/bin/flutter" +FVM_DART_BIN="$CLIENT_DIR/.fvm/flutter_sdk/bin/dart" + +if [ -x "$FVM_FLUTTER_BIN" ] && [ -x "$FVM_DART_BIN" ]; then + FLUTTER_CMD="$FVM_FLUTTER_BIN" + DART_CMD="$FVM_DART_BIN" +else + FLUTTER_CMD="flutter" + DART_CMD="dart" +fi + STAMP_DIR="$REPO_ROOT/.local/dev-stamps" mkdir -p "$STAMP_DIR" DATA_MODELS_STAMP="$STAMP_DIR/data_models_build.stamp" FUNCTIONS_STAMP="$STAMP_DIR/functions_build.stamp" +SDK_FINGERPRINT_FILE="$STAMP_DIR/sdk_fingerprint.txt" EMULATOR_PID="" @@ -20,6 +32,36 @@ log() { printf '\n[run-dev] %s\n' "$*" } +current_sdk_fingerprint() { + local flutter_version + flutter_version="$("$FLUTTER_CMD" --version 2>/dev/null || true)" + flutter_version="${flutter_version%%$'\n'*}" + + { + printf '%s\n' "$flutter_version" + "$DART_CMD" --version 2>&1 + } | tr -d '\r' +} + +refresh_stamps_on_sdk_change() { + local current_fingerprint + current_fingerprint="$(current_sdk_fingerprint)" + + if [ ! -f "$SDK_FINGERPRINT_FILE" ]; then + printf '%s\n' "$current_fingerprint" > "$SDK_FINGERPRINT_FILE" + return + fi + + local previous_fingerprint + previous_fingerprint="$(cat "$SDK_FINGERPRINT_FILE")" + + if [ "$previous_fingerprint" != "$current_fingerprint" ]; then + log "Flutter/Dart SDK changed; clearing build stamps." + rm -f "$DATA_MODELS_STAMP" "$FUNCTIONS_STAMP" + printf '%s\n' "$current_fingerprint" > "$SDK_FINGERPRINT_FILE" + fi +} + require_dir() { local dir="$1" if [ ! -d "$dir" ]; then @@ -114,11 +156,13 @@ require_dir "$FUNCTIONS_DIR" log "Repo root: $REPO_ROOT" +refresh_stamps_on_sdk_change + log "Checking client Dart dependencies..." cd "$CLIENT_DIR" if need_flutter_pub_get; then log "Running flutter pub get in client..." - flutter pub get + "$FLUTTER_CMD" pub get else log "Client dependencies unchanged; skipping flutter pub get." fi @@ -127,14 +171,14 @@ log "Checking data_models Dart dependencies..." cd "$DATA_MODELS_DIR" if need_flutter_pub_get; then log "Running flutter pub get in data_models..." - flutter pub get + "$FLUTTER_CMD" pub get else log "data_models dependencies unchanged; skipping flutter pub get." fi if need_data_models_build; then log "Rebuilding data_models..." - dart run build_runner build --delete-conflicting-outputs + "$DART_CMD" run build_runner build --delete-conflicting-outputs touch "$DATA_MODELS_STAMP" else log "data_models unchanged; skipping build_runner." @@ -153,14 +197,14 @@ fi log "Checking functions Dart dependencies..." if need_flutter_pub_get; then log "Running flutter pub get in firebase/functions..." - flutter pub get + "$FLUTTER_CMD" pub get else log "Functions Dart dependencies unchanged; skipping flutter pub get." fi if need_functions_build; then log "Rebuilding firebase/functions..." - dart run build_runner build --output=build + "$DART_CMD" run build_runner build --output=build touch "$FUNCTIONS_STAMP" else log "firebase/functions unchanged; skipping build_runner." @@ -181,7 +225,7 @@ wait_for_port 5001 30 log "Launching Flutter client..." cd "$CLIENT_DIR" -flutter run \ +"$FLUTTER_CMD" run \ -d chrome \ --web-renderer html \ -t lib/dev_emulators_main.dart \ From e7e4a5601aee27622cad053af9a6d578bc0642e9 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:02:26 -0400 Subject: [PATCH 03/39] removed defunct old build scripts --- data_models/build.sh | 1 - firebase/functions/build.sh | 1 - 2 files changed, 2 deletions(-) delete mode 100755 data_models/build.sh delete mode 100755 firebase/functions/build.sh diff --git a/data_models/build.sh b/data_models/build.sh deleted file mode 100755 index 6d9e1d76d..000000000 --- a/data_models/build.sh +++ /dev/null @@ -1 +0,0 @@ -flutter pub get && dart run build_runner build --delete-conflicting-outputs \ No newline at end of file diff --git a/firebase/functions/build.sh b/firebase/functions/build.sh deleted file mode 100755 index 2a478e2b2..000000000 --- a/firebase/functions/build.sh +++ /dev/null @@ -1 +0,0 @@ -dart pub run tool/build_node.dart $@ From 7a3b4310b4df6bbd3b075c8484a80f709debcac5 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:31:55 -0400 Subject: [PATCH 04/39] Add meetingEndedAt field + endMeetingForAll CF - Add meetingEndedAt (DateTime?) and kFieldMeetingEndedAt to LiveMeeting - Add EndMeetingForAllRequest to data_models requests - Break out shared recording-stop logic into stop_all_event_recordings.dart - Refactor EventEnded CF to use shared helper - Add EndMeetingForAll callable CF with mod/admin/owner auth check, idempotency guard, recording stop, and post-event email - Register EndMeetingForAll in main.dart --- data_models/lib/cloud_functions/requests.dart | 12 ++ .../lib/cloud_functions/requests.freezed.dart | 143 ++++++++++++++++++ .../lib/cloud_functions/requests.g.dart | 12 ++ .../events/live_meetings/live_meeting.dart | 3 + .../live_meetings/live_meeting.freezed.dart | 43 +++++- .../events/live_meetings/live_meeting.g.dart | 2 + .../functions/lib/events/event_ended.dart | 48 +----- .../live_meetings/end_meeting_for_all.dart | 120 +++++++++++++++ .../stop_all_event_recordings.dart | 50 ++++++ firebase/functions/node/main.dart | 2 + 10 files changed, 385 insertions(+), 50 deletions(-) create mode 100644 firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart create mode 100644 firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart diff --git a/data_models/lib/cloud_functions/requests.dart b/data_models/lib/cloud_functions/requests.dart index 43f80a416..2b1492474 100644 --- a/data_models/lib/cloud_functions/requests.dart +++ b/data_models/lib/cloud_functions/requests.dart @@ -831,6 +831,18 @@ class EventEndedRequest _$EventEndedRequestFromJson(json); } +@Freezed(makeCollectionsUnmodifiable: false) +class EndMeetingForAllRequest + with _$EndMeetingForAllRequest + implements SerializeableRequest { + factory EndMeetingForAllRequest({ + required String eventPath, + }) = _EndMeetingForAllRequest; + + factory EndMeetingForAllRequest.fromJson(Map json) => + _$EndMeetingForAllRequestFromJson(json); +} + @Freezed(makeCollectionsUnmodifiable: false) class GetCommunityDonationsEnabledRequest with _$GetCommunityDonationsEnabledRequest diff --git a/data_models/lib/cloud_functions/requests.freezed.dart b/data_models/lib/cloud_functions/requests.freezed.dart index eb78c0af8..8146cff12 100644 --- a/data_models/lib/cloud_functions/requests.freezed.dart +++ b/data_models/lib/cloud_functions/requests.freezed.dart @@ -10401,6 +10401,149 @@ abstract class _EventEndedRequest implements EventEndedRequest { throw _privateConstructorUsedError; } +EndMeetingForAllRequest _$EndMeetingForAllRequestFromJson( + Map json) { + return _EndMeetingForAllRequest.fromJson(json); +} + +/// @nodoc +mixin _$EndMeetingForAllRequest { + String get eventPath => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $EndMeetingForAllRequestCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $EndMeetingForAllRequestCopyWith<$Res> { + factory $EndMeetingForAllRequestCopyWith(EndMeetingForAllRequest value, + $Res Function(EndMeetingForAllRequest) then) = + _$EndMeetingForAllRequestCopyWithImpl<$Res, EndMeetingForAllRequest>; + @useResult + $Res call({String eventPath}); +} + +/// @nodoc +class _$EndMeetingForAllRequestCopyWithImpl<$Res, + $Val extends EndMeetingForAllRequest> + implements $EndMeetingForAllRequestCopyWith<$Res> { + _$EndMeetingForAllRequestCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? eventPath = null, + }) { + return _then(_value.copyWith( + eventPath: null == eventPath + ? _value.eventPath + : eventPath // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$_EndMeetingForAllRequestCopyWith<$Res> + implements $EndMeetingForAllRequestCopyWith<$Res> { + factory _$$_EndMeetingForAllRequestCopyWith(_$_EndMeetingForAllRequest value, + $Res Function(_$_EndMeetingForAllRequest) then) = + __$$_EndMeetingForAllRequestCopyWithImpl<$Res>; + @override + @useResult + $Res call({String eventPath}); +} + +/// @nodoc +class __$$_EndMeetingForAllRequestCopyWithImpl<$Res> + extends _$EndMeetingForAllRequestCopyWithImpl<$Res, + _$_EndMeetingForAllRequest> + implements _$$_EndMeetingForAllRequestCopyWith<$Res> { + __$$_EndMeetingForAllRequestCopyWithImpl(_$_EndMeetingForAllRequest _value, + $Res Function(_$_EndMeetingForAllRequest) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? eventPath = null, + }) { + return _then(_$_EndMeetingForAllRequest( + eventPath: null == eventPath + ? _value.eventPath + : eventPath // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$_EndMeetingForAllRequest implements _EndMeetingForAllRequest { + _$_EndMeetingForAllRequest({required this.eventPath}); + + factory _$_EndMeetingForAllRequest.fromJson(Map json) => + _$$_EndMeetingForAllRequestFromJson(json); + + @override + final String eventPath; + + @override + String toString() { + return 'EndMeetingForAllRequest(eventPath: $eventPath)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_EndMeetingForAllRequest && + (identical(other.eventPath, eventPath) || + other.eventPath == eventPath)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, eventPath); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$_EndMeetingForAllRequestCopyWith<_$_EndMeetingForAllRequest> + get copyWith => + __$$_EndMeetingForAllRequestCopyWithImpl<_$_EndMeetingForAllRequest>( + this, _$identity); + + @override + Map toJson() { + return _$$_EndMeetingForAllRequestToJson( + this, + ); + } +} + +abstract class _EndMeetingForAllRequest implements EndMeetingForAllRequest { + factory _EndMeetingForAllRequest({required final String eventPath}) = + _$_EndMeetingForAllRequest; + + factory _EndMeetingForAllRequest.fromJson(Map json) = + _$_EndMeetingForAllRequest.fromJson; + + @override + String get eventPath; + @override + @JsonKey(ignore: true) + _$$_EndMeetingForAllRequestCopyWith<_$_EndMeetingForAllRequest> + get copyWith => throw _privateConstructorUsedError; +} + GetCommunityDonationsEnabledRequest _$GetCommunityDonationsEnabledRequestFromJson(Map json) { return _GetCommunityDonationsEnabledRequest.fromJson(json); diff --git a/data_models/lib/cloud_functions/requests.g.dart b/data_models/lib/cloud_functions/requests.g.dart index 661282082..bcfbd95e4 100644 --- a/data_models/lib/cloud_functions/requests.g.dart +++ b/data_models/lib/cloud_functions/requests.g.dart @@ -934,6 +934,18 @@ Map _$$_EventEndedRequestToJson( 'eventPath': instance.eventPath, }; +_$_EndMeetingForAllRequest _$$_EndMeetingForAllRequestFromJson( + Map json) => + _$_EndMeetingForAllRequest( + eventPath: json['eventPath'] as String, + ); + +Map _$$_EndMeetingForAllRequestToJson( + _$_EndMeetingForAllRequest instance) => + { + 'eventPath': instance.eventPath, + }; + _$_GetCommunityDonationsEnabledRequest _$$_GetCommunityDonationsEnabledRequestFromJson( Map json) => diff --git a/data_models/lib/events/live_meetings/live_meeting.dart b/data_models/lib/events/live_meetings/live_meeting.dart index 160008d75..d17a6b1b7 100644 --- a/data_models/lib/events/live_meetings/live_meeting.dart +++ b/data_models/lib/events/live_meetings/live_meeting.dart @@ -27,6 +27,7 @@ class LiveMeeting with _$LiveMeeting implements SerializeableRequest { static const String kFieldMeetingId = 'meetingId'; static const String kFieldIsMeetingCardMinimized = 'isMeetingCardMinimized'; static const String kFieldRecordingSessionId = 'recordingSessionId'; + static const String kFieldMeetingEndedAt = 'meetingEndedAt'; factory LiveMeeting({ // TODO(null-safety): There are places that we set various fields on the live meeting possibly @@ -45,6 +46,8 @@ class LiveMeeting with _$LiveMeeting implements SerializeableRequest { @Default(false) bool isMeetingCardMinimized, @Default([]) List pinnedUserIds, String? recordingSessionId, + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + DateTime? meetingEndedAt, }) = _LiveMeeting; factory LiveMeeting.fromJson(Map json) => diff --git a/data_models/lib/events/live_meetings/live_meeting.freezed.dart b/data_models/lib/events/live_meetings/live_meeting.freezed.dart index 791bb5090..f4369dfb5 100644 --- a/data_models/lib/events/live_meetings/live_meeting.freezed.dart +++ b/data_models/lib/events/live_meetings/live_meeting.freezed.dart @@ -38,6 +38,8 @@ mixin _$LiveMeeting { bool get isMeetingCardMinimized => throw _privateConstructorUsedError; List get pinnedUserIds => throw _privateConstructorUsedError; String? get recordingSessionId => throw _privateConstructorUsedError; + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + DateTime? get meetingEndedAt => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -59,7 +61,9 @@ abstract class $LiveMeetingCopyWith<$Res> { bool record, bool isMeetingCardMinimized, List pinnedUserIds, - String? recordingSessionId}); + String? recordingSessionId, + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + DateTime? meetingEndedAt}); $BreakoutRoomSessionCopyWith<$Res>? get currentBreakoutSession; } @@ -85,6 +89,7 @@ class _$LiveMeetingCopyWithImpl<$Res, $Val extends LiveMeeting> Object? isMeetingCardMinimized = null, Object? pinnedUserIds = null, Object? recordingSessionId = freezed, + Object? meetingEndedAt = freezed, }) { return _then(_value.copyWith( meetingId: freezed == meetingId @@ -119,6 +124,10 @@ class _$LiveMeetingCopyWithImpl<$Res, $Val extends LiveMeeting> ? _value.recordingSessionId : recordingSessionId // ignore: cast_nullable_to_non_nullable as String?, + meetingEndedAt: freezed == meetingEndedAt + ? _value.meetingEndedAt + : meetingEndedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, ) as $Val); } @@ -152,7 +161,9 @@ abstract class _$$_LiveMeetingCopyWith<$Res> bool record, bool isMeetingCardMinimized, List pinnedUserIds, - String? recordingSessionId}); + String? recordingSessionId, + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + DateTime? meetingEndedAt}); @override $BreakoutRoomSessionCopyWith<$Res>? get currentBreakoutSession; @@ -177,6 +188,7 @@ class __$$_LiveMeetingCopyWithImpl<$Res> Object? isMeetingCardMinimized = null, Object? pinnedUserIds = null, Object? recordingSessionId = freezed, + Object? meetingEndedAt = freezed, }) { return _then(_$_LiveMeeting( meetingId: freezed == meetingId @@ -211,6 +223,10 @@ class __$$_LiveMeetingCopyWithImpl<$Res> ? _value.recordingSessionId : recordingSessionId // ignore: cast_nullable_to_non_nullable as String?, + meetingEndedAt: freezed == meetingEndedAt + ? _value.meetingEndedAt + : meetingEndedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, )); } } @@ -226,7 +242,9 @@ class _$_LiveMeeting implements _LiveMeeting { this.record = false, this.isMeetingCardMinimized = false, this.pinnedUserIds = const [], - this.recordingSessionId}); + this.recordingSessionId, + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + this.meetingEndedAt}); factory _$_LiveMeeting.fromJson(Map json) => _$$_LiveMeetingFromJson(json); @@ -260,10 +278,13 @@ class _$_LiveMeeting implements _LiveMeeting { final List pinnedUserIds; @override final String? recordingSessionId; + @override + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + final DateTime? meetingEndedAt; @override String toString() { - return 'LiveMeeting(meetingId: $meetingId, participants: $participants, events: $events, currentBreakoutSession: $currentBreakoutSession, record: $record, isMeetingCardMinimized: $isMeetingCardMinimized, pinnedUserIds: $pinnedUserIds, recordingSessionId: $recordingSessionId)'; + return 'LiveMeeting(meetingId: $meetingId, participants: $participants, events: $events, currentBreakoutSession: $currentBreakoutSession, record: $record, isMeetingCardMinimized: $isMeetingCardMinimized, pinnedUserIds: $pinnedUserIds, recordingSessionId: $recordingSessionId, meetingEndedAt: $meetingEndedAt)'; } @override @@ -284,7 +305,9 @@ class _$_LiveMeeting implements _LiveMeeting { const DeepCollectionEquality() .equals(other.pinnedUserIds, pinnedUserIds) && (identical(other.recordingSessionId, recordingSessionId) || - other.recordingSessionId == recordingSessionId)); + other.recordingSessionId == recordingSessionId) && + (identical(other.meetingEndedAt, meetingEndedAt) || + other.meetingEndedAt == meetingEndedAt)); } @JsonKey(ignore: true) @@ -298,7 +321,8 @@ class _$_LiveMeeting implements _LiveMeeting { record, isMeetingCardMinimized, const DeepCollectionEquality().hash(pinnedUserIds), - recordingSessionId); + recordingSessionId, + meetingEndedAt); @JsonKey(ignore: true) @override @@ -323,7 +347,9 @@ abstract class _LiveMeeting implements LiveMeeting { final bool record, final bool isMeetingCardMinimized, final List pinnedUserIds, - final String? recordingSessionId}) = _$_LiveMeeting; + final String? recordingSessionId, + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + final DateTime? meetingEndedAt}) = _$_LiveMeeting; factory _LiveMeeting.fromJson(Map json) = _$_LiveMeeting.fromJson; @@ -352,6 +378,9 @@ abstract class _LiveMeeting implements LiveMeeting { @override String? get recordingSessionId; @override + @JsonKey(fromJson: dateTimeFromTimestamp, toJson: serverTimestampOrNull) + DateTime? get meetingEndedAt; + @override @JsonKey(ignore: true) _$$_LiveMeetingCopyWith<_$_LiveMeeting> get copyWith => throw _privateConstructorUsedError; diff --git a/data_models/lib/events/live_meetings/live_meeting.g.dart b/data_models/lib/events/live_meetings/live_meeting.g.dart index 4b16b54d3..51409b66e 100644 --- a/data_models/lib/events/live_meetings/live_meeting.g.dart +++ b/data_models/lib/events/live_meetings/live_meeting.g.dart @@ -29,6 +29,7 @@ _$_LiveMeeting _$$_LiveMeetingFromJson(Map json) => .toList() ?? const [], recordingSessionId: json['recordingSessionId'] as String?, + meetingEndedAt: dateTimeFromTimestamp(json['meetingEndedAt']), ); Map _$$_LiveMeetingToJson(_$_LiveMeeting instance) => @@ -41,6 +42,7 @@ Map _$$_LiveMeetingToJson(_$_LiveMeeting instance) => 'isMeetingCardMinimized': instance.isMeetingCardMinimized, 'pinnedUserIds': instance.pinnedUserIds, 'recordingSessionId': instance.recordingSessionId, + 'meetingEndedAt': serverTimestampOrNull(instance.meetingEndedAt), }; _$_LiveMeetingParticipant _$$_LiveMeetingParticipantFromJson( diff --git a/firebase/functions/lib/events/event_ended.dart b/firebase/functions/lib/events/event_ended.dart index 6a27b024a..95dd4d66a 100644 --- a/firebase/functions/lib/events/event_ended.dart +++ b/firebase/functions/lib/events/event_ended.dart @@ -9,9 +9,9 @@ import '../utils/infra/firestore_utils.dart'; import '../utils/notifications_utils.dart'; import '../utils/subscription_plan_util.dart'; import 'live_meetings/agora_api.dart'; +import 'live_meetings/stop_all_event_recordings.dart'; import 'package:data_models/cloud_functions/requests.dart'; import 'package:data_models/events/event.dart'; -import 'package:data_models/events/live_meetings/live_meeting.dart'; import 'package:data_models/community/community.dart'; /// This function handles events after event ends @@ -48,49 +48,11 @@ class EventEnded extends OnCallMethod { constructor: (map) => Event.fromJson(map), ); - // Stop main room recording if one is active. final liveMeetingPath = '${request.eventPath}/live-meetings/${event.id}'; - try { - final liveMeeting = await firestoreUtils.getFirestoreObject( - path: liveMeetingPath, - constructor: (map) => LiveMeeting.fromJson(map), - ); - if (liveMeeting.recordingSessionId != null) { - await agoraUtils.stopRoom(sessionId: liveMeeting.recordingSessionId!); - } - } catch (e) { - // Do not block event-ended flow on recording stop failure. - print('Error stopping main room recording on event end: $e'); - } - - // Stop all breakout room recordings. - // Structure: {liveMeetingPath}/breakout-room-sessions/{sessionId}/breakout-rooms/{roomId} - try { - final breakoutSessionDocs = await firestore - .collection('$liveMeetingPath/breakout-room-sessions') - .get(); - for (final sessionDoc in breakoutSessionDocs.documents) { - final breakoutRoomDocs = await firestore - .collection('${sessionDoc.reference.path}/breakout-rooms') - .get(); - for (final roomDoc in breakoutRoomDocs.documents) { - final breakoutRoom = BreakoutRoom.fromJson( - firestoreUtils.fromFirestoreJson(roomDoc.data.toMap()), - ); - if (breakoutRoom.recordingSessionId != null) { - try { - await agoraUtils.stopRoom( - sessionId: breakoutRoom.recordingSessionId!); - } catch (e) { - print( - 'Error stopping breakout recording ${breakoutRoom.recordingSessionId}: $e'); - } - } - } - } - } catch (e) { - print('Error stopping breakout room recordings on event end: $e'); - } + await stopAllEventRecordings( + liveMeetingPath: liveMeetingPath, + agoraUtils: agoraUtils, + ); final capabilities = await subscriptionPlanUtil.calculateCapabilities(event.communityId); diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart new file mode 100644 index 000000000..8a28a7df8 --- /dev/null +++ b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:firebase_admin_interop/firebase_admin_interop.dart' + as admin_interop; +import 'package:firebase_admin_interop/firebase_admin_interop.dart'; +import 'package:firebase_functions_interop/firebase_functions_interop.dart'; +import '../../on_call_function.dart'; +import '../../utils/email_templates.dart'; +import '../../utils/infra/firestore_utils.dart'; +import '../../utils/notifications_utils.dart'; +import '../../utils/subscription_plan_util.dart'; +import 'agora_api.dart'; +import 'stop_all_event_recordings.dart'; +import 'package:data_models/cloud_functions/requests.dart'; +import 'package:data_models/community/community.dart'; +import 'package:data_models/community/membership.dart'; +import 'package:data_models/events/event.dart'; +import 'package:data_models/events/live_meetings/live_meeting.dart'; + +class EndMeetingForAll extends OnCallMethod { + static const String kEndMeetingForAllApi = 'endMeetingForAll'; + NotificationsUtils notificationsUtils; + AgoraUtils agoraUtils; + + EndMeetingForAll( + {NotificationsUtils? notificationsUtils, AgoraUtils? agoraUtils,}) + : notificationsUtils = notificationsUtils ?? NotificationsUtils(), + agoraUtils = agoraUtils ?? AgoraUtils(), + super( + kEndMeetingForAllApi, + (jsonMap) => EndMeetingForAllRequest.fromJson(jsonMap), + ); + + @override + Future action( + EndMeetingForAllRequest request, + CallableContext context, + ) async { + final event = await firestoreUtils.getFirestoreObject( + path: request.eventPath, + constructor: (map) => Event.fromJson(map), + ); + + // Verify caller is mod/admin/owner. + final membership = await firestoreUtils.getFirestoreObject( + path: + 'memberships/${context.authUid}/community-membership/${event.communityId}', + constructor: (map) => Membership.fromJson(map), + ); + const allowedStatuses = [ + MembershipStatus.moderator, + MembershipStatus.admin, + MembershipStatus.owner, + ]; + if (!allowedStatuses.contains(membership.status)) { + throw HttpsError(HttpsError.failedPrecondition, 'unauthorized', null); + } + + // Read the LiveMeeting doc. If meetingEndedAt is already set, return (idempotent). + final liveMeetingPath = '${request.eventPath}/live-meetings/${event.id}'; + final liveMeeting = await firestoreUtils.getFirestoreObject( + path: liveMeetingPath, + constructor: (map) => LiveMeeting.fromJson(map), + ); + if (liveMeeting.meetingEndedAt != null) { + return; + } + + // Write meetingEndedAt to the LiveMeeting doc. + await firestore.document(liveMeetingPath).updateData( + UpdateData.fromMap({ + LiveMeeting.kFieldMeetingEndedAt: + Firestore.fieldValues.serverTimestamp(), + }), + ); + + // Stop all recordings (main + breakout). + await stopAllEventRecordings( + liveMeetingPath: liveMeetingPath, + agoraUtils: agoraUtils, + ); + + // Send the post-event email to all active participants. + final participantDocs = await firestore + .collection('${request.eventPath}/event-participants') + .get(); + final activeParticipantIds = participantDocs.documents + .map((doc) => Participant.fromJson( + firestoreUtils.fromFirestoreJson(doc.data.toMap()), + ),) + .where((p) => p.status == ParticipantStatus.active) + .map((p) => p.id) + .whereType() + .toList(); + + if (activeParticipantIds.isEmpty) return; + + final capabilities = + await subscriptionPlanUtil.calculateCapabilities(event.communityId); + final hasPrePost = capabilities.hasPrePost ?? false; + + await notificationsUtils.sendEventEndedEmail( + event: event, + communityId: event.communityId, + userIds: activeParticipantIds, + emailType: EventEmailType.ended, + generateMessage: (Community community, admin_interop.UserRecord user) => + SendGridEmailMessage( + subject: 'Thanks for joining', + html: generateEventEndedContent( + header: 'Thanks for joining ${event.title}!', + community: community, + userRecord: user, + event: event, + allowPrePost: hasPrePost, + ), + ), + ); + } +} diff --git a/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart b/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart new file mode 100644 index 000000000..c99d42082 --- /dev/null +++ b/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart @@ -0,0 +1,50 @@ +import 'package:data_models/events/live_meetings/live_meeting.dart'; +import '../../utils/infra/firestore_utils.dart'; +import 'agora_api.dart'; + +/// Stops all active recordings for an event (main room + breakout rooms). +Future stopAllEventRecordings({ + required String liveMeetingPath, + required AgoraUtils agoraUtils, +}) async { + // Stop main room recording if one is active. + try { + final liveMeeting = await firestoreUtils.getFirestoreObject( + path: liveMeetingPath, + constructor: (map) => LiveMeeting.fromJson(map), + ); + if (liveMeeting.recordingSessionId != null) { + await agoraUtils.stopRoom(sessionId: liveMeeting.recordingSessionId!); + } + } catch (e) { + print('Error stopping main room recording on event end: $e'); + } + + // Stop all breakout room recordings. + try { + final breakoutSessionDocs = await firestore + .collection('$liveMeetingPath/breakout-room-sessions') + .get(); + for (final sessionDoc in breakoutSessionDocs.documents) { + final breakoutRoomDocs = await firestore + .collection('${sessionDoc.reference.path}/breakout-rooms') + .get(); + for (final roomDoc in breakoutRoomDocs.documents) { + final breakoutRoom = BreakoutRoom.fromJson( + firestoreUtils.fromFirestoreJson(roomDoc.data.toMap()), + ); + if (breakoutRoom.recordingSessionId != null) { + try { + await agoraUtils.stopRoom( + sessionId: breakoutRoom.recordingSessionId!,); + } catch (e) { + print( + 'Error stopping breakout recording ${breakoutRoom.recordingSessionId}: $e',); + } + } + } + } + } catch (e) { + print('Error stopping breakout room recordings on event end: $e'); + } +} diff --git a/firebase/functions/node/main.dart b/firebase/functions/node/main.dart index d170a32ca..7819d61d6 100644 --- a/firebase/functions/node/main.dart +++ b/firebase/functions/node/main.dart @@ -17,6 +17,7 @@ import 'package:functions/events/live_meetings/create_live_stream.dart'; import 'package:functions/admin/payments/create_stripe_connected_account.dart'; import 'package:functions/admin/payments/create_subscription_checkout_session.dart'; import 'package:functions/events/event_ended.dart'; +import 'package:functions/events/live_meetings/end_meeting_for_all.dart'; import 'package:functions/events/live_meetings/breakouts/get_breakout_room_assignment.dart'; import 'package:functions/events/live_meetings/breakouts/get_breakout_room_join_info.dart'; import 'package:functions/events/calendar/get_calendar_link.dart'; @@ -115,6 +116,7 @@ final _onCallFunctions = [ UpdateStripeSubscriptionPlan(), VoteToKick(), EventEnded(), + EndMeetingForAll(), ]; final _onRequestFunctions = [ From 34cc52d16062b79991be62326fb8e8cb4696ea4e Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:32:21 -0400 Subject: [PATCH 05/39] Add tests for EndMeetingForAll CF - Verify meetingEndedAt is written and email sent - Verify idempotency (second call is a no-op) - Verify non-mod/admin/owner is rejected --- .../end_meeting_for_all_test.dart | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 firebase/functions/test/events/live_meetings/end_meeting_for_all_test.dart diff --git a/firebase/functions/test/events/live_meetings/end_meeting_for_all_test.dart b/firebase/functions/test/events/live_meetings/end_meeting_for_all_test.dart new file mode 100644 index 000000000..5d1f00691 --- /dev/null +++ b/firebase/functions/test/events/live_meetings/end_meeting_for_all_test.dart @@ -0,0 +1,203 @@ +import 'package:data_models/cloud_functions/requests.dart'; +import 'package:data_models/community/community.dart'; +import 'package:data_models/community/membership.dart'; +import 'package:data_models/events/event.dart'; +import 'package:data_models/events/live_meetings/live_meeting.dart'; +import 'package:firebase_functions_interop/firebase_functions_interop.dart'; +import 'package:functions/events/live_meetings/end_meeting_for_all.dart'; +import 'package:functions/utils/infra/firestore_utils.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; +import '../../util/community_test_utils.dart'; +import '../../util/email_test_utils.dart'; +import '../../util/event_test_utils.dart'; +import '../../util/function_test_fixture.dart'; +import '../../util/live_meeting_test_utils.dart'; + +void main() { + late String communityId; + const templateId = '9654988'; + final communityTestUtils = CommunityTestUtils(); + final eventTestUtils = EventTestUtils(); + final liveMeetingTestUtils = LiveMeetingTestUtils(); + setupTestFixture(); + + setUp(() async { + communityId = await communityTestUtils.createTestCommunity(); + }); + + Future createTestEvent() async { + var event = Event( + id: 'end-meeting-test-event', + status: EventStatus.active, + communityId: communityId, + templateId: templateId, + creatorId: adminUserId, + nullableEventType: EventType.hosted, + collectionPath: '', + agendaItems: [ + AgendaItem( + id: '55005', + title: 'Test item', + content: 'Test content', + ), + ], + ); + return eventTestUtils.createEvent(event: event, userId: adminUserId); + } + + group('EndMeetingForAll', () { + test('sets meetingEndedAt and sends email', () async { + final event = await createTestEvent(); + registerFallbackValue(event); + registerFallbackValue(EventEmailType.ended); + + await liveMeetingTestUtils.addMeetingEvent( + liveMeetingPath: liveMeetingTestUtils.getLiveMeetingPath(event), + liveMeetingId: event.id, + meetingEvent: LiveMeetingEvent( + event: LiveMeetingEventType.agendaItemStarted, + timestamp: DateTime.now(), + ), + ); + + final notificationsUtils = MockNotificationsUtils(); + when( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: communityId, + userIds: any(named: 'userIds'), + emailType: EventEmailType.ended, + generateMessage: any(named: 'generateMessage'), + ), + ).thenAnswer((_) async {}); + + final agoraUtils = MockAgoraUtils(); + final endMeeting = EndMeetingForAll( + notificationsUtils: notificationsUtils, + agoraUtils: agoraUtils, + ); + + final req = EndMeetingForAllRequest(eventPath: event.fullPath); + await endMeeting.action( + req, + CallableContext(adminUserId, null, 'fakeInstanceId'), + ); + + // Verify meetingEndedAt was written. + final liveMeetingPath = + liveMeetingTestUtils.getLiveMeetingPath(event); + final liveMeeting = await firestoreUtils.getFirestoreObject( + path: liveMeetingPath, + constructor: (map) => LiveMeeting.fromJson(map), + ); + expect(liveMeeting.meetingEndedAt, isNotNull); + + // Verify email was sent. + verify( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: communityId, + userIds: any(named: 'userIds'), + emailType: EventEmailType.ended, + generateMessage: any(named: 'generateMessage'), + ), + ).called(1); + }); + + test('is idempotent -- second call is a no-op', () async { + final event = await createTestEvent(); + registerFallbackValue(event); + registerFallbackValue(EventEmailType.ended); + + await liveMeetingTestUtils.addMeetingEvent( + liveMeetingPath: liveMeetingTestUtils.getLiveMeetingPath(event), + liveMeetingId: event.id, + meetingEvent: LiveMeetingEvent( + event: LiveMeetingEventType.agendaItemStarted, + timestamp: DateTime.now(), + ), + ); + + final notificationsUtils = MockNotificationsUtils(); + when( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: any(named: 'communityId'), + userIds: any(named: 'userIds'), + emailType: any(named: 'emailType'), + generateMessage: any(named: 'generateMessage'), + ), + ).thenAnswer((_) async {}); + + final agoraUtils = MockAgoraUtils(); + final endMeeting = EndMeetingForAll( + notificationsUtils: notificationsUtils, + agoraUtils: agoraUtils, + ); + + final req = EndMeetingForAllRequest(eventPath: event.fullPath); + final context = CallableContext(adminUserId, null, 'fakeInstanceId'); + + // First call sets meetingEndedAt. + await endMeeting.action(req, context); + + // Second call returns early without sending another email. + await endMeeting.action(req, context); + + verify( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: any(named: 'communityId'), + userIds: any(named: 'userIds'), + emailType: any(named: 'emailType'), + generateMessage: any(named: 'generateMessage'), + ), + ).called(1); + }); + + test('rejects non-mod/admin/owner caller', () async { + final event = await createTestEvent(); + + await liveMeetingTestUtils.addMeetingEvent( + liveMeetingPath: liveMeetingTestUtils.getLiveMeetingPath(event), + liveMeetingId: event.id, + meetingEvent: LiveMeetingEvent( + event: LiveMeetingEventType.agendaItemStarted, + timestamp: DateTime.now(), + ), + ); + + const regularUserId = 'regularUser'; + await communityTestUtils.addCommunityMember( + userId: regularUserId, + communityId: communityId, + status: MembershipStatus.member, + ); + await eventTestUtils.joinEvent( + communityId: communityId, + templateId: templateId, + eventId: event.id, + uid: regularUserId, + ); + + final notificationsUtils = MockNotificationsUtils(); + final agoraUtils = MockAgoraUtils(); + final endMeeting = EndMeetingForAll( + notificationsUtils: notificationsUtils, + agoraUtils: agoraUtils, + ); + + final req = EndMeetingForAllRequest(eventPath: event.fullPath); + expect( + () => endMeeting.action( + req, + CallableContext(regularUserId, null, 'fakeInstanceId'), + ), + throwsA(isA()), + ); + }); + }); +} + +class MockCommunity extends Mock implements Community {} From b190f08af5d5eb03045b00d29a6cebbf13e0e935 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:18:49 -0400 Subject: [PATCH 06/39] Client reacts to meetingEndedAt + End Meeting button - Auto-leave when meetingEndedAt is set on LiveMeeting doc - Add endMeetingForAll to CloudFunctionsEventService - Add End Meeting menu item in admin panel for mods/admins/owners - Show confirmation dialog before ending - Add l10n strings in all 4 ARB files (en, es, zh, zh_Hant_TW) --- .../cloud_functions_event_service.dart | 9 +++++++- .../data/providers/live_meeting_provider.dart | 6 +++++ .../presentation/widgets/admin_panel.dart | 23 +++++++++++++++++++ client/lib/l10n/app_en.arb | 2 ++ client/lib/l10n/app_es.arb | 2 ++ client/lib/l10n/app_zh.arb | 2 ++ client/lib/l10n/app_zh_Hant_TW.arb | 2 ++ 7 files changed, 45 insertions(+), 1 deletion(-) diff --git a/client/lib/features/events/data/services/cloud_functions_event_service.dart b/client/lib/features/events/data/services/cloud_functions_event_service.dart index 61585b7c5..0b08ac14d 100644 --- a/client/lib/features/events/data/services/cloud_functions_event_service.dart +++ b/client/lib/features/events/data/services/cloud_functions_event_service.dart @@ -39,11 +39,18 @@ class CloudFunctionsEventService { await cloudFunctions.callFunction('eventEnded', request.toJson()); } + Future endMeetingForAll(EndMeetingForAllRequest request) async { + loggingService.log( + 'CloudFunctionsService.endMeetingForAll: Data: ${request.toJson()}', + ); + await cloudFunctions.callFunction('endMeetingForAll', request.toJson()); + } + Future getCommunityCalendarLink( GetCommunityCalendarLinkRequest request, ) async { final result = await cloudFunctions.callFunction( - 'getCommunityCalendarLink', request.toJson()); + 'getCommunityCalendarLink', request.toJson(),); return GetCommunityCalendarLinkResponse.fromJson(result); } } 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 dc8854264..d4cb8f4db 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 @@ -474,6 +474,12 @@ class LiveMeetingProvider with ChangeNotifier { _checkLoadBreakoutsStream(liveMeeting); + // Auto-leave when the host (or server) ends the meeting for everyone. + if (liveMeeting.meetingEndedAt != null && !_leftMeeting) { + leaveMeeting(); + return; + } + if (!breakoutsActive && !isNullOrEmpty(_activeBreakoutRoomId)) { leaveBreakoutRoom(); _userLeftBreakouts = false; diff --git a/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/widgets/admin_panel.dart b/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/widgets/admin_panel.dart index a8b9dd6e6..fcbdd6829 100644 --- a/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/widgets/admin_panel.dart +++ b/client/lib/features/events/features/live_meeting/features/admin_panel/presentation/widgets/admin_panel.dart @@ -579,6 +579,29 @@ class _MeetingControlsMenuState extends State<_MeetingControlsMenu> { ), ), ), + if (liveMeetingProvider.isHost == true || canModerateContent) + PopupMenuItem( + value: () => alertOnError(context, () async { + final confirmed = await ConfirmDialog( + mainText: context.l10n.endMeetingConfirmation, + confirmText: context.l10n.endMeeting, + cancelText: context.l10n.cancel, + ).show(context: context); + if (!confirmed) return; + await cloudFunctionsEventService.endMeetingForAll( + EndMeetingForAllRequest( + eventPath: provider.event.fullPath, + ), + ); + }), + child: HeightConstrainedText( + context.l10n.endMeeting, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ), + ), if (Environment.enableFakeParticipants) PopupMenuItem( value: () => alertOnError(context, () async { diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index 0b6ded2f6..73de6786b 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -410,6 +410,8 @@ "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", + "endMeeting": "End Meeting", + "endMeetingConfirmation": "End this event for all participants? Everyone will be disconnected.", "endTime": "End Time", "enterAmount": "Enter Amount", "enterCategoryNum": "Enter Category {number}", diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index e60da4b16..ac976194e 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -483,6 +483,8 @@ "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", + "endMeeting": "Finalizar reunión", + "endMeetingConfirmation": "¿Finalizar este evento para todos los participantes? Todos serán desconectados.", "endTime": "Hora de finalización", "enterAmount": "Ingrese el monto", "enterCategoryNum": "Ingrese Categoría {number}", diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index fefbf8104..455976ae4 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -485,6 +485,8 @@ "emailAddressAlreadyInUse": "您已经创建了一个与此电子邮件地址关联的帐户。请使用电子邮件登录,如果不知道密码请点击忘记密码。或者,使用其他电子邮件重新注册。", "emailAddressAlreadyInUseLoginError": "此邮箱已被使用。请尝试", "endBreakoutRooms": "结束分组讨论", + "endMeeting": "结束会议", + "endMeetingConfirmation": "为所有参与者结束此活动?所有人将被断开连接。", "endTime": "结束时间", "enterAmount": "输入金额", "enterCategoryNum": "输入类别 {number}", diff --git a/client/lib/l10n/app_zh_Hant_TW.arb b/client/lib/l10n/app_zh_Hant_TW.arb index f641dbcad..0e0cfb1df 100644 --- a/client/lib/l10n/app_zh_Hant_TW.arb +++ b/client/lib/l10n/app_zh_Hant_TW.arb @@ -485,6 +485,8 @@ "emailAddressAlreadyInUse": "您已經創建了一個與此電子郵件地址關聯的帳戶。請使用電子郵件登入,如果不知道密碼請點擊忘記密碼。或者,使用其他電子郵件重新註冊。", "emailAddressAlreadyInUseLoginError": "此電子郵件已被使用。請嘗試 ", "endBreakoutRooms": "結束分組討論", + "endMeeting": "結束會議", + "endMeetingConfirmation": "為所有參與者結束此活動?所有人將被斷開連接。", "endTime": "結束時間", "enterAmount": "輸入金額", "enterCategoryNum": "輸入類別 {number}", From 547212af3fc47c26382cc1213ec3678542fed114 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:01:40 -0400 Subject: [PATCH 07/39] Schedule automatic meeting end via Cloud Tasks - Add ScheduledEndMeeting OnRequestMethod CF with idempotency guard - Add isFirstJoin flag to MeetingJoinResult - Schedule end task on first join when event has scheduledTime + duration - Register ScheduledEndMeeting in main.dart --- .../live_meetings/get_meeting_join_info.dart | 22 ++++ .../live_meetings/live_meeting_utils.dart | 10 +- .../live_meetings/scheduled_end_meeting.dart | 103 ++++++++++++++++++ firebase/functions/node/main.dart | 2 + 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index 5c7288436..4bba4a967 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:firebase_functions_interop/firebase_functions_interop.dart'; import '../../utils/infra/firebase_auth_utils.dart'; import 'live_meeting_utils.dart'; +import 'scheduled_end_meeting.dart'; import '../../on_call_function.dart'; import '../../utils/infra/firestore_utils.dart'; import 'package:data_models/cloud_functions/requests.dart'; @@ -82,6 +83,27 @@ class GetMeetingJoinInfo extends OnCallMethod { ); } + // On first join, schedule automatic meeting end if the event has a + // scheduled time and duration. + if (result.isFirstJoin) { + final event = await firestoreUtils.getFirestoreObject( + path: request.eventPath, + constructor: (map) => Event.fromJson(map), + ); + final scheduledTime = event.scheduledTime; + if (scheduledTime != null) { + final endTime = scheduledTime.add( + Duration(minutes: event.durationInMinutes), + ); + if (endTime.isAfter(DateTime.now())) { + await ScheduledEndMeeting().schedule( + EndMeetingForAllRequest(eventPath: request.eventPath), + endTime, + ); + } + } + } + return result.response.toJson(); } } diff --git a/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart b/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart index 6c8a4a48b..d5b5e5bda 100644 --- a/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart +++ b/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart @@ -31,8 +31,13 @@ class PendingRecording { class MeetingJoinResult { final GetMeetingJoinInfoResponse response; final PendingRecording? pendingRecording; + final bool isFirstJoin; - MeetingJoinResult({required this.response, this.pendingRecording}); + MeetingJoinResult({ + required this.response, + this.pendingRecording, + this.isFirstJoin = false, + }); } class LiveMeetingUtils { @@ -95,6 +100,8 @@ class LiveMeetingUtils { ); } + final isFirstJoin = !liveMeetingSnapshot.exists; + PendingRecording? pendingRecording; if (newSessionId != null) { final chatPath = @@ -124,6 +131,7 @@ class LiveMeetingUtils { meetingId: meetingId, ), pendingRecording: pendingRecording, + isFirstJoin: isFirstJoin, ); } diff --git a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart new file mode 100644 index 000000000..0fb13fe21 --- /dev/null +++ b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:firebase_admin_interop/firebase_admin_interop.dart' + as admin_interop; +import 'package:firebase_admin_interop/firebase_admin_interop.dart'; +import '../../on_request_method.dart'; +import '../../utils/email_templates.dart'; +import '../../utils/infra/firestore_utils.dart'; +import '../../utils/notifications_utils.dart'; +import '../../utils/subscription_plan_util.dart'; +import 'agora_api.dart'; +import 'stop_all_event_recordings.dart'; +import 'package:data_models/cloud_functions/requests.dart'; +import 'package:data_models/community/community.dart'; +import 'package:data_models/events/event.dart'; +import 'package:data_models/events/live_meetings/live_meeting.dart'; + +class ScheduledEndMeeting + extends OnRequestMethod { + NotificationsUtils notificationsUtils; + AgoraUtils agoraUtils; + + ScheduledEndMeeting( + {NotificationsUtils? notificationsUtils, AgoraUtils? agoraUtils,}) + : notificationsUtils = notificationsUtils ?? NotificationsUtils(), + agoraUtils = agoraUtils ?? AgoraUtils(), + super( + 'ScheduledEndMeeting', + (jsonMap) => EndMeetingForAllRequest.fromJson(jsonMap), + ); + + @override + Future action(EndMeetingForAllRequest request) async { + final event = await firestoreUtils.getFirestoreObject( + path: request.eventPath, + constructor: (map) => Event.fromJson(map), + ); + + final liveMeetingPath = '${request.eventPath}/live-meetings/${event.id}'; + final liveMeeting = await firestoreUtils.getFirestoreObject( + path: liveMeetingPath, + constructor: (map) => LiveMeeting.fromJson(map), + ); + + // Already ended (by host or a previous task). No-op. + if (liveMeeting.meetingEndedAt != null) { + return ''; + } + + // Write meetingEndedAt to the LiveMeeting doc. + await firestore.document(liveMeetingPath).updateData( + UpdateData.fromMap({ + LiveMeeting.kFieldMeetingEndedAt: + Firestore.fieldValues.serverTimestamp(), + }), + ); + + // Stop all recordings (main + breakout). + await stopAllEventRecordings( + liveMeetingPath: liveMeetingPath, + agoraUtils: agoraUtils, + ); + + // Send the post-event email to all active participants. + final participantDocs = await firestore + .collection('${request.eventPath}/event-participants') + .get(); + final activeParticipantIds = participantDocs.documents + .map((doc) => Participant.fromJson( + firestoreUtils.fromFirestoreJson(doc.data.toMap()), + ),) + .where((p) => p.status == ParticipantStatus.active) + .map((p) => p.id) + .whereType() + .toList(); + + if (activeParticipantIds.isEmpty) return ''; + + final capabilities = + await subscriptionPlanUtil.calculateCapabilities(event.communityId); + final hasPrePost = capabilities.hasPrePost ?? false; + + await notificationsUtils.sendEventEndedEmail( + event: event, + communityId: event.communityId, + userIds: activeParticipantIds, + emailType: EventEmailType.ended, + generateMessage: (Community community, admin_interop.UserRecord user) => + SendGridEmailMessage( + subject: 'Thanks for joining', + html: generateEventEndedContent( + header: 'Thanks for joining ${event.title}!', + community: community, + userRecord: user, + event: event, + allowPrePost: hasPrePost, + ), + ), + ); + + return ''; + } +} diff --git a/firebase/functions/node/main.dart b/firebase/functions/node/main.dart index 7819d61d6..2dd33ecb8 100644 --- a/firebase/functions/node/main.dart +++ b/firebase/functions/node/main.dart @@ -18,6 +18,7 @@ import 'package:functions/admin/payments/create_stripe_connected_account.dart'; import 'package:functions/admin/payments/create_subscription_checkout_session.dart'; import 'package:functions/events/event_ended.dart'; import 'package:functions/events/live_meetings/end_meeting_for_all.dart'; +import 'package:functions/events/live_meetings/scheduled_end_meeting.dart'; import 'package:functions/events/live_meetings/breakouts/get_breakout_room_assignment.dart'; import 'package:functions/events/live_meetings/breakouts/get_breakout_room_join_info.dart'; import 'package:functions/events/calendar/get_calendar_link.dart'; @@ -127,6 +128,7 @@ final _onRequestFunctions = [ EmailEventReminder(), ExtendCloudTaskScheduler(), MuxWebhooks(), + ScheduledEndMeeting(), ShareLink(), StripeConnectedAccountWebhooks(), StripeWebhooks(), From c4c34713d9a377ad3ea3a15747b303cc6563e516 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:03:20 -0400 Subject: [PATCH 08/39] Add tests for ScheduledEndMeeting CF - Verify meetingEndedAt is written and email sent - Verify idempotency (second call is a no-op) --- .../scheduled_end_meeting_test.dart | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart diff --git a/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart b/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart new file mode 100644 index 000000000..3f601ded9 --- /dev/null +++ b/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart @@ -0,0 +1,143 @@ +import 'package:data_models/cloud_functions/requests.dart'; +import 'package:data_models/community/community.dart'; +import 'package:data_models/events/event.dart'; +import 'package:data_models/events/live_meetings/live_meeting.dart'; +import 'package:functions/events/live_meetings/scheduled_end_meeting.dart'; +import 'package:functions/utils/infra/firestore_utils.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; +import '../../util/community_test_utils.dart'; +import '../../util/email_test_utils.dart'; +import '../../util/event_test_utils.dart'; +import '../../util/function_test_fixture.dart'; +import '../../util/live_meeting_test_utils.dart'; + +void main() { + late String communityId; + const templateId = '9654988'; + final communityTestUtils = CommunityTestUtils(); + final eventTestUtils = EventTestUtils(); + final liveMeetingTestUtils = LiveMeetingTestUtils(); + setupTestFixture(); + + setUp(() async { + communityId = await communityTestUtils.createTestCommunity(); + }); + + Future createTestEvent() async { + var event = Event( + id: 'scheduled-end-test', + status: EventStatus.active, + communityId: communityId, + templateId: templateId, + creatorId: adminUserId, + nullableEventType: EventType.hosted, + collectionPath: '', + agendaItems: [ + AgendaItem( + id: '55005', + title: 'Test item', + content: 'Test content', + ), + ], + ); + return eventTestUtils.createEvent(event: event, userId: adminUserId); + } + + group('ScheduledEndMeeting', () { + test('sets meetingEndedAt and sends email', () async { + final event = await createTestEvent(); + registerFallbackValue(event); + registerFallbackValue(EventEmailType.ended); + + await liveMeetingTestUtils.addMeetingEvent( + liveMeetingPath: liveMeetingTestUtils.getLiveMeetingPath(event), + liveMeetingId: event.id, + meetingEvent: LiveMeetingEvent( + event: LiveMeetingEventType.agendaItemStarted, + timestamp: DateTime.now(), + ), + ); + + final notificationsUtils = MockNotificationsUtils(); + when( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: any(named: 'communityId'), + userIds: any(named: 'userIds'), + emailType: any(named: 'emailType'), + generateMessage: any(named: 'generateMessage'), + ), + ).thenAnswer((_) async {}); + + final agoraUtils = MockAgoraUtils(); + final scheduledEnd = ScheduledEndMeeting( + notificationsUtils: notificationsUtils, + agoraUtils: agoraUtils, + ); + + final req = EndMeetingForAllRequest(eventPath: event.fullPath); + await scheduledEnd.action(req); + + // Verify meetingEndedAt was written. + final liveMeetingPath = liveMeetingTestUtils.getLiveMeetingPath(event); + final liveMeeting = await firestoreUtils.getFirestoreObject( + path: liveMeetingPath, + constructor: (map) => LiveMeeting.fromJson(map), + ); + expect(liveMeeting.meetingEndedAt, isNotNull); + }); + + test('is idempotent -- no-op if meetingEndedAt already set', () async { + final event = await createTestEvent(); + registerFallbackValue(event); + registerFallbackValue(EventEmailType.ended); + + await liveMeetingTestUtils.addMeetingEvent( + liveMeetingPath: liveMeetingTestUtils.getLiveMeetingPath(event), + liveMeetingId: event.id, + meetingEvent: LiveMeetingEvent( + event: LiveMeetingEventType.agendaItemStarted, + timestamp: DateTime.now(), + ), + ); + + final notificationsUtils = MockNotificationsUtils(); + when( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: any(named: 'communityId'), + userIds: any(named: 'userIds'), + emailType: any(named: 'emailType'), + generateMessage: any(named: 'generateMessage'), + ), + ).thenAnswer((_) async {}); + + final agoraUtils = MockAgoraUtils(); + final scheduledEnd = ScheduledEndMeeting( + notificationsUtils: notificationsUtils, + agoraUtils: agoraUtils, + ); + + final req = EndMeetingForAllRequest(eventPath: event.fullPath); + + // First call sets meetingEndedAt. + await scheduledEnd.action(req); + + // Second call returns early. + await scheduledEnd.action(req); + + verify( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: any(named: 'communityId'), + userIds: any(named: 'userIds'), + emailType: any(named: 'emailType'), + generateMessage: any(named: 'generateMessage'), + ), + ).called(1); + }); + }); +} + +class MockCommunity extends Mock implements Community {} From 29107097f9e4488fa75bdf0c9c085c6530b63778 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:10:40 -0400 Subject: [PATCH 09/39] Comment _postEventEmailThresholdInMinutes --- .../live_meeting/data/providers/live_meeting_provider.dart | 3 +++ 1 file changed, 3 insertions(+) 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 d4cb8f4db..f476245d9 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 @@ -149,6 +149,9 @@ class LiveMeetingProvider with ChangeNotifier { required this.showToast, }); + /// Suppress the post-event email for meetings shorter than this many minutes + /// past scheduled start. Prevents spam from test joins or accidental entries. + /// Recording stop still fires regardless of this threshold. static const int _postEventEmailThresholdInMinutes = 5; MeetingUiState get activeUiState { From 887a9e1c6febf6ee3e17de37f297c02737c766b6 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:54:18 -0400 Subject: [PATCH 10/39] Display end time alongside start time in event UI - Add optional endTime parameter to VerticalTimeAndDateIndicator - Show time range (e.g. '2:30p - 3:30p') when endTime is provided - Pass calculated endTime (scheduledTime + durationInMinutes) at all 5 call sites: event_card, carousel_tabs, home_page_event_card, event_button, event_info --- .../widgets/carousel/carousel_tabs.dart | 7 ++++++- .../widgets/carousel/time_indicator.dart | 15 ++++++++++----- .../presentation/widgets/event_card.dart | 3 +++ .../presentation/widgets/event_info.dart | 5 +++++ .../events/presentation/widgets/event_button.dart | 3 +++ .../presentation/views/home_page_event_card.dart | 3 +++ 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/client/lib/features/community/presentation/widgets/carousel/carousel_tabs.dart b/client/lib/features/community/presentation/widgets/carousel/carousel_tabs.dart index 37a7dedd4..c52fe0de1 100644 --- a/client/lib/features/community/presentation/widgets/carousel/carousel_tabs.dart +++ b/client/lib/features/community/presentation/widgets/carousel/carousel_tabs.dart @@ -94,7 +94,12 @@ class FeaturedEventCarouselTab extends StatelessWidget { if (event.scheduledTime != null) ...[ Align( alignment: Alignment.centerLeft, - child: VerticalTimeAndDateIndicator(time: event.scheduledTime!), + child: VerticalTimeAndDateIndicator( + time: event.scheduledTime!, + endTime: event.scheduledTime!.add( + Duration(minutes: event.durationInMinutes), + ), + ), ), SizedBox(height: 8), ], diff --git a/client/lib/features/community/presentation/widgets/carousel/time_indicator.dart b/client/lib/features/community/presentation/widgets/carousel/time_indicator.dart index 7eaa918b4..8d9e5c2d4 100644 --- a/client/lib/features/community/presentation/widgets/carousel/time_indicator.dart +++ b/client/lib/features/community/presentation/widgets/carousel/time_indicator.dart @@ -8,24 +8,29 @@ import 'package:client/core/widgets/height_constained_text.dart'; /// Text is contained in a rounded container with vertical aspect ratio and box shadow class VerticalTimeAndDateIndicator extends StatelessWidget { final DateTime time; + final DateTime? endTime; final bool shadow; final bool isDisabled; final EdgeInsetsGeometry padding; const VerticalTimeAndDateIndicator({ required this.time, + this.endTime, this.shadow = true, this.isDisabled = false, this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), Key? key, }) : super(key: key); - String get _timeString { - final timeString = DateFormat('h:mma').format(time); - final correctlyFormattedTimeString = - timeString.substring(0, timeString.length - 1).toLowerCase(); + String _formatTime(DateTime t) { + final timeString = DateFormat('h:mma').format(t); + return timeString.substring(0, timeString.length - 1).toLowerCase(); + } - return correctlyFormattedTimeString; + String get _timeString { + final start = _formatTime(time); + if (endTime == null) return start; + return '$start - ${_formatTime(endTime!)}'; } @override diff --git a/client/lib/features/community/presentation/widgets/event_card.dart b/client/lib/features/community/presentation/widgets/event_card.dart index 398f6991e..bb5991070 100644 --- a/client/lib/features/community/presentation/widgets/event_card.dart +++ b/client/lib/features/community/presentation/widgets/event_card.dart @@ -114,6 +114,9 @@ class EventCard extends StatelessWidget { time: DateTime.fromMillisecondsSinceEpoch( (event.scheduledTime?.millisecondsSinceEpoch ?? 0), ), + endTime: event.scheduledTime?.add( + Duration(minutes: event.durationInMinutes), + ), ) else SizedBox(width: 100), diff --git a/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart b/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart index 901324332..da7ac76f6 100644 --- a/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart +++ b/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart @@ -843,6 +843,11 @@ class _EventInfoState extends State { .event.scheduledTime?.millisecondsSinceEpoch ?? 0), ), + endTime: eventProvider.event.scheduledTime?.add( + Duration( + minutes: eventProvider.event.durationInMinutes, + ), + ), ), SizedBox( height: isMobile ? 90 : 100, diff --git a/client/lib/features/events/presentation/widgets/event_button.dart b/client/lib/features/events/presentation/widgets/event_button.dart index 1f74d8924..9e4961721 100644 --- a/client/lib/features/events/presentation/widgets/event_button.dart +++ b/client/lib/features/events/presentation/widgets/event_button.dart @@ -32,6 +32,9 @@ class EventButton extends HookWidget { time: DateTime.fromMillisecondsSinceEpoch( (scheduledTime.millisecondsSinceEpoch), ), + endTime: scheduledTime.add( + Duration(minutes: event.durationInMinutes), + ), ); } diff --git a/client/lib/features/home/presentation/views/home_page_event_card.dart b/client/lib/features/home/presentation/views/home_page_event_card.dart index bf64314b3..f5963c277 100644 --- a/client/lib/features/home/presentation/views/home_page_event_card.dart +++ b/client/lib/features/home/presentation/views/home_page_event_card.dart @@ -81,6 +81,9 @@ class _HomePageEventCardState extends State { height: 100, child: VerticalTimeAndDateIndicator( time: widget.event.scheduledTime ?? clockService.now(), + endTime: widget.event.scheduledTime?.add( + Duration(minutes: widget.event.durationInMinutes), + ), shadow: false, padding: const EdgeInsets.all(3), ), From 4fc78c4d532d3ac89b17eaafe0e4dc03a13d3c65 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:21:02 -0400 Subject: [PATCH 11/39] Add widget tests for VerticalTimeAndDateIndicator end time - Verify start-only display - Verify start-end range display --- .../widgets/carousel/time_indicator_test.dart | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 client/test/lib/features/community/presentation/widgets/carousel/time_indicator_test.dart diff --git a/client/test/lib/features/community/presentation/widgets/carousel/time_indicator_test.dart b/client/test/lib/features/community/presentation/widgets/carousel/time_indicator_test.dart new file mode 100644 index 000000000..efdd874a3 --- /dev/null +++ b/client/test/lib/features/community/presentation/widgets/carousel/time_indicator_test.dart @@ -0,0 +1,35 @@ +import 'package:client/features/community/presentation/widgets/carousel/time_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildTestWidget({ + required DateTime time, + DateTime? endTime, + }) { + return MaterialApp( + home: Scaffold( + body: VerticalTimeAndDateIndicator( + time: time, + endTime: endTime, + ), + ), + ); + } + + testWidgets('shows start time only when no endTime', (tester) async { + final time = DateTime(2026, 5, 1, 14, 30); + await tester.pumpWidget(buildTestWidget(time: time)); + + expect(find.text('2:30p'), findsOneWidget); + expect(find.textContaining('-'), findsNothing); + }); + + testWidgets('shows time range when endTime provided', (tester) async { + final time = DateTime(2026, 5, 1, 14, 30); + final endTime = DateTime(2026, 5, 1, 15, 30); + await tester.pumpWidget(buildTestWidget(time: time, endTime: endTime)); + + expect(find.text('2:30p - 3:30p'), findsOneWidget); + }); +} From 5992828e00f5867e00e51ff8168bdbbe758e54a5 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Fri, 1 May 2026 13:31:02 -0400 Subject: [PATCH 12/39] Reject join attempts on ended meetings --- .../live_meetings/get_meeting_join_info.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index 4bba4a967..0c9f552d9 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -9,6 +9,7 @@ import '../../on_call_function.dart'; import '../../utils/infra/firestore_utils.dart'; import 'package:data_models/cloud_functions/requests.dart'; import 'package:data_models/events/event.dart'; +import 'package:data_models/events/live_meetings/live_meeting.dart'; import 'package:data_models/user/public_user_info.dart'; import 'package:data_models/utils/utils.dart'; @@ -43,6 +44,24 @@ class GetMeetingJoinInfo extends OnCallMethod { throw HttpsError(HttpsError.failedPrecondition, 'unauthorized', null); } + // Reject join if the meeting has already ended. + final liveMeetingPath = + '${request.eventPath}/live-meetings/${event.id}'; + final liveMeetingSnap = + await transaction.get(firestore.document(liveMeetingPath)); + if (liveMeetingSnap.exists) { + final liveMeeting = LiveMeeting.fromJson( + firestoreUtils.fromFirestoreJson(liveMeetingSnap.data.toMap()), + ); + if (liveMeeting.meetingEndedAt != null) { + throw HttpsError( + HttpsError.failedPrecondition, + 'meeting-ended', + null, + ); + } + } + // Decide on users identifier final userSnapshot = await firestore.document('publicUser/${context.authUid}').get(); From aa1fde93f296232875f1f20d4f9f040f3b79eb96 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Fri, 1 May 2026 14:11:18 -0400 Subject: [PATCH 13/39] Add test for join rejection on ended meetings --- .../get_meeting_join_info_test.dart | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart b/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart index bbe7800bd..a042df2a5 100644 --- a/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart +++ b/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart @@ -1,6 +1,9 @@ import 'package:data_models/events/live_meetings/live_meeting.dart'; +import 'package:firebase_admin_interop/firebase_admin_interop.dart' + as admin_interop; import 'package:firebase_functions_interop/firebase_functions_interop.dart'; import 'package:functions/events/live_meetings/live_meeting_utils.dart'; +import 'package:functions/utils/infra/firestore_utils.dart'; import 'package:get_it/get_it.dart'; import 'package:functions/events/live_meetings/get_meeting_join_info.dart'; import 'package:data_models/events/event.dart'; @@ -141,4 +144,64 @@ void main() { ), ); }); + + test('Rejects join when meeting has already ended', () async { + var event = Event( + id: '5678', + status: EventStatus.active, + communityId: communityId, + templateId: templateId, + creatorId: userId, + nullableEventType: EventType.hosted, + collectionPath: '', + agendaItems: [ + AgendaItem( + id: '555', + title: 'Test Agenda', + content: 'Test Content', + ), + ], + ); + event = await eventUtils.createEvent( + event: event, + userId: userId, + ); + + // Create LiveMeeting doc with a meeting event. + await liveMeetingTestUtils.addMeetingEvent( + liveMeetingPath: liveMeetingTestUtils.getLiveMeetingPath(event), + liveMeetingId: event.id, + meetingEvent: LiveMeetingEvent( + agendaItem: event.agendaItems.first.id, + event: LiveMeetingEventType.agendaItemStarted, + ), + ); + + // Set meetingEndedAt on the LiveMeeting doc to simulate an ended meeting. + final liveMeetingPath = liveMeetingTestUtils.getLiveMeetingPath(event); + await firestore.document(liveMeetingPath).updateData( + admin_interop.UpdateData.fromMap({ + LiveMeeting.kFieldMeetingEndedAt: + admin_interop.Firestore.fieldValues.serverTimestamp(), + }), + ); + + final req = GetMeetingJoinInfoRequest(eventPath: event.fullPath); + final getMeetingJoinInfo = GetMeetingJoinInfo(); + + expect( + () => getMeetingJoinInfo.action( + req, + CallableContext(userId, null, 'fakeInstanceId'), + ), + throwsA( + predicate( + (e) => + e is HttpsError && + e.code == HttpsError.failedPrecondition && + e.message == 'meeting-ended', + ), + ), + ); + }); } From b2ef50a80bf6868d28cb6bd65961a4d060a21b68 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Fri, 1 May 2026 19:01:07 -0400 Subject: [PATCH 14/39] Prompt host/admin to end meeting on leave --- .../data/providers/live_meeting_provider.dart | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 f476245d9..1cee9349f 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 @@ -698,6 +698,33 @@ class LiveMeetingProvider with ChangeNotifier { Future leaveMeeting() async { if (_leftMeeting) return; + // If the meeting is still running and the user is the host or an admin, + // prompt whether to end the meeting for all participants. + final meetingAlreadyEnded = liveMeeting?.meetingEndedAt != null; + if (!meetingAlreadyEnded && eventProvider.event.isHosted) { + final isAdmin = userDataService + .getMembership(communityProvider.communityId) + .isAdmin; + if (isHost || isAdmin) { + final endForAll = await ConfirmDialog( + mainText: appLocalizationService + .getLocalization() + .endMeetingConfirmation, + confirmText: appLocalizationService + .getLocalization() + .endMeeting, + cancelText: 'Leave without ending', + ).show(); + if (endForAll) { + await cloudFunctionsEventService.endMeetingForAll( + EndMeetingForAllRequest(eventPath: eventPath), + ); + } + } + } + + if (_leftMeeting) return; + _leftMeeting = true; notifyListeners(); From 62bf5e144fc151b8f319fc57229bd163f87b19ee Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Fri, 1 May 2026 20:32:36 -0400 Subject: [PATCH 15/39] added option to close all rooms at meeting end time, with grace period --- .../event_settings_presenter.dart | 12 ++++ .../views/event_settings_drawer.dart | 59 ++++++++++++++++++ data_models/lib/events/event.dart | 6 ++ data_models/lib/events/event.freezed.dart | 61 ++++++++++++++++--- data_models/lib/events/event.g.dart | 4 ++ .../live_meetings/get_meeting_join_info.dart | 27 ++++---- 6 files changed, 149 insertions(+), 20 deletions(-) diff --git a/client/lib/features/events/features/event_page/presentation/event_settings_presenter.dart b/client/lib/features/events/features/event_page/presentation/event_settings_presenter.dart index 659cf64e1..418638fc7 100644 --- a/client/lib/features/events/features/event_page/presentation/event_settings_presenter.dart +++ b/client/lib/features/events/features/event_page/presentation/event_settings_presenter.dart @@ -118,6 +118,18 @@ class EventSettingsPresenter { _view.updateView(); } + void updateSettingValue(String setting, dynamic value) { + final settings = EventSettings.fromJson( + _model.eventSettings.toJson() + ..addEntries([ + MapEntry(setting, value), + ]), + ); + _model.eventSettings = settings; + _appDrawerProvider.setUnsavedChanges(_helper.wereChangesMade(_model)); + _view.updateView(); + } + Future saveSettings() async { switch (_model.eventSettingsDrawerType) { case EventSettingsDrawerType.template: diff --git a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart index 3f8472bb2..a91bfb652 100644 --- a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart +++ b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart @@ -131,6 +131,65 @@ class _EventSettingsDrawerState extends State ), ), SizedBox(height: 16), + _SwitchAndTooltip( + onUpdate: (isSelected) => _presenter.updateSetting( + EventSettings.kFieldAutoEndMeeting, + isSelected, + ), + text: 'Auto-end meeting', + val: _model.eventSettings.autoEndMeeting ?? false, + isIndicatorShown: _presenter.isSettingNotDefaultIndicatorShown( + (settings) => settings.autoEndMeeting, + ), + ), + if (_model.eventSettings.autoEndMeeting == true) ...[ + SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Row( + children: [ + Expanded( + child: HeightConstrainedText( + 'Grace period (minutes):', + style: context.theme.textTheme.bodyMedium, + ), + ), + SizedBox( + width: 64, + child: TextField( + controller: TextEditingController( + text: (_model.eventSettings + .autoEndGracePeriodMinutes ?? + 0) + .toString(), + ), + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + style: context.theme.textTheme.bodyMedium, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + border: OutlineInputBorder(), + ), + onChanged: (value) { + final parsed = int.tryParse(value); + if (parsed != null && parsed >= 0) { + _presenter.updateSettingValue( + EventSettings.kFieldAutoEndGracePeriodMinutes, + parsed, + ); + } + }, + ), + ), + ], + ), + ), + ], + SizedBox(height: 16), _SwitchAndTooltip( onUpdate: (isSelected) => _presenter.updateSetting( EventSettings.kFieldTalkingTimer, diff --git a/data_models/lib/events/event.dart b/data_models/lib/events/event.dart index 4455fc7a1..d2810ba8b 100644 --- a/data_models/lib/events/event.dart +++ b/data_models/lib/events/event.dart @@ -201,6 +201,8 @@ class EventSettings with _$EventSettings { static const kFieldChat = 'chat'; static const kFieldShowChatMessagesInRealTime = 'showChatMessagesInRealTime'; static const kFieldAgendaPreview = 'agendaPreview'; + static const kFieldAutoEndMeeting = 'autoEndMeeting'; + static const kFieldAutoEndGracePeriodMinutes = 'autoEndGracePeriodMinutes'; static const EventSettings defaultSettings = EventSettings( reminderEmails: true, @@ -215,6 +217,8 @@ class EventSettings with _$EventSettings { alwaysRecord: false, enablePrerequisites: false, agendaPreview: true, + autoEndMeeting: false, + autoEndGracePeriodMinutes: 0, ); const factory EventSettings({ @@ -232,6 +236,8 @@ class EventSettings with _$EventSettings { bool? alwaysRecord, bool? enablePrerequisites, bool? agendaPreview, + bool? autoEndMeeting, + int? autoEndGracePeriodMinutes, }) = _EventSettings; factory EventSettings.fromJson(Map json) => diff --git a/data_models/lib/events/event.freezed.dart b/data_models/lib/events/event.freezed.dart index 178f07fb3..83aafe282 100644 --- a/data_models/lib/events/event.freezed.dart +++ b/data_models/lib/events/event.freezed.dart @@ -1060,6 +1060,8 @@ mixin _$EventSettings { bool? get alwaysRecord => throw _privateConstructorUsedError; bool? get enablePrerequisites => throw _privateConstructorUsedError; bool? get agendaPreview => throw _privateConstructorUsedError; + bool? get autoEndMeeting => throw _privateConstructorUsedError; + int? get autoEndGracePeriodMinutes => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -1085,7 +1087,9 @@ abstract class $EventSettingsCopyWith<$Res> { bool? showSmartMatchingForBreakouts, bool? alwaysRecord, bool? enablePrerequisites, - bool? agendaPreview}); + bool? agendaPreview, + bool? autoEndMeeting, + int? autoEndGracePeriodMinutes}); } /// @nodoc @@ -1113,6 +1117,8 @@ class _$EventSettingsCopyWithImpl<$Res, $Val extends EventSettings> Object? alwaysRecord = freezed, Object? enablePrerequisites = freezed, Object? agendaPreview = freezed, + Object? autoEndMeeting = freezed, + Object? autoEndGracePeriodMinutes = freezed, }) { return _then(_value.copyWith( reminderEmails: freezed == reminderEmails @@ -1164,6 +1170,14 @@ class _$EventSettingsCopyWithImpl<$Res, $Val extends EventSettings> ? _value.agendaPreview : agendaPreview // ignore: cast_nullable_to_non_nullable as bool?, + autoEndMeeting: freezed == autoEndMeeting + ? _value.autoEndMeeting + : autoEndMeeting // ignore: cast_nullable_to_non_nullable + as bool?, + autoEndGracePeriodMinutes: freezed == autoEndGracePeriodMinutes + ? _value.autoEndGracePeriodMinutes + : autoEndGracePeriodMinutes // ignore: cast_nullable_to_non_nullable + as int?, ) as $Val); } } @@ -1188,7 +1202,9 @@ abstract class _$$_EventSettingsCopyWith<$Res> bool? showSmartMatchingForBreakouts, bool? alwaysRecord, bool? enablePrerequisites, - bool? agendaPreview}); + bool? agendaPreview, + bool? autoEndMeeting, + int? autoEndGracePeriodMinutes}); } /// @nodoc @@ -1214,6 +1230,8 @@ class __$$_EventSettingsCopyWithImpl<$Res> Object? alwaysRecord = freezed, Object? enablePrerequisites = freezed, Object? agendaPreview = freezed, + Object? autoEndMeeting = freezed, + Object? autoEndGracePeriodMinutes = freezed, }) { return _then(_$_EventSettings( reminderEmails: freezed == reminderEmails @@ -1265,6 +1283,14 @@ class __$$_EventSettingsCopyWithImpl<$Res> ? _value.agendaPreview : agendaPreview // ignore: cast_nullable_to_non_nullable as bool?, + autoEndMeeting: freezed == autoEndMeeting + ? _value.autoEndMeeting + : autoEndMeeting // ignore: cast_nullable_to_non_nullable + as bool?, + autoEndGracePeriodMinutes: freezed == autoEndGracePeriodMinutes + ? _value.autoEndGracePeriodMinutes + : autoEndGracePeriodMinutes // ignore: cast_nullable_to_non_nullable + as int?, )); } } @@ -1284,7 +1310,9 @@ class _$_EventSettings implements _EventSettings { this.showSmartMatchingForBreakouts, this.alwaysRecord, this.enablePrerequisites, - this.agendaPreview}); + this.agendaPreview, + this.autoEndMeeting, + this.autoEndGracePeriodMinutes}); factory _$_EventSettings.fromJson(Map json) => _$$_EventSettingsFromJson(json); @@ -1315,10 +1343,14 @@ class _$_EventSettings implements _EventSettings { final bool? enablePrerequisites; @override final bool? agendaPreview; + @override + final bool? autoEndMeeting; + @override + final int? autoEndGracePeriodMinutes; @override String toString() { - return 'EventSettings(reminderEmails: $reminderEmails, chat: $chat, showChatMessagesInRealTime: $showChatMessagesInRealTime, talkingTimer: $talkingTimer, allowPredefineBreakoutsOnHosted: $allowPredefineBreakoutsOnHosted, defaultStageView: $defaultStageView, enableBreakoutsByCategory: $enableBreakoutsByCategory, allowMultiplePeopleOnStage: $allowMultiplePeopleOnStage, showSmartMatchingForBreakouts: $showSmartMatchingForBreakouts, alwaysRecord: $alwaysRecord, enablePrerequisites: $enablePrerequisites, agendaPreview: $agendaPreview)'; + return 'EventSettings(reminderEmails: $reminderEmails, chat: $chat, showChatMessagesInRealTime: $showChatMessagesInRealTime, talkingTimer: $talkingTimer, allowPredefineBreakoutsOnHosted: $allowPredefineBreakoutsOnHosted, defaultStageView: $defaultStageView, enableBreakoutsByCategory: $enableBreakoutsByCategory, allowMultiplePeopleOnStage: $allowMultiplePeopleOnStage, showSmartMatchingForBreakouts: $showSmartMatchingForBreakouts, alwaysRecord: $alwaysRecord, enablePrerequisites: $enablePrerequisites, agendaPreview: $agendaPreview, autoEndMeeting: $autoEndMeeting, autoEndGracePeriodMinutes: $autoEndGracePeriodMinutes)'; } @override @@ -1329,8 +1361,7 @@ class _$_EventSettings implements _EventSettings { (identical(other.reminderEmails, reminderEmails) || other.reminderEmails == reminderEmails) && (identical(other.chat, chat) || other.chat == chat) && - (identical(other.showChatMessagesInRealTime, - showChatMessagesInRealTime) || + (identical(other.showChatMessagesInRealTime, showChatMessagesInRealTime) || other.showChatMessagesInRealTime == showChatMessagesInRealTime) && (identical(other.talkingTimer, talkingTimer) || @@ -1356,7 +1387,11 @@ class _$_EventSettings implements _EventSettings { (identical(other.enablePrerequisites, enablePrerequisites) || other.enablePrerequisites == enablePrerequisites) && (identical(other.agendaPreview, agendaPreview) || - other.agendaPreview == agendaPreview)); + other.agendaPreview == agendaPreview) && + (identical(other.autoEndMeeting, autoEndMeeting) || + other.autoEndMeeting == autoEndMeeting) && + (identical(other.autoEndGracePeriodMinutes, autoEndGracePeriodMinutes) || + other.autoEndGracePeriodMinutes == autoEndGracePeriodMinutes)); } @JsonKey(ignore: true) @@ -1374,7 +1409,9 @@ class _$_EventSettings implements _EventSettings { showSmartMatchingForBreakouts, alwaysRecord, enablePrerequisites, - agendaPreview); + agendaPreview, + autoEndMeeting, + autoEndGracePeriodMinutes); @JsonKey(ignore: true) @override @@ -1403,7 +1440,9 @@ abstract class _EventSettings implements EventSettings { final bool? showSmartMatchingForBreakouts, final bool? alwaysRecord, final bool? enablePrerequisites, - final bool? agendaPreview}) = _$_EventSettings; + final bool? agendaPreview, + final bool? autoEndMeeting, + final int? autoEndGracePeriodMinutes}) = _$_EventSettings; factory _EventSettings.fromJson(Map json) = _$_EventSettings.fromJson; @@ -1434,6 +1473,10 @@ abstract class _EventSettings implements EventSettings { @override bool? get agendaPreview; @override + bool? get autoEndMeeting; + @override + int? get autoEndGracePeriodMinutes; + @override @JsonKey(ignore: true) _$$_EventSettingsCopyWith<_$_EventSettings> get copyWith => throw _privateConstructorUsedError; diff --git a/data_models/lib/events/event.g.dart b/data_models/lib/events/event.g.dart index 41a4bf2e5..e7a80f62b 100644 --- a/data_models/lib/events/event.g.dart +++ b/data_models/lib/events/event.g.dart @@ -127,6 +127,8 @@ _$_EventSettings _$$_EventSettingsFromJson(Map json) => alwaysRecord: json['alwaysRecord'] as bool?, enablePrerequisites: json['enablePrerequisites'] as bool?, agendaPreview: json['agendaPreview'] as bool?, + autoEndMeeting: json['autoEndMeeting'] as bool?, + autoEndGracePeriodMinutes: json['autoEndGracePeriodMinutes'] as int?, ); Map _$$_EventSettingsToJson(_$_EventSettings instance) => @@ -144,6 +146,8 @@ Map _$$_EventSettingsToJson(_$_EventSettings instance) => 'alwaysRecord': instance.alwaysRecord, 'enablePrerequisites': instance.enablePrerequisites, 'agendaPreview': instance.agendaPreview, + 'autoEndMeeting': instance.autoEndMeeting, + 'autoEndGracePeriodMinutes': instance.autoEndGracePeriodMinutes, }; _$_LiveStreamInfo _$$_LiveStreamInfoFromJson(Map json) => diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index 0c9f552d9..820309752 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -102,23 +102,28 @@ class GetMeetingJoinInfo extends OnCallMethod { ); } - // On first join, schedule automatic meeting end if the event has a - // scheduled time and duration. + // On first join, schedule automatic meeting end if the event has + // autoEndMeeting enabled, a scheduled time, and a duration. if (result.isFirstJoin) { final event = await firestoreUtils.getFirestoreObject( path: request.eventPath, constructor: (map) => Event.fromJson(map), ); - final scheduledTime = event.scheduledTime; - if (scheduledTime != null) { - final endTime = scheduledTime.add( - Duration(minutes: event.durationInMinutes), - ); - if (endTime.isAfter(DateTime.now())) { - await ScheduledEndMeeting().schedule( - EndMeetingForAllRequest(eventPath: request.eventPath), - endTime, + final autoEnd = event.eventSettings?.autoEndMeeting ?? false; + if (autoEnd) { + final scheduledTime = event.scheduledTime; + if (scheduledTime != null) { + final gracePeriod = + event.eventSettings?.autoEndGracePeriodMinutes ?? 0; + final endTime = scheduledTime.add( + Duration(minutes: event.durationInMinutes + gracePeriod), ); + if (endTime.isAfter(DateTime.now())) { + await ScheduledEndMeeting().schedule( + EndMeetingForAllRequest(eventPath: request.eventPath), + endTime, + ); + } } } } From b42c2bd5c36f60acb01902759c5be5c39c36a17e Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Fri, 1 May 2026 20:39:15 -0400 Subject: [PATCH 16/39] Fixed settings type error by only filtering listed non-bool values from the bool check in Settings display --- .../presentation/views/event_settings_drawer.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart index a91bfb652..de17360f6 100644 --- a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart +++ b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart @@ -237,12 +237,17 @@ class _EventSettingsDrawerState extends State style: context.theme.textTheme.headlineSmall!, ), SizedBox(height: 40), - for (final feature in _model.eventSettings.toJson().keys.toList()) + for (final entry + in _model.eventSettings.toJson().entries.where( + (e) => + e.key != + EventSettings.kFieldAutoEndGracePeriodMinutes, + )) CustomSwitchTile( onUpdate: (isSelected) => - _presenter.updateSetting(feature, isSelected), - text: feature, - val: _model.eventSettings.toJson()[feature] ?? false, + _presenter.updateSetting(entry.key, isSelected), + text: entry.key, + val: (entry.value as bool?) ?? false, style: context.theme.textTheme.bodyMedium, ), ], From 4e27021f39a0034098e89c8b1a6732c8c9527bc4 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 10:01:44 -0400 Subject: [PATCH 17/39] Wrap idempotent meetingEndedAt check in transaction --- .../live_meetings/end_meeting_for_all.dart | 35 ++++++++++-------- .../live_meetings/scheduled_end_meeting.dart | 36 ++++++++++--------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart index 8a28a7df8..9e3fc6459 100644 --- a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart +++ b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart @@ -56,23 +56,28 @@ class EndMeetingForAll extends OnCallMethod { throw HttpsError(HttpsError.failedPrecondition, 'unauthorized', null); } - // Read the LiveMeeting doc. If meetingEndedAt is already set, return (idempotent). + // Atomically set meetingEndedAt. If already set, return (idempotent). final liveMeetingPath = '${request.eventPath}/live-meetings/${event.id}'; - final liveMeeting = await firestoreUtils.getFirestoreObject( - path: liveMeetingPath, - constructor: (map) => LiveMeeting.fromJson(map), - ); - if (liveMeeting.meetingEndedAt != null) { - return; - } + final liveMeetingRef = firestore.document(liveMeetingPath); + final didEnd = await firestore.runTransaction((transaction) async { + final snap = await transaction.get(liveMeetingRef); + final liveMeeting = LiveMeeting.fromJson( + firestoreUtils.fromFirestoreJson(snap.data.toMap()), + ); + if (liveMeeting.meetingEndedAt != null) { + return false; + } + transaction.update( + liveMeetingRef, + UpdateData.fromMap({ + LiveMeeting.kFieldMeetingEndedAt: + Firestore.fieldValues.serverTimestamp(), + }), + ); + return true; + }); - // Write meetingEndedAt to the LiveMeeting doc. - await firestore.document(liveMeetingPath).updateData( - UpdateData.fromMap({ - LiveMeeting.kFieldMeetingEndedAt: - Firestore.fieldValues.serverTimestamp(), - }), - ); + if (!didEnd) return; // Stop all recordings (main + breakout). await stopAllEventRecordings( diff --git a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart index 0fb13fe21..bc009dfd2 100644 --- a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart +++ b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart @@ -36,24 +36,28 @@ class ScheduledEndMeeting constructor: (map) => Event.fromJson(map), ); + // Atomically set meetingEndedAt. If already set, return (idempotent). final liveMeetingPath = '${request.eventPath}/live-meetings/${event.id}'; - final liveMeeting = await firestoreUtils.getFirestoreObject( - path: liveMeetingPath, - constructor: (map) => LiveMeeting.fromJson(map), - ); - - // Already ended (by host or a previous task). No-op. - if (liveMeeting.meetingEndedAt != null) { - return ''; - } + final liveMeetingRef = firestore.document(liveMeetingPath); + final didEnd = await firestore.runTransaction((transaction) async { + final snap = await transaction.get(liveMeetingRef); + final liveMeeting = LiveMeeting.fromJson( + firestoreUtils.fromFirestoreJson(snap.data.toMap()), + ); + if (liveMeeting.meetingEndedAt != null) { + return false; + } + transaction.update( + liveMeetingRef, + UpdateData.fromMap({ + LiveMeeting.kFieldMeetingEndedAt: + Firestore.fieldValues.serverTimestamp(), + }), + ); + return true; + }); - // Write meetingEndedAt to the LiveMeeting doc. - await firestore.document(liveMeetingPath).updateData( - UpdateData.fromMap({ - LiveMeeting.kFieldMeetingEndedAt: - Firestore.fieldValues.serverTimestamp(), - }), - ); + if (!didEnd) return ''; // Stop all recordings (main + breakout). await stopAllEventRecordings( From 9a3b0c7b5ffde5bc2200ef92590c663df3c5650e Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 10:41:07 -0400 Subject: [PATCH 18/39] Remove dead displayName resolution code and unused imports --- .../live_meetings/get_meeting_join_info.dart | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index 820309752..b2fd09bf8 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -1,8 +1,4 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; import 'package:firebase_functions_interop/firebase_functions_interop.dart'; -import '../../utils/infra/firebase_auth_utils.dart'; import 'live_meeting_utils.dart'; import 'scheduled_end_meeting.dart'; import '../../on_call_function.dart'; @@ -10,8 +6,6 @@ import '../../utils/infra/firestore_utils.dart'; import 'package:data_models/cloud_functions/requests.dart'; import 'package:data_models/events/event.dart'; import 'package:data_models/events/live_meetings/live_meeting.dart'; -import 'package:data_models/user/public_user_info.dart'; -import 'package:data_models/utils/utils.dart'; class GetMeetingJoinInfo extends OnCallMethod { LiveMeetingUtils liveMeetingUtils; @@ -62,23 +56,6 @@ class GetMeetingJoinInfo extends OnCallMethod { } } - // Decide on users identifier - final userSnapshot = - await firestore.document('publicUser/${context.authUid}').get(); - final publicUserInfo = PublicUserInfo.fromJson( - firestoreUtils.fromFirestoreJson(userSnapshot.data.toMap()), - ); - var displayName = publicUserInfo.displayName; - print('Public user display name: $displayName'); - - if (displayName == null || displayName.trim().isEmpty) { - final userLookup = await firebaseAuthUtils.getUsers([context.authUid!]); - displayName = - firstAndLastInitial(userLookup.firstOrNull?.displayName) ?? - 'User-${context.authUid!.substring(0, 4)}'; - print('Public user display name: $displayName'); - } - return liveMeetingUtils.getMeetingJoinInfo( transaction: transaction, event: event, From 2a3df80883942f4928687b92f876012c18d0a4de Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 10:52:59 -0400 Subject: [PATCH 19/39] Pass event from transaction via MeetingJoinResult --- .../lib/events/live_meetings/get_meeting_join_info.dart | 5 +---- .../lib/events/live_meetings/live_meeting_utils.dart | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index b2fd09bf8..f09dd366b 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -82,10 +82,7 @@ class GetMeetingJoinInfo extends OnCallMethod { // On first join, schedule automatic meeting end if the event has // autoEndMeeting enabled, a scheduled time, and a duration. if (result.isFirstJoin) { - final event = await firestoreUtils.getFirestoreObject( - path: request.eventPath, - constructor: (map) => Event.fromJson(map), - ); + final event = result.event; final autoEnd = event.eventSettings?.autoEndMeeting ?? false; if (autoEnd) { final scheduledTime = event.scheduledTime; diff --git a/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart b/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart index d5b5e5bda..fd4e90704 100644 --- a/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart +++ b/firebase/functions/lib/events/live_meetings/live_meeting_utils.dart @@ -32,9 +32,11 @@ class MeetingJoinResult { final GetMeetingJoinInfoResponse response; final PendingRecording? pendingRecording; final bool isFirstJoin; + final Event event; MeetingJoinResult({ required this.response, + required this.event, this.pendingRecording, this.isFirstJoin = false, }); @@ -130,6 +132,7 @@ class LiveMeetingUtils { meetingToken: token, meetingId: meetingId, ), + event: event, pendingRecording: pendingRecording, isFirstJoin: isFirstJoin, ); From 97ee210c58fd2e87a8c06148e049350cf837e105 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 11:00:08 -0400 Subject: [PATCH 20/39] Wrap auto-end task scheduling in try/catch --- .../events/live_meetings/get_meeting_join_info.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index f09dd366b..e55873b7b 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -93,10 +93,14 @@ class GetMeetingJoinInfo extends OnCallMethod { Duration(minutes: event.durationInMinutes + gracePeriod), ); if (endTime.isAfter(DateTime.now())) { - await ScheduledEndMeeting().schedule( - EndMeetingForAllRequest(eventPath: request.eventPath), - endTime, - ); + try { + await ScheduledEndMeeting().schedule( + EndMeetingForAllRequest(eventPath: request.eventPath), + endTime, + ); + } catch (e) { + print('Failed to schedule auto-end task: $e'); + } } } } From 09e4eb52c4cf6b49bc4d257c8656c3a70d247575 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 11:08:21 -0400 Subject: [PATCH 21/39] Localize hard-coded strings in leave prompt and event settings --- .../event_page/presentation/views/event_settings_drawer.dart | 4 ++-- .../live_meeting/data/providers/live_meeting_provider.dart | 4 +++- client/lib/l10n/app_en.arb | 3 +++ client/lib/l10n/app_es.arb | 3 +++ client/lib/l10n/app_zh.arb | 3 +++ client/lib/l10n/app_zh_Hant_TW.arb | 3 +++ 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart index de17360f6..db49ab5b8 100644 --- a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart +++ b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart @@ -136,7 +136,7 @@ class _EventSettingsDrawerState extends State EventSettings.kFieldAutoEndMeeting, isSelected, ), - text: 'Auto-end meeting', + text: context.l10n.autoEndMeeting, val: _model.eventSettings.autoEndMeeting ?? false, isIndicatorShown: _presenter.isSettingNotDefaultIndicatorShown( (settings) => settings.autoEndMeeting, @@ -150,7 +150,7 @@ class _EventSettingsDrawerState extends State children: [ Expanded( child: HeightConstrainedText( - 'Grace period (minutes):', + context.l10n.gracePeriodMinutes, style: context.theme.textTheme.bodyMedium, ), ), 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 1cee9349f..d9aa9c3e4 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 @@ -713,7 +713,9 @@ class LiveMeetingProvider with ChangeNotifier { confirmText: appLocalizationService .getLocalization() .endMeeting, - cancelText: 'Leave without ending', + cancelText: appLocalizationService + .getLocalization() + .leaveWithoutEnding, ).show(); if (endForAll) { await cloudFunctionsEventService.endMeetingForAll( diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index 73de6786b..21d881a0e 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -296,6 +296,7 @@ "areYouSureYouWantToDeleteMedia": "Are you sure you want to delete media?", "areYouSureYouWantToRefreshTheGuide": "Are you sure you want to refresh the guide?", "aspectRatioClipped": "Aspect ratio clipped", + "autoEndMeeting": "Auto-end meeting", "attendee": "Attendee", "audioDeviceUpdated": "Audio device updated.", "audioInputDevice": "Audio Input Device:", @@ -465,6 +466,7 @@ "goTo": "Go to", "googleCalendar": "Google Calendar", "googleMeet": "Google Meet", + "gracePeriodMinutes": "Grace period (0-120 min):", "headlineCannotBeEmpty": "Headline cannot be empty", "headlineIsRequired": "Headline is required", "helpCenter": "Help Center", @@ -491,6 +493,7 @@ "language": "Language", "languageSelector": "Language Selector", "lastSession": "Last session", + "leaveWithoutEnding": "Leave without ending", "lightColorHex": "Light Color HEX#", "linkedinUrl": "LinkedIn URL", "links": "Links", diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index ac976194e..817c305c7 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -369,6 +369,7 @@ "areYouSureYouWantToDeleteMedia": "¿Estás seguro de que quieres eliminar este medio?", "areYouSureYouWantToRefreshTheGuide": "¿Estás seguro de que quieres actualizar la guía?", "aspectRatioClipped": "Relación de aspecto recortada", + "autoEndMeeting": "Finalizar reunión automáticamente", "attendee": "Asistente", "audioDeviceUpdated": "Dispositivo de audio actualizado.", "audioInputDevice": "Dispositivo de entrada de audio:", @@ -538,6 +539,7 @@ "goTo": "Ir a", "googleCalendar": "Google Calendar", "googleMeet": "Google Meet", + "gracePeriodMinutes": "Período de gracia (0-120 min):", "headlineCannotBeEmpty": "El titular no puede estar vacío", "headlineIsRequired": "El titular es obligatorio", "helpCenter": "Centro de Ayuda", @@ -564,6 +566,7 @@ "language": "Idioma", "languageSelector": "Selector de idioma", "lastSession": "Última sesión", + "leaveWithoutEnding": "Salir sin finalizar", "lightColorHex": "Color claro HEX#", "linkedinUrl": "URL de LinkedIn", "links": "Enlaces", diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index 455976ae4..fb8d02227 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -371,6 +371,7 @@ "areYouSureYouWantToDeleteMedia": "您确定要删除媒体吗?", "areYouSureYouWantToRefreshTheGuide": "您确定要刷新指南吗?", "aspectRatioClipped": "纵横比裁剪", + "autoEndMeeting": "自动结束会议", "attendee": "参与者", "audioDeviceUpdated": "音频设备已更新。", "audioInputDevice": "音频输入设备:", @@ -540,6 +541,7 @@ "goTo": "前往", "googleCalendar": "Google 日历", "googleMeet": "Google Meet", + "gracePeriodMinutes": "宽限期 (0-120 分钟):", "headlineCannotBeEmpty": "大标题不能为空", "headlineIsRequired": "大标题为必填项", "helpCenter": "帮助中心", @@ -567,6 +569,7 @@ "language": "语言", "languageSelector": "语言选择器", "lastSession": "上次会话", + "leaveWithoutEnding": "离开但不结束", "lightColorHex": "浅色十六进制值", "linkedinUrl": "LinkedIn链接", "links": "链接", diff --git a/client/lib/l10n/app_zh_Hant_TW.arb b/client/lib/l10n/app_zh_Hant_TW.arb index 0e0cfb1df..a5bad3f7f 100644 --- a/client/lib/l10n/app_zh_Hant_TW.arb +++ b/client/lib/l10n/app_zh_Hant_TW.arb @@ -371,6 +371,7 @@ "areYouSureYouWantToDeleteMedia": "您確定要刪除媒體嗎?", "areYouSureYouWantToRefreshTheGuide": "您確定要刷新指南嗎?", "aspectRatioClipped": "長寬比剪裁", + "autoEndMeeting": "自動結束會議", "attendee": "參與者", "audioDeviceUpdated": "音訊裝置已更新。", "audioInputDevice": "音訊輸入裝置:", @@ -540,6 +541,7 @@ "goTo": "前往", "googleCalendar": "Google 日曆", "googleMeet": "Google Meet", + "gracePeriodMinutes": "寬限期 (0-120 分鐘):", "headlineCannotBeEmpty": "大標題不能為空", "headlineIsRequired": "大標題為必填項", "helpCenter": "幫助中心", @@ -567,6 +569,7 @@ "language": "語言", "languageSelector": "語言選擇器", "lastSession": "上次會議", + "leaveWithoutEnding": "離開但不結束", "lightColorHex": "淡色十六進制值", "linkedinUrl": "LinkedIn連結", "links": "連結", From 0ed29a9b4203a18620ada35d319d8fc8dd6c181d Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 11:25:00 -0400 Subject: [PATCH 22/39] Validate Cloud Tasks header in ScheduledEndMeeting --- .../live_meetings/scheduled_end_meeting.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart index bc009dfd2..cf451fc3b 100644 --- a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart +++ b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:firebase_admin_interop/firebase_admin_interop.dart' as admin_interop; import 'package:firebase_admin_interop/firebase_admin_interop.dart'; +import 'package:firebase_functions_interop/firebase_functions_interop.dart' + hide CloudFunction; import '../../on_request_method.dart'; import '../../utils/email_templates.dart'; import '../../utils/infra/firestore_utils.dart'; @@ -29,6 +31,20 @@ class ScheduledEndMeeting (jsonMap) => EndMeetingForAllRequest.fromJson(jsonMap), ); + @override + Future handleRequest(ExpressHttpRequest expressRequest) async { + // Only Cloud Tasks sets X-CloudTasks-TaskName. Cloud Functions strips + // this header from external requests, so its presence confirms the + // call originated from a Cloud Tasks queue. + final taskName = expressRequest.headers.value('X-CloudTasks-TaskName'); + if (taskName == null || taskName.isEmpty) { + expressRequest.response.statusCode = 403; + expressRequest.response.write('Forbidden'); + return; + } + await super.handleRequest(expressRequest); + } + @override Future action(EndMeetingForAllRequest request) async { final event = await firestoreUtils.getFirestoreObject( From b42c8d3fe3015374d9af9449aa7de4dfe1109ac0 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 11:38:09 -0400 Subject: [PATCH 23/39] Add email verify to scheduled end meeting test --- .../live_meetings/scheduled_end_meeting_test.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart b/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart index 3f601ded9..e487ec9a4 100644 --- a/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart +++ b/firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart @@ -86,6 +86,17 @@ void main() { constructor: (map) => LiveMeeting.fromJson(map), ); expect(liveMeeting.meetingEndedAt, isNotNull); + + // Verify email was sent. + verify( + () => notificationsUtils.sendEventEndedEmail( + event: any(named: 'event'), + communityId: any(named: 'communityId'), + userIds: any(named: 'userIds'), + emailType: any(named: 'emailType'), + generateMessage: any(named: 'generateMessage'), + ), + ).called(1); }); test('is idempotent -- no-op if meetingEndedAt already set', () async { From 295e7db581af50c1dea0ac8aebcbfaa536e8a5ce Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 14:03:19 -0400 Subject: [PATCH 24/39] Clamp grace period to 0-120 minutes server-side --- .../lib/events/live_meetings/get_meeting_join_info.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index e55873b7b..326f92c88 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -88,7 +88,8 @@ class GetMeetingJoinInfo extends OnCallMethod { final scheduledTime = event.scheduledTime; if (scheduledTime != null) { final gracePeriod = - event.eventSettings?.autoEndGracePeriodMinutes ?? 0; + (event.eventSettings?.autoEndGracePeriodMinutes ?? 0) + .clamp(0, 120); final endTime = scheduledTime.add( Duration(minutes: event.durationInMinutes + gracePeriod), ); From b56e9a01a7b95d7b7b62ad3e4151a24fdde82eaa Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 14:28:11 -0400 Subject: [PATCH 25/39] Add grace period input validation and auto-correction --- .../views/event_settings_drawer.dart | 125 +++++++++++++++--- 1 file changed, 107 insertions(+), 18 deletions(-) diff --git a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart index db49ab5b8..911235bd8 100644 --- a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart +++ b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:client/core/utils/toast_utils.dart'; import 'package:client/styles/styles.dart'; import 'package:flutter/material.dart'; @@ -40,6 +42,11 @@ class _EventSettingsDrawerState extends State implements EventSettingsView { late final EventSettingsModel _model; late final EventSettingsPresenter _presenter; + final _gracePeriodController = TextEditingController(); + final _gracePeriodFocusNode = FocusNode(); + Timer? _gracePeriodDebounce; + bool _gracePeriodInvalid = false; + bool _gracePeriodEditing = false; @override void initState() { @@ -48,6 +55,77 @@ class _EventSettingsDrawerState extends State _model = EventSettingsModel(widget.eventSettingsDrawerType); _presenter = EventSettingsPresenter(context, this, _model); _presenter.init(); + _syncGracePeriodController(); + _gracePeriodFocusNode.addListener(_onGracePeriodFocusChange); + } + + @override + void dispose() { + _gracePeriodDebounce?.cancel(); + _gracePeriodController.dispose(); + _gracePeriodFocusNode.removeListener(_onGracePeriodFocusChange); + _gracePeriodFocusNode.dispose(); + super.dispose(); + } + + void _syncGracePeriodController() { + if (_gracePeriodEditing) return; + final value = + (_model.eventSettings.autoEndGracePeriodMinutes ?? 0).toString(); + if (_gracePeriodController.text != value) { + _gracePeriodController.text = value; + } + } + + void _onGracePeriodFocusChange() { + if (_gracePeriodFocusNode.hasFocus) { + _gracePeriodEditing = true; + } else { + _correctGracePeriodInput(); + _gracePeriodEditing = false; + } + } + + void _onGracePeriodChanged(String value) { + _gracePeriodDebounce?.cancel(); + _gracePeriodEditing = true; + final parsed = int.tryParse(value); + final isValid = parsed != null && parsed >= 0 && parsed <= 120; + if (isValid) { + _presenter.updateSettingValue( + EventSettings.kFieldAutoEndGracePeriodMinutes, + parsed, + ); + } + setState(() { + _gracePeriodInvalid = !isValid && value.isNotEmpty; + }); + _gracePeriodDebounce = Timer(const Duration(seconds: 1), () { + _correctGracePeriodInput(); + }); + } + + void _correctGracePeriodInput() { + _gracePeriodDebounce?.cancel(); + final text = _gracePeriodController.text; + final parsed = num.tryParse(text); + int corrected; + if (parsed == null || parsed < 0) { + corrected = 0; + } else if (parsed > 120) { + corrected = 120; + } else { + corrected = parsed.floor(); + } + _gracePeriodController.text = corrected.toString(); + _gracePeriodEditing = false; + _presenter.updateSettingValue( + EventSettings.kFieldAutoEndGracePeriodMinutes, + corrected, + ); + setState(() { + _gracePeriodInvalid = false; + }); } @override @@ -156,16 +234,16 @@ class _EventSettingsDrawerState extends State ), SizedBox( width: 64, - child: TextField( - controller: TextEditingController( - text: (_model.eventSettings - .autoEndGracePeriodMinutes ?? - 0) - .toString(), - ), + child: TextFormField( + controller: _gracePeriodController, + focusNode: _gracePeriodFocusNode, keyboardType: TextInputType.number, textAlign: TextAlign.center, - style: context.theme.textTheme.bodyMedium, + style: context.theme.textTheme.bodyMedium?.copyWith( + color: _gracePeriodInvalid + ? context.theme.colorScheme.error + : null, + ), decoration: InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric( @@ -173,16 +251,23 @@ class _EventSettingsDrawerState extends State vertical: 8, ), border: OutlineInputBorder(), + enabledBorder: _gracePeriodInvalid + ? OutlineInputBorder( + borderSide: BorderSide( + color: context.theme.colorScheme.error, + ), + ) + : null, + focusedBorder: _gracePeriodInvalid + ? OutlineInputBorder( + borderSide: BorderSide( + color: context.theme.colorScheme.error, + width: 2, + ), + ) + : null, ), - onChanged: (value) { - final parsed = int.tryParse(value); - if (parsed != null && parsed >= 0) { - _presenter.updateSettingValue( - EventSettings.kFieldAutoEndGracePeriodMinutes, - parsed, - ); - } - }, + onChanged: _onGracePeriodChanged, ), ), ], @@ -215,7 +300,10 @@ class _EventSettingsDrawerState extends State ActionButton( expand: true, text: context.l10n.saveSettings, - onPressed: () => _presenter.saveSettings(), + onPressed: () { + _correctGracePeriodInput(); + _presenter.saveSettings(); + }, color: context.theme.colorScheme.primary, textColor: context.theme.colorScheme.onPrimary, ), @@ -268,6 +356,7 @@ class _EventSettingsDrawerState extends State @override void updateView() { + _syncGracePeriodController(); setState(() {}); } } From e428a3fe099138d7f03672308f22619582531068 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 15:16:06 -0400 Subject: [PATCH 26/39] Extract shared end meeting core logic --- .../live_meetings/end_meeting_core.dart | 62 +++++++++++++++++++ .../live_meetings/end_meeting_for_all.dart | 50 ++------------- .../live_meetings/scheduled_end_meeting.dart | 50 ++------------- 3 files changed, 72 insertions(+), 90 deletions(-) create mode 100644 firebase/functions/lib/events/live_meetings/end_meeting_core.dart diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_core.dart b/firebase/functions/lib/events/live_meetings/end_meeting_core.dart new file mode 100644 index 000000000..95ca18f91 --- /dev/null +++ b/firebase/functions/lib/events/live_meetings/end_meeting_core.dart @@ -0,0 +1,62 @@ +import 'package:firebase_admin_interop/firebase_admin_interop.dart' + as admin_interop; +import '../../utils/email_templates.dart'; +import '../../utils/infra/firestore_utils.dart'; +import '../../utils/notifications_utils.dart'; +import '../../utils/subscription_plan_util.dart'; +import 'agora_api.dart'; +import 'stop_all_event_recordings.dart'; +import 'package:data_models/cloud_functions/requests.dart'; +import 'package:data_models/community/community.dart'; +import 'package:data_models/events/event.dart'; + +/// Shared post-transaction logic for ending a meeting: stop recordings, +/// query active participants, and send the post-event email. +Future endMeetingCore({ + required String eventPath, + required String liveMeetingPath, + required Event event, + required AgoraUtils agoraUtils, + required NotificationsUtils notificationsUtils, +}) async { + await stopAllEventRecordings( + liveMeetingPath: liveMeetingPath, + agoraUtils: agoraUtils, + ); + + final participantDocs = await firestore + .collection('$eventPath/event-participants') + .get(); + final activeParticipantIds = participantDocs.documents + .map((doc) => Participant.fromJson( + firestoreUtils.fromFirestoreJson(doc.data.toMap()), + ),) + .where((p) => p.status == ParticipantStatus.active) + .map((p) => p.id) + .whereType() + .toList(); + + if (activeParticipantIds.isEmpty) return; + + final capabilities = + await subscriptionPlanUtil.calculateCapabilities(event.communityId); + final hasPrePost = capabilities.hasPrePost ?? false; + + await notificationsUtils.sendEventEndedEmail( + event: event, + communityId: event.communityId, + userIds: activeParticipantIds, + emailType: EventEmailType.ended, + generateMessage: (Community community, admin_interop.UserRecord user) => + SendGridEmailMessage( + subject: 'Thanks for joining', + html: generateEventEndedContent( + header: 'Thanks for joining ${event.title}!', + community: community, + userRecord: user, + event: event, + allowPrePost: hasPrePost, + ), + ), + ); +} diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart index 9e3fc6459..f0c40fa6d 100644 --- a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart +++ b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart @@ -1,18 +1,13 @@ import 'dart:async'; -import 'package:firebase_admin_interop/firebase_admin_interop.dart' - as admin_interop; import 'package:firebase_admin_interop/firebase_admin_interop.dart'; import 'package:firebase_functions_interop/firebase_functions_interop.dart'; import '../../on_call_function.dart'; -import '../../utils/email_templates.dart'; import '../../utils/infra/firestore_utils.dart'; import '../../utils/notifications_utils.dart'; -import '../../utils/subscription_plan_util.dart'; import 'agora_api.dart'; -import 'stop_all_event_recordings.dart'; +import 'end_meeting_core.dart'; import 'package:data_models/cloud_functions/requests.dart'; -import 'package:data_models/community/community.dart'; import 'package:data_models/community/membership.dart'; import 'package:data_models/events/event.dart'; import 'package:data_models/events/live_meetings/live_meeting.dart'; @@ -79,47 +74,12 @@ class EndMeetingForAll extends OnCallMethod { if (!didEnd) return; - // Stop all recordings (main + breakout). - await stopAllEventRecordings( + await endMeetingCore( + eventPath: request.eventPath, liveMeetingPath: liveMeetingPath, - agoraUtils: agoraUtils, - ); - - // Send the post-event email to all active participants. - final participantDocs = await firestore - .collection('${request.eventPath}/event-participants') - .get(); - final activeParticipantIds = participantDocs.documents - .map((doc) => Participant.fromJson( - firestoreUtils.fromFirestoreJson(doc.data.toMap()), - ),) - .where((p) => p.status == ParticipantStatus.active) - .map((p) => p.id) - .whereType() - .toList(); - - if (activeParticipantIds.isEmpty) return; - - final capabilities = - await subscriptionPlanUtil.calculateCapabilities(event.communityId); - final hasPrePost = capabilities.hasPrePost ?? false; - - await notificationsUtils.sendEventEndedEmail( event: event, - communityId: event.communityId, - userIds: activeParticipantIds, - emailType: EventEmailType.ended, - generateMessage: (Community community, admin_interop.UserRecord user) => - SendGridEmailMessage( - subject: 'Thanks for joining', - html: generateEventEndedContent( - header: 'Thanks for joining ${event.title}!', - community: community, - userRecord: user, - event: event, - allowPrePost: hasPrePost, - ), - ), + agoraUtils: agoraUtils, + notificationsUtils: notificationsUtils, ); } } diff --git a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart index cf451fc3b..c1e57a603 100644 --- a/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart +++ b/firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart @@ -1,19 +1,14 @@ import 'dart:async'; -import 'package:firebase_admin_interop/firebase_admin_interop.dart' - as admin_interop; import 'package:firebase_admin_interop/firebase_admin_interop.dart'; import 'package:firebase_functions_interop/firebase_functions_interop.dart' hide CloudFunction; import '../../on_request_method.dart'; -import '../../utils/email_templates.dart'; import '../../utils/infra/firestore_utils.dart'; import '../../utils/notifications_utils.dart'; -import '../../utils/subscription_plan_util.dart'; import 'agora_api.dart'; -import 'stop_all_event_recordings.dart'; +import 'end_meeting_core.dart'; import 'package:data_models/cloud_functions/requests.dart'; -import 'package:data_models/community/community.dart'; import 'package:data_models/events/event.dart'; import 'package:data_models/events/live_meetings/live_meeting.dart'; @@ -75,47 +70,12 @@ class ScheduledEndMeeting if (!didEnd) return ''; - // Stop all recordings (main + breakout). - await stopAllEventRecordings( + await endMeetingCore( + eventPath: request.eventPath, liveMeetingPath: liveMeetingPath, - agoraUtils: agoraUtils, - ); - - // Send the post-event email to all active participants. - final participantDocs = await firestore - .collection('${request.eventPath}/event-participants') - .get(); - final activeParticipantIds = participantDocs.documents - .map((doc) => Participant.fromJson( - firestoreUtils.fromFirestoreJson(doc.data.toMap()), - ),) - .where((p) => p.status == ParticipantStatus.active) - .map((p) => p.id) - .whereType() - .toList(); - - if (activeParticipantIds.isEmpty) return ''; - - final capabilities = - await subscriptionPlanUtil.calculateCapabilities(event.communityId); - final hasPrePost = capabilities.hasPrePost ?? false; - - await notificationsUtils.sendEventEndedEmail( event: event, - communityId: event.communityId, - userIds: activeParticipantIds, - emailType: EventEmailType.ended, - generateMessage: (Community community, admin_interop.UserRecord user) => - SendGridEmailMessage( - subject: 'Thanks for joining', - html: generateEventEndedContent( - header: 'Thanks for joining ${event.title}!', - community: community, - userRecord: user, - event: event, - allowPrePost: hasPrePost, - ), - ), + agoraUtils: agoraUtils, + notificationsUtils: notificationsUtils, ); return ''; From 5f89aa07511d373771343b62d9829b40203e7074 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 16:44:27 -0400 Subject: [PATCH 27/39] Treat dialog dismiss as cancel in host leave prompt --- .../data/providers/live_meeting_provider.dart | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) 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 d9aa9c3e4..a23ac6d46 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 @@ -706,17 +706,21 @@ class LiveMeetingProvider with ChangeNotifier { .getMembership(communityProvider.communityId) .isAdmin; if (isHost || isAdmin) { - final endForAll = await ConfirmDialog( - mainText: appLocalizationService - .getLocalization() - .endMeetingConfirmation, - confirmText: appLocalizationService - .getLocalization() - .endMeeting, - cancelText: appLocalizationService - .getLocalization() - .leaveWithoutEnding, - ).show(); + final endForAll = await showCustomDialog( + isDismissible: false, + builder: (_) => ConfirmDialog( + mainText: appLocalizationService + .getLocalization() + .endMeetingConfirmation, + confirmText: appLocalizationService + .getLocalization() + .endMeeting, + cancelText: appLocalizationService + .getLocalization() + .leaveWithoutEnding, + ), + ); + if (endForAll == null) return; if (endForAll) { await cloudFunctionsEventService.endMeetingForAll( EndMeetingForAllRequest(eventPath: eventPath), From aafef468f6d6dd9054fe65e1dc21f42d13976097 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 16:55:56 -0400 Subject: [PATCH 28/39] Parallelize breakout room recording stops with Future.wait --- .../stop_all_event_recordings.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart b/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart index c99d42082..79b2f273b 100644 --- a/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart +++ b/firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart @@ -20,11 +20,12 @@ Future stopAllEventRecordings({ print('Error stopping main room recording on event end: $e'); } - // Stop all breakout room recordings. + // Stop all breakout room recordings in parallel. try { final breakoutSessionDocs = await firestore .collection('$liveMeetingPath/breakout-room-sessions') .get(); + final stopFutures = >[]; for (final sessionDoc in breakoutSessionDocs.documents) { final breakoutRoomDocs = await firestore .collection('${sessionDoc.reference.path}/breakout-rooms') @@ -34,16 +35,18 @@ Future stopAllEventRecordings({ firestoreUtils.fromFirestoreJson(roomDoc.data.toMap()), ); if (breakoutRoom.recordingSessionId != null) { - try { - await agoraUtils.stopRoom( - sessionId: breakoutRoom.recordingSessionId!,); - } catch (e) { - print( - 'Error stopping breakout recording ${breakoutRoom.recordingSessionId}: $e',); - } + stopFutures.add( + agoraUtils + .stopRoom(sessionId: breakoutRoom.recordingSessionId!) + .catchError((e) { + print( + 'Error stopping breakout recording ${breakoutRoom.recordingSessionId}: $e',); + }), + ); } } } + await Future.wait(stopFutures); } catch (e) { print('Error stopping breakout room recordings on event end: $e'); } From 72f7f3e48cc8f3dcd61d127f5c66f4f6edf04de8 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 17:04:03 -0400 Subject: [PATCH 29/39] Run recording stops in parallel with email delivery --- .../events/live_meetings/end_meeting_core.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_core.dart b/firebase/functions/lib/events/live_meetings/end_meeting_core.dart index 95ca18f91..6e3d713fe 100644 --- a/firebase/functions/lib/events/live_meetings/end_meeting_core.dart +++ b/firebase/functions/lib/events/live_meetings/end_meeting_core.dart @@ -19,11 +19,26 @@ Future endMeetingCore({ required AgoraUtils agoraUtils, required NotificationsUtils notificationsUtils, }) async { - await stopAllEventRecordings( + // Run recording stops in parallel with participant query + email send. + final recordingsFuture = stopAllEventRecordings( liveMeetingPath: liveMeetingPath, agoraUtils: agoraUtils, ); + final emailFuture = _sendEndedEmail( + eventPath: eventPath, + event: event, + notificationsUtils: notificationsUtils, + ); + + await Future.wait([recordingsFuture, emailFuture]); +} + +Future _sendEndedEmail({ + required String eventPath, + required Event event, + required NotificationsUtils notificationsUtils, +}) async { final participantDocs = await firestore .collection('$eventPath/event-participants') .get(); From 0938e94480d6ec96abadbc99d1c2bb1aba41bca1 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 17:19:39 -0400 Subject: [PATCH 30/39] Use permissionDenied for auth errors --- .../functions/lib/events/live_meetings/end_meeting_for_all.dart | 2 +- .../lib/events/live_meetings/get_meeting_join_info.dart | 2 +- .../test/events/live_meetings/get_meeting_join_info_test.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart index f0c40fa6d..7d9a83285 100644 --- a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart +++ b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart @@ -48,7 +48,7 @@ class EndMeetingForAll extends OnCallMethod { MembershipStatus.owner, ]; if (!allowedStatuses.contains(membership.status)) { - throw HttpsError(HttpsError.failedPrecondition, 'unauthorized', null); + throw HttpsError(HttpsError.permissionDenied, 'unauthorized', null); } // Atomically set meetingEndedAt. If already set, return (idempotent). diff --git a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart index 326f92c88..1a72a4bf4 100644 --- a/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart +++ b/firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart @@ -35,7 +35,7 @@ class GetMeetingJoinInfo extends OnCallMethod { ); if (participant.status != ParticipantStatus.active) { - throw HttpsError(HttpsError.failedPrecondition, 'unauthorized', null); + throw HttpsError(HttpsError.permissionDenied, 'unauthorized', null); } // Reject join if the meeting has already ended. diff --git a/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart b/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart index a042df2a5..14cc857fd 100644 --- a/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart +++ b/firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart @@ -138,7 +138,7 @@ void main() { predicate( (e) => e is HttpsError && - e.code == HttpsError.failedPrecondition && + e.code == HttpsError.permissionDenied && e.message == 'unauthorized', ), ), From a978fdcb462daffe59f4f9d5cd1551de3e8cf7f6 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 17:38:02 -0400 Subject: [PATCH 31/39] Short-circuit auth check for event creator --- .../live_meetings/end_meeting_for_all.dart | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart index 7d9a83285..e8be8fb42 100644 --- a/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart +++ b/firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart @@ -36,19 +36,21 @@ class EndMeetingForAll extends OnCallMethod { constructor: (map) => Event.fromJson(map), ); - // Verify caller is mod/admin/owner. - final membership = await firestoreUtils.getFirestoreObject( - path: - 'memberships/${context.authUid}/community-membership/${event.communityId}', - constructor: (map) => Membership.fromJson(map), - ); - const allowedStatuses = [ - MembershipStatus.moderator, - MembershipStatus.admin, - MembershipStatus.owner, - ]; - if (!allowedStatuses.contains(membership.status)) { - throw HttpsError(HttpsError.permissionDenied, 'unauthorized', null); + // Verify caller is the event creator or a mod/admin/owner. + if (context.authUid != event.creatorId) { + final membership = await firestoreUtils.getFirestoreObject( + path: + 'memberships/${context.authUid}/community-membership/${event.communityId}', + constructor: (map) => Membership.fromJson(map), + ); + const allowedStatuses = [ + MembershipStatus.moderator, + MembershipStatus.admin, + MembershipStatus.owner, + ]; + if (!allowedStatuses.contains(membership.status)) { + throw HttpsError(HttpsError.permissionDenied, 'unauthorized', null); + } } // Atomically set meetingEndedAt. If already set, return (idempotent). From d3b0dcd9527988889d99d1384ba73f089979253b Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 17:59:57 -0400 Subject: [PATCH 32/39] Guard against double-tap on leave with _leavingInProgress flag --- .../data/providers/live_meeting_provider.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 a23ac6d46..8a5fc9e4c 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 @@ -82,6 +82,7 @@ class LiveMeetingProvider with ChangeNotifier { final Function(String, {bool? hideOnMobile}) showToast; bool _leftMeeting = false; + bool _leavingInProgress = false; bool _userLeftBreakouts = false; String? _activeBreakoutRoomId; String? _breakoutRoomOverride; @@ -696,8 +697,10 @@ class LiveMeetingProvider with ChangeNotifier { } Future leaveMeeting() async { - if (_leftMeeting) return; + if (_leftMeeting || _leavingInProgress) return; + _leavingInProgress = true; + try { // If the meeting is still running and the user is the host or an admin, // prompt whether to end the meeting for all participants. final meetingAlreadyEnded = liveMeeting?.meetingEndedAt != null; @@ -819,6 +822,9 @@ class LiveMeetingProvider with ChangeNotifier { html.window.location.href = html.window.location.origin! + (location.state as BeamState).uri.toString(); } + } finally { + _leavingInProgress = false; + } } void enterBreakoutRoom({String? roomId}) { From 3b6f104d84d4d70f460e0bc97cacb355493e7d4b Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Sun, 3 May 2026 23:59:43 -0400 Subject: [PATCH 33/39] Add widget tests for grace period input validation and auto-correction --- .../views/event_settings_drawer_test.dart | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 client/test/lib/features/events/features/event_page/presentation/views/event_settings_drawer_test.dart diff --git a/client/test/lib/features/events/features/event_page/presentation/views/event_settings_drawer_test.dart b/client/test/lib/features/events/features/event_page/presentation/views/event_settings_drawer_test.dart new file mode 100644 index 000000000..1c4e96a5a --- /dev/null +++ b/client/test/lib/features/events/features/event_page/presentation/views/event_settings_drawer_test.dart @@ -0,0 +1,259 @@ +import 'package:client/core/localization/app_localization_service.dart'; +import 'package:client/core/data/services/firestore_database.dart'; +import 'package:client/core/utils/dialogs.dart'; +import 'package:client/features/community/data/providers/community_provider.dart'; +import 'package:client/features/events/features/event_page/data/providers/event_provider.dart'; +import 'package:client/features/events/features/event_page/presentation/views/event_settings_drawer.dart'; +import 'package:data_models/events/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../../mocked_classes.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAppDrawerProvider mockAppDrawerProvider; + late MockCommunityProvider mockCommunityProvider; + late MockEventProvider mockEventProvider; + late MockFirestoreDatabase mockFirestoreDatabase; + late MockEvent mockEvent; + late MockTemplate mockTemplate; + + const settingsWithAutoEnd = EventSettings( + autoEndMeeting: true, + autoEndGracePeriodMinutes: 15, + chat: true, + showChatMessagesInRealTime: true, + talkingTimer: true, + agendaPreview: true, + ); + + setUpAll(() async { + GetIt.instance.registerSingleton(AppLocalizationService()); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + GetIt.instance().setLocalization(l10n); + }); + + tearDownAll(() async { + await GetIt.instance.reset(); + }); + + setUp(() { + mockAppDrawerProvider = MockAppDrawerProvider(); + mockCommunityProvider = MockCommunityProvider(); + mockEventProvider = MockEventProvider(); + mockFirestoreDatabase = MockFirestoreDatabase(); + mockEvent = MockEvent(); + mockTemplate = MockTemplate(); + + GetIt.instance.registerSingleton(mockFirestoreDatabase); + + when(mockCommunityProvider.eventSettings) + .thenReturn(EventSettings.defaultSettings); + when(mockEventProvider.event).thenReturn(mockEvent); + when(mockEvent.eventSettings).thenReturn(settingsWithAutoEnd); + when(mockEvent.templateId).thenReturn('misc'); + when(mockEventProvider.template).thenReturn(mockTemplate); + when(mockTemplate.eventSettings) + .thenReturn(EventSettings.defaultSettings); + }); + + tearDown(() { + GetIt.instance.unregister(); + reset(mockAppDrawerProvider); + reset(mockCommunityProvider); + reset(mockEventProvider); + reset(mockFirestoreDatabase); + reset(mockEvent); + reset(mockTemplate); + }); + + Widget buildDrawer() { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: mockAppDrawerProvider, + ), + ChangeNotifierProvider.value( + value: mockCommunityProvider, + ), + ChangeNotifierProvider.value( + value: mockEventProvider, + ), + ], + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: EventSettingsDrawer( + eventSettingsDrawerType: EventSettingsDrawerType.event, + ), + ), + ), + ); + } + + Finder findGracePeriodField() { + return find.byType(TextFormField); + } + + Finder findGracePeriodTextField() { + return find.byType(TextField); + } + + void setLargeScreen(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + // Suppress pre-existing RenderFlex overflow errors in the drawer layout. + final originalOnError = FlutterError.onError; + FlutterError.onError = (details) { + if (details.toString().contains('overflowed')) return; + originalOnError?.call(details); + }; + addTearDown(() => FlutterError.onError = originalOnError); + } + + group('grace period input', () { + testWidgets('shows initial value from event settings', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + expect(findGracePeriodField(), findsOneWidget); + final textField = tester.widget(findGracePeriodTextField()); + expect(textField.controller!.text, equals('15')); + }); + + testWidgets('retains focus while typing valid values', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + final field = findGracePeriodField(); + await tester.tap(field); + await tester.pump(); + + // Clear and type a new value. + await tester.enterText(field, '30'); + await tester.pump(); + + // The field should still have focus. + final textField = tester.widget(findGracePeriodTextField()); + expect(textField.focusNode!.hasFocus, isTrue); + }); + + testWidgets('shows error styling for out-of-range input', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + final field = findGracePeriodField(); + await tester.tap(field); + await tester.pump(); + + await tester.enterText(field, '999'); + await tester.pump(); + + // The error path sets enabledBorder explicitly. + final textField = tester.widget(findGracePeriodTextField()); + expect(textField.decoration!.enabledBorder, isA()); + }); + + testWidgets('auto-corrects after debounce timeout', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + final field = findGracePeriodField(); + await tester.tap(field); + await tester.pump(); + + await tester.enterText(field, '999'); + await tester.pump(); + + // Wait for the 1-second debounce. + await tester.pump(const Duration(seconds: 1)); + + final textField = tester.widget(findGracePeriodTextField()); + expect(textField.controller!.text, equals('120')); + }); + + testWidgets('auto-corrects negative value to 0', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + final field = findGracePeriodField(); + await tester.tap(field); + await tester.pump(); + + await tester.enterText(field, '-5'); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final textField = tester.widget(findGracePeriodTextField()); + expect(textField.controller!.text, equals('0')); + }); + + testWidgets('auto-corrects on focus loss', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + final field = findGracePeriodField(); + await tester.tap(field); + await tester.pump(); + + await tester.enterText(field, '200'); + await tester.pump(); + + // Tap elsewhere to lose focus. + await tester.tapAt(Offset.zero); + await tester.pump(); + + final textField = tester.widget(findGracePeriodTextField()); + expect(textField.controller!.text, equals('120')); + }); + + testWidgets('clears error styling after auto-correction', (tester) async { + setLargeScreen(tester); + await tester.pumpWidget(buildDrawer()); + await tester.pumpAndSettle(); + + final field = findGracePeriodField(); + await tester.tap(field); + await tester.pump(); + + await tester.enterText(field, 'abc'); + await tester.pump(); + + // Verify error state. + var tf = tester.widget(findGracePeriodTextField()); + expect(tf.decoration!.enabledBorder, isA()); + + // Wait for debounce auto-correction. + await tester.pump(const Duration(seconds: 1)); + + tf = tester.widget(findGracePeriodTextField()); + expect(tf.decoration!.enabledBorder, isNull); + expect(tf.controller!.text, equals('0')); + }); + }); +} From cb49d913d8fd056665ba8b4c4e4f94c14513bb3c Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Mon, 4 May 2026 00:10:23 -0400 Subject: [PATCH 34/39] Added comments explaining input validation for meeting auto-end grace period. --- .../views/event_settings_drawer.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart index 911235bd8..4f21ec587 100644 --- a/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart +++ b/client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart @@ -42,6 +42,18 @@ class _EventSettingsDrawerState extends State implements EventSettingsView { late final EventSettingsModel _model; late final EventSettingsPresenter _presenter; + // Grace period input uses a managed TextEditingController + FocusNode + // rather than TextFormField's initialValue/ValueKey pattern. This avoids + // losing focus on every keystroke (ValueKey rebuilds destroy the widget). + // + // Validation: valid range is 0-120 (integer). While typing, invalid input + // shows a red border and red text immediately. Auto-correction fires after + // a 1-second debounce, on focus loss, or on save -- whichever comes first + // -- clamping to 0-120 and flooring any decimals. + // + // The _gracePeriodEditing flag prevents _syncGracePeriodController (called + // from updateView) from overwriting the controller text while the user is + // actively typing. final _gracePeriodController = TextEditingController(); final _gracePeriodFocusNode = FocusNode(); Timer? _gracePeriodDebounce; @@ -68,6 +80,8 @@ class _EventSettingsDrawerState extends State super.dispose(); } + /// Syncs the controller text to the model value. Skipped while the user + /// is actively editing to avoid overwriting mid-keystroke. void _syncGracePeriodController() { if (_gracePeriodEditing) return; final value = @@ -105,6 +119,8 @@ class _EventSettingsDrawerState extends State }); } + /// Clamps the current input to 0-120, floors decimals, and pushes the + /// corrected value to the controller and presenter. void _correctGracePeriodInput() { _gracePeriodDebounce?.cancel(); final text = _gracePeriodController.text; From 1ec6c69497cffe6d46121158ddfdee701ed6f9f5 Mon Sep 17 00:00:00 2001 From: mikewillems <14206684+mikewillems@users.noreply.github.com> Date: Mon, 4 May 2026 12:02:06 -0400 Subject: [PATCH 35/39] Rework event info widget to show start and end time without overflow - Move date into own line below event title - Enlarge event picture and justify left - Add missing label for message participants button - Locate that button with the other event control buttons (cancel and add to calendar), next to the share buttons because it's semantically related - Emphasize that button mainly for aesthetics but also because it's the most common action (cal add is probably only done once, canceling the event is not generally expected, want to encourage messaging through Frankly platform) --- .../widgets/calendar_menu_button.dart | 1 + .../presentation/widgets/event_info.dart | 117 ++++++++++-------- 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/client/lib/features/events/features/event_page/presentation/widgets/calendar_menu_button.dart b/client/lib/features/events/features/event_page/presentation/widgets/calendar_menu_button.dart index a7ca5e39e..c0820e3e4 100644 --- a/client/lib/features/events/features/event_page/presentation/widgets/calendar_menu_button.dart +++ b/client/lib/features/events/features/event_page/presentation/widgets/calendar_menu_button.dart @@ -57,6 +57,7 @@ class _CalendarMenuButtonState extends State { ), onSelected: (value) => widget.onSelected(value), child: Row( + mainAxisSize: MainAxisSize.min, children: [ Icon( CupertinoIcons.calendar_badge_plus, diff --git a/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart b/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart index 8712ec15f..aadb50557 100644 --- a/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart +++ b/client/lib/features/events/features/event_page/presentation/widgets/event_info.dart @@ -21,8 +21,8 @@ import 'package:client/core/widgets/buttons/circle_icon_button.dart'; import 'package:client/features/events/features/event_page/presentation/widgets/event_pop_up_menu_button.dart'; import 'package:client/features/events/features/event_page/presentation/views/participants_dialog.dart'; import 'package:client/features/events/features/event_page/presentation/widgets/warning_info.dart'; -import 'package:client/features/community/presentation/widgets/carousel/time_indicator.dart'; import 'package:client/core/localization/localization_helper.dart'; +import 'package:intl/intl.dart'; import 'package:client/features/community/data/providers/community_provider.dart'; import 'package:client/features/templates/features/create_template/presentation/views/create_custom_template_page.dart'; import 'package:client/features/templates/features/create_template/presentation/views/create_template_dialog.dart'; @@ -672,6 +672,23 @@ class _EventInfoState extends State { ); } + Widget _buildMessageParticipantsButton() { + return ActionButton( + onPressed: widget.onMessagePressed, + type: ActionButtonType.outline, + color: context.theme.colorScheme.surfaceContainerLowest, + icon: Icon( + CupertinoIcons.paperplane, + size: 24, + color: context.theme.colorScheme.onSurfaceVariant, + ), + text: 'Message participants', + textStyle: context.theme.textTheme.titleMedium!.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ); + } + Widget _buildCancelParticipationButton() { return ActionButton( onPressed: _cancelParticipation, @@ -841,23 +858,9 @@ class _EventInfoState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - VerticalTimeAndDateIndicator( - shadow: false, - padding: EdgeInsets.only(right: 24), - time: DateTime.fromMillisecondsSinceEpoch( - (eventProvider - .event.scheduledTime?.millisecondsSinceEpoch ?? - 0), - ), - endTime: eventProvider.event.scheduledTime?.add( - Duration( - minutes: eventProvider.event.durationInMinutes, - ), - ), - ), SizedBox( - height: isMobile ? 90 : 100, - width: isMobile ? 90 : 100, + height: isMobile ? 100 : 120, + width: isMobile ? 100 : 120, child: CustomStreamBuilder