Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
31d044a
Merge branch 'mw/feat/breakout-room-recordings' into mw/feat/event-end
mikewillems Apr 11, 2026
3fd465b
merge 'staging' into mw/feat/event-end
mikewillems Apr 24, 2026
34cc52d
Add tests for EndMeetingForAll CF
mikewillems Apr 30, 2026
b190f08
Client reacts to meetingEndedAt + End Meeting button
mikewillems Apr 30, 2026
547212a
Schedule automatic meeting end via Cloud Tasks
mikewillems Apr 30, 2026
c4c3471
Add tests for ScheduledEndMeeting CF
mikewillems Apr 30, 2026
2910709
Comment _postEventEmailThresholdInMinutes
mikewillems Apr 30, 2026
b76fd50
Merge branch 'staging' into mw/feat/event-end
mikewillems Apr 30, 2026
887a9e1
Display end time alongside start time in event UI
mikewillems Apr 30, 2026
4fc78c4
Add widget tests for VerticalTimeAndDateIndicator end time
mikewillems Apr 30, 2026
5b56ab3
Unify build into build-all.sh and register as npm run build.
mikewillems Apr 28, 2026
554f56c
use FVM-pinned SDK in run-dev.sh; invalidate build stamps on SDK change
mikewillems Apr 28, 2026
e7e4a56
removed defunct old build scripts
mikewillems Apr 28, 2026
7a3b431
Add meetingEndedAt field + endMeetingForAll CF
mikewillems Apr 30, 2026
5992828
Reject join attempts on ended meetings
mikewillems May 1, 2026
aa1fde9
Add test for join rejection on ended meetings
mikewillems May 1, 2026
b2ef50a
Prompt host/admin to end meeting on leave
mikewillems May 1, 2026
62bf5e1
added option to close all rooms at meeting end time, with grace period
mikewillems May 2, 2026
b42c2bd
Fixed settings type error by only filtering listed non-bool values fr…
mikewillems May 2, 2026
4e27021
Wrap idempotent meetingEndedAt check in transaction
mikewillems May 3, 2026
9a3b0c7
Remove dead displayName resolution code and unused imports
mikewillems May 3, 2026
2a3df80
Pass event from transaction via MeetingJoinResult
mikewillems May 3, 2026
97ee210
Wrap auto-end task scheduling in try/catch
mikewillems May 3, 2026
09e4eb5
Localize hard-coded strings in leave prompt and event settings
mikewillems May 3, 2026
0ed29a9
Validate Cloud Tasks header in ScheduledEndMeeting
mikewillems May 3, 2026
b42c8d3
Add email verify to scheduled end meeting test
mikewillems May 3, 2026
295e7db
Clamp grace period to 0-120 minutes server-side
mikewillems May 3, 2026
b56e9a0
Add grace period input validation and auto-correction
mikewillems May 3, 2026
e428a3f
Extract shared end meeting core logic
mikewillems May 3, 2026
5f89aa0
Treat dialog dismiss as cancel in host leave prompt
mikewillems May 3, 2026
aafef46
Parallelize breakout room recording stops with Future.wait
mikewillems May 3, 2026
72f7f3e
Run recording stops in parallel with email delivery
mikewillems May 3, 2026
0938e94
Use permissionDenied for auth errors
mikewillems May 3, 2026
a978fdc
Short-circuit auth check for event creator
mikewillems May 3, 2026
d3b0dcd
Guard against double-tap on leave with _leavingInProgress flag
mikewillems May 3, 2026
3b6f104
Add widget tests for grace period input validation and auto-correction
mikewillems May 4, 2026
cb49d91
Added comments explaining input validation for meeting auto-end grace…
mikewillems May 4, 2026
c98b172
Merge branch 'staging' into mw/feat/event-end
mikewillems May 4, 2026
1ec6c69
Rework event info widget to show start and end time without overflow
mikewillems May 4, 2026
49c78aa
Use ActionButton consistently in grouped info card buttons.
mikewillems May 4, 2026
9b45973
Avoid BuildContext use across async gap
mikewillems May 4, 2026
4bbfc29
Merge branch 'staging' into mw/feat/event-end
mikewillems May 5, 2026
6813be8
Merge branch 'staging' into mw/feat/event-end
mikewillems May 6, 2026
f696265
regenerated l10n files
mikewillems May 7, 2026
8a975ec
test
mikewillems Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ class CloudFunctionsEventService {
await cloudFunctions.callFunction('eventEnded', request.toJson());
}

Future<void> endMeetingForAll(EndMeetingForAllRequest request) async {
loggingService.log(
'CloudFunctionsService.endMeetingForAll: Data: ${request.toJson()}',
);
await cloudFunctions.callFunction('endMeetingForAll', request.toJson());
}

Future<GetCommunityCalendarLinkResponse> getCommunityCalendarLink(
GetCommunityCalendarLinkRequest request,
) async {
final result = await cloudFunctions.callFunction(
'getCommunityCalendarLink', request.toJson());
'getCommunityCalendarLink', request.toJson(),);
return GetCommunityCalendarLinkResponse.fromJson(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> saveSettings() async {
switch (_model.eventSettingsDrawerType) {
case EventSettingsDrawerType.template:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ class _EventTabsDefinitionState extends State<EventTabsDefinition> {
);

if (message != null) {
if (!mounted) return;
await alertOnError(context, () => tabsController.sendMessage(message));
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,6 +42,23 @@ class _EventSettingsDrawerState extends State<EventSettingsDrawer>
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;
bool _gracePeriodInvalid = false;
bool _gracePeriodEditing = false;

@override
void initState() {
Expand All @@ -48,6 +67,81 @@ class _EventSettingsDrawerState extends State<EventSettingsDrawer>
_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();
}

/// 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 =
(_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();
});
}

/// 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;
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
Expand Down Expand Up @@ -131,6 +225,72 @@ class _EventSettingsDrawerState extends State<EventSettingsDrawer>
),
),
SizedBox(height: 16),
_SwitchAndTooltip(
onUpdate: (isSelected) => _presenter.updateSetting(
EventSettings.kFieldAutoEndMeeting,
isSelected,
),
text: context.l10n.autoEndMeeting,
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(
context.l10n.gracePeriodMinutes,
style: context.theme.textTheme.bodyMedium,
),
Comment thread
mikewillems marked this conversation as resolved.
),
SizedBox(
width: 64,
child: TextFormField(
controller: _gracePeriodController,
focusNode: _gracePeriodFocusNode,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: context.theme.textTheme.bodyMedium?.copyWith(
color: _gracePeriodInvalid
? context.theme.colorScheme.error
: null,
),
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
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: _onGracePeriodChanged,
),
),
],
),
),
],
SizedBox(height: 16),
_SwitchAndTooltip(
onUpdate: (isSelected) => _presenter.updateSetting(
EventSettings.kFieldTalkingTimer,
Expand All @@ -156,7 +316,10 @@ class _EventSettingsDrawerState extends State<EventSettingsDrawer>
ActionButton(
expand: true,
text: context.l10n.saveSettings,
onPressed: () => _presenter.saveSettings(),
onPressed: () {
_correctGracePeriodInput();
_presenter.saveSettings();
},
color: context.theme.colorScheme.primary,
textColor: context.theme.colorScheme.onPrimary,
),
Expand All @@ -178,12 +341,17 @@ class _EventSettingsDrawerState extends State<EventSettingsDrawer>
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,
),
],
Expand All @@ -204,6 +372,7 @@ class _EventSettingsDrawerState extends State<EventSettingsDrawer>

@override
void updateView() {
_syncGracePeriodController();
setState(() {});
}
}
Expand Down
Loading