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/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/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/event_tab_controller.dart b/client/lib/features/events/features/event_page/presentation/event_tab_controller.dart index ccdf3b067..398445f46 100644 --- a/client/lib/features/events/features/event_page/presentation/event_tab_controller.dart +++ b/client/lib/features/events/features/event_page/presentation/event_tab_controller.dart @@ -216,6 +216,7 @@ class _EventTabsDefinitionState extends State { ); if (message != null) { + if (!mounted) return; await alertOnError(context, () => tabsController.sendMessage(message)); } } 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..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 @@ -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,23 @@ 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; + bool _gracePeriodInvalid = false; + bool _gracePeriodEditing = false; @override void initState() { @@ -48,6 +67,81 @@ 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(); + } + + /// 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 @@ -131,6 +225,72 @@ class _EventSettingsDrawerState extends State ), ), 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, + ), + ), + 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, @@ -156,7 +316,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, ), @@ -178,12 +341,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, ), ], @@ -204,6 +372,7 @@ class _EventSettingsDrawerState extends State @override void updateView() { + _syncGracePeriodController(); setState(() {}); } } 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..c42d40e90 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 @@ -24,75 +24,65 @@ class CalendarMenuButton extends StatefulWidget { } class _CalendarMenuButtonState extends State { - var _isHovered = false; - @override Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) { - if (!_isHovered) { - setState(() => _isHovered = true); - } - }, - onExit: (_) { - if (_isHovered) { - setState(() => _isHovered = false); - } - }, - child: Container( - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: _isHovered ? context.theme.colorScheme.primaryFixed : null, - ), - child: TooltipTheme( - data: TooltipThemeData( - decoration: BoxDecoration(color: Colors.transparent), + return PopupMenuButton( + padding: EdgeInsets.zero, + offset: Offset(0, 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + onSelected: (value) => widget.onSelected(value), + child: OutlinedButton( + onPressed: null, + style: OutlinedButton.styleFrom( + side: BorderSide( + color: context.theme.colorScheme.surfaceContainerLowest, + ), + foregroundColor: context.theme.colorScheme.onSurfaceVariant, + minimumSize: Size(96, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - offset: Offset(0, 20), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(right: 14), + child: Icon( + CupertinoIcons.calendar_badge_plus, + size: 20, + color: context.theme.colorScheme.onSurfaceVariant, + ), ), - onSelected: (value) => widget.onSelected(value), - child: Row( - children: [ - Icon( - CupertinoIcons.calendar_badge_plus, - size: 20, - color: context.theme.colorScheme.onSurfaceVariant, - ), - SizedBox(width: 10), - HeightConstrainedText( - context.l10n.addToCalendar, - style: context.theme.textTheme.bodyMedium!.copyWith( - color: context.theme.colorScheme.onSurfaceVariant, - ), - ), - ], + HeightConstrainedText( + context.l10n.addToCalendar, + style: context.theme.textTheme.bodyMedium!.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), ), - itemBuilder: (context) { - return CalendarMenuSelection.values.map( - (e) { - final text = _getText(e); - return PopupMenuItem( - value: e, - padding: EdgeInsets.all(10.0), - child: SizedBox( - width: 100, - child: HeightConstrainedText( - text, - style: context.theme.textTheme.bodyLarge, - ), - ), - ); - }, - ).toList(); - }, - ), + ], ), ), + itemBuilder: (context) { + return CalendarMenuSelection.values.map( + (e) { + final text = _getText(e); + return PopupMenuItem( + value: e, + padding: EdgeInsets.all(10.0), + child: SizedBox( + width: 100, + child: HeightConstrainedText( + text, + style: context.theme.textTheme.bodyLarge, + ), + ), + ); + }, + ).toList(); + }, ); } 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 238b0197c..d8e1d980e 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:client/core/utils/date_utils.dart'; import 'package:client/core/utils/template_utils.dart'; import 'package:client/core/utils/navigation_utils.dart'; @@ -21,8 +23,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'; @@ -524,27 +526,96 @@ class _EventInfoState extends State { .asStream(), builder: (context, snapshot) { if (snapshot == null) return SizedBox.shrink(); - return CalendarMenuButton( - onSelected: (selection) { - switch (selection) { - case CalendarMenuSelection.google: - launch(snapshot.googleCalendarLink); - break; - case CalendarMenuSelection.office365: - launch(snapshot.office365CalendarLink); - break; - case CalendarMenuSelection.outlook: - launch(snapshot.outlookCalendarLink); - break; - case CalendarMenuSelection.ical: - _downloadICSfile(snapshot.icsLink); - } + return Builder( + builder: (context) { + return ActionButton( + onPressed: () async { + final button = context.findRenderObject() as RenderBox; + final overlay = Navigator.of(context) + .overlay! + .context + .findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal( + Offset(0, button.size.height), + ancestor: overlay, + ), + button.localToGlobal( + button.size.bottomRight(Offset.zero), + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + final selection = + await showMenu( + context: context, + position: position, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + items: CalendarMenuSelection.values.map((e) { + final text = _getCalendarMenuText(e); + return PopupMenuItem( + value: e, + padding: EdgeInsets.all(10.0), + child: SizedBox( + width: 100, + child: HeightConstrainedText( + text, + style: context.theme.textTheme.bodyLarge, + ), + ), + ); + }).toList(), + ); + if (selection == null) return; + switch (selection) { + case CalendarMenuSelection.google: + unawaited(launch(snapshot.googleCalendarLink)); + break; + case CalendarMenuSelection.office365: + unawaited(launch(snapshot.office365CalendarLink)); + break; + case CalendarMenuSelection.outlook: + unawaited(launch(snapshot.outlookCalendarLink)); + break; + case CalendarMenuSelection.ical: + _downloadICSfile(snapshot.icsLink); + } + }, + type: ActionButtonType.outline, + color: context.theme.colorScheme.surfaceContainerLowest, + icon: Icon( + CupertinoIcons.calendar_badge_plus, + size: 20, + color: context.theme.colorScheme.onSurfaceVariant, + ), + text: context.l10n.addToCalendar, + textStyle: context.theme.textTheme.bodyMedium!.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ); }, ); }, ); } + String _getCalendarMenuText(CalendarMenuSelection selection) { + switch (selection) { + case CalendarMenuSelection.google: + return context.l10n.googleCalendar; + case CalendarMenuSelection.outlook: + return context.l10n.outlookCalendar; + case CalendarMenuSelection.office365: + return context.l10n.office365Calendar; + case CalendarMenuSelection.ical: + return context.l10n.iCalCalendar; + } + } + Widget _buildCancelEventButton() { return ActionButton( onPressed: _cancelEvent, @@ -562,6 +633,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, @@ -731,18 +819,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), - ), - ), SizedBox( - height: isMobile ? 90 : 100, - width: isMobile ? 90 : 100, + height: isMobile ? 100 : 120, + width: isMobile ? 100 : 120, child: CustomStreamBuilder