From 37ddce3a298242265f340fe9386a039445faecf3 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Sat, 23 Sep 2023 09:47:48 +0800 Subject: [PATCH] chore: calendar UI polish (#3484) * chore: update calendar theming * feat: add event popup editor * chore: new event button redesign and add card shadows * chore: unscheduled events button * chore: event title text field * fix: focus node double dispose * chore: show popover when create new event * test: integrate some tests for integration testing purposes * fix: some fixes and more integration tests --- .../database_calendar_test.dart | 87 ++++-- .../util/database_test_op.dart | 61 ++++ .../calendar/application/calendar_bloc.dart | 7 + .../calendar_event_editor_bloc.dart | 82 +++++ .../application/unschedule_event_bloc.dart | 5 +- .../calendar/presentation/calendar_day.dart | 158 ++++++---- .../presentation/calendar_event_card.dart | 131 ++++++-- .../presentation/calendar_event_editor.dart | 280 ++++++++++++++++++ .../calendar/presentation/calendar_page.dart | 244 ++++++++++++--- .../calendar/presentation/layout/sizes.dart | 4 +- .../toolbar/calendar_setting_bar.dart | 159 ---------- .../widgets/header/field_editor.dart | 11 + .../header/type_option/select_option.dart | 11 + .../widgets/row/cell_builder.dart | 4 +- .../row/cells/date_cell/date_editor.dart | 12 + .../row/cells/text_cell/text_cell.dart | 77 +++-- .../widgets/auto_completion_node_widget.dart | 1 + .../lib/workspace/application/appearance.dart | 1 + .../lib/colorscheme/colorscheme.dart | 2 + .../lib/colorscheme/dandelion.dart | 2 + .../lib/colorscheme/default_colorscheme.dart | 2 + .../flowy_infra/lib/colorscheme/lavender.dart | 2 + .../flowy_infra/lib/theme_extension.dart | 7 + .../lib/style_widget/icon_button.dart | 5 +- .../lib/style_widget/text_field.dart | 19 +- frontend/resources/translations/en.json | 1 + 26 files changed, 1020 insertions(+), 355 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_event_editor_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart diff --git a/frontend/appflowy_flutter/integration_test/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/database_calendar_test.dart index ca16f801d5..59987a0984 100644 --- a/frontend/appflowy_flutter/integration_test/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/database_calendar_test.dart @@ -79,33 +79,33 @@ void main() { // Tap on create new event button await tester.tapAddCalendarEventButton(); - // Make sure that the row details page is opened - tester.assertRowDetailPageOpened(); - - // Dismiss the row details page - await tester.dismissRowDetailPage(); + // Make sure that the event editor popup is shown + tester.assertEventEditorOpen(); tester.assertNumberOfEventsInCalendar(1); + // Dismiss the event editor popup + await tester.dismissEventEditor(); + // Double click on today's calendar cell to create a new event await tester.doubleClickCalendarCell(DateTime.now()); - // Make sure that the row details page is opened - tester.assertRowDetailPageOpened(); - - // Dismiss the row details page - await tester.dismissRowDetailPage(); + // Make sure that the event editor popup is shown + tester.assertEventEditorOpen(); // Make sure that the event is inserted in the cell tester.assertNumberOfEventsInCalendar(2); + // Dismiss the event editor popup + await tester.dismissEventEditor(); + // Click on the event await tester.openCalendarEvent(index: 0); - tester.assertRowDetailPageOpened(); + tester.assertEventEditorOpen(); // Change the title of the event - await tester.editTitleInRowDetailPage('hello world'); - await tester.dismissRowDetailPage(); + await tester.editEventTitle('hello world'); + await tester.dismissEventEditor(); // Make sure that the event is edited tester.assertNumberOfEventsInCalendar(1, title: 'hello world'); @@ -113,6 +113,10 @@ void main() { // Click on the event await tester.openCalendarEvent(index: 1); + tester.assertEventEditorOpen(); + + // Click on the open icon + await tester.openEventToRowDetailPage(); tester.assertRowDetailPageOpened(); // Duplicate the event @@ -126,12 +130,23 @@ void main() { // Delete an event await tester.openCalendarEvent(index: 1); - await tester.tapRowDetailPageRowActionButton(); - await tester.tapRowDetailPageDeleteRowButton(); + await tester.deleteEventFromEventEditor(); // Check that there is 1 event tester.assertNumberOfEventsInCalendar(1, title: 'hello world'); tester.assertNumberOfEventsOnSpecificDay(2, DateTime.now()); + + // Delete event from row detail page + await tester.openCalendarEvent(index: 1); + await tester.openEventToRowDetailPage(); + tester.assertRowDetailPageOpened(); + + await tester.tapRowDetailPageRowActionButton(); + await tester.tapRowDetailPageDeleteRowButton(); + + // Check that there is 0 event + tester.assertNumberOfEventsInCalendar(0, title: 'hello world'); + tester.assertNumberOfEventsOnSpecificDay(1, DateTime.now()); }); testWidgets('rescheduling events', (tester) async { @@ -145,7 +160,7 @@ void main() { final today = DateTime.now(); final firstOfThisMonth = DateTime(today.year, today.month, 1); await tester.doubleClickCalendarCell(firstOfThisMonth); - await tester.dismissRowDetailPage(); + await tester.dismissEventEditor(); // Drag and drop the event onto the next week, same day await tester.dragDropRescheduleCalendarEvent(firstOfThisMonth); @@ -157,13 +172,12 @@ void main() { // Delete the event await tester.openCalendarEvent(index: 0, date: sameDayNextWeek); - await tester.tapRowDetailPageRowActionButton(); - await tester.tapRowDetailPageDeleteRowButton(); + await tester.deleteEventFromEventEditor(); // Create a new event in today's calendar cell await tester.scrollToToday(); await tester.doubleClickCalendarCell(today); - await tester.dismissRowDetailPage(); + await tester.dismissEventEditor(); // Make sure that the event is today tester.assertNumberOfEventsOnSpecificDay(1, today); @@ -182,12 +196,43 @@ void main() { await tester.selectDay(content: newDate.day); await tester.dismissCellEditor(); - // Dismiss the row details page - await tester.dismissRowDetailPage(); + // Dismiss the event editor + await tester.dismissEventEditor(); // Make sure that the event is edited tester.assertNumberOfEventsInCalendar(1); tester.assertNumberOfEventsOnSpecificDay(1, newDate); + + // Click on the unscheduled events button + await tester.openUnscheduledEventsPopup(); + + // Assert that nothing shows up + tester.findUnscheduledPopup(findsNothing, 0); + + // Click on the event in the calendar + await tester.openCalendarEvent(index: 0, date: newDate); + + // Open the date editor of the event + await tester.tapDateCellInRowDetailPage(); + await tester.findDateEditor(findsOneWidget); + + // Clear the date of the event + await tester.clearDate(); + + // Dismiss the event editor + await tester.dismissEventEditor(); + tester.assertNumberOfEventsInCalendar(0); + + // Click on the unscheduled events button + await tester.openUnscheduledEventsPopup(); + + // Assert that a popup appears and 1 unscheduled event + tester.findUnscheduledPopup(findsOneWidget, 1); + + // Click on the unscheduled event + await tester.clickUnscheduledEvent(); + + tester.assertRowDetailPageOpened(); }); }); } diff --git a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart index 8f863648e5..3ae390fa62 100644 --- a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/database_view/board/presentation/board_page.dar import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_day.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_event_card.dart'; +import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_event_editor.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart'; import 'package:appflowy/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart'; @@ -1291,12 +1292,72 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(cards.at(index)); } + void assertEventEditorOpen() { + expect(find.byType(CalendarEventEditor), findsOneWidget); + } + + Future dismissEventEditor() async { + await simulateKeyEvent(LogicalKeyboardKey.escape); + } + + Future editEventTitle(String title) async { + final textField = find.descendant( + of: find.byType(CalendarEventEditor), + matching: find.byType(FlowyTextField), + ); + + await enterText(textField, title); + await pumpAndSettle(const Duration(milliseconds: 300)); + } + + Future openEventToRowDetailPage() async { + final button = find.descendant( + of: find.byType(CalendarEventEditor), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.full_view_s, + ), + ); + + await tapButton(button); + } + + Future deleteEventFromEventEditor() async { + final button = find.descendant( + of: find.byType(CalendarEventEditor), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, + ), + ); + + await tapButton(button); + } + Future dragDropRescheduleCalendarEvent(DateTime startDate) async { final findEventCard = find.byType(EventCard); await drag(findEventCard.first, const Offset(0, 300)); await pumpAndSettle(); } + Future openUnscheduledEventsPopup() async { + final button = find.byType(UnscheduledEventsButton); + await tapButton(button); + } + + void findUnscheduledPopup(Matcher matcher, int numUnscheduledEvents) { + expect(find.byType(UnscheduleEventsList), matcher); + if (matcher != findsNothing) { + expect( + find.byType(UnscheduledEventCell), + findsNWidgets(numUnscheduledEvents), + ); + } + } + + Future clickUnscheduledEvent() async { + final unscheduledEvent = find.byType(UnscheduledEventCell); + await tapButton(unscheduledEvent); + } + Future tapCreateLinkedDatabaseViewButton(AddButtonAction action) async { final findAddButton = find.byType(AddDatabaseViewButton); await tapButton(findAddButton); diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart index 924f527402..6a518e3975 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_bloc.dart @@ -63,6 +63,9 @@ class CalendarBloc extends Bloc { createEvent: (DateTime date) async { await _createEvent(date); }, + newEventPopupDisplayed: () { + emit(state.copyWith(editingEvent: null)); + }, moveEvent: (CalendarDayEvent event, DateTime date) async { await _moveEvent(event, date); }, @@ -378,6 +381,10 @@ class CalendarEvent with _$CalendarEvent { CalendarEventData event, ) = _DidReceiveNewEvent; + // Called after creating a new event + const factory CalendarEvent.newEventPopupDisplayed() = + _NewEventPopupDisplayed; + // Called when receive a new event const factory CalendarEvent.didReceiveEvent( CalendarEventData event, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_event_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_event_editor_bloc.dart new file mode 100644 index 0000000000..06904f3994 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/calendar_event_editor_bloc.dart @@ -0,0 +1,82 @@ +import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'calendar_event_editor_bloc.freezed.dart'; + +class CalendarEventEditorBloc + extends Bloc { + final RowController rowController; + final CalendarLayoutSettingPB layoutSettings; + final RowBackendService _rowService; + + CalendarEventEditorBloc({ + required this.rowController, + required this.layoutSettings, + }) : _rowService = RowBackendService(viewId: rowController.viewId), + super(CalendarEventEditorState.initial()) { + on((event, emit) async { + await event.when( + initial: () { + _startListening(); + final cells = rowController.loadData(); + if (!isClosed) { + add( + CalendarEventEditorEvent.didReceiveCellDatas( + cells.values.toList(), + ), + ); + } + }, + didReceiveCellDatas: (cells) { + emit(state.copyWith(cells: cells)); + }, + delete: () async { + final result = await _rowService.deleteRow(rowController.rowId); + result.fold((l) => null, (err) => Log.error(err)); + }, + ); + }); + } + + void _startListening() { + rowController.addListener( + onRowChanged: (cells, reason) { + if (!isClosed) { + add( + CalendarEventEditorEvent.didReceiveCellDatas(cells.values.toList()), + ); + } + }, + ); + } + + @override + Future close() async { + rowController.dispose(); + return super.close(); + } +} + +@freezed +class CalendarEventEditorEvent with _$CalendarEventEditorEvent { + const factory CalendarEventEditorEvent.initial() = _Initial; + const factory CalendarEventEditorEvent.didReceiveCellDatas( + List cells, + ) = _DidReceiveCellDatas; + const factory CalendarEventEditorEvent.delete() = _Delete; +} + +@freezed +class CalendarEventEditorState with _$CalendarEventEditorState { + const factory CalendarEventEditorState({ + required List cells, + }) = _CalendarEventEditorState; + + factory CalendarEventEditorState.initial() => + CalendarEventEditorState(cells: List.empty()); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/unschedule_event_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/unschedule_event_bloc.dart index 6f119690e4..c59d838555 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/unschedule_event_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/application/unschedule_event_bloc.dart @@ -58,9 +58,12 @@ class UnscheduleEventsBloc ); }, didReceiveEvent: (CalendarEventPB event) { + final events = [...state.allEvents, event]; emit( state.copyWith( - allEvents: [...state.allEvents, event], + allEvents: events, + unscheduleEvents: + events.where((element) => !element.isScheduled).toList(), ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_day.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_day.dart index 88704d77f8..8027ac1549 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_day.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_day.dart @@ -1,13 +1,14 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; import 'package:easy_localization/easy_localization.dart'; - import 'package:flowy_infra/size.dart'; + import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra/time/duration.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:table_calendar/table_calendar.dart'; import '../../grid/presentation/layout/sizes.dart'; import '../application/calendar_bloc.dart'; @@ -18,7 +19,7 @@ class CalendarDayCard extends StatelessWidget { final bool isToday; final bool isInMonth; final DateTime date; - final RowCache _rowCache; + final RowCache rowCache; final List events; final void Function(DateTime) onCreateEvent; @@ -28,18 +29,21 @@ class CalendarDayCard extends StatelessWidget { required this.isInMonth, required this.date, required this.onCreateEvent, - required RowCache rowCache, + required this.rowCache, required this.events, - Key? key, - }) : _rowCache = rowCache, - super(key: key); + super.key, + }); @override Widget build(BuildContext context) { - Color backgroundColor = Theme.of(context).colorScheme.surface; - if (!isInMonth) { - backgroundColor = AFThemeExtension.of(context).lightGreyHover; + Color backgroundColor = Colors.transparent; + if (date.isWeekend) { + backgroundColor = AFThemeExtension.of(context).calendarWeekendBGColor; } + final hoverBackgroundColor = + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.secondaryContainer + : Colors.transparent; return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { @@ -57,14 +61,14 @@ class CalendarDayCard extends StatelessWidget { ), // Add a separator between the header and the content. - VSpace(GridSize.typeOptionSeparatorHeight), + const VSpace(6.0), // List of cards or empty space if (events.isNotEmpty) _EventList( events: events, viewId: viewId, - rowCache: _rowCache, + rowCache: rowCache, constraints: constraints, ), ], @@ -79,34 +83,29 @@ class CalendarDayCard extends StatelessWidget { DragTarget( builder: (context, candidate, __) { return Stack( - fit: StackFit.expand, children: [ - if (candidate.isNotEmpty) - Container( - color: Theme.of(context) - .colorScheme - .secondaryContainer, - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), + Container( + width: double.infinity, + height: double.infinity, + color: + candidate.isEmpty ? null : hoverBackgroundColor, + padding: const EdgeInsets.only(top: 5.0), child: child, - ) + ), + if (candidate.isEmpty) + NewEventButton(onCreate: () => onCreateEvent(date)), ], ); }, - onWillAccept: (CalendarDayEvent? event) { - if (event == null) { - return false; - } - return !isSameDay(event.date, date); - }, onAccept: (CalendarDayEvent event) { + if (event.date == date) { + return; + } context .read() .add(CalendarEvent.moveEvent(event, date)); }, ), - NewEventButton(onCreate: () => onCreateEvent(date)), MouseRegion( onEnter: (p) => notifyEnter(context, true), onExit: (p) => notifyEnter(context, false), @@ -143,7 +142,7 @@ class _Header extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 6.0), child: _DayBadge( isToday: isToday, isInMonth: isInMonth, @@ -166,7 +165,7 @@ class NewEventButton extends StatelessWidget { return const SizedBox.shrink(); } return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(4.0), child: FlowyIconButton( onPressed: onCreate, iconPadding: EdgeInsets.zero, @@ -174,6 +173,35 @@ class NewEventButton extends StatelessWidget { fillColor: Theme.of(context).colorScheme.background, hoverColor: AFThemeExtension.of(context).lightGreyHover, width: 22, + tooltipText: LocaleKeys.calendar_newEventButtonTooltip.tr(), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xffd0d3d6) + : const Color(0xff59647a), + width: 0.5, + ), + ), + borderRadius: Corners.s5Border, + boxShadow: [ + BoxShadow( + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 2, + ), + BoxShadow( + spreadRadius: 0, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 4, + ), + BoxShadow( + spreadRadius: 2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 8, + ), + ], + ), ), ); }, @@ -195,37 +223,49 @@ class _DayBadge extends StatelessWidget { @override Widget build(BuildContext context) { Color dayTextColor = Theme.of(context).colorScheme.onBackground; + Color monthTextColor = Theme.of(context).colorScheme.onBackground; final String monthString = DateFormat("MMM ", context.locale.toLanguageTag()).format(date); final String dayString = date.day.toString(); if (!isInMonth) { dayTextColor = Theme.of(context).disabledColor; + monthTextColor = Theme.of(context).disabledColor; } if (isToday) { dayTextColor = Theme.of(context).colorScheme.onPrimary; } - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (date.day == 1) FlowyText.medium(monthString), - Container( - decoration: BoxDecoration( - color: isToday ? Theme.of(context).colorScheme.primary : null, - borderRadius: Corners.s6Border, - ), - width: isToday ? 26 : null, - height: isToday ? 26 : null, - padding: GridSize.typeOptionContentInsets, - child: Center( - child: FlowyText.medium( - dayString, - color: dayTextColor, + return SizedBox( + height: 18, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (date.day == 1) + FlowyText.medium( + monthString, + fontSize: 11, + color: monthTextColor, + ), + Container( + decoration: BoxDecoration( + color: isToday ? Theme.of(context).colorScheme.primary : null, + borderRadius: BorderRadius.circular(10), + ), + width: isToday ? 18 : null, + height: isToday ? 18 : null, + // padding: GridSize.typeOptionContentInsets, + child: Center( + child: FlowyText.medium( + dayString, + fontSize: 11, + color: dayTextColor, + ), ), ), - ), - ], + ], + ), ); } } @@ -246,20 +286,26 @@ class _EventList extends StatelessWidget { @override Widget build(BuildContext context) { + final editingEvent = context.watch().state.editingEvent; return Flexible( child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: true, ), child: ListView.separated( - itemBuilder: (BuildContext context, int index) => EventCard( - event: events[index], - viewId: viewId, - rowCache: rowCache, - constraints: constraints, - ), + itemBuilder: (BuildContext context, int index) { + final autoEdit = + editingEvent?.event?.eventId == events[index].eventId; + return EventCard( + event: events[index], + viewId: viewId, + rowCache: rowCache, + constraints: constraints, + autoEdit: autoEdit, + ); + }, itemCount: events.length, - padding: const EdgeInsets.fromLTRB(8.0, 0, 8.0, 8.0), + padding: const EdgeInsets.fromLTRB(4.0, 0, 4.0, 4.0), separatorBuilder: (BuildContext context, int index) => VSpace(GridSize.typeOptionSeparatorHeight), shrinkWrap: true, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart index 85f9ec36f9..6df4d67f25 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_card.dart @@ -8,40 +8,66 @@ import 'package:appflowy/plugins/database_view/widgets/card/cells/number_card_ce import 'package:appflowy/plugins/database_view/widgets/card/cells/url_card_cell.dart'; import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../application/calendar_bloc.dart'; -import 'calendar_page.dart'; +import 'calendar_event_editor.dart'; -class EventCard extends StatelessWidget { +class EventCard extends StatefulWidget { final CalendarDayEvent event; final String viewId; final RowCache rowCache; final BoxConstraints constraints; + final bool autoEdit; const EventCard({ required this.event, required this.viewId, required this.rowCache, required this.constraints, + required this.autoEdit, super.key, }); + @override + State createState() => _EventCardState(); +} + +class _EventCardState extends State { + late final PopoverController _popoverController; + + @override + void initState() { + super.initState(); + _popoverController = PopoverController(); + if (widget.autoEdit) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _popoverController.show(); + context + .read() + .add(const CalendarEvent.newEventPopupDisplayed()); + }); + } + } + @override Widget build(BuildContext context) { - final rowInfo = rowCache.getRow(event.eventId); + final rowInfo = widget.rowCache.getRow(widget.event.eventId); + if (rowInfo == null) { + return const SizedBox.shrink(); + } final styles = { FieldType.Number: NumberCardCellStyle(10), FieldType.URL: URLCardCellStyle(10), }; final cellBuilder = CardCellBuilder( - rowCache.cellCache, + widget.rowCache.cellCache, styles: styles, ); final renderHook = _calendarEventCardRenderHook(context); @@ -49,24 +75,22 @@ class EventCard extends StatelessWidget { final card = RowCard( // Add the key here to make sure the card is rebuilt when the cells // in this row are updated. - key: ValueKey(event.eventId), - rowMeta: rowInfo!.rowMeta, - viewId: viewId, - rowCache: rowCache, - cardData: event, + key: ValueKey(widget.event.eventId), + rowMeta: rowInfo.rowMeta, + viewId: widget.viewId, + rowCache: widget.rowCache, + cardData: widget.event, isEditing: false, cellBuilder: cellBuilder, - openCard: (context) => showEventDetails( - context: context, - event: event.event, - viewId: viewId, - rowCache: rowCache, - ), + openCard: (_) => _popoverController.show(), styleConfiguration: RowCardStyleConfiguration( showAccessory: false, cellPadding: EdgeInsets.zero, + cardPadding: const EdgeInsets.all(6), hoverStyle: HoverStyle( - hoverColor: AFThemeExtension.of(context).lightGreyHover, + hoverColor: Theme.of(context).brightness == Brightness.light + ? const Color(0x0F1F2329) + : const Color(0x0FEFF4FB), foregroundColorOnHover: Theme.of(context).colorScheme.onBackground, ), ), @@ -76,28 +100,76 @@ class EventCard extends StatelessWidget { ); final decoration = BoxDecoration( + color: Theme.of(context).colorScheme.surface, border: Border.fromBorderSide( - BorderSide(color: Theme.of(context).dividerColor), + BorderSide( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xffd0d3d6) + : const Color(0xff59647a), + width: 0.5, + ), ), borderRadius: Corners.s6Border, + boxShadow: [ + BoxShadow( + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 2, + ), + BoxShadow( + spreadRadius: 0, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 4, + ), + BoxShadow( + spreadRadius: 2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 8, + ), + ], ); return Draggable( - data: event, + data: widget.event, feedback: ConstrainedBox( constraints: BoxConstraints( - maxWidth: constraints.maxWidth - 16.0, + maxWidth: widget.constraints.maxWidth - 8.0, ), - child: DecoratedBox( - decoration: decoration.copyWith( - color: AFThemeExtension.of(context).lightGreyHover, + child: Opacity( + opacity: 0.6, + child: DecoratedBox( + decoration: decoration, + child: card, ), - child: card, ), ), - child: DecoratedBox( - decoration: decoration, - child: card, + child: AppFlowyPopover( + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.rightWithCenterAligned, + controller: _popoverController, + constraints: const BoxConstraints(maxWidth: 360, maxHeight: 348), + asBarrier: true, + margin: EdgeInsets.zero, + offset: const Offset(10.0, 0), + popupBuilder: (BuildContext popoverContext) { + final settings = context.watch().state.settings.fold( + () => null, + (layoutSettings) => layoutSettings, + ); + if (settings == null) { + return const SizedBox.shrink(); + } + return CalendarEventEditor( + rowCache: widget.rowCache, + rowMeta: widget.event.event.rowMeta, + viewId: widget.viewId, + layoutSettings: settings, + ); + }, + child: DecoratedBox( + decoration: decoration, + child: card, + ), ), ); } @@ -127,8 +199,9 @@ class EventCard extends StatelessWidget { child: FlowyText.medium( text, textAlign: TextAlign.left, - fontSize: 11, - maxLines: null, // Enable multiple lines + fontSize: isTitle ? 11 : 10, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart new file mode 100644 index 0000000000..cbc897c5a5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_event_editor.dart @@ -0,0 +1,280 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database_view/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database_view/calendar/application/calendar_event_editor_bloc.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart'; +import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CalendarEventEditor extends StatelessWidget { + final RowController rowController; + final CalendarLayoutSettingPB layoutSettings; + final GridCellBuilder cellBuilder; + + CalendarEventEditor({ + super.key, + required RowCache rowCache, + required RowMetaPB rowMeta, + required String viewId, + required this.layoutSettings, + }) : rowController = RowController( + rowMeta: rowMeta, + viewId: viewId, + rowCache: rowCache, + ), + cellBuilder = GridCellBuilder(cellCache: rowCache.cellCache); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CalendarEventEditorBloc( + rowController: rowController, + layoutSettings: layoutSettings, + )..add(const CalendarEventEditorEvent.initial()), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + EventEditorControls(rowController: rowController), + Flexible( + child: EventPropertyList( + dateFieldId: layoutSettings.fieldId, + cellBuilder: cellBuilder, + ), + ), + ], + ), + ); + } +} + +class EventEditorControls extends StatelessWidget { + final RowController rowController; + const EventEditorControls({ + super.key, + required this.rowController, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12.0, 8.0, 12.0, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.delete_s), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () => context + .read() + .add(const CalendarEventEditorEvent.delete()), + ), + const HSpace(8.0), + FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.full_view_s), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () { + PopoverContainer.of(context).close(); + FlowyOverlay.show( + context: context, + builder: (BuildContext context) { + return RowDetailPage( + cellBuilder: GridCellBuilder( + cellCache: rowController.cellCache, + ), + rowController: rowController, + ); + }, + ); + }, + ), + ], + ), + ); + } +} + +class EventPropertyList extends StatelessWidget { + final String dateFieldId; + final GridCellBuilder cellBuilder; + const EventPropertyList({ + super.key, + required this.dateFieldId, + required this.cellBuilder, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final reorderedList = List.from(state.cells) + ..retainWhere((cell) => !cell.fieldInfo.isPrimary); + + final primaryCellContext = + state.cells.firstWhereOrNull((cell) => cell.fieldInfo.isPrimary); + final dateFieldIndex = + reorderedList.indexWhere((cell) => cell.fieldId == dateFieldId); + if (primaryCellContext == null || dateFieldIndex == -1) { + return const SizedBox.shrink(); + } + reorderedList.insert(0, reorderedList.removeAt(dateFieldIndex)); + + final children = [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0), + child: cellBuilder.build( + primaryCellContext, + style: GridTextCellStyle( + cellPadding: EdgeInsets.zero, + placeholder: LocaleKeys.calendar_defaultNewCalendarTitle.tr(), + textStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 11), + autofocus: true, + useRoundedBorder: true, + ), + ), + ), + ...reorderedList.map( + (cell) => PropertyCell(cellContext: cell, cellBuilder: cellBuilder), + ), + ]; + + return ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 16.0), + children: children, + ); + }, + ); + } +} + +class PropertyCell extends StatefulWidget { + final DatabaseCellContext cellContext; + final GridCellBuilder cellBuilder; + const PropertyCell({ + required this.cellContext, + required this.cellBuilder, + Key? key, + }) : super(key: key); + + @override + State createState() => _PropertyCellState(); +} + +class _PropertyCellState extends State { + @override + Widget build(BuildContext context) { + final style = _customCellStyle(widget.cellContext.fieldType); + final cell = widget.cellBuilder.build(widget.cellContext, style: style); + + final gesture = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => cell.requestFocus.notify(), + child: AccessoryHover(child: cell), + ); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + constraints: const BoxConstraints(minHeight: 28), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + width: 88, + height: 28, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), + child: Row( + children: [ + FlowySvg( + widget.cellContext.fieldType.icon(), + color: Theme.of(context).hintColor, + size: const Size.square(14), + ), + const HSpace(4.0), + FlowyText.regular( + widget.cellContext.fieldInfo.name, + color: Theme.of(context).hintColor, + fontSize: 11, + ), + ], + ), + ), + ), + const HSpace(8), + Expanded(child: gesture), + ], + ), + ); + } + + GridCellStyle? _customCellStyle(FieldType fieldType) { + switch (fieldType) { + case FieldType.Checkbox: + return GridCheckboxCellStyle( + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + ); + case FieldType.DateTime: + return DateCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + alignment: Alignment.centerLeft, + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + ); + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return TimestampCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + alignment: Alignment.centerLeft, + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + ); + case FieldType.MultiSelect: + return SelectOptionCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + ); + case FieldType.Checklist: + return SelectOptionCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + ); + case FieldType.Number: + return GridNumberCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + ); + case FieldType.RichText: + return GridTextCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + ); + case FieldType.SingleSelect: + return SelectOptionCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + ); + + case FieldType.URL: + return GridURLCellStyle( + placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), + accessoryTypes: [ + GridURLCellAccessoryType.copyURL, + GridURLCellAccessoryType.visitURL, + ], + ); + } + throw UnimplementedError; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart index c41e455377..1fbd80ae3d 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/calendar_page.dart @@ -2,11 +2,15 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/database_controller.dart'; import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart'; +import 'package:appflowy/plugins/database_view/calendar/application/unschedule_event_bloc.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -119,19 +123,6 @@ class _CalendarPageState extends State { ); }, ), - BlocListener( - listenWhen: (p, c) => p.editingEvent != c.editingEvent, - listener: (context, state) { - if (state.editingEvent != null) { - showEventDetails( - context: context, - event: state.editingEvent!.event!.event, - viewId: widget.view.id, - rowCache: _calendarBloc.rowCache, - ); - } - }, - ), BlocListener( // Event create by click the + button or double click on the // calendar @@ -211,40 +202,49 @@ class _CalendarPageState extends State { } Widget _headerNavigatorBuilder(DateTime currentMonth) { - return Row( - children: [ - FlowyText.medium( - DateFormat('MMMM y', context.locale.toLanguageTag()) - .format(currentMonth), - ), - const Spacer(), - FlowyIconButton( - width: CalendarSize.navigatorButtonWidth, - height: CalendarSize.navigatorButtonHeight, - icon: const FlowySvg(FlowySvgs.arrow_left_s), - tooltipText: LocaleKeys.calendar_navigation_previousMonth.tr(), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - onPressed: () => _calendarState?.currentState?.previousPage(), - ), - FlowyTextButton( - LocaleKeys.calendar_navigation_today.tr(), - fillColor: Colors.transparent, - fontWeight: FontWeight.w500, - tooltip: LocaleKeys.calendar_navigation_jumpToday.tr(), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - onPressed: () => - _calendarState?.currentState?.animateToMonth(DateTime.now()), - ), - FlowyIconButton( - width: CalendarSize.navigatorButtonWidth, - height: CalendarSize.navigatorButtonHeight, - icon: const FlowySvg(FlowySvgs.arrow_right_s), - tooltipText: LocaleKeys.calendar_navigation_nextMonth.tr(), - hoverColor: AFThemeExtension.of(context).lightGreyHover, - onPressed: () => _calendarState?.currentState?.nextPage(), - ), - ], + return SizedBox( + height: 24, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FlowyText.medium( + DateFormat('MMMM y', context.locale.toLanguageTag()) + .format(currentMonth), + ), + const Spacer(), + FlowyIconButton( + width: CalendarSize.navigatorButtonWidth, + height: CalendarSize.navigatorButtonHeight, + icon: const FlowySvg(FlowySvgs.arrow_left_s), + tooltipText: LocaleKeys.calendar_navigation_previousMonth.tr(), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () => _calendarState?.currentState?.previousPage(), + ), + FlowyTextButton( + LocaleKeys.calendar_navigation_today.tr(), + fillColor: Colors.transparent, + fontWeight: FontWeight.w400, + fontSize: 10, + tooltip: LocaleKeys.calendar_navigation_jumpToday.tr(), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () => + _calendarState?.currentState?.animateToMonth(DateTime.now()), + ), + FlowyIconButton( + width: CalendarSize.navigatorButtonWidth, + height: CalendarSize.navigatorButtonHeight, + icon: const FlowySvg(FlowySvgs.arrow_right_s), + tooltipText: LocaleKeys.calendar_navigation_nextMonth.tr(), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () => _calendarState?.currentState?.nextPage(), + ), + const HSpace(6.0), + UnscheduledEventsButton( + databaseController: widget.databaseController, + ), + ], + ), ); } @@ -255,8 +255,9 @@ class _CalendarPageState extends State { return Center( child: Padding( padding: CalendarSize.daysOfWeekInsets, - child: FlowyText.medium( + child: FlowyText.regular( weekDayString, + fontSize: 9, color: Theme.of(context).hintColor, ), ), @@ -321,3 +322,150 @@ void showEventDetails({ }, ); } + +class UnscheduledEventsButton extends StatefulWidget { + final DatabaseController databaseController; + + const UnscheduledEventsButton({super.key, required this.databaseController}); + + @override + State createState() => + _UnscheduledEventsButtonState(); +} + +class _UnscheduledEventsButtonState extends State { + late final PopoverController _popoverController; + + @override + void initState() { + super.initState(); + _popoverController = PopoverController(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + UnscheduleEventsBloc(databaseController: widget.databaseController) + ..add(const UnscheduleEventsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + triggerActions: PopoverTriggerFlags.none, + controller: _popoverController, + offset: const Offset(0, 8), + constraints: const BoxConstraints(maxWidth: 282, maxHeight: 600), + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + borderRadius: Corners.s6Border, + ), + side: + BorderSide(color: Theme.of(context).dividerColor, width: 1), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + visualDensity: VisualDensity.compact, + ), + onPressed: () { + if (state.unscheduleEvents.isNotEmpty) { + _popoverController.show(); + } + }, + child: FlowyText.regular( + "${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})", + fontSize: 10, + ), + ), + popupBuilder: (context) { + return UnscheduleEventsList( + viewId: widget.databaseController.viewId, + rowCache: widget.databaseController.rowCache, + unscheduleEvents: state.unscheduleEvents, + ); + }, + ); + }, + ), + ); + } +} + +class UnscheduleEventsList extends StatelessWidget { + final String viewId; + final RowCache rowCache; + final List unscheduleEvents; + const UnscheduleEventsList({ + required this.viewId, + required this.unscheduleEvents, + required this.rowCache, + super.key, + }); + + @override + Widget build(BuildContext context) { + final cells = [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FlowyText.medium( + LocaleKeys.calendar_settings_clickToAdd.tr(), + fontSize: 10, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ...unscheduleEvents.map( + (e) => UnscheduledEventCell( + event: e, + onPressed: () { + showEventDetails( + context: context, + event: e, + viewId: viewId, + rowCache: rowCache, + ); + PopoverContainer.of(context).close(); + }, + ), + ) + ]; + + return ListView.separated( + itemBuilder: (context, index) => cells[index], + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ); + } +} + +class UnscheduledEventCell extends StatelessWidget { + final CalendarEventPB event; + final VoidCallback onPressed; + const UnscheduledEventCell({ + required this.event, + required this.onPressed, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 26, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + text: FlowyText.medium( + event.title.isEmpty + ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() + : event.title, + fontSize: 11, + ), + onTap: onPressed, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/layout/sizes.dart index 3840f7133e..221efce6ca 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/layout/sizes.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/layout/sizes.dart @@ -15,7 +15,7 @@ class CalendarSize { static double get scrollBarSize => 8 * scale; static double get navigatorButtonWidth => 20 * scale; - static double get navigatorButtonHeight => 25 * scale; + static double get navigatorButtonHeight => 24 * scale; static EdgeInsets get daysOfWeekInsets => - EdgeInsets.symmetric(vertical: 10.0 * scale); + EdgeInsets.only(top: 12.0 * scale, bottom: 5.0 * scale); } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_setting_bar.dart index c244e8d9a7..517a572695 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_setting_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/calendar/presentation/toolbar/calendar_setting_bar.dart @@ -1,18 +1,6 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/database_controller.dart'; -import 'package:appflowy/plugins/database_view/application/row/row_cache.dart'; -import 'package:appflowy/plugins/database_view/calendar/application/unschedule_event_bloc.dart'; -import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart'; -import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; class CalendarSettingBar extends StatelessWidget { final DatabaseController databaseController; @@ -28,8 +16,6 @@ class CalendarSettingBar extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - UnscheduleEventsButton(databaseController: databaseController), - const HSpace(2), SettingButton( databaseController: databaseController, ), @@ -38,148 +24,3 @@ class CalendarSettingBar extends StatelessWidget { ); } } - -class UnscheduleEventsButton extends StatefulWidget { - final DatabaseController databaseController; - const UnscheduleEventsButton({ - required this.databaseController, - Key? key, - }) : super(key: key); - - @override - State createState() => _UnscheduleEventsButtonState(); -} - -class _UnscheduleEventsButtonState extends State { - late final PopoverController _popoverController; - late final UnscheduleEventsBloc _bloc; - - @override - void initState() { - super.initState(); - _bloc = UnscheduleEventsBloc(databaseController: widget.databaseController) - ..add(const UnscheduleEventsEvent.initial()); - _popoverController = PopoverController(); - } - - @override - dispose() { - _bloc.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithCenterAligned, - controller: _popoverController, - offset: const Offset(0, 8), - constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600), - child: BlocProvider.value( - value: _bloc, - child: BlocBuilder( - buildWhen: (previous, current) => - previous.unscheduleEvents.length != - current.unscheduleEvents.length, - builder: (context, state) { - return FlowyTextButton( - "${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})", - fontSize: FontSizes.s11, - fontColor: AFThemeExtension.of(context).textColor, - fontWeight: FontWeight.w400, - fillColor: Colors.transparent, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - padding: GridSize.toolbarSettingButtonInsets, - radius: Corners.s4Border, - onPressed: () => _popoverController.show(), - ); - }, - ), - ), - popupBuilder: (context) { - return UnscheduleEventsList( - viewId: _bloc.viewId, - rowCache: _bloc.rowCache, - controller: _popoverController, - unscheduleEvents: _bloc.state.unscheduleEvents, - ); - }, - ); - } -} - -class UnscheduleEventsList extends StatelessWidget { - final String viewId; - final RowCache rowCache; - final PopoverController controller; - final List unscheduleEvents; - const UnscheduleEventsList({ - required this.viewId, - required this.controller, - required this.unscheduleEvents, - required this.rowCache, - super.key, - }); - - @override - Widget build(BuildContext context) { - final cells = [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: FlowyText.medium( - LocaleKeys.calendar_settings_clickToAdd.tr(), - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, - ), - ), - const VSpace(6), - ...unscheduleEvents.map( - (e) => UnscheduledEventCell( - event: e, - onPressed: () { - showEventDetails( - context: context, - event: e, - viewId: viewId, - rowCache: rowCache, - ); - controller.close(); - }, - ), - ) - ]; - - return ListView.separated( - itemBuilder: (context, index) => cells[index], - itemCount: cells.length, - separatorBuilder: (context, index) => - VSpace(GridSize.typeOptionSeparatorHeight), - shrinkWrap: true, - ); - } -} - -class UnscheduledEventCell extends StatelessWidget { - final CalendarEventPB event; - final VoidCallback onPressed; - const UnscheduledEventCell({ - required this.event, - required this.onPressed, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: GridSize.popoverItemHeight, - child: FlowyButton( - text: FlowyText.medium( - event.title.isEmpty - ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() - : event.title, - ), - onTap: onPressed, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_editor.dart index 9eeb29161f..b4f57e2b60 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/field_editor.dart @@ -205,6 +205,17 @@ class _FieldNameTextFieldState extends State { }, ); } + + @override + void dispose() { + focusNode.removeListener(() { + if (focusNode.hasFocus) { + widget.popoverMutex.close(); + } + }); + focusNode.dispose(); + super.dispose(); + } } class _DeleteFieldButton extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart index fd9322260a..c3b9bca2f4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option.dart @@ -325,4 +325,15 @@ class _CreateOptionTextFieldState extends State { }, ); } + + @override + void dispose() { + _focusNode.removeListener(() { + if (_focusNode.hasFocus) { + widget.popoverMutex?.close(); + } + }); + _focusNode.dispose(); + super.dispose(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart index d45c699231..31ddb023fb 100755 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cell_builder.dart @@ -247,7 +247,9 @@ class RequestFocusListener extends ChangeNotifier { } } -abstract class GridCellStyle {} +abstract class GridCellStyle { + const GridCellStyle(); +} class SingleListenerFocusNode extends FocusNode { VoidCallback? _listener; diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart index d301dcf751..d8bd787057 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart @@ -438,6 +438,18 @@ class _TimeTextFieldState extends State<_TimeTextField> { }, ); } + + @override + void dispose() { + _textController.dispose(); + _focusNode.removeListener(() { + if (_focusNode.hasFocus) { + widget.popoverMutex.close(); + } + }); + _focusNode.dispose(); + super.dispose(); + } } @visibleForTesting diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart index efc03a6838..bbba5001be 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/text_cell/text_cell.dart @@ -8,22 +8,24 @@ import '../../../../grid/presentation/layout/sizes.dart'; import '../../cell_builder.dart'; class GridTextCellStyle extends GridCellStyle { - String? placeholder; - TextStyle? textStyle; - bool? autofocus; - double emojiFontSize; - double emojiHPadding; - bool showEmoji; - EdgeInsets? cellPadding; + final String? placeholder; + final TextStyle? textStyle; + final EdgeInsets? cellPadding; + final bool autofocus; + final double emojiFontSize; + final double emojiHPadding; + final bool showEmoji; + final bool useRoundedBorder; - GridTextCellStyle({ + const GridTextCellStyle({ this.placeholder, this.textStyle, - this.autofocus, + this.cellPadding, + this.autofocus = false, this.showEmoji = true, this.emojiFontSize = 16, this.emojiHPadding = 0, - this.cellPadding, + this.useRoundedBorder = false, }); } @@ -38,7 +40,7 @@ class GridTextCell extends GridCellWidget { if (style != null) { cellStyle = (style as GridTextCellStyle); } else { - cellStyle = GridTextCellStyle(); + cellStyle = const GridTextCellStyle(); } } @@ -55,12 +57,12 @@ class _GridTextCellState extends GridEditableTextCell { @override void initState() { + super.initState(); final cellController = widget.cellControllerBuilder.build() as TextCellController; - _cellBloc = TextCellBloc(cellController: cellController); - _cellBloc.add(const TextCellEvent.initial()); + _cellBloc = TextCellBloc(cellController: cellController) + ..add(const TextCellEvent.initial()); _controller = TextEditingController(text: _cellBloc.state.content); - super.initState(); } @override @@ -94,23 +96,36 @@ class _GridTextCellState extends GridEditableTextCell { ), HSpace(widget.cellStyle.emojiHPadding), Expanded( - child: TextField( - controller: _controller, - focusNode: focusNode, - maxLines: null, - style: widget.cellStyle.textStyle ?? - Theme.of(context).textTheme.bodyMedium, - autofocus: widget.cellStyle.autofocus ?? false, - decoration: InputDecoration( - contentPadding: EdgeInsets.only( - top: GridSize.cellContentInsets.top, - bottom: GridSize.cellContentInsets.bottom, - ), - border: InputBorder.none, - hintText: widget.cellStyle.placeholder, - isDense: true, - ), - ), + child: widget.cellStyle.useRoundedBorder + ? FlowyTextField( + controller: _controller, + textStyle: widget.cellStyle.textStyle ?? + Theme.of(context).textTheme.bodyMedium, + focusNode: focusNode, + autoFocus: widget.cellStyle.autofocus, + hintText: widget.cellStyle.placeholder, + onChanged: (text) => _cellBloc.add( + TextCellEvent.updateText(text), + ), + debounceDuration: const Duration(milliseconds: 300), + ) + : TextField( + controller: _controller, + focusNode: focusNode, + maxLines: null, + style: widget.cellStyle.textStyle ?? + Theme.of(context).textTheme.bodyMedium, + autofocus: widget.cellStyle.autofocus, + decoration: InputDecoration( + contentPadding: EdgeInsets.only( + top: GridSize.cellContentInsets.top, + bottom: GridSize.cellContentInsets.bottom, + ), + border: InputBorder.none, + hintText: widget.cellStyle.placeholder, + isDense: true, + ), + ), ) ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart index e0d8355d6f..53b7a70f13 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/auto_completion_node_widget.dart @@ -127,6 +127,7 @@ class _AutoCompletionBlockComponentState _onExit(); _unsubscribeSelectionGesture(); controller.dispose(); + textFieldFocusNode.dispose(); super.dispose(); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance.dart index 9a4ece33cd..9ea685c99c 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance.dart @@ -433,6 +433,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState { toggleOffFill: theme.shader5, progressBarBGColor: theme.progressBarBGColor, toggleButtonBGColor: theme.toggleButtonBGColor, + calendarWeekendBGColor: theme.calendarWeekendBGColor, code: _getFontStyle( fontFamily: monospaceFontFamily, fontColor: theme.shader3, diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart index 9f80fb694a..b51b671514 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart @@ -85,6 +85,7 @@ class FlowyColorScheme { //editor toolbar BG color final Color toolbarColor; final Color toggleButtonBGColor; + final Color calendarWeekendBGColor; const FlowyColorScheme({ required this.surface, @@ -133,6 +134,7 @@ class FlowyColorScheme { required this.progressBarBGColor, required this.toolbarColor, required this.toggleButtonBGColor, + required this.calendarWeekendBGColor, }); factory FlowyColorScheme.fromJson(Map json) => diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart index f5590b4055..73bd257e33 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart @@ -72,6 +72,7 @@ class DandelionColorScheme extends FlowyColorScheme { progressBarBGColor: _lightTint9, toolbarColor: _lightShader1, toggleButtonBGColor: _lightShader5, + calendarWeekendBGColor: const Color(0xFFFBFBFC), ); const DandelionColorScheme.dark() @@ -122,5 +123,6 @@ class DandelionColorScheme extends FlowyColorScheme { progressBarBGColor: _darkShader3, toolbarColor: _darkInput, toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart index 03115ddadb..c965766a12 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart @@ -70,6 +70,7 @@ class DefaultColorScheme extends FlowyColorScheme { progressBarBGColor: _lightTint9, toolbarColor: _lightShader1, toggleButtonBGColor: _lightShader5, + calendarWeekendBGColor: const Color(0xFFFBFBFC), ); const DefaultColorScheme.dark() @@ -120,5 +121,6 @@ class DefaultColorScheme extends FlowyColorScheme { progressBarBGColor: _darkShader3, toolbarColor: _darkInput, toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart index 9a49fc4185..1de275192d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart @@ -73,6 +73,7 @@ class LavenderColorScheme extends FlowyColorScheme { progressBarBGColor: _lightTint9, toolbarColor: _lightShader1, toggleButtonBGColor: _lightSelector, + calendarWeekendBGColor: const Color(0xFFFBFBFC), ); const LavenderColorScheme.dark() @@ -123,5 +124,6 @@ class LavenderColorScheme extends FlowyColorScheme { progressBarBGColor: _darkShader3, toolbarColor: _darkInput, toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart index 526ece2111..089c33b057 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart @@ -24,6 +24,7 @@ class AFThemeExtension extends ThemeExtension { final Color toggleButtonBGColor; final Color calloutBGColor; final Color tableCellBGColor; + final Color calendarWeekendBGColor; final TextStyle code; final TextStyle callout; @@ -48,6 +49,7 @@ class AFThemeExtension extends ThemeExtension { required this.textColor, required this.calloutBGColor, required this.tableCellBGColor, + required this.calendarWeekendBGColor, required this.code, required this.callout, required this.caption, @@ -81,6 +83,7 @@ class AFThemeExtension extends ThemeExtension { Color? toggleOffFill, Color? progressBarBGColor, Color? toggleButtonBGColor, + Color? calendarWeekendBGColor, TextStyle? code, TextStyle? callout, TextStyle? caption, @@ -106,6 +109,8 @@ class AFThemeExtension extends ThemeExtension { toggleOffFill: toggleOffFill ?? this.toggleOffFill, progressBarBGColor: progressBarBGColor ?? this.progressBarBGColor, toggleButtonBGColor: toggleButtonBGColor ?? this.toggleButtonBGColor, + calendarWeekendBGColor: + calendarWeekendBGColor ?? this.calendarWeekendBGColor, code: code ?? this.code, callout: callout ?? this.callout, caption: caption ?? this.caption, @@ -142,6 +147,8 @@ class AFThemeExtension extends ThemeExtension { Color.lerp(progressBarBGColor, other.progressBarBGColor, t)!, toggleButtonBGColor: Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!, + calendarWeekendBGColor: + Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!, code: other.code, callout: other.callout, caption: other.caption, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index f2af716d43..e3ab209406 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -16,6 +16,7 @@ class FlowyIconButton extends StatelessWidget { final String? tooltipText; final InlineSpan? richTooltipText; final bool preferBelow; + final BoxDecoration? decoration; const FlowyIconButton({ Key? key, @@ -27,6 +28,7 @@ class FlowyIconButton extends StatelessWidget { this.iconColorOnHover, this.iconPadding = EdgeInsets.zero, this.radius, + this.decoration, this.tooltipText, this.richTooltipText, this.preferBelow = true, @@ -47,11 +49,12 @@ class FlowyIconButton extends StatelessWidget { assert(size.width > iconPadding.horizontal); assert(size.height > iconPadding.vertical); - return ConstrainedBox( + return Container( constraints: BoxConstraints.tightFor( width: size.width, height: size.height, ), + decoration: decoration, child: Tooltip( preferBelow: preferBelow, message: tooltipMessage, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart index b444a91d24..c1d871c203 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -5,8 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class FlowyTextField extends StatefulWidget { - final String hintText; - final String text; + final String? hintText; + final String? text; + final TextStyle? textStyle; final void Function(String)? onChanged; final void Function()? onEditingComplete; final void Function(String)? onSubmitted; @@ -23,7 +24,8 @@ class FlowyTextField extends StatefulWidget { const FlowyTextField({ this.hintText = "", - this.text = "", + this.text, + this.textStyle, this.onChanged, this.onEditingComplete, this.onSubmitted, @@ -55,7 +57,10 @@ class FlowyTextFieldState extends State { focusNode.addListener(notifyDidEndEditing); controller = widget.controller ?? TextEditingController(); - controller.text = widget.text; + if (widget.text != null) { + controller.text = widget.text!; + } + if (widget.autoFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); @@ -105,7 +110,7 @@ class FlowyTextFieldState extends State { maxLines: widget.maxLines, maxLength: widget.maxLength, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, - style: Theme.of(context).textTheme.bodySmall, + style: widget.textStyle ?? Theme.of(context).textTheme.bodySmall, decoration: InputDecoration( constraints: BoxConstraints( maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58), @@ -158,7 +163,9 @@ class FlowyTextFieldState extends State { @override void dispose() { focusNode.removeListener(notifyDidEndEditing); - focusNode.dispose(); + if (widget.focusNode == null) { + focusNode.dispose(); + } super.dispose(); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index aef1f545c6..610a35f6d9 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -659,6 +659,7 @@ "calendar": { "menuName": "Calendar", "defaultNewCalendarTitle": "Untitled", + "newEventButtonTooltip": "Add a new event", "navigation": { "today": "Today", "jumpToday": "Jump to Today",