diff --git a/frontend/appflowy_flutter/integration_test/database/database_reminder_test.dart b/frontend/appflowy_flutter/integration_test/database/database_reminder_test.dart new file mode 100644 index 0000000000..dde29a8e4e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/database/database_reminder_test.dart @@ -0,0 +1,122 @@ +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../util/database_test_op.dart'; +import '../util/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('reminder in database', () { + testWidgets('add date field and add reminder', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditPropertyButton(); + + // Change to date type + await tester.tapTypeOptionButton(); + await tester.selectFieldType(FieldType.DateTime); + await tester.dismissFieldEditor(); + + // Open date picker + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Select date + await tester.selectLastDateInPicker(); + + // Select Time of event reminder + await tester.selectReminderOption(ReminderOption.atTimeOfEvent); + + // Expect Time of event to be displayed + tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open date picker again + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Expect Time of event to be displayed + tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open "Upcoming" in Notification hub + await tester.openNotificationHub(tabIndex: 1); + + // Expect 1 notification + tester.expectNotificationItems(1); + }); + + testWidgets('navigate from reminder to open row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditPropertyButton(); + + // Change to date type + await tester.tapTypeOptionButton(); + await tester.selectFieldType(FieldType.DateTime); + await tester.dismissFieldEditor(); + + // Open date picker + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Select date + await tester.selectLastDateInPicker(); + + // Select Time of event reminder + await tester.selectReminderOption(ReminderOption.atTimeOfEvent); + + // Expect Time of event to be displayed + tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open date picker again + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Expect Time of event to be displayed + tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Create and Navigate to a new document + await tester.createNewPageWithNameUnderParent(); + await tester.pumpAndSettle(); + + // Open "Upcoming" in Notification hub + await tester.openNotificationHub(tabIndex: 1); + + // Expect 1 notification + tester.expectNotificationItems(1); + + // Tap on the notification + await tester.tap(find.byType(NotificationItem)); + await tester.pumpAndSettle(); + + // Expect to see Row Editor Dialog + tester.expectToSeeRowDetailsPageDialog(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart b/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart index fa79f9820f..85c3d68265 100644 --- a/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart +++ b/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart @@ -1,20 +1,23 @@ +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:calendar_view/calendar_view.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import '../util/base.dart'; import '../util/common_operations.dart'; import '../util/editor_test_operations.dart'; +import '../util/expectation.dart'; import '../util/keyboard.dart'; void main() { @@ -35,7 +38,7 @@ void main() { await tester.pumpAndSettle(); - // Trigger iline action menu and type 'remind tomorrow' + // Trigger inline action menu and type 'remind tomorrow' final tomorrow = await _insertReminderTomorrow(tester); Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; @@ -43,7 +46,7 @@ void main() { node.delta!.first.attributes![MentionBlockKeys.mention]; expect(node.type, 'paragraph'); - expect(mentionAttr['type'], MentionType.reminder.name); + expect(mentionAttr['type'], MentionType.date.name); expect(mentionAttr['date'], tomorrow.toIso8601String()); await tester.tap( @@ -67,9 +70,57 @@ void main() { _dateWithTime(dateTimeSettings.timeFormat, tomorrow, time); expect(node.type, 'paragraph'); - expect(mentionAttr['type'], MentionType.reminder.name); + expect(mentionAttr['type'], MentionType.date.name); expect(mentionAttr['date'], tomorrowWithTime.toIso8601String()); }); + + testWidgets('Add reminder for tomorrow, and navigate to it', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.getCurrentEditorState().insertNewLine(); + + await tester.pumpAndSettle(); + + // Trigger inline action menu and type 'remind tomorrow' + final tomorrow = await _insertReminderTomorrow(tester); + + final Node node = + tester.editor.getCurrentEditorState().getNodeAtPath([1])!; + final Map mentionAttr = + node.delta!.first.attributes![MentionBlockKeys.mention]; + + expect(node.type, 'paragraph'); + expect(mentionAttr['type'], MentionType.date.name); + expect(mentionAttr['date'], tomorrow.toIso8601String()); + + // Create and Navigate to a new document + await tester.createNewPageWithNameUnderParent(); + await tester.pumpAndSettle(); + + // Open "Upcoming" in Notification hub + await tester.openNotificationHub(tabIndex: 1); + + // Expect 1 notification + tester.expectNotificationItems(1); + + // Tap on the notification + await tester.tap(find.byType(NotificationItem)); + await tester.pumpAndSettle(); + + // Expect node at path 1 to be the date/reminder + expect( + tester.editor + .getCurrentEditorState() + .getNodeAtPath([1]) + ?.delta + ?.first + .attributes?[MentionBlockKeys.mention]['type'], + MentionType.date.name, + ); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index 576a4da6e8..1ae7b93aa5 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -14,6 +14,9 @@ import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_it import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; @@ -493,6 +496,30 @@ extension CommonOperations on WidgetTester { await tapEmoji(icon); await pumpAndSettle(); } + + Future openNotificationHub({ + int tabIndex = 0, + }) async { + final finder = find.descendant( + of: find.byType(NotificationButton), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.clock_alarm_s, + ), + ); + + await tap(finder); + await pumpAndSettle(); + + if (tabIndex == 1) { + final tabFinder = find.descendant( + of: find.byType(NotificationTabBar), + matching: find.byType(FlowyTabItem).at(1), + ); + + await tap(tabFinder); + await pumpAndSettle(); + } + } } extension ViewLayoutPBTest on ViewLayoutPB { 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 f0ae678248..1f71e1a17b 100644 --- a/frontend/appflowy_flutter/integration_test/util/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/util/database_test_op.dart @@ -60,6 +60,7 @@ import 'package:appflowy/plugins/database/widgets/setting/setting_property_list. import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; @@ -76,6 +77,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:table_calendar/table_calendar.dart'; +// Non-exported member of the table_calendar library +import 'package:table_calendar/src/widgets/cell_content.dart'; + import 'base.dart'; import 'common_operations.dart'; import 'expectation.dart'; @@ -343,6 +347,23 @@ extension AppFlowyDatabaseTest on WidgetTester { await tapButton(finder); } + Future selectReminderOption(ReminderOption option) async { + await hoverOnWidget(find.byType(ReminderSelector)); + + final finder = find.descendant( + of: find.byType(FlowyButton), + matching: find.text(option.label), + ); + + await tapButton(finder); + } + + Future selectLastDateInPicker() async { + final finder = find.byType(CellContent).last; + + await tapButton(finder); + } + Future toggleDateRange() async { final findDateEditor = find.byType(EndTimeButton); final findToggle = find.byType(Toggle); diff --git a/frontend/appflowy_flutter/integration_test/util/expectation.dart b/frontend/appflowy_flutter/integration_test/util/expectation.dart index c059e9e331..cfca2e595b 100644 --- a/frontend/appflowy_flutter/integration_test/util/expectation.dart +++ b/frontend/appflowy_flutter/integration_test/util/expectation.dart @@ -1,14 +1,18 @@ import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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_test/flutter_test.dart'; import 'util.dart'; @@ -242,4 +246,29 @@ extension Expectation on WidgetTester { ); expect(icon, findsOneWidget); } + + void expectSelectedReminder(ReminderOption option) { + final findSelectedText = find.descendant( + of: find.byType(ReminderSelector), + matching: find.text(option.label), + ); + + expect(findSelectedText, findsOneWidget); + } + + void expectNotificationItems(int amount) { + final findItems = find.byType(NotificationItem); + + expect(findItems, findsNWidgets(amount)); + } + + void expectToSeeRowDetailsPageDialog() { + expect( + find.descendant( + of: find.byType(RowDetailPage), + matching: find.byType(SimpleDialog), + ), + findsOneWidget, + ); + } } diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart index a97fd7ef37..b5292ca414 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; @@ -8,11 +10,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; extension MobileRouter on BuildContext { - Future pushView(ViewPB view) async { + Future pushView(ViewPB view, [Map? arguments]) async { push( Uri( path: view.routeName, - queryParameters: view.queryParameters, + queryParameters: view.queryParameters(arguments), ).toString(), ).then((value) { RecentService().updateRecentViews([view.id], true); @@ -36,7 +38,7 @@ extension on ViewPB { } } - Map get queryParameters { + Map queryParameters([Map? arguments]) { switch (layout) { case ViewLayoutPB.Document: return { @@ -47,6 +49,7 @@ extension on ViewPB { return { MobileGridScreen.viewId: id, MobileGridScreen.viewTitle: name, + MobileGridScreen.viewArgs: jsonEncode(arguments), }; case ViewLayoutPB.Calendar: return { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart index 53fdb30d2b..aa34be6eef 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -1,9 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_ext.dart'; @@ -13,7 +17,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:dartz/dartz.dart' hide State; 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'; import 'package:go_router/go_router.dart'; @@ -21,14 +24,16 @@ class MobileViewPage extends StatefulWidget { const MobileViewPage({ super.key, required this.id, - this.title, required this.viewLayout, + this.title, + this.arguments, }); /// view id final String id; - final String? title; final ViewLayoutPB viewLayout; + final String? title; + final Map? arguments; @override State createState() => _MobileViewPageState(); @@ -40,7 +45,6 @@ class _MobileViewPageState extends State { @override void initState() { super.initState(); - future = ViewBackendService.getView(widget.id); } @@ -67,7 +71,10 @@ class _MobileViewPageState extends State { body = state.data!.fold((view) { viewPB = view; actions.add(_buildAppBarMoreButton(view)); - return view.plugin().widgetBuilder.buildWidget(shrinkWrap: false); + return view + .plugin(arguments: widget.arguments ?? const {}) + .widgetBuilder + .buildWidget(shrinkWrap: false); }, (error) { return FlowyMobileStateContainer.error( emoji: '😔', @@ -89,6 +96,10 @@ class _MobileViewPageState extends State { create: (_) => ViewBloc(view: viewPB!)..add(const ViewEvent.initial()), ), + BlocProvider.value( + value: getIt() + ..add(const ReminderEvent.started()), + ), ], child: Builder( builder: (context) { @@ -131,9 +142,7 @@ class _MobileViewPageState extends State { leading: const AppBarBackButton(), actions: actions, ), - body: SafeArea( - child: child, - ), + body: SafeArea(child: child), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart index 50314f6c0e..bf5193d8f4 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart @@ -1,24 +1,24 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; -import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/plugins/base/drag_handler.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/date_cell/date_cell_editor_bloc.dart'; -import 'package:appflowy/plugins/database/widgets/row/cells/date_cell/mobile_date_editor.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; class MobileDateCellEditScreen extends StatefulWidget { static const routeName = '/edit_date_cell'; // the type is DateCellController static const dateCellController = 'date_cell_controller'; + // bool value, default is true static const fullScreen = 'full_screen'; @@ -38,20 +38,13 @@ class MobileDateCellEditScreen extends StatefulWidget { class _MobileDateCellEditScreenState extends State { @override - Widget build(BuildContext context) { - return widget.showAsFullScreen ? _buildFullScreen() : _buildNotFullScreen(); - } + Widget build(BuildContext context) => + widget.showAsFullScreen ? _buildFullScreen() : _buildNotFullScreen(); Widget _buildFullScreen() { return Scaffold( - appBar: AppBar( - title: FlowyText.medium( - LocaleKeys.titleBar_date.tr(), - ), - ), - body: _DateCellEditBody( - dateCellController: widget.controller, - ), + appBar: AppBar(title: FlowyText.medium(LocaleKeys.titleBar_date.tr())), + body: _buildDatePicker(), ); } @@ -71,353 +64,73 @@ class _MobileDateCellEditScreenState extends State { color: Theme.of(context).colorScheme.surface, child: const Center(child: DragHandler()), ), - _buildHeader(), - _DateCellEditBody( - dateCellController: widget.controller, - ), + const MobileDateHeader(), + _buildDatePicker(), ], ), ), ); } - Widget _buildHeader() { - const iconWidth = 30.0; - const height = 44.0; - return Container( - color: Theme.of(context).colorScheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Stack( - children: [ - Align( - alignment: Alignment.centerLeft, - child: FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(iconWidth), - ), - width: iconWidth, - iconPadding: EdgeInsets.zero, - onPressed: () => context.pop(), - ), + Widget _buildDatePicker() => MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => DateCellEditorBloc( + reminderBloc: getIt(), + cellController: widget.controller, + )..add(const DateCellEditorEvent.initial()), ), - Align( - alignment: Alignment.center, - child: FlowyText.medium( - LocaleKeys.grid_field_dateFieldName.tr(), - fontSize: 16, - ), - ), - ].map((e) => SizedBox(height: height, child: e)).toList(), - ), - ); - } -} - -class _DateCellEditBody extends StatelessWidget { - const _DateCellEditBody({ - required this.dateCellController, - }); - - final DateCellController dateCellController; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DateCellEditorBloc( - cellController: dateCellController, - )..add(const DateCellEditorEvent.initial()), - child: const Column( - children: [ - FlowyOptionDecorateBox( - showTopBorder: false, - child: _IncludeTimePicker(), - ), - _Divider(), - FlowyOptionDecorateBox( - child: MobileDatePicker(), - ), - _Divider(), - _EndDateSwitch(), - _IncludeTimeSwitch(), - _Divider(), - _ClearDateButton(), - _Divider(), ], - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) { - return const VSpace(20.0); - } -} - -class _IncludeTimePicker extends StatefulWidget { - const _IncludeTimePicker(); - - @override - State<_IncludeTimePicker> createState() => _IncludeTimePickerState(); -} - -class _IncludeTimePickerState extends State<_IncludeTimePicker> { - String? _selectedTime; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final startDay = state.dateStr; - final endDay = state.endDateStr; - final includeTime = state.includeTime; - final use24hFormat = - state.dateTypeOptionPB.timeFormat == TimeFormatPB.TwentyFourHour; - if (startDay == null || startDay.isEmpty) { - return const Divider( - height: 1, - ); - } - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTime( - context, - includeTime, - use24hFormat, - true, - startDay, - state.timeStr, - ), - VSpace( - 8.0, - color: Theme.of(context).colorScheme.surface, - ), - _buildTime( - context, - includeTime, - use24hFormat, - false, - endDay, - state.endTimeStr, - ), - ], - ), - ); - }, - ); - } - - Widget _buildTime( - BuildContext context, - bool isIncludeTime, - bool use24hFormat, - bool isStartDay, - String? dateStr, - String? timeStr, - ) { - if (dateStr == null) { - return const SizedBox.shrink(); - } - - final List children = []; - - if (!isIncludeTime) { - children.addAll([ - const HSpace(12.0), - FlowyText( - dateStr, - ), - ]); - } else { - children.addAll([ - Expanded( - child: FlowyText( - dateStr, - textAlign: TextAlign.center, - ), - ), - Container( - width: 1, - height: 16, - color: Colors.grey, - ), - Expanded( - child: FlowyText( - timeStr ?? '', - textAlign: TextAlign.center, - ), - ), - ]); - } - - return GestureDetector( - onTap: () async { - final bloc = context.read(); - await showMobileBottomSheet( - context, - builder: (context) { - return ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 300, - ), - child: CupertinoDatePicker( - showDayOfWeek: false, - mode: CupertinoDatePickerMode.time, - use24hFormat: use24hFormat, - onDateTimeChanged: (dateTime) { - _selectedTime = use24hFormat - ? DateFormat('HH:mm').format(dateTime) - : DateFormat('hh:mm a').format(dateTime); - }, - ), + child: BlocBuilder( + builder: (context, state) { + return MobileAppFlowyDatePicker( + selectedDay: state.dateTime, + dateStr: state.dateStr, + endDateStr: state.endDateStr, + timeStr: state.timeStr, + endTimeStr: state.endTimeStr, + startDay: state.startDay, + endDay: state.endDay, + enableRanges: true, + isRange: state.isRange, + includeTime: state.includeTime, + use24hFormat: state.dateTypeOptionPB.timeFormat == + TimeFormatPB.TwentyFourHour, + selectedReminderOption: state.reminderOption, + onStartTimeChanged: (String? time) { + if (time != null) { + context + .read() + .add(DateCellEditorEvent.setTime(time)); + } + }, + onEndTimeChanged: (String? time) { + if (time != null) { + context + .read() + .add(DateCellEditorEvent.setEndTime(time)); + } + }, + onDaySelected: (selectedDay, focusedDay) => context + .read() + .add(DateCellEditorEvent.selectDay(selectedDay)), + onRangeSelected: (start, end, focused) => context + .read() + .add(DateCellEditorEvent.selectDateRange(start, end)), + onRangeChanged: (value) => context + .read() + .add(DateCellEditorEvent.setIsRange(value)), + onIncludeTimeChanged: (value) => context + .read() + .add(DateCellEditorEvent.setIncludeTime(value)), + onClearDate: () => context + .read() + .add(const DateCellEditorEvent.clearDate()), + onReminderSelected: (option) => context + .read() + .add(DateCellEditorEvent.setReminderOption(option: option)), ); }, - ); - - if (_selectedTime != null) { - bloc.add( - isStartDay - ? DateCellEditorEvent.setTime(_selectedTime!) - : DateCellEditorEvent.setEndTime(_selectedTime!), - ); - } - }, - child: Container( - constraints: const BoxConstraints( - minHeight: 36, ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: Theme.of(context).colorScheme.secondaryContainer, - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Row( - children: children, - ), - ), - ); - } -} - -class _EndDateSwitch extends StatelessWidget { - const _EndDateSwitch(); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.isRange, - builder: (context, isRange) { - return FlowyOptionTile.toggle( - text: LocaleKeys.grid_field_isRange.tr(), - isSelected: isRange, - onValueChanged: (value) { - context - .read() - .add(DateCellEditorEvent.setIsRange(value)); - }, - ); - }, - ); - } -} - -class _IncludeTimeSwitch extends StatelessWidget { - const _IncludeTimeSwitch(); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.includeTime, - builder: (context, includeTime) { - return FlowyOptionTile.toggle( - showTopBorder: false, - text: LocaleKeys.grid_field_includeTime.tr(), - isSelected: includeTime, - onValueChanged: (value) { - context - .read() - .add(DateCellEditorEvent.setIncludeTime(value)); - }, - ); - }, - ); - } -} - -class _TimeTextField extends StatefulWidget { - const _TimeTextField({ - required this.timeStr, - required this.isEndTime, - }); - - final String? timeStr; - final bool isEndTime; - - @override - State<_TimeTextField> createState() => _TimeTextFieldState(); -} - -class _TimeTextFieldState extends State<_TimeTextField> { - late final TextEditingController _textController = - TextEditingController(text: widget.timeStr); - - @override - Widget build(BuildContext context) { - return BlocConsumer( - listener: (context, state) { - _textController.text = - widget.isEndTime ? state.endTimeStr ?? "" : state.timeStr ?? ""; - }, - builder: (context, state) { - return TextFormField( - controller: _textController, - textAlign: TextAlign.end, - decoration: InputDecoration( - hintText: state.timeHintText, - errorText: widget.isEndTime - ? state.parseEndTimeError - : state.parseTimeError, - ), - keyboardType: TextInputType.datetime, - onFieldSubmitted: (timeStr) { - context.read().add( - widget.isEndTime - ? DateCellEditorEvent.setEndTime(timeStr) - : DateCellEditorEvent.setTime(timeStr), - ); - }, - ); - }, - ); - } - - @override - void dispose() { - _textController.dispose(); - super.dispose(); - } -} - -class _ClearDateButton extends StatelessWidget { - const _ClearDateButton(); - - @override - Widget build(BuildContext context) { - return FlowyOptionTile.text( - text: LocaleKeys.grid_field_clearDate.tr(), - onTap: () => context - .read() - .add(const DateCellEditorEvent.clearDate()), - ); - } + ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart index 2ad4dfb3a1..b8f29ed134 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart @@ -1,28 +1,33 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; class MobileGridScreen extends StatelessWidget { static const routeName = '/grid'; static const viewId = 'id'; static const viewTitle = 'title'; + static const viewArgs = 'arguments'; const MobileGridScreen({ super.key, required this.id, this.title, + this.arguments, }); /// view id final String id; final String? title; + final Map? arguments; @override Widget build(BuildContext context) { return MobileViewPage( id: id, title: title, - viewLayout: ViewLayoutPB.Document, + viewLayout: ViewLayoutPB.Grid, + arguments: arguments, ); } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart index d8102900f9..8156e8debe 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -159,7 +159,7 @@ class _NotificationScreenContent extends StatelessWidget { ); void _onDelete(ReminderPB reminder) => - reminderBloc.add(ReminderEvent.remove(reminder: reminder)); + reminderBloc.add(ReminderEvent.remove(reminderId: reminder.id)); void _onReadChanged(ReminderPB reminder, bool isRead) => reminderBloc.add( ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart index 1528906590..b1da135aa6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/date_cell_service.dart @@ -18,12 +18,13 @@ final class DateCellBackendService { ..rowId = rowId; Future> update({ + required bool includeTime, + required bool isRange, DateTime? date, String? time, DateTime? endDate, String? endTime, - required includeTime, - required isRange, + String? reminderId, }) { final payload = DateChangesetPB.create() ..cellId = cellId @@ -44,6 +45,9 @@ final class DateCellBackendService { if (endTime != null) { payload.endTime = endTime; } + if (reminderId != null) { + payload.reminderId = reminderId; + } return DatabaseEventUpdateDateCell(payload).send(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index fd88098a29..9389147085 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -1,5 +1,8 @@ import 'dart:collection'; +import 'package:flutter/material.dart' hide Card; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_content.dart'; @@ -21,8 +24,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart' hide Card; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../widgets/card/card.dart'; @@ -30,6 +31,7 @@ import '../../widgets/card/card_cell_builder.dart'; import '../../widgets/card/cells/card_cell.dart'; import '../../widgets/row/cell_builder.dart'; import '../application/board_bloc.dart'; + import 'toolbar/board_setting_bar.dart'; import 'widgets/board_hidden_groups.dart'; @@ -40,6 +42,7 @@ class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder { ViewPB view, DatabaseController controller, bool shrinkWrap, + String? initialRowId, ) => BoardPage(view: view, databaseController: controller); diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart index 0a0808675f..a217923279 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; @@ -19,7 +21,6 @@ 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/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -27,6 +28,7 @@ import '../../application/row/row_cache.dart'; import '../../application/row/row_controller.dart'; import '../../widgets/row/cell_builder.dart'; import '../../widgets/row/row_detail.dart'; + import 'calendar_day.dart'; import 'layout/sizes.dart'; import 'toolbar/calendar_setting_bar.dart'; @@ -38,6 +40,7 @@ class CalendarPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder { ViewPB view, DatabaseController controller, bool shrinkWrap, + String? initialRowId, ) { return CalendarPage( key: _makeValueKey(controller), diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index 75a8b5bad7..a4a2b8a706 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -1,9 +1,15 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart'; import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; import 'package:appflowy/plugins/database/widgets/row/cell_builder.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -11,22 +17,22 @@ import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter/material.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; + +import '../../application/database_controller.dart'; import '../../application/row/row_cache.dart'; import '../../application/row/row_controller.dart'; -import '../application/grid_bloc.dart'; -import '../../application/database_controller.dart'; -import 'grid_scroll.dart'; import '../../tab_bar/tab_bar_view.dart'; +import '../../widgets/row/row_detail.dart'; +import '../application/grid_bloc.dart'; + +import 'grid_scroll.dart'; import 'layout/layout.dart'; import 'layout/sizes.dart'; -import 'widgets/row/row.dart'; import 'widgets/footer/grid_footer.dart'; import 'widgets/header/grid_header.dart'; -import '../../widgets/row/row_detail.dart'; +import 'widgets/row/row.dart'; import 'widgets/shortcuts.dart'; class ToggleExtensionNotifier extends ChangeNotifier { @@ -49,11 +55,13 @@ class DesktopGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder { ViewPB view, DatabaseController controller, bool shrinkWrap, + String? initialRowId, ) { return GridPage( key: _makeValueKey(controller), view: view, databaseController: controller, + initialRowId: initialRowId, ); } @@ -85,31 +93,33 @@ class DesktopGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder { } class GridPage extends StatefulWidget { - final DatabaseController databaseController; const GridPage({ + super.key, required this.view, required this.databaseController, this.onDeleted, - super.key, + this.initialRowId, }); final ViewPB view; + final DatabaseController databaseController; final VoidCallback? onDeleted; + final String? initialRowId; @override State createState() => _GridPageState(); } class _GridPageState extends State { - @override - void initState() { - super.initState(); - } + bool _didOpenInitialRow = false; @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ + BlocProvider.value( + value: getIt(), + ), BlocProvider( create: (context) => GridBloc( view: widget.view, @@ -117,35 +127,88 @@ class _GridPageState extends State { )..add(const GridEvent.initial()), ), ], - child: BlocBuilder( - builder: (context, state) { - return state.loadingState.map( + child: BlocListener( + listener: (context, state) { + final action = state.action; + if (action?.type == ActionType.openRow && + action?.objectId == widget.view.id) { + final rowId = action!.arguments?[ActionArgumentKeys.rowId]; + if (rowId != null) { + // If Reminder in existing database is pressed + // then open the row + _openRow(context, rowId); + } + } + }, + child: BlocConsumer( + listener: (context, state) => state.loadingState.whenOrNull( + // If initial row id is defined, open row details overlay + finish: (_) { + if (widget.initialRowId != null && !_didOpenInitialRow) { + _didOpenInitialRow = true; + + _openRow(context, widget.initialRowId!); + } + + return; + }, + ), + builder: (context, state) => state.loadingState.map( loading: (_) => const Center(child: CircularProgressIndicator.adaptive()), finish: (result) => result.successOrFail.fold( - (_) => GridShortcuts( - child: GridPageContent(view: widget.view), - ), + (_) => GridShortcuts(child: GridPageContent(view: widget.view)), (err) => FlowyErrorPage.message( err.toString(), howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), ), ), idle: (_) => const SizedBox.shrink(), - ); - }, + ), + ), ), ); } + + void _openRow( + BuildContext context, + String rowId, + ) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final gridBloc = context.read(); + final rowCache = gridBloc.getRowCache(rowId); + final rowMeta = rowCache.getRow(rowId)?.rowMeta; + if (rowMeta == null) { + return; + } + + final fieldController = gridBloc.databaseController.fieldController; + final rowController = RowController( + viewId: widget.view.id, + rowMeta: rowMeta, + rowCache: rowCache, + ); + + FlowyOverlay.show( + context: context, + builder: (_) => RowDetailPage( + cellBuilder: GridCellBuilder(cellCache: rowController.cellCache), + rowController: rowController, + fieldController: fieldController, + ), + ); + }); + } } class GridPageContent extends StatefulWidget { - final ViewPB view; const GridPageContent({ - required this.view, super.key, + required this.view, }); + final ViewPB view; + @override State createState() => _GridPageContentState(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart index c035839841..2f566ec9ca 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; @@ -7,6 +9,8 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/shortcuts.dart'; import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; @@ -15,7 +19,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; @@ -33,11 +36,13 @@ class MobileGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder { ViewPB view, DatabaseController controller, bool shrinkWrap, + String? initialRowId, ) { return MobileGridPage( key: _makeValueKey(controller), view: view, databaseController: controller, + initialRowId: initialRowId, ); } @@ -58,26 +63,33 @@ class MobileGridTabBarBuilderImpl implements DatabaseTabBarItemBuilder { } class MobileGridPage extends StatefulWidget { - final DatabaseController databaseController; const MobileGridPage({ + super.key, required this.view, required this.databaseController, this.onDeleted, - super.key, + this.initialRowId, }); final ViewPB view; + final DatabaseController databaseController; final VoidCallback? onDeleted; + final String? initialRowId; @override State createState() => _MobileGridPageState(); } class _MobileGridPageState extends State { + bool _didOpenInitialRow = false; + @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ + BlocProvider.value( + value: getIt(), + ), BlocProvider( create: (context) => GridBloc( view: widget.view, @@ -90,19 +102,43 @@ class _MobileGridPageState extends State { return state.loadingState.map( loading: (_) => const Center(child: CircularProgressIndicator.adaptive()), - finish: (result) => result.successOrFail.fold( - (_) => GridShortcuts(child: GridPageContent(view: widget.view)), - (err) => FlowyErrorPage.message( - err.toString(), - howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), - ), - ), + finish: (result) { + _openRow(context, widget.initialRowId, true); + return result.successOrFail.fold( + (_) => GridShortcuts(child: GridPageContent(view: widget.view)), + (err) => FlowyErrorPage.message( + err.toString(), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + ); + }, idle: (_) => const SizedBox.shrink(), ); }, ), ); } + + void _openRow( + BuildContext context, + String? rowId, [ + bool initialRow = false, + ]) { + if (rowId != null && (!initialRow || (initialRow && !_didOpenInitialRow))) { + _didOpenInitialRow = initialRow; + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: rowId, + MobileRowDetailPage.argDatabaseController: + widget.databaseController, + }, + ); + }); + } + } } class GridPageContent extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart index c599eaba76..70b132d5e5 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart @@ -1,3 +1,6 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import "package:appflowy/generated/locale_keys.g.dart"; import 'package:appflowy/plugins/database/application/cell/cell_service.dart'; @@ -9,14 +12,13 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../../../../widgets/row/accessory/cell_accessory.dart'; import '../../../../widgets/row/cells/cell_container.dart'; import '../../layout/sizes.dart'; + import 'action.dart'; class GridRow extends StatefulWidget { @@ -186,15 +188,15 @@ class InsertRowButton extends StatelessWidget { } class RowMenuButton extends StatefulWidget { - final VoidCallback openMenu; - final bool isDragEnabled; - const RowMenuButton({ + super.key, required this.openMenu, this.isDragEnabled = false, - super.key, }); + final VoidCallback openMenu; + final bool isDragEnabled; + @override State createState() => _RowMenuButtonState(); } @@ -227,14 +229,15 @@ class _RowMenuButtonState extends State { } class RowContent extends StatelessWidget { - final VoidCallback onExpand; - final GridCellBuilder builder; const RowContent({ + super.key, required this.builder, required this.onExpand, - super.key, }); + final GridCellBuilder builder; + final VoidCallback onExpand; + @override Widget build(BuildContext context) { return BlocBuilder( diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart index ccc5c76181..331c6ccc20 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; @@ -10,7 +12,6 @@ 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'; import 'tab_bar_add_button.dart'; @@ -37,9 +38,7 @@ class TabBarHeader extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Flexible( - child: DatabaseTabBar(), - ), + const Flexible(child: DatabaseTabBar()), BlocBuilder( builder: (context, state) { return SizedBox( diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart index e043c1b42c..7235d422e4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; @@ -10,7 +12,6 @@ import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'desktop/tab_bar_header.dart'; @@ -26,6 +27,7 @@ abstract class DatabaseTabBarItemBuilder { ViewPB view, DatabaseController controller, bool shrinkWrap, + String? initialRowId, ); /// Returns the setting bar of the tab bar item. The setting bar is shown on the @@ -44,10 +46,16 @@ abstract class DatabaseTabBarItemBuilder { class DatabaseTabBarView extends StatefulWidget { final ViewPB view; final bool shrinkWrap; + + /// Used to open a Row on plugin load + /// + final String? initialRowId; + const DatabaseTabBarView({ + super.key, required this.view, required this.shrinkWrap, - super.key, + this.initialRowId, }); @override @@ -55,19 +63,12 @@ class DatabaseTabBarView extends StatefulWidget { } class _DatabaseTabBarViewState extends State { - PageController? _pageController; - - @override - void initState() { - super.initState(); - _pageController = PageController( - initialPage: 0, - ); - } + final PageController _pageController = PageController(initialPage: 0); + late String? _initialRowId = widget.initialRowId; @override void dispose() { - _pageController?.dispose(); + _pageController.dispose(); super.dispose(); } @@ -75,15 +76,14 @@ class _DatabaseTabBarViewState extends State { Widget build(BuildContext context) { return BlocProvider( create: (context) => DatabaseTabBarBloc(view: widget.view) - ..add( - const DatabaseTabBarEvent.initial(), - ), + ..add(const DatabaseTabBarEvent.initial()), child: MultiBlocListener( listeners: [ BlocListener( listenWhen: (p, c) => p.selectedIndex != c.selectedIndex, listener: (context, state) { - _pageController?.jumpToPage(state.selectedIndex); + _initialRowId = null; + _pageController.jumpToPage(state.selectedIndex); }, ), ], @@ -120,20 +120,17 @@ class _DatabaseTabBarViewState extends State { }, ), BlocBuilder( - builder: (context, state) { - return pageSettingBarExtensionFromState(state); - }, + builder: (context, state) => + pageSettingBarExtensionFromState(state), ), Expanded( child: BlocBuilder( - builder: (context, state) { - return PageView( - pageSnapping: false, - physics: const NeverScrollableScrollPhysics(), - controller: _pageController, - children: pageContentFromState(state), - ); - }, + builder: (context, state) => PageView( + pageSnapping: false, + physics: const NeverScrollableScrollPhysics(), + controller: _pageController, + children: pageContentFromState(state), + ), ), ), ], @@ -146,11 +143,13 @@ class _DatabaseTabBarViewState extends State { return state.tabBars.map((tabBar) { final controller = state.tabBarControllerByViewId[tabBar.viewId]!.controller; + return tabBar.builder.content( context, tabBar.view, controller, widget.shrinkWrap, + _initialRowId, ); }).toList(); } @@ -174,15 +173,21 @@ class DatabaseTabBarViewPlugin extends Plugin { final ViewPluginNotifier notifier; final PluginType _pluginType; + /// Used to open a Row on plugin load + /// + final String? initialRowId; + DatabaseTabBarViewPlugin({ required ViewPB view, required PluginType pluginType, + this.initialRowId, }) : _pluginType = pluginType, notifier = ViewPluginNotifier(view: view); @override PluginWidgetBuilder get widgetBuilder => DatabasePluginWidgetBuilder( notifier: notifier, + initialRowId: initialRowId, ); @override @@ -195,9 +200,14 @@ class DatabaseTabBarViewPlugin extends Plugin { class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { final ViewPluginNotifier notifier; + /// Used to open a Row on plugin load + /// + final String? initialRowId; + DatabasePluginWidgetBuilder({ - required this.notifier, Key? key, + required this.notifier, + this.initialRowId, }); @override @@ -219,6 +229,7 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { key: ValueKey(notifier.view.id), view: notifier.view, shrinkWrap: shrinkWrap, + initialRowId: initialRowId, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart index 9c7cf14a72..29d0cd6f43 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart @@ -1,14 +1,14 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.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/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; import '../cell_builder.dart'; @@ -52,14 +52,15 @@ abstract mixin class GridCellAccessoryState { } class PrimaryCellAccessory extends StatefulWidget { - final VoidCallback onTapCallback; - final bool isCellEditing; const PrimaryCellAccessory({ + super.key, required this.onTapCallback, required this.isCellEditing, - super.key, }); + final VoidCallback onTapCallback; + final bool isCellEditing; + @override State createState() => _PrimaryCellAccessoryState(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell.dart index 196121383a..1f1a35420a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell.dart @@ -1,14 +1,20 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../grid/presentation/layout/sizes.dart'; import '../../cell_builder.dart'; + import 'date_cell_bloc.dart'; import 'date_editor.dart'; @@ -85,22 +91,32 @@ class _DateCellState extends GridCellState { child: Container( alignment: alignment, padding: padding, - child: FlowyText.medium( - text, - color: color, - overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText.medium( + text, + color: color, + overflow: TextOverflow.ellipsis, + ), + ), + if (state.data?.reminderId.isNotEmpty == true) ...[ + const HSpace(5), + FlowyTooltip( + message: + LocaleKeys.grid_field_reminderOnDateTooltip.tr(), + child: const FlowySvg(FlowySvgs.clock_alarm_s), + ), + ], + ], ), ), - popupBuilder: (BuildContext popoverContent) { - return DateCellEditor( - cellController: _cellController, - onDismissed: () => - widget.cellContainerNotifier.isFocus = false, - ); - }, - onClose: () { - widget.cellContainerNotifier.isFocus = false; - }, + popupBuilder: (_) => DateCellEditor( + cellController: _cellController, + onDismissed: () => widget.cellContainerNotifier.isFocus = false, + ), + onClose: () => widget.cellContainerNotifier.isFocus = false, ); } else if (widget.cellStyle.useRoundedBorder) { return InkWell( @@ -108,12 +124,10 @@ class _DateCellState extends GridCellState { onTap: () => showMobileBottomSheet( context, padding: EdgeInsets.zero, - builder: (context) { - return MobileDateCellEditScreen( - controller: _cellController, - showAsFullScreen: false, - ); - }, + builder: (_) => MobileDateCellEditScreen( + controller: _cellController, + showAsFullScreen: false, + ), ), child: Container( constraints: const BoxConstraints( @@ -146,28 +160,36 @@ class _DateCellState extends GridCellState { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: FlowyText( - text, - color: color, - fontSize: 15, - maxLines: 1, + child: Row( + children: [ + if (state.data?.reminderId.isNotEmpty == true) ...[ + FlowyTooltip( + message: + LocaleKeys.grid_field_reminderOnDateTooltip.tr(), + child: const FlowySvg(FlowySvgs.clock_alarm_s), + ), + const HSpace(5), + ], + FlowyText( + text, + color: color, + fontSize: 15, + maxLines: 1, + ), + ], ), ), ), - onTap: () { - showMobileBottomSheet( - context, - padding: EdgeInsets.zero, - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - builder: (context) { - return MobileDateCellEditScreen( - controller: _cellController, - showAsFullScreen: false, - ); - }, - ); - }, + onTap: () => showMobileBottomSheet( + context, + padding: EdgeInsets.zero, + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + builder: (_) => MobileDateCellEditScreen( + controller: _cellController, + showAsFullScreen: false, + ), + ), ); } }, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell_editor_bloc.dart index 2ffb3908d1..1d810dfec7 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell_editor_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_cell_editor_bloc.dart @@ -5,15 +5,22 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller_build import 'package:appflowy/plugins/database/application/cell/date_cell_service.dart'; import 'package:appflowy/plugins/database/application/field/field_service.dart'; import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart' show StringTranslateExtension; +import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra/time/duration.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:nanoid/non_secure.dart'; import 'package:protobuf/protobuf.dart'; part 'date_cell_editor_bloc.freezed.dart'; @@ -22,16 +29,19 @@ class DateCellEditorBloc extends Bloc { final DateCellBackendService _dateCellBackendService; final DateCellController cellController; + final ReminderBloc _reminderBloc; void Function()? _onCellChangedFn; DateCellEditorBloc({ required this.cellController, - }) : _dateCellBackendService = DateCellBackendService( + required ReminderBloc reminderBloc, + }) : _reminderBloc = reminderBloc, + _dateCellBackendService = DateCellBackendService( viewId: cellController.viewId, fieldId: cellController.fieldId, rowId: cellController.rowId, ), - super(DateCellEditorState.initial(cellController)) { + super(DateCellEditorState.initial(cellController, reminderBloc)) { on( (event, emit) async { await event.when( @@ -42,6 +52,41 @@ class DateCellEditorBloc dateCellData.isRange == state.isRange && dateCellData.isRange ? dateCellData.endDateTime : null; + + if (dateCellData.dateTime != null && + (state.reminderId?.isEmpty ?? true) && + (dateCellData.reminderId?.isNotEmpty ?? false) && + state.reminderOption != ReminderOption.none) { + // Add Reminder + _reminderBloc.add( + ReminderEvent.addById( + reminderId: dateCellData.reminderId!, + objectId: cellController.viewId, + meta: {ReminderMetaKeys.rowId: cellController.rowId}, + scheduledAt: Int64( + dateCellData.dateTime! + .subtract(state.reminderOption.time) + .millisecondsSinceEpoch ~/ + 1000, + ), + ), + ); + } + + if ((dateCellData.reminderId?.isNotEmpty ?? false) && + dateCellData.dateTime != null) { + // Update Reminder + _reminderBloc.add( + ReminderEvent.update( + ReminderUpdate( + id: state.reminderId!, + scheduledAt: dateCellData.dateTime! + .subtract(state.reminderOption.time), + ), + ), + ); + } + emit( state.copyWith( dateTime: dateCellData.dateTime, @@ -54,11 +99,14 @@ class DateCellEditorBloc endDay: endDay, dateStr: dateCellData.dateStr, endDateStr: dateCellData.endDateStr, + reminderId: dateCellData.reminderId, ), ); }, - didReceiveTimeFormatError: - (String? parseTimeError, String? parseEndTimeError) { + didReceiveTimeFormatError: ( + String? parseTimeError, + String? parseEndTimeError, + ) { emit( state.copyWith( parseTimeError: parseTimeError, @@ -67,17 +115,14 @@ class DateCellEditorBloc ); }, selectDay: (date) async { - if (state.isRange) { - return; + if (!state.isRange) { + await _updateDateData(date: date); } - await _updateDateData(date: date); - }, - setIncludeTime: (includeTime) async { - await _updateDateData(includeTime: includeTime); - }, - setIsRange: (isRange) async { - await _updateDateData(isRange: isRange); }, + setIncludeTime: (includeTime) async => + await _updateDateData(includeTime: includeTime), + setIsRange: (isRange) async => + await _updateDateData(isRange: isRange), setTime: (timeStr) async { emit(state.copyWith(timeStr: timeStr)); await _updateDateData(timeStr: timeStr); @@ -87,89 +132,88 @@ class DateCellEditorBloc final (newStart, newEnd) = state.startDay!.isBefore(start!) ? (state.startDay!, start) : (start, state.startDay!); - emit( - state.copyWith( - startDay: null, - endDay: null, - ), - ); - await _updateDateData( - date: newStart.date, - endDate: newEnd.date, - ); + + emit(state.copyWith(startDay: null, endDay: null)); + + await _updateDateData(date: newStart.date, endDate: newEnd.date); } else if (end == null) { - emit( - state.copyWith( - startDay: start, - endDay: null, - ), - ); + emit(state.copyWith(startDay: start, endDay: null)); } else { - await _updateDateData( - date: start!.date, - endDate: end.date, - ); + await _updateDateData(date: start!.date, endDate: end.date); } }, setStartDay: (DateTime startDay) async { if (state.endDay == null) { - emit( - state.copyWith( - startDay: startDay, - ), - ); + emit(state.copyWith(startDay: startDay)); } else if (startDay.isAfter(state.endDay!)) { - emit( - state.copyWith( - startDay: startDay, - endDay: null, - ), - ); + emit(state.copyWith(startDay: startDay, endDay: null)); } else { - emit( - state.copyWith( - startDay: startDay, - ), + emit(state.copyWith(startDay: startDay)); + await _updateDateData( + date: startDay.date, + endDate: state.endDay!.date, ); - _updateDateData(date: startDay.date, endDate: state.endDay!.date); } }, - setEndDay: (DateTime endDay) async { + setEndDay: (DateTime endDay) { if (state.startDay == null) { - emit( - state.copyWith( - endDay: endDay, - ), - ); + emit(state.copyWith(endDay: endDay)); } else if (endDay.isBefore(state.startDay!)) { - emit( - state.copyWith( - startDay: null, - endDay: endDay, - ), - ); + emit(state.copyWith(startDay: null, endDay: endDay)); } else { - emit( - state.copyWith( - endDay: endDay, - ), - ); + emit(state.copyWith(endDay: endDay)); _updateDateData(date: state.startDay!.date, endDate: endDay.date); } }, - setEndTime: (String endTime) async { + setEndTime: (String? endTime) async { emit(state.copyWith(endTimeStr: endTime)); await _updateDateData(endTimeStr: endTime); }, - setDateFormat: (dateFormat) async { - await _updateTypeOption(emit, dateFormat: dateFormat); - }, - setTimeFormat: (timeFormat) async { - await _updateTypeOption(emit, timeFormat: timeFormat); - }, + setDateFormat: (DateFormatPB dateFormat) async => + await _updateTypeOption(emit, dateFormat: dateFormat), + setTimeFormat: (TimeFormatPB timeFormat) async => + await _updateTypeOption(emit, timeFormat: timeFormat), clearDate: () async { + // Remove reminder if neccessary + if (state.reminderId != null) { + _reminderBloc + .add(ReminderEvent.remove(reminderId: state.reminderId!)); + } + await _clearDate(); }, + setReminderOption: (ReminderOption option) async { + if (state.reminderId?.isEmpty ?? + true && + state.dateTime != null && + option != ReminderOption.none) { + // New Reminder + final reminderId = nanoid(); + await _updateDateData(reminderId: reminderId); + + emit(state.copyWith(reminderOption: option)); + } else if (option == ReminderOption.none && + (state.reminderId?.isNotEmpty ?? false)) { + // Remove reminder + _reminderBloc + .add(ReminderEvent.remove(reminderId: state.reminderId!)); + await _updateDateData(reminderId: ""); + emit(state.copyWith(reminderOption: option)); + } else if (state.dateTime != null && + (state.reminderId?.isNotEmpty ?? false)) { + // Update reminder + _reminderBloc.add( + ReminderEvent.update( + ReminderUpdate( + id: state.reminderId!, + scheduledAt: state.dateTime!.subtract(option.time), + ), + ), + ); + } + }, + // Empty String signifies no reminder + removeReminder: () async => await _updateDateData(reminderId: ""), ); }, ); @@ -182,6 +226,7 @@ class DateCellEditorBloc String? endTimeStr, bool? includeTime, bool? isRange, + String? reminderId, }) async { // make sure that not both date and time are updated at the same time assert( @@ -191,21 +236,15 @@ class DateCellEditorBloc // if not updating the time, use the old time in the state final String? newTime = timeStr ?? state.timeStr; - DateTime? newDate; - if (timeStr != null && timeStr.isNotEmpty) { - newDate = state.dateTime ?? DateTime.now(); - } else { - newDate = _utcToLocalAndAddCurrentTime(date); - } + final DateTime? newDate = timeStr != null && timeStr.isNotEmpty + ? state.dateTime ?? DateTime.now() + : _utcToLocalAndAddCurrentTime(date); // if not updating the time, use the old time in the state final String? newEndTime = endTimeStr ?? state.endTimeStr; - DateTime? newEndDate; - if (endTimeStr != null && endTimeStr.isNotEmpty) { - newEndDate = state.endDateTime ?? DateTime.now(); - } else { - newEndDate = _utcToLocalAndAddCurrentTime(endDate); - } + final DateTime? newEndDate = endTimeStr != null && endTimeStr.isNotEmpty + ? state.endDateTime ?? DateTime.now() + : _utcToLocalAndAddCurrentTime(endDate); final result = await _dateCellBackendService.update( date: newDate, @@ -214,15 +253,14 @@ class DateCellEditorBloc endTime: newEndTime, includeTime: includeTime ?? state.includeTime, isRange: isRange ?? state.isRange, + reminderId: reminderId ?? state.reminderId, ); result.fold( (_) { if (!isClosed && (state.parseEndTimeError != null || state.parseTimeError != null)) { - add( - const DateCellEditorEvent.didReceiveTimeFormatError(null, null), - ); + add(const DateCellEditorEvent.didReceiveTimeFormatError(null, null)); } }, (err) { @@ -231,10 +269,12 @@ class DateCellEditorBloc if (isClosed) { return; } + // to determine which textfield should show error final (startError, endError) = newDate != null ? (timeFormatPrompt(err), null) : (null, timeFormatPrompt(err)); + add( DateCellEditorEvent.didReceiveTimeFormatError( startError, @@ -253,13 +293,9 @@ class DateCellEditorBloc final result = await _dateCellBackendService.clear(); result.fold( (_) { - if (isClosed) { - return; + if (!isClosed) { + add(const DateCellEditorEvent.didReceiveTimeFormatError(null, null)); } - - add( - const DateCellEditorEvent.didReceiveTimeFormatError(null, null), - ); }, (err) => Log.error(err), ); @@ -304,11 +340,11 @@ class DateCellEditorBloc void _startListening() { _onCellChangedFn = cellController.startListening( - onCellChanged: ((cell) { + onCellChanged: (cell) { if (!isClosed) { add(DateCellEditorEvent.didReceiveCellUpdate(cell)); } - }), + }, ); } @@ -335,7 +371,7 @@ class DateCellEditorBloc ); result.fold( - (l) => emit( + (_) => emit( state.copyWith( dateTypeOptionPB: newDateTypeOption, timeHintText: _timeHintText(newDateTypeOption), @@ -355,6 +391,7 @@ class DateCellEditorEvent with _$DateCellEditorEvent { const factory DateCellEditorEvent.didReceiveCellUpdate( DateCellDataPB? data, ) = _DidReceiveCellUpdate; + const factory DateCellEditorEvent.didReceiveTimeFormatError( String? parseTimeError, String? parseEndTimeError, @@ -362,27 +399,41 @@ class DateCellEditorEvent with _$DateCellEditorEvent { // date cell data is modified const factory DateCellEditorEvent.selectDay(DateTime day) = _SelectDay; + const factory DateCellEditorEvent.selectDateRange( DateTime? start, DateTime? end, ) = _SelectDateRange; + const factory DateCellEditorEvent.setStartDay( DateTime startDay, ) = _SetStartDay; + const factory DateCellEditorEvent.setEndDay( DateTime endDay, ) = _SetEndDay; - const factory DateCellEditorEvent.setTime(String time) = _Time; - const factory DateCellEditorEvent.setEndTime(String endTime) = _EndTime; + + const factory DateCellEditorEvent.setTime(String time) = _SetTime; + + const factory DateCellEditorEvent.setEndTime(String endTime) = _SetEndTime; + const factory DateCellEditorEvent.setIncludeTime(bool includeTime) = _IncludeTime; - const factory DateCellEditorEvent.setIsRange(bool isRange) = _IsRange; + + const factory DateCellEditorEvent.setIsRange(bool isRange) = _SetIsRange; + + const factory DateCellEditorEvent.setReminderOption({ + required ReminderOption option, + }) = _SetReminderOption; + + const factory DateCellEditorEvent.removeReminder() = _RemoveReminder; // date field type options are modified const factory DateCellEditorEvent.setTimeFormat(TimeFormatPB timeFormat) = - _TimeFormat; + _SetTimeFormat; + const factory DateCellEditorEvent.setDateFormat(DateFormatPB dateFormat) = - _DateFormat; + _SetDateFormat; const factory DateCellEditorEvent.clearDate() = _ClearDate; } @@ -406,17 +457,36 @@ class DateCellEditorState with _$DateCellEditorState { required bool isRange, required String? dateStr, required String? endDateStr, + required String? reminderId, // error and hint text required String? parseTimeError, required String? parseEndTimeError, required String timeHintText, + @Default(ReminderOption.none) ReminderOption reminderOption, }) = _DateCellEditorState; - factory DateCellEditorState.initial(DateCellController controller) { + factory DateCellEditorState.initial( + DateCellController controller, + ReminderBloc reminderBloc, + ) { final typeOption = controller.getTypeOption(DateTypeOptionDataParser()); final cellData = controller.getCellData(); final dateCellData = _dateDataFromCellData(cellData); + + ReminderOption reminderOption = ReminderOption.none; + if ((dateCellData.reminderId?.isNotEmpty ?? false) && + dateCellData.dateTime != null) { + final reminder = reminderBloc.state.reminders + .firstWhereOrNull((r) => r.id == dateCellData.reminderId); + if (reminder != null) { + reminderOption = ReminderOption.fromDateDifference( + dateCellData.dateTime!, + reminder.scheduledAt.toDateTime(), + ); + } + } + return DateCellEditorState( dateTypeOptionPB: typeOption, startDay: dateCellData.isRange ? dateCellData.dateTime : null, @@ -432,6 +502,8 @@ class DateCellEditorState with _$DateCellEditorState { parseTimeError: null, parseEndTimeError: null, timeHintText: _timeHintText(typeOption), + reminderId: dateCellData.reminderId, + reminderOption: reminderOption, ); } } @@ -462,6 +534,7 @@ _DateCellData _dateDataFromCellData( isRange: false, dateStr: null, endDateStr: null, + reminderId: null, ); } @@ -481,12 +554,14 @@ _DateCellData _dateDataFromCellData( endTimeStr = cellData.endTime; } } + final bool includeTime = cellData.includeTime; final bool isRange = cellData.isRange; if (cellData.isRange) { endDateStr = cellData.endDate; } + final String dateStr = cellData.date; return _DateCellData( @@ -498,6 +573,7 @@ _DateCellData _dateDataFromCellData( isRange: isRange, dateStr: dateStr, endDateStr: endDateStr, + reminderId: cellData.reminderId, ); } @@ -510,6 +586,7 @@ class _DateCellData { final bool isRange; final String? dateStr; final String? endDateStr; + final String? reminderId; _DateCellData({ required this.dateTime, @@ -520,5 +597,6 @@ class _DateCellData { required this.isRange, required this.dateStr, required this.endDateStr, + required this.reminderId, }); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_editor.dart index 7b4f01362f..597c413128 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/date_editor.dart @@ -1,7 +1,12 @@ -import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'date_cell_editor_bloc.dart'; @@ -31,20 +36,28 @@ class _DateCellEditor extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DateCellEditorBloc( - cellController: widget.cellController, - )..add(const DateCellEditorEvent.initial()), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => DateCellEditorBloc( + reminderBloc: getIt(), + cellController: widget.cellController, + )..add(const DateCellEditorEvent.initial()), + ), + ], child: BlocBuilder( builder: (context, state) { - final bloc = context.read(); + final dateCellBloc = context.read(); return AppFlowyDatePicker( includeTime: state.includeTime, + rebuildOnDaySelected: false, onIncludeTimeChanged: (value) => - bloc.add(DateCellEditorEvent.setIncludeTime(!value)), + dateCellBloc.add(DateCellEditorEvent.setIncludeTime(!value)), isRange: state.isRange, + startDay: state.isRange ? state.startDay : null, + endDay: state.isRange ? state.endDay : null, onIsRangeChanged: (value) => - bloc.add(DateCellEditorEvent.setIsRange(!value)), + dateCellBloc.add(DateCellEditorEvent.setIsRange(!value)), dateFormat: state.dateTypeOptionPB.dateFormat, timeFormat: state.dateTypeOptionPB.timeFormat, selectedDay: state.dateTime, @@ -54,28 +67,36 @@ class _DateCellEditor extends State { parseEndTimeError: state.parseEndTimeError, parseTimeError: state.parseTimeError, popoverMutex: popoverMutex, - onStartTimeSubmitted: (timeStr) { - bloc.add(DateCellEditorEvent.setTime(timeStr)); - }, - onEndTimeSubmitted: (timeStr) { - bloc.add(DateCellEditorEvent.setEndTime(timeStr)); - }, - onDaySelected: (selectedDay, _) { - bloc.add(DateCellEditorEvent.selectDay(selectedDay)); - }, - onRangeSelected: (start, end, _) { - bloc.add(DateCellEditorEvent.selectDateRange(start, end)); - }, - allowFormatChanges: true, - onDateFormatChanged: (format) { - bloc.add(DateCellEditorEvent.setDateFormat(format)); - }, - onTimeFormatChanged: (format) { - bloc.add(DateCellEditorEvent.setTimeFormat(format)); - }, - onClearDate: () { - bloc.add(const DateCellEditorEvent.clearDate()); - }, + onReminderSelected: (option) => dateCellBloc + .add(DateCellEditorEvent.setReminderOption(option: option)), + selectedReminderOption: state.reminderOption, + options: [ + OptionGroup( + options: [ + DateTypeOptionButton( + popoverMutex: popoverMutex, + dateFormat: state.dateTypeOptionPB.dateFormat, + timeFormat: state.dateTypeOptionPB.timeFormat, + onDateFormatChanged: (format) => dateCellBloc + .add(DateCellEditorEvent.setDateFormat(format)), + onTimeFormatChanged: (format) => dateCellBloc + .add(DateCellEditorEvent.setTimeFormat(format)), + ), + ClearDateButton( + onClearDate: () => + dateCellBloc.add(const DateCellEditorEvent.clearDate()), + ), + ], + ), + ], + onStartTimeSubmitted: (timeStr) => + dateCellBloc.add(DateCellEditorEvent.setTime(timeStr)), + onEndTimeSubmitted: (timeStr) => + dateCellBloc.add(DateCellEditorEvent.setEndTime(timeStr)), + onDaySelected: (selectedDay, _) => + dateCellBloc.add(DateCellEditorEvent.selectDay(selectedDay)), + onRangeSelected: (start, end, _) => dateCellBloc + .add(DateCellEditorEvent.selectDateRange(start, end)), ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/mobile_date_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/mobile_date_editor.dart deleted file mode 100644 index dc41190171..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/date_cell/mobile_date_editor.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:table_calendar/table_calendar.dart'; - -import 'date_cell_editor_bloc.dart'; - -class MobileDatePicker extends StatefulWidget { - const MobileDatePicker({ - super.key, - }); - - @override - State createState() => _MobileDatePickerState(); -} - -class _MobileDatePickerState extends State { - DateTime _focusedDay = DateTime.now(); - CalendarFormat _calendarFormat = CalendarFormat.month; - - final ValueNotifier<(DateTime, dynamic)> _currentDateNotifier = ValueNotifier( - (DateTime.now(), null), - ); - PageController? _pageController; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - const VSpace(8.0), - _buildHeader(context), - const VSpace(8.0), - _buildCalendar(context), - const VSpace(16.0), - ], - ); - } - - Widget _buildCalendar(BuildContext context) { - const selectedColor = Color(0xFF00BCF0); - final textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith(); - const boxDecoration = BoxDecoration( - shape: BoxShape.circle, - ); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: BlocBuilder( - builder: (context, state) { - return TableCalendar( - firstDay: kFirstDay, - lastDay: kLastDay, - focusedDay: _focusedDay, - rowHeight: 48.0, - calendarFormat: _calendarFormat, - daysOfWeekHeight: 48.0, - rangeSelectionMode: state.isRange - ? RangeSelectionMode.enforced - : RangeSelectionMode.disabled, - rangeStartDay: state.isRange ? state.startDay : null, - rangeEndDay: state.isRange ? state.endDay : null, - onCalendarCreated: (pageController) => - _pageController = pageController, - headerVisible: false, - availableGestures: AvailableGestures.horizontalSwipe, - calendarStyle: CalendarStyle( - cellMargin: const EdgeInsets.all(3.5), - defaultDecoration: boxDecoration, - selectedDecoration: boxDecoration.copyWith( - color: selectedColor, - ), - todayDecoration: boxDecoration.copyWith( - color: Colors.transparent, - border: Border.all(color: selectedColor), - ), - weekendDecoration: boxDecoration, - outsideDecoration: boxDecoration, - rangeStartDecoration: boxDecoration.copyWith( - color: selectedColor, - ), - rangeEndDecoration: boxDecoration.copyWith( - color: selectedColor, - ), - defaultTextStyle: textStyle, - weekendTextStyle: textStyle, - selectedTextStyle: textStyle.copyWith( - color: Theme.of(context).colorScheme.surface, - ), - rangeStartTextStyle: textStyle.copyWith( - color: Theme.of(context).colorScheme.surface, - ), - rangeEndTextStyle: textStyle.copyWith( - color: Theme.of(context).colorScheme.surface, - ), - todayTextStyle: textStyle, - outsideTextStyle: textStyle.copyWith( - color: Theme.of(context).disabledColor, - ), - rangeHighlightColor: - Theme.of(context).colorScheme.secondaryContainer, - ), - calendarBuilders: CalendarBuilders( - dowBuilder: (context, day) { - final locale = context.locale.toLanguageTag(); - final label = DateFormat.E(locale).format(day).substring(0, 2); - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Center( - child: Text( - label, - style: textStyle.copyWith( - color: Theme.of(context).hintColor, - fontSize: 14.0, - ), - ), - ), - ); - }, - ), - selectedDayPredicate: (day) => - state.isRange ? false : isSameDay(state.dateTime, day), - onDaySelected: (selectedDay, focusedDay) { - context.read().add( - DateCellEditorEvent.selectDay(selectedDay), - ); - }, - onRangeSelected: (start, end, focusedDay) { - context.read().add( - DateCellEditorEvent.selectDateRange(start, end), - ); - }, - onFormatChanged: (calendarFormat) => setState(() { - _calendarFormat = calendarFormat; - }), - onPageChanged: (focusedDay) => setState(() { - _focusedDay = focusedDay; - _currentDateNotifier.value = (focusedDay, null); - }), - ); - }, - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Row( - children: [ - const HSpace(16.0), - ValueListenableBuilder( - valueListenable: _currentDateNotifier, - builder: (_, value, ___) { - return FlowyText( - DateFormat.yMMMM(value.$2).format(value.$1), - ); - }, - ), - const Spacer(), - FlowyButton( - useIntrinsicWidth: true, - text: FlowySvg( - FlowySvgs.arrow_left_s, - color: Theme.of(context).iconTheme.color, - size: const Size.square(24.0), - ), - onTap: () => _pageController?.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ), - ), - const HSpace(24.0), - FlowyButton( - useIntrinsicWidth: true, - text: FlowySvg( - FlowySvgs.arrow_right_s, - color: Theme.of(context).iconTheme.color, - size: const Size.square(24.0), - ), - onTap: () => _pageController?.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ), - ), - const HSpace(8.0), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index 7636e2d7d4..752cd0fdbe 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -1,5 +1,7 @@ library document_plugin; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/document_page.dart'; @@ -12,9 +14,9 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.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 DocumentPluginBuilder extends PluginBuilder { @@ -22,9 +24,9 @@ class DocumentPluginBuilder extends PluginBuilder { Plugin build(dynamic data) { if (data is ViewPB) { return DocumentPlugin(pluginType: pluginType, view: data); - } else { - throw FlowyPluginException.invalidData; } + + throw FlowyPluginException.invalidData; } @override @@ -41,26 +43,28 @@ class DocumentPluginBuilder extends PluginBuilder { } class DocumentPlugin extends Plugin { + DocumentPlugin({ + Key? key, + required ViewPB view, + required PluginType pluginType, + bool listenOnViewChanged = false, + this.initialSelection, + }) : notifier = ViewPluginNotifier(view: view) { + _pluginType = pluginType; + } + late PluginType _pluginType; @override final ViewPluginNotifier notifier; - DocumentPlugin({ - required PluginType pluginType, - required ViewPB view, - bool listenOnViewChanged = false, - Key? key, - }) : notifier = ViewPluginNotifier(view: view) { - _pluginType = pluginType; - } + final Selection? initialSelection; @override - PluginWidgetBuilder get widgetBuilder { - return DocumentPluginWidgetBuilder( - notifier: notifier, - ); - } + PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder( + notifier: notifier, + initialSelection: initialSelection, + ); @override PluginType get pluginType => _pluginType; @@ -71,14 +75,16 @@ class DocumentPlugin extends Plugin { class DocumentPluginWidgetBuilder extends PluginWidgetBuilder with NavigationItem { + DocumentPluginWidgetBuilder({ + Key? key, + required this.notifier, + this.initialSelection, + }); + final ViewPluginNotifier notifier; ViewPB get view => notifier.view; int? deletedViewIndex; - - DocumentPluginWidgetBuilder({ - required this.notifier, - Key? key, - }); + final Selection? initialSelection; @override EdgeInsets get contentPadding => EdgeInsets.zero; @@ -86,21 +92,23 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder @override Widget buildWidget({PluginContext? context, required bool shrinkWrap}) { notifier.isDeleted.addListener(() { - notifier.isDeleted.value.fold(() => null, (deletedView) { - if (deletedView.hasIndex()) { - deletedViewIndex = deletedView.index; - } - }); + notifier.isDeleted.value.fold( + () => null, + (deletedView) { + if (deletedView.hasIndex()) { + deletedViewIndex = deletedView.index; + } + }, + ); }); return BlocBuilder( - builder: (_, state) { - return DocumentPage( - view: view, - onDeleted: () => context?.onDeleted(view, deletedViewIndex), - key: ValueKey(view.id), - ); - }, + builder: (_, state) => DocumentPage( + key: ValueKey(view.id), + view: view, + onDeleted: () => context?.onDeleted(view, deletedViewIndex), + initialSelection: initialSelection, + ), ); } @@ -114,10 +122,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder Widget? get rightBarItem { return Row( children: [ - DocumentShareButton( - key: ValueKey(view.id), - view: view, - ), + DocumentShareButton(key: ValueKey(view.id), view: view), const HSpace(4), const DocumentMoreButton(), ], diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index b91bb6b770..85ecb54c61 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; @@ -13,7 +15,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; enum EditorNotificationType { @@ -35,12 +36,14 @@ class EditorNotification extends Notification { class DocumentPage extends StatefulWidget { const DocumentPage({ super.key, - required this.onDeleted, required this.view, + required this.onDeleted, + this.initialSelection, }); - final VoidCallback onDeleted; final ViewPB view; + final VoidCallback onDeleted; + final Selection? initialSelection; @override State createState() => _DocumentPageState(); @@ -88,10 +91,8 @@ class _DocumentPageState extends State { return BlocListener( listener: _onNotificationAction, - child: _buildEditorPage( - context, - state, - ), + listenWhen: (_, curr) => curr.action != null, + child: _buildEditorPage(context, state), ); }, ), @@ -107,6 +108,7 @@ class _DocumentPageState extends State { padding: EditorStyleCustomizer.documentPadding, ), header: _buildCoverAndIcon(context, state.editorState!), + initialSelection: widget.initialSelection, ); return Column( @@ -167,14 +169,12 @@ class _DocumentPageState extends State { NotificationActionState state, ) async { if (state.action != null && state.action!.type == ActionType.jumpToBlock) { - final path = state.action?.arguments?[ActionArgumentKeys.nodePath.name]; + final path = state.action?.arguments?[ActionArgumentKeys.nodePath]; final editorState = context.read().state.editorState; if (editorState != null && widget.view.id == state.action?.objectId) { editorState.updateSelectionWithReason( - Selection.collapsed( - Position(path: [path]), - ), + Selection.collapsed(Position(path: [path])), reason: SelectionUpdateReason.transaction, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index d6c763a0dd..5ddadecec0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,3 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; @@ -12,9 +15,6 @@ import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.d import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/workspace/application/notifications/notification_action.dart'; -import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; @@ -22,8 +22,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.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/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; final List commandShortcutEvents = [ @@ -52,6 +50,7 @@ class AppFlowyEditorPage extends StatefulWidget { required this.styleCustomizer, this.showParagraphPlaceholder, this.placeholderText, + this.initialSelection, }); final Widget? header; @@ -63,6 +62,10 @@ class AppFlowyEditorPage extends StatefulWidget { final ShowPlaceholder? showParagraphPlaceholder; final String Function(Node)? placeholderText; + /// Used to provide an initial selection on Page-load + /// + final Selection? initialSelection; + @override State createState() => _AppFlowyEditorPageState(); } @@ -97,13 +100,8 @@ class _AppFlowyEditorPageState extends State { smartEditItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, ...headingItems - ..forEach( - (e) => e.isActive = onlyShowInSingleSelectionAndTextType, - ), - ...markdownFormatItems - ..forEach( - (e) => e.isActive = showInAnyTextType, - ), + ..forEach((e) => e.isActive = onlyShowInSingleSelectionAndTextType), + ...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType), quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, bulletedListItem ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, @@ -177,14 +175,11 @@ class _AppFlowyEditorPageState extends State { late final EditorScrollController editorScrollController; - Future showSlashMenu(editorState) async { - final result = await customSlashCommand( - slashMenuItems, - shouldInsertSlash: false, - style: styleCustomizer.selectionMenuStyleBuilder(), - ).handler(editorState); - return result; - } + Future showSlashMenu(editorState) async => await customSlashCommand( + slashMenuItems, + shouldInsertSlash: false, + style: styleCustomizer.selectionMenuStyleBuilder(), + ).handler(editorState); @override void initState() { @@ -216,6 +211,15 @@ class _AppFlowyEditorPageState extends State { // customize the dynamic theme color _customizeBlockComponentBackgroundColorDecorator(); + + if (widget.initialSelection != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.editorState.updateSelectionWithReason( + widget.initialSelection, + reason: SelectionUpdateReason.transaction, + ); + }); + } } @override @@ -275,7 +279,6 @@ class _AppFlowyEditorPageState extends State { ); final editorState = widget.editorState; - _setInitialSelection(editorScrollController); if (PlatformExtension.isMobile) { return AppFlowyMobileToolbar( @@ -337,21 +340,6 @@ class _AppFlowyEditorPageState extends State { ); } - void _setInitialSelection(EditorScrollController scrollController) { - final action = getIt().state.action; - final viewId = action?.objectId; - final nodePath = - action?.arguments?[ActionArgumentKeys.nodePath.name] as int?; - - if (viewId != null && viewId == documentBloc.view.id && nodePath != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - scrollController.itemScrollController.jumpTo(index: nodePath); - widget.editorState.selection = - Selection.collapsed(Position(path: [nodePath])); - }); - } - } - List _customSlashMenuItems() { final items = [...standardSelectionMenuItems]; final imageItem = items.firstWhereOrNull( @@ -387,9 +375,7 @@ class _AppFlowyEditorPageState extends State { if (widget.editorState.document.isEmpty) { return ( true, - Selection.collapsed( - Position(path: [0], offset: 0), - ), + Selection.collapsed(Position(path: [0], offset: 0)), ); } final nodes = widget.editorState.document.root.children @@ -399,9 +385,7 @@ class _AppFlowyEditorPageState extends State { if (isAllEmpty) { return ( true, - Selection.collapsed( - Position(path: nodes.first.path, offset: 0), - ) + Selection.collapsed(Position(path: nodes.first.path, offset: 0)) ); } return const (false, null); @@ -421,9 +405,7 @@ class _AppFlowyEditorPageState extends State { void _setRTLToolbarItems(bool isRTL) { final textDirectionItemIds = textDirectionItems.map((e) => e.id); // clear all the text direction items - toolbarItems.removeWhere( - (item) => textDirectionItemIds.contains(item.id), - ); + toolbarItems.removeWhere((item) => textDirectionItemIds.contains(item.id)); // only show the rtl item when the layout direction is ltr. if (isRTL) { toolbarItems.addAll(textDirectionItems); @@ -441,20 +423,19 @@ class _AppFlowyEditorPageState extends State { style, showReplaceMenu, onDismiss, - ) { - return Material( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(4), - ), - child: FindAndReplaceMenuWidget( - editorState: editorState, - onDismiss: onDismiss, - ), + ) => + Material( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), ), - ); - }, + child: FindAndReplaceMenuWidget( + editorState: editorState, + onDismiss: onDismiss, + ), + ), + ), ), ); } @@ -468,6 +449,7 @@ class _AppFlowyEditorPageState extends State { if (tintColor != null) { return tintColor.color(context); } + final themeColor = themeBackgroundColors[colorString]; if (themeColor != null) { return themeColor.color(context); @@ -488,9 +470,7 @@ class _AppFlowyEditorPageState extends State { }; } - void _initEditorL10n() { - AppFlowyEditorL10n.current = EditorI18n(); - } + void _initEditorL10n() => AppFlowyEditorL10n.current = EditorI18n(); Future _focusOnLastEmptyParagraph() async { final editorState = widget.editorState; @@ -518,6 +498,7 @@ bool showInAnyTextType(EditorState editorState) { if (selection == null) { return false; } + final nodes = editorState.getNodesInSelection(selection); return nodes.any( (node) => toolbarItemWhiteList.contains(node.type), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart index e23f5d929b..18afae962e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -1,39 +1,56 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:provider/provider.dart'; enum MentionType { page, - date, - reminder; + reminder, + date; - static MentionType fromString(String value) { - switch (value) { - case 'page': - return page; - case 'date': - return date; - case 'reminder': - return reminder; - default: - throw UnimplementedError(); - } - } + static MentionType fromString(String value) => switch (value) { + 'page' => page, + 'date' => date, + // Backwards compatibility + 'reminder' => date, + _ => throw UnimplementedError(), + }; +} + +Node dateMentionNode() { + return paragraphNode( + delta: Delta( + operations: [ + TextInsert( + '\$', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: DateTime.now().toIso8601String(), + }, + }, + ), + ], + ), + ); } class MentionBlockKeys { const MentionBlockKeys._(); - static const uid = 'uid'; // UniqueID + static const reminderId = 'reminder_id'; // ReminderID static const mention = 'mention'; static const type = 'type'; // MentionType, String static const pageId = 'page_id'; // Related to Reminder and Date blocks - static const date = 'date'; + static const date = 'date'; // Start Date static const includeTime = 'include_time'; + static const reminderOption = 'reminder_option'; } class MentionBlock extends StatelessWidget { @@ -62,21 +79,21 @@ class MentionBlock extends StatelessWidget { pageId: pageId, textStyle: textStyle, ); - case MentionType.reminder: case MentionType.date: final String date = mention[MentionBlockKeys.date]; - final BuildContext editorContext = - context.read().document.root.context!; + final editorState = context.read(); + final reminderOption = ReminderOption.values.firstWhereOrNull( + (o) => o.name == mention[MentionBlockKeys.reminderOption], + ); + return MentionDateBlock( key: ValueKey(date), - editorContext: editorContext, + editorState: editorState, date: date, node: node, index: index, - isReminder: type == MentionType.reminder, - reminderId: type == MentionType.reminder - ? mention[MentionBlockKeys.uid] - : null, + reminderId: mention[MentionBlockKeys.reminderId], + reminderOption: reminderOption, includeTime: mention[MentionBlockKeys.includeTime] ?? false, ); default: diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart index aa808f5ead..ebfce9c443 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -1,45 +1,57 @@ -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:calendar_view/calendar_view.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:calendar_view/calendar_view.dart'; import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nanoid/non_secure.dart'; class MentionDateBlock extends StatefulWidget { const MentionDateBlock({ super.key, - required this.editorContext, + required this.editorState, required this.date, required this.index, required this.node, - this.isReminder = false, this.reminderId, + this.reminderOption, this.includeTime = false, }); - final BuildContext editorContext; + final EditorState editorState; final String date; final int index; final Node node; - final bool isReminder; - /// If [isReminder] is true, then this must not be /// null or empty final String? reminderId; + final ReminderOption? reminderOption; + final bool includeTime; @override @@ -47,14 +59,13 @@ class MentionDateBlock extends StatefulWidget { } class _MentionDateBlockState extends State { - late bool includeTime = widget.includeTime; final PopoverMutex mutex = PopoverMutex(); + late bool _includeTime = widget.includeTime; + late DateTime? parsedDate = DateTime.tryParse(widget.date); + @override Widget build(BuildContext context) { - final editorState = context.read(); - - DateTime? parsedDate = DateTime.tryParse(widget.date); if (parsedDate == null) { return const SizedBox.shrink(); } @@ -77,10 +88,9 @@ class _MentionDateBlockState extends State { builder: (context, state) { final reminder = state.reminders .firstWhereOrNull((r) => r.id == widget.reminderId); - final noReminder = reminder == null && widget.isReminder; final formattedDate = appearance.dateFormat - .formatDate(parsedDate!, includeTime, appearance.timeFormat); + .formatDate(parsedDate!, _includeTime, appearance.timeFormat); final timeStr = parsedDate != null ? _timeFromDate(parsedDate!, appearance.timeFormat) @@ -90,28 +100,25 @@ class _MentionDateBlockState extends State { focusedDay: parsedDate, popoverMutex: mutex, selectedDay: parsedDate, - firstDay: widget.isReminder - ? noReminder - ? parsedDate - : DateTime.now() - : null, - lastDay: noReminder ? parsedDate : null, timeStr: timeStr, - includeTime: includeTime, - enableRanges: false, + includeTime: _includeTime, dateFormat: appearance.dateFormat, timeFormat: appearance.timeFormat, + enableRanges: true, + selectedReminderOption: widget.reminderOption, onIncludeTimeChanged: (includeTime) { - this.includeTime = includeTime; - _updateBlock(parsedDate!.withoutTime, includeTime); + _includeTime = includeTime; - // We can remove time from the date/reminder - // block when toggled off. - if (widget.isReminder) { - _updateScheduledAt( - reminderId: widget.reminderId!, - selectedDay: - includeTime ? parsedDate! : parsedDate!.withoutTime, + if (![null, ReminderOption.none] + .contains(widget.reminderOption)) { + _updateReminder( + widget.reminderOption!, + reminder, + includeTime, + ); + } else { + _updateBlock( + parsedDate!.withoutTime, includeTime: includeTime, ); } @@ -121,37 +128,100 @@ class _MentionDateBlockState extends State { parsedDate = parsedDate!.withoutTime .add(Duration(hours: parsed.hour, minutes: parsed.minute)); - _updateBlock(parsedDate!, includeTime); - - if (widget.isReminder && - widget.date != parsedDate!.toIso8601String()) { - _updateScheduledAt( - reminderId: widget.reminderId!, - selectedDay: parsedDate!, + if (![null, ReminderOption.none] + .contains(widget.reminderOption)) { + _updateReminder( + widget.reminderOption!, + reminder, + _includeTime, ); + } else { + _updateBlock(parsedDate!, includeTime: _includeTime); } }, onDaySelected: (selectedDay, focusedDay) { parsedDate = selectedDay; - _updateBlock(selectedDay, includeTime); - if (widget.isReminder && - widget.date != selectedDay.toIso8601String()) { - _updateScheduledAt( - reminderId: widget.reminderId!, - selectedDay: selectedDay, + if (![null, ReminderOption.none] + .contains(widget.reminderOption)) { + _updateReminder( + widget.reminderOption!, + reminder, + _includeTime, ); + } else { + _updateBlock(selectedDay, includeTime: _includeTime); } }, + onReminderSelected: (reminderOption) => + _updateReminder(reminderOption, reminder), ); return GestureDetector( - onTapDown: editorState.editable - ? (details) => DatePickerMenu( - context: context, - editorState: context.read(), - ).show(details.globalPosition, options: options) - : null, + onTapDown: (details) { + if (widget.editorState.editable) { + if (PlatformExtension.isMobile) { + showMobileBottomSheet( + context, + resizeToAvoidBottomInset: false, + builder: (_) => DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.7, + minChildSize: 0.4, + snapSizes: const [0.4, 0.7, 1.0], + builder: (_, controller) => Material( + color: + Theme.of(context).colorScheme.secondaryContainer, + child: ListView( + controller: controller, + children: [ + ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: const Center(child: DragHandler()), + ), + const MobileDateHeader(), + MobileAppFlowyDatePicker( + selectedDay: parsedDate, + timeStr: timeStr, + dateStr: parsedDate != null + ? options.dateFormat + .formatDate(parsedDate!, _includeTime) + : null, + includeTime: options.includeTime, + use24hFormat: options.timeFormat == + UserTimeFormatPB.TwentyFourHour, + rebuildOnDaySelected: true, + rebuildOnTimeChanged: true, + selectedReminderOption: widget.reminderOption, + onDaySelected: options.onDaySelected, + onStartTimeChanged: (time) => options + .onStartTimeChanged + ?.call(time ?? ""), + onIncludeTimeChanged: + options.onIncludeTimeChanged, + liveDateFormatter: (selected) => + appearance.dateFormat.formatDate( + selected, + false, + appearance.timeFormat, + ), + onReminderSelected: (option) => + _updateReminder(option, reminder), + ), + ], + ), + ), + ), + ); + } else { + DatePickerMenu( + context: context, + editorState: widget.editorState, + ).show(details.globalPosition, options: options); + } + } + }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: MouseRegion( @@ -160,11 +230,11 @@ class _MentionDateBlockState extends State { mainAxisSize: MainAxisSize.min, children: [ FlowySvg( - widget.isReminder + widget.reminderId != null ? FlowySvgs.clock_alarm_s : FlowySvgs.date_s, size: const Size.square(18.0), - color: widget.isReminder && reminder?.isAck == true + color: reminder?.isAck == true ? Theme.of(context).colorScheme.error : null, ), @@ -172,7 +242,7 @@ class _MentionDateBlockState extends State { FlowyText( formattedDate, fontSize: fontSize, - color: widget.isReminder && reminder?.isAck == true + color: reminder?.isAck == true ? Theme.of(context).colorScheme.error : null, ), @@ -191,11 +261,16 @@ class _MentionDateBlockState extends State { final twelveHourFormat = DateFormat('HH:mm a'); final twentyFourHourFormat = DateFormat('HH:mm'); - if (timeFormat == TimeFormatPB.TwelveHour) { - return twelveHourFormat.parse(timeStr); - } + try { + if (timeFormat == TimeFormatPB.TwelveHour) { + return twelveHourFormat.parse(timeStr); + } - return twentyFourHourFormat.parse(timeStr); + return twentyFourHourFormat.parse(timeStr); + } on FormatException { + Log.error("failed to parse time string ($timeStr)"); + return DateTime.now(); + } } String _timeFromDate(DateTime date, UserTimeFormatPB timeFormat) { @@ -210,43 +285,94 @@ class _MentionDateBlockState extends State { } void _updateBlock( - DateTime date, [ + DateTime date, { bool includeTime = false, - ]) { - final editorState = widget.editorContext.read(); - final transaction = editorState.transaction + String? reminderId, + ReminderOption? reminderOption, + }) { + final rId = reminderId ?? + (reminderOption == ReminderOption.none ? null : widget.reminderId); + + final transaction = widget.editorState.transaction ..formatText(widget.node, widget.index, 1, { MentionBlockKeys.mention: { - MentionBlockKeys.type: widget.isReminder - ? MentionType.reminder.name - : MentionType.date.name, + MentionBlockKeys.type: MentionType.date.name, MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.uid: widget.reminderId, + MentionBlockKeys.reminderId: rId, MentionBlockKeys.includeTime: includeTime, + MentionBlockKeys.reminderOption: + reminderOption?.name ?? widget.reminderOption?.name, }, }); - editorState.apply(transaction, withUpdateSelection: false); + widget.editorState.apply(transaction, withUpdateSelection: false); // Length of rendered block changes, this synchronizes // the cursor with the new block render - editorState.updateSelectionWithReason( - editorState.selection, + widget.editorState.updateSelectionWithReason( + widget.editorState.selection, reason: SelectionUpdateReason.transaction, ); } - void _updateScheduledAt({ - required String reminderId, - required DateTime selectedDay, - bool? includeTime, - }) { - widget.editorContext.read().add( - ReminderEvent.update( - ReminderUpdate( + void _updateReminder( + ReminderOption reminderOption, + ReminderPB? reminder, [ + bool includeTime = false, + ]) { + final rootContext = widget.editorState.document.root.context; + if (parsedDate == null || rootContext == null) { + return; + } + + if (widget.reminderId != null) { + _updateBlock( + parsedDate!, + includeTime: includeTime, + reminderOption: reminderOption, + ); + + if (ReminderOption.none == reminderOption && reminder != null) { + // Delete existing reminder + return rootContext + .read() + .add(ReminderEvent.remove(reminderId: reminder.id)); + } + + // Update existing reminder + return rootContext.read().add( + ReminderEvent.update( + ReminderUpdate( + id: widget.reminderId!, + scheduledAt: parsedDate!.subtract(reminderOption.time), + ), + ), + ); + } + + final reminderId = nanoid(); + _updateBlock( + parsedDate!, + includeTime: includeTime, + reminderId: reminderId, + reminderOption: reminderOption, + ); + + // Add new reminder + final viewId = rootContext.read().view.id; + return rootContext.read().add( + ReminderEvent.add( + reminder: ReminderPB( id: reminderId, - scheduledAt: selectedDay, - includeTime: includeTime, + objectId: viewId, + title: LocaleKeys.reminderNotification_title.tr(), + message: LocaleKeys.reminderNotification_message.tr(), + meta: { + ReminderMetaKeys.includeTime: false.toString(), + ReminderMetaKeys.blockId: widget.node.id, + }, + scheduledAt: Int64(parsedDate!.millisecondsSinceEpoch ~/ 1000), + isAck: parsedDate!.isBefore(DateTime.now()), ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart index 0031927e17..ef775f0d05 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart @@ -1,7 +1,10 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_toolbar_theme.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; @@ -9,7 +12,6 @@ import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; final addBlockToolbarItem = AppFlowyMobileToolbarItem( @@ -213,6 +215,15 @@ class _AddBlockMenu extends StatelessWidget { }); }, ), + + // date + _AddBlockMenuItemData( + blockType: ParagraphBlockKeys.type, + backgroundColor: const Color(0xFFF49898), + text: LocaleKeys.editor_date.tr(), + icon: FlowySvgs.date_s, + onTap: () => _insertBlock(dateMentionNode()), + ), ]; @override diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 899ebca639..cd6d07bacd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -252,9 +252,7 @@ class EditorStyleCustomizer { key: ValueKey( switch (type) { MentionType.page => mention[MentionBlockKeys.pageId], - MentionType.date || - MentionType.reminder => - mention[MentionBlockKeys.date], + MentionType.date => mention[MentionBlockKeys.date], _ => MentionBlockKeys.mention, }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart index 6b5f29af99..bf06b8e096 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -1,16 +1,18 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/date/date_service.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/doc_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; -import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nanoid/nanoid.dart'; @@ -147,9 +149,10 @@ class ReminderReferenceService { '\$', attributes: { MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.reminder.name, + MentionBlockKeys.type: MentionType.date.name, MentionBlockKeys.date: date.toIso8601String(), - MentionBlockKeys.uid: reminder.id, + MentionBlockKeys.reminderId: reminder.id, + MentionBlockKeys.reminderOption: ReminderOption.atTimeOfEvent.name, }, }, ); @@ -213,8 +216,8 @@ class ReminderReferenceService { title: LocaleKeys.reminderNotification_title.tr(), message: LocaleKeys.reminderNotification_message.tr(), meta: { - ReminderMetaKeys.includeTime.name: false.toString(), - ReminderMetaKeys.blockId.name: node.id, + ReminderMetaKeys.includeTime: false.toString(), + ReminderMetaKeys.blockId: node.id, }, scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000), isAck: date.isBefore(DateTime.now()), diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 38b9b2d7d7..119feb149d 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,18 +1,22 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/application/mobile_router.dart'; import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; import 'package:appflowy/workspace/application/notifications/notification_action.dart'; import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_service.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -140,30 +144,33 @@ class _ApplicationWidgetState extends State { create: (_) => DocumentAppearanceCubit()..fetch(), ), BlocProvider.value(value: getIt()), + BlocProvider.value( + value: getIt()..add(const ReminderEvent.started()), + ), ], child: BlocListener( + listenWhen: (_, curr) => curr.action != null, listener: (context, state) { - if (state.action?.type == ActionType.openView) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final view = - state.action!.arguments?[ActionArgumentKeys.view.name]; + final action = state.action; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (action?.type == ActionType.openView && + PlatformExtension.isDesktop) { + final view = action!.arguments?[ActionArgumentKeys.view]; if (view != null) { AppGlobals.rootNavKey.currentContext?.pushView(view); - - final nodePath = state.action! - .arguments?[ActionArgumentKeys.nodePath.name] as int?; - - if (nodePath != null) { - context.read().add( - NotificationActionEvent.performAction( - action: state.action! - .copyWith(type: ActionType.jumpToBlock), - ), - ); - } } - }); - } + } else if (action?.type == ActionType.openRow && + PlatformExtension.isMobile) { + final view = action!.arguments?[ActionArgumentKeys.view]; + if (view != null) { + final view = action.arguments?[ActionArgumentKeys.view]; + final rowId = action.arguments?[ActionArgumentKeys.rowId]; + AppGlobals.rootNavKey.currentContext?.pushView(view, { + PluginArgumentKeys.rowId: rowId, + }); + } + } + }); }, child: BlocBuilder( builder: (context, state) => MaterialApp.router( diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index 87ffc1b005..7985c6887e 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; @@ -489,10 +491,13 @@ GoRoute _mobileGridScreenRoute() { pageBuilder: (context, state) { final id = state.uri.queryParameters[MobileGridScreen.viewId]!; final title = state.uri.queryParameters[MobileGridScreen.viewTitle]; + final arguments = state.uri.queryParameters[MobileGridScreen.viewArgs]; + return MaterialPage( child: MobileGridScreen( id: id, title: title, + arguments: arguments != null ? jsonDecode(arguments) : null, ), ); }, diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart index 834bd67621..3d590f7e4a 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -5,6 +5,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_service.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/util/int64_extension.dart'; import 'package:appflowy/workspace/application/notifications/notification_action.dart'; import 'package:appflowy/workspace/application/notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/notifications/notification_service.dart'; @@ -15,19 +16,18 @@ import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:fixnum/fixnum.dart'; -import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'reminder_bloc.freezed.dart'; class ReminderBloc extends Bloc { - late final NotificationActionBloc actionBloc; - late final ReminderService reminderService; + late final NotificationActionBloc _actionBloc; + late final ReminderService _reminderService; late final Timer timer; ReminderBloc() : super(ReminderState()) { - actionBloc = getIt(); - reminderService = const ReminderService(); + _actionBloc = getIt(); + _reminderService = const ReminderService(); timer = _periodicCheck(); on((event, emit) async { @@ -42,7 +42,7 @@ class ReminderBloc extends Bloc { reminders.remove(reminder); reminder.isRead = true; - await reminderService.updateReminder(reminder: reminder); + await _reminderService.updateReminder(reminder: reminder); updatedReminders.add(reminder); } @@ -51,29 +51,29 @@ class ReminderBloc extends Bloc { emit(state.copyWith(reminders: reminders)); }, started: () async { - final remindersOrFailure = await reminderService.fetchReminders(); + final remindersOrFailure = await _reminderService.fetchReminders(); remindersOrFailure.fold( (error) => Log.error(error), (reminders) => emit(state.copyWith(reminders: reminders)), ); }, - remove: (reminder) async { + remove: (reminderId) async { final unitOrFailure = - await reminderService.removeReminder(reminderId: reminder.id); + await _reminderService.removeReminder(reminderId: reminderId); unitOrFailure.fold( (error) => Log.error(error), (_) { final reminders = [...state.reminders]; - reminders.removeWhere((e) => e.id == reminder.id); + reminders.removeWhere((e) => e.id == reminderId); emit(state.copyWith(reminders: reminders)); }, ); }, add: (reminder) async { final unitOrFailure = - await reminderService.addReminder(reminder: reminder); + await _reminderService.addReminder(reminder: reminder); return unitOrFailure.fold( (error) => Log.error(error), @@ -83,6 +83,19 @@ class ReminderBloc extends Bloc { }, ); }, + addById: (reminderId, objectId, scheduledAt, meta) async => add( + ReminderEvent.add( + reminder: ReminderPB( + id: reminderId, + objectId: objectId, + title: LocaleKeys.reminderNotification_title.tr(), + message: LocaleKeys.reminderNotification_message.tr(), + scheduledAt: scheduledAt, + isAck: scheduledAt.toDateTime().isBefore(DateTime.now()), + meta: meta, + ), + ), + ), update: (updateObject) async { final reminder = state.reminders.firstWhereOrNull((r) => r.id == updateObject.id); @@ -92,7 +105,7 @@ class ReminderBloc extends Bloc { } final newReminder = updateObject.merge(a: reminder); - final failureOrUnit = await reminderService.updateReminder( + final failureOrUnit = await _reminderService.updateReminder( reminder: updateObject.merge(a: reminder), ); @@ -124,17 +137,34 @@ class ReminderBloc extends Bloc { ), ); - actionBloc.add( - NotificationActionEvent.performAction( - action: NotificationAction( - objectId: reminder.objectId, - arguments: { - ActionArgumentKeys.nodePath.name: path, - ActionArgumentKeys.view.name: view, - }, - ), - ), + String? rowId; + if (view?.layout != ViewLayoutPB.Document) { + rowId = reminder.meta[ReminderMetaKeys.rowId]; + } + + final action = NotificationAction( + objectId: reminder.objectId, + arguments: { + ActionArgumentKeys.view: view, + ActionArgumentKeys.nodePath: path, + ActionArgumentKeys.rowId: rowId, + }, ); + + if (!isClosed) { + _actionBloc.add( + NotificationActionEvent.performAction( + action: action, + nextActions: [ + action.copyWith( + type: rowId != null + ? ActionType.openRow + : ActionType.jumpToBlock, + ), + ], + ), + ); + } }, ); }); @@ -151,9 +181,7 @@ class ReminderBloc extends Bloc { continue; } - final scheduledAt = DateTime.fromMillisecondsSinceEpoch( - reminder.scheduledAt.toInt() * 1000, - ); + final scheduledAt = reminder.scheduledAt.toDateTime(); if (scheduledAt.isBefore(now)) { final notificationSettings = @@ -163,7 +191,7 @@ class ReminderBloc extends Bloc { identifier: reminder.id, title: LocaleKeys.reminderNotification_title.tr(), body: LocaleKeys.reminderNotification_message.tr(), - onClick: () => actionBloc.add( + onClick: () => _actionBloc.add( NotificationActionEvent.performAction( action: NotificationAction(objectId: reminder.objectId), ), @@ -189,11 +217,19 @@ class ReminderEvent with _$ReminderEvent { const factory ReminderEvent.started() = _Started; // Remove a reminder - const factory ReminderEvent.remove({required ReminderPB reminder}) = _Remove; + const factory ReminderEvent.remove({required String reminderId}) = _Remove; // Add a reminder const factory ReminderEvent.add({required ReminderPB reminder}) = _Add; + // Add a reminder + const factory ReminderEvent.addById({ + required String reminderId, + required String objectId, + required Int64 scheduledAt, + @Default(null) Map? meta, + }) = _AddById; + // Update a reminder (eg. isAck, isRead, etc.) const factory ReminderEvent.update(ReminderUpdate update) = _Update; @@ -232,7 +268,7 @@ class ReminderUpdate { final meta = a.meta; if (includeTime != a.includeTime) { - meta[ReminderMetaKeys.includeTime.name] = includeTime.toString(); + meta[ReminderMetaKeys.includeTime] = includeTime.toString(); } return ReminderPB( diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart index 0fcdfa5d41..94bf638de5 100644 --- a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart @@ -1,17 +1,14 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -enum ReminderMetaKeys { - includeTime("include_time"), - blockId("block_id"); - - const ReminderMetaKeys(this.name); - - final String name; +class ReminderMetaKeys { + static String includeTime = "include_time"; + static String blockId = "block_id"; + static String rowId = "row_id"; } extension ReminderExtension on ReminderPB { bool? get includeTime { - final String? includeTimeStr = meta[ReminderMetaKeys.includeTime.name]; + final String? includeTimeStr = meta[ReminderMetaKeys.includeTime]; return includeTimeStr != null ? includeTimeStr == true.toString() : null; } diff --git a/frontend/appflowy_flutter/lib/util/int64_extension.dart b/frontend/appflowy_flutter/lib/util/int64_extension.dart new file mode 100644 index 0000000000..8c7f1579f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/int64_extension.dart @@ -0,0 +1,5 @@ +import 'package:fixnum/fixnum.dart'; + +extension DateConversion on Int64 { + DateTime toDateTime() => DateTime.fromMillisecondsSinceEpoch(toInt() * 1000); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart index 2fc470ec3e..cb05bc88ce 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action.dart @@ -1,6 +1,13 @@ enum ActionType { openView, jumpToBlock, + openRow, +} + +class ActionArgumentKeys { + static String view = "view"; + static String nodePath = "node_path"; + static String rowId = "row_id"; } /// A [NotificationAction] is used to communicate with the @@ -31,12 +38,3 @@ class NotificationAction { arguments: arguments ?? this.arguments, ); } - -enum ActionArgumentKeys { - view('view'), - nodePath('node_path'); - - final String name; - - const ActionArgumentKeys(this.name); -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action_bloc.dart index 18f7df669c..ab837deee6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/notifications/notification_action_bloc.dart @@ -9,8 +9,20 @@ class NotificationActionBloc NotificationActionBloc() : super(const NotificationActionState.initial()) { on((event, emit) async { event.when( - performAction: (action) { - emit(state.copyWith(action: action)); + performAction: (action, nextActions) { + emit(state.copyWith(action: action, nextActions: nextActions)); + + if (nextActions.isNotEmpty) { + final newActions = [...nextActions]; + final next = newActions.removeAt(0); + + add( + NotificationActionEvent.performAction( + action: next, + nextActions: newActions, + ), + ); + } }, ); }); @@ -21,18 +33,29 @@ class NotificationActionBloc class NotificationActionEvent with _$NotificationActionEvent { const factory NotificationActionEvent.performAction({ required NotificationAction action, + @Default([]) List nextActions, }) = _PerformAction; } class NotificationActionState { - const NotificationActionState({required this.action}); + const NotificationActionState({ + required this.action, + this.nextActions = const [], + }); final NotificationAction? action; + final List nextActions; - const NotificationActionState.initial() : action = null; + const NotificationActionState.initial() + : action = null, + nextActions = const []; NotificationActionState copyWith({ NotificationAction? action, + List? nextActions, }) => - NotificationActionState(action: action ?? this.action); + NotificationActionState( + action: action ?? this.action, + nextActions: nextActions ?? this.nextActions, + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart index d7af85f1f2..bc3dae67a4 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import 'package:appflowy/plugins/util.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; @@ -6,7 +8,6 @@ import 'package:appflowy/workspace/presentation/home/home_stack.dart'; import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:bloc/bloc.dart'; -import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'tabs_event.dart'; @@ -67,6 +68,14 @@ class TabsBloc extends Bloc { add(TabsEvent.openTab(plugin: view.plugin(), view: view)); /// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB] - void openPlugin(ViewPB view) => - add(TabsEvent.openPlugin(plugin: view.plugin(), view: view)); + void openPlugin( + ViewPB view, { + Map arguments = const {}, + }) => + add( + TabsEvent.openPlugin( + plugin: view.plugin(arguments: arguments), + view: view, + ), + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart index 9fd851927d..7e66c1a64a 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; @@ -9,14 +11,19 @@ import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/workspace/application/view/view_service.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:dartz/dartz.dart' hide id; -import 'package:flutter/material.dart'; enum FlowyPlugin { editor, kanban, } +class PluginArgumentKeys { + static String selection = "selection"; + static String rowId = "row_id"; +} + extension ViewExtension on ViewPB { Widget defaultIcon() => FlowySvg( switch (layout) { @@ -36,17 +43,30 @@ extension ViewExtension on ViewPB { _ => throw UnimplementedError(), }; - Plugin plugin({bool listenOnViewChanged = false}) { + Plugin plugin({ + bool listenOnViewChanged = false, + Map arguments = const {}, + }) { switch (layout) { case ViewLayoutPB.Board: case ViewLayoutPB.Calendar: case ViewLayoutPB.Grid: - return DatabaseTabBarViewPlugin(view: this, pluginType: pluginType); + final String? rowId = arguments[PluginArgumentKeys.rowId]; + + return DatabaseTabBarViewPlugin( + view: this, + pluginType: pluginType, + initialRowId: rowId, + ); case ViewLayoutPB.Document: + final Selection? initialSelection = + arguments[PluginArgumentKeys.selection]; + return DocumentPlugin( view: this, pluginType: pluginType, listenOnViewChanged: listenOnViewChanged, + initialSelection: initialSelection, ); } throw UnimplementedError; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 3981c9bfbf..e3df2530d3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; @@ -14,8 +16,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// Home Sidebar is the left side bar of the home page. @@ -63,6 +65,7 @@ class HomeSideBar extends StatelessWidget { ), ), BlocListener( + listenWhen: (_, curr) => curr.action != null, listener: _onNotificationAction, ), ], @@ -147,17 +150,21 @@ class HomeSideBar extends StatelessWidget { context.read().state.views.findView(action.objectId); if (view != null) { - context.read().openPlugin(view); + final Map arguments = {}; - final nodePath = - action.arguments?[ActionArgumentKeys.nodePath.name] as int?; + final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; if (nodePath != null) { - context.read().add( - NotificationActionEvent.performAction( - action: action.copyWith(type: ActionType.jumpToBlock), - ), - ); + arguments[PluginArgumentKeys.selection] = Selection.collapsed( + Position(path: [nodePath]), + ); } + + final rowId = action.arguments?[ActionArgumentKeys.rowId]; + if (rowId != null) { + arguments[PluginArgumentKeys.rowId] = rowId; + } + + context.read().openPlugin(view, arguments: arguments); } } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart index c83d9bdf55..dec66ea408 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -117,7 +117,7 @@ class _NotificationDialogState extends State } void _onDelete(ReminderPB reminder) { - _reminderBloc.add(ReminderEvent.remove(reminder: reminder)); + _reminderBloc.add(ReminderEvent.remove(reminderId: reminder.id)); } void _onReadChanged(ReminderPB reminder, bool isRead) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart index 267def3941..3d155cdc5f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart @@ -1,7 +1,10 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; -import 'package:appflowy/user/application/reminder/reminder_extension.dart'; import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notifications_hub_empty.dart'; import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; @@ -9,9 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:collection/collection.dart'; import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; /// Displays a Lsit of Notifications, currently used primarily to /// display Reminders. @@ -62,8 +63,7 @@ class NotificationsView extends StatelessWidget { children: [ ...shownReminders.map( (ReminderPB reminder) { - final blockId = - reminder.meta[ReminderMetaKeys.blockId.name]; + final blockId = reminder.meta[ReminderMetaKeys.blockId]; final documentService = DocumentService(); final documentFuture = documentService.openDocument( @@ -76,9 +76,7 @@ class NotificationsView extends StatelessWidget { _getNodeFromDocument(documentFuture, blockId); } - final view = views - .firstWhereOrNull((v) => v.id == reminder.objectId); - + final view = views.findView(reminder.objectId); return NotificationItem( reminderId: reminder.id, key: ValueKey(reminder.id), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart index c10fe9a71b..12d223af93 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart @@ -1,16 +1,22 @@ -import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; -import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/start_text_field.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class OptionGroup { + OptionGroup({required this.options}); + + final List options; +} typedef DaySelectedCallback = Function(DateTime, DateTime); typedef RangeSelectedCallback = Function(DateTime?, DateTime?, DateTime); @@ -32,20 +38,27 @@ class AppFlowyDatePicker extends StatefulWidget { this.focusedDay, this.firstDay, this.lastDay, + this.startDay, + this.endDay, this.timeStr, this.endTimeStr, this.timeHintText, this.parseEndTimeError, this.parseTimeError, this.popoverMutex, + this.selectedReminderOption = ReminderOption.none, this.onStartTimeSubmitted, this.onEndTimeSubmitted, this.onDaySelected, this.onRangeSelected, + this.onReminderSelected, + this.options, this.allowFormatChanges = false, this.onDateFormatChanged, this.onTimeFormatChanged, this.onClearDate, + this.onCalendarCreated, + this.onPageChanged, }); final bool includeTime; @@ -64,17 +77,33 @@ class AppFlowyDatePicker extends StatefulWidget { final DateTime? focusedDay; final DateTime? firstDay; final DateTime? lastDay; + + /// Start date in selected range + final DateTime? startDay; + + /// End date in selected range + final DateTime? endDay; + final String? timeStr; final String? endTimeStr; final String? timeHintText; final String? parseEndTimeError; final String? parseTimeError; final PopoverMutex? popoverMutex; + final ReminderOption selectedReminderOption; final TimeChangedCallback? onStartTimeSubmitted; final TimeChangedCallback? onEndTimeSubmitted; final DaySelectedCallback? onDaySelected; final RangeSelectedCallback? onRangeSelected; + final OnReminderSelected? onReminderSelected; + + /// A list of [OptionGroup] that will be rendered with proper + /// separators, each group can contain multiple options. + /// + /// __Supported on Desktop & Web__ + /// + final List? options; /// If this value is true, then [onTimeFormatChanged] and [onDateFormatChanged] /// cannot be null @@ -94,21 +123,45 @@ class AppFlowyDatePicker extends StatefulWidget { /// final VoidCallback? onClearDate; + final void Function(PageController pageController)? onCalendarCreated; + + final void Function(DateTime focusedDay)? onPageChanged; + @override State createState() => _AppFlowyDatePickerState(); } class _AppFlowyDatePickerState extends State { late DateTime? _selectedDay = widget.selectedDay; + late ReminderOption _selectedReminderOption = widget.selectedReminderOption; @override - void didChangeDependencies() { - _selectedDay = widget.selectedDay; - super.didChangeDependencies(); + Widget build(BuildContext context) => + PlatformExtension.isMobile ? buildMobilePicker() : buildDesktopPicker(); + + Widget buildMobilePicker() { + return DatePicker( + isRange: widget.isRange, + onDaySelected: (selectedDay, focusedDay) { + widget.onDaySelected?.call(selectedDay, focusedDay); + + if (widget.rebuildOnDaySelected) { + setState(() => _selectedDay = selectedDay); + } + }, + onRangeSelected: widget.onRangeSelected, + selectedDay: + widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay, + firstDay: widget.firstDay, + lastDay: widget.lastDay, + startDay: widget.startDay, + endDay: widget.endDay, + onCalendarCreated: widget.onCalendarCreated, + onPageChanged: widget.onPageChanged, + ); } - @override - Widget build(BuildContext context) { + Widget buildDesktopPicker() { return Padding( padding: const EdgeInsets.only(top: 18.0, bottom: 12.0), child: Column( @@ -142,9 +195,14 @@ class _AppFlowyDatePickerState extends State { } }, onRangeSelected: widget.onRangeSelected, - selectedDay: _selectedDay, + selectedDay: + widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay, firstDay: widget.firstDay, lastDay: widget.lastDay, + startDay: widget.startDay, + endDay: widget.endDay, + onCalendarCreated: widget.onCalendarCreated, + onPageChanged: widget.onPageChanged, ), const TypeOptionSeparator(spacing: 12.0), if (widget.enableRanges && widget.onIsRangeChanged != null) ...[ @@ -161,28 +219,44 @@ class _AppFlowyDatePickerState extends State { onChanged: widget.onIncludeTimeChanged, ), ), - if (widget.onClearDate != null || - (widget.allowFormatChanges && - widget.onDateFormatChanged != null && - widget.onTimeFormatChanged != null)) - // Only show if either of the options are below it - const TypeOptionSeparator(spacing: 8.0), - if (widget.allowFormatChanges && - widget.onDateFormatChanged != null && - widget.onTimeFormatChanged != null) - DateTypeOptionButton( - popoverMutex: widget.popoverMutex, - dateFormat: widget.dateFormat, - timeFormat: widget.timeFormat, - onDateFormatChanged: widget.onDateFormatChanged!, - onTimeFormatChanged: widget.onTimeFormatChanged!, + const _GroupSeparator(), + ReminderSelector( + mutex: widget.popoverMutex, + selectedOption: _selectedReminderOption, + onOptionSelected: (option) { + setState(() => _selectedReminderOption = option); + widget.onReminderSelected?.call(option); + }, + ), + if (widget.options?.isNotEmpty ?? false) ...[ + const _GroupSeparator(), + ListView.separated( + shrinkWrap: true, + itemCount: widget.options!.length, + separatorBuilder: (_, __) => const _GroupSeparator(), + itemBuilder: (_, index) => + _renderGroupOptions(widget.options![index].options), ), - if (widget.onClearDate != null) ...[ - const VSpace(4.0), - ClearDateButton(onClearDate: widget.onClearDate!), ], ], ), ); } + + Widget _renderGroupOptions(List options) => ListView.separated( + shrinkWrap: true, + itemCount: options.length, + separatorBuilder: (_, __) => const VSpace(4), + itemBuilder: (_, index) => options[index], + ); +} + +class _GroupSeparator extends StatelessWidget { + const _GroupSeparator(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container(color: Theme.of(context).dividerColor, height: 1.0), + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart new file mode 100644 index 0000000000..98a3fa1483 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_appflowy_date_picker.dart @@ -0,0 +1,468 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:go_router/go_router.dart'; + +class MobileAppFlowyDatePicker extends StatefulWidget { + const MobileAppFlowyDatePicker({ + super.key, + this.selectedDay, + this.startDay, + this.endDay, + this.dateStr, + this.endDateStr, + this.timeStr, + this.endTimeStr, + this.enableRanges = false, + this.isRange = false, + this.rebuildOnDaySelected = false, + this.rebuildOnTimeChanged = false, + required this.includeTime, + required this.use24hFormat, + this.selectedReminderOption, + required this.onStartTimeChanged, + this.onEndTimeChanged, + required this.onIncludeTimeChanged, + this.onRangeChanged, + this.onDaySelected, + this.onRangeSelected, + this.onClearDate, + this.liveDateFormatter, + this.onReminderSelected, + }); + + final DateTime? selectedDay; + final DateTime? startDay; + final DateTime? endDay; + + final String? dateStr; + final String? endDateStr; + final String? timeStr; + final String? endTimeStr; + + final bool enableRanges; + final bool isRange; + final bool includeTime; + final bool rebuildOnDaySelected; + final bool rebuildOnTimeChanged; + final bool use24hFormat; + + final ReminderOption? selectedReminderOption; + + final Function(String? time) onStartTimeChanged; + final Function(String? time)? onEndTimeChanged; + final Function(bool) onIncludeTimeChanged; + final Function(bool)? onRangeChanged; + + final DaySelectedCallback? onDaySelected; + final RangeSelectedCallback? onRangeSelected; + final VoidCallback? onClearDate; + final OnReminderSelected? onReminderSelected; + + final String Function(DateTime)? liveDateFormatter; + + @override + State createState() => + _MobileAppFlowyDatePickerState(); +} + +class _MobileAppFlowyDatePickerState extends State { + late bool _includeTime = widget.includeTime; + late String? _dateStr = widget.dateStr; + late ReminderOption _reminderOption = + widget.selectedReminderOption ?? ReminderOption.none; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FlowyOptionDecorateBox( + showTopBorder: false, + child: _IncludeTimePicker( + dateStr: + widget.liveDateFormatter != null ? _dateStr : widget.dateStr, + endDateStr: widget.endDateStr, + timeStr: widget.timeStr, + endTimeStr: widget.endTimeStr, + includeTime: _includeTime, + use24hFormat: widget.use24hFormat, + onStartTimeChanged: widget.onStartTimeChanged, + onEndTimeChanged: widget.onEndTimeChanged, + rebuildOnTimeChanged: widget.rebuildOnTimeChanged, + ), + ), + const _Divider(), + FlowyOptionDecorateBox( + child: MobileDatePicker( + isRange: widget.isRange, + selectedDay: widget.selectedDay, + startDay: widget.startDay, + endDay: widget.endDay, + onDaySelected: (selected, focused) { + widget.onDaySelected?.call(selected, focused); + + if (widget.liveDateFormatter != null) { + setState(() => _dateStr = widget.liveDateFormatter!(selected)); + } + }, + onRangeSelected: widget.onRangeSelected, + rebuildOnDaySelected: widget.rebuildOnDaySelected, + ), + ), + const _Divider(), + if (widget.enableRanges && widget.onRangeChanged != null) + _EndDateSwitch( + isRange: widget.isRange, + onRangeChanged: widget.onRangeChanged!, + ), + _IncludeTimeSwitch( + showTopBorder: !widget.enableRanges || widget.onRangeChanged == null, + includeTime: _includeTime, + onIncludeTimeChanged: (includeTime) { + widget.onIncludeTimeChanged(includeTime); + setState(() => _includeTime = includeTime); + }, + ), + if (widget.onReminderSelected != null) ...[ + const _Divider(), + _ReminderSelector( + selectedReminderOption: _reminderOption, + onReminderSelected: (option) { + widget.onReminderSelected!.call(option); + setState(() => _reminderOption = option); + }, + ), + ], + if (widget.onClearDate != null) ...[ + const _Divider(), + _ClearDateButton(onClearDate: widget.onClearDate!), + ], + const _Divider(), + ], + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) => const VSpace(20.0); +} + +class _ReminderSelector extends StatelessWidget { + const _ReminderSelector({ + this.selectedReminderOption, + required this.onReminderSelected, + }); + + final ReminderOption? selectedReminderOption; + final OnReminderSelected onReminderSelected; + + @override + Widget build(BuildContext context) { + final option = selectedReminderOption ?? ReminderOption.none; + + final availableOptions = [...ReminderOption.values]; + if (option != ReminderOption.custom) { + availableOptions.remove(ReminderOption.custom); + } + + return FlowyOptionTile.text( + text: 'Reminder', + trailing: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const HSpace(6.0), + FlowyText( + option.label, + color: Theme.of(context).hintColor, + ), + const HSpace(4.0), + FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).hintColor, + size: const Size.square(18.0), + ), + ], + ), + onTap: () => showMobileBottomSheet( + context, + padding: EdgeInsets.zero, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.7, + minChildSize: 0.7, + builder: (context, controller) => Column( + children: [ + const _ReminderSelectHeader(), + const VSpace(12.0), + Flexible( + child: SingleChildScrollView( + controller: controller, + child: Column( + children: availableOptions + .map( + (o) => FlowyOptionTile.text( + text: o.label, + showTopBorder: o == ReminderOption.none, + onTap: () { + onReminderSelected(o); + context.pop(); + }, + ), + ) + .toList(), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _ReminderSelectHeader extends StatelessWidget { + const _ReminderSelectHeader(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 56, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 120, + child: AppBarCancelButton(onTap: context.pop), + ), + const FlowyText.medium( + 'Select reminder', + fontSize: 17.0, + ), + const HSpace(120), + ], + ), + ); + } +} + +class _IncludeTimePicker extends StatefulWidget { + const _IncludeTimePicker({ + required this.includeTime, + this.dateStr, + this.endDateStr, + this.timeStr, + this.endTimeStr, + this.rebuildOnTimeChanged = false, + required this.use24hFormat, + required this.onStartTimeChanged, + required this.onEndTimeChanged, + }); + + final bool includeTime; + + final String? dateStr; + final String? endDateStr; + + final String? timeStr; + final String? endTimeStr; + + final bool rebuildOnTimeChanged; + + final bool use24hFormat; + + final Function(String? time) onStartTimeChanged; + final Function(String? time)? onEndTimeChanged; + + @override + State<_IncludeTimePicker> createState() => _IncludeTimePickerState(); +} + +class _IncludeTimePickerState extends State<_IncludeTimePicker> { + late String? _timeStr = widget.timeStr; + late String? _endTimeStr = widget.endTimeStr; + + @override + Widget build(BuildContext context) { + if (widget.dateStr == null || widget.dateStr!.isEmpty) { + return const Divider(height: 1); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTime( + context, + widget.includeTime, + widget.use24hFormat, + true, + widget.dateStr, + widget.rebuildOnTimeChanged ? _timeStr : widget.timeStr, + ), + VSpace(8.0, color: Theme.of(context).colorScheme.surface), + _buildTime( + context, + widget.includeTime, + widget.use24hFormat, + false, + widget.endDateStr, + widget.rebuildOnTimeChanged ? _endTimeStr : widget.endTimeStr, + ), + ], + ), + ); + } + + Widget _buildTime( + BuildContext context, + bool isIncludeTime, + bool use24hFormat, + bool isStartDay, + String? dateStr, + String? timeStr, + ) { + if (dateStr == null) { + return const SizedBox.shrink(); + } + + final List children = []; + + if (!isIncludeTime) { + children.addAll([ + const HSpace(12.0), + FlowyText(dateStr), + ]); + } else { + children.addAll([ + Expanded(child: FlowyText(dateStr, textAlign: TextAlign.center)), + Container(width: 1, height: 16, color: Colors.grey), + Expanded(child: FlowyText(timeStr ?? '', textAlign: TextAlign.center)), + ]); + } + + return GestureDetector( + onTap: !isIncludeTime + ? null + : () async { + await showMobileBottomSheet( + context, + builder: (context) => ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: CupertinoDatePicker( + showDayOfWeek: false, + mode: CupertinoDatePickerMode.time, + use24hFormat: use24hFormat, + onDateTimeChanged: (dateTime) { + final selectedTime = use24hFormat + ? DateFormat('HH:mm').format(dateTime) + : DateFormat('hh:mm a').format(dateTime); + + if (isStartDay) { + widget.onStartTimeChanged(selectedTime); + + if (widget.rebuildOnTimeChanged && mounted) { + setState(() => _timeStr = selectedTime); + } + } else { + widget.onEndTimeChanged?.call(selectedTime); + + if (widget.rebuildOnTimeChanged && mounted) { + setState(() => _endTimeStr = selectedTime); + } + } + }, + ), + ), + ); + }, + child: Container( + constraints: const BoxConstraints(minHeight: 36), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Theme.of(context).colorScheme.secondaryContainer, + border: Border.all( + color: Theme.of(context).colorScheme.outline, + width: 1, + ), + ), + child: Row(children: children), + ), + ); + } +} + +class _EndDateSwitch extends StatelessWidget { + const _EndDateSwitch({ + required this.isRange, + required this.onRangeChanged, + }); + + final bool isRange; + final Function(bool) onRangeChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.toggle( + text: LocaleKeys.grid_field_isRange.tr(), + isSelected: isRange, + onValueChanged: onRangeChanged, + ); + } +} + +class _IncludeTimeSwitch extends StatelessWidget { + const _IncludeTimeSwitch({ + this.showTopBorder = true, + required this.includeTime, + required this.onIncludeTimeChanged, + }); + + final bool showTopBorder; + final bool includeTime; + final Function(bool) onIncludeTimeChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.toggle( + showTopBorder: showTopBorder, + text: LocaleKeys.grid_field_includeTime.tr(), + isSelected: includeTime, + onValueChanged: onIncludeTimeChanged, + ); + } +} + +class _ClearDateButton extends StatelessWidget { + const _ClearDateButton({required this.onClearDate}); + + final VoidCallback onClearDate; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.grid_field_clearDate.tr(), + onTap: onClearDate, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart index 62b8eb85f9..930caa1cf2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -20,6 +21,8 @@ class DatePicker extends StatefulWidget { this.lastDay, this.onDaySelected, this.onRangeSelected, + this.onCalendarCreated, + this.onPageChanged, }); final bool isRange; @@ -48,12 +51,16 @@ class DatePicker extends StatefulWidget { DateTime focusedDay, )? onRangeSelected; + final void Function(PageController pageController)? onCalendarCreated; + + final void Function(DateTime focusedDay)? onPageChanged; + @override State createState() => _DatePickerState(); } class _DatePickerState extends State { - DateTime _focusedDay = DateTime.now(); + late DateTime _focusedDay = widget.selectedDay ?? DateTime.now(); late CalendarFormat _calendarFormat = widget.calendarFormat; @override @@ -64,57 +71,56 @@ class _DatePickerState extends State { shape: BoxShape.circle, ); + final calendarStyle = PlatformExtension.isMobile + ? _CalendarStyle.mobile( + dowTextStyle: textStyle.copyWith( + color: Theme.of(context).hintColor, + fontSize: 14.0, + ), + ) + : _CalendarStyle.desktop( + textStyle: textStyle, + iconColor: Theme.of(context).iconTheme.color, + dowTextStyle: AFThemeExtension.of(context).caption, + selectedColor: Theme.of(context).colorScheme.primary, + ); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: TableCalendar( firstDay: widget.firstDay ?? kFirstDay, lastDay: widget.lastDay ?? kLastDay, focusedDay: _focusedDay, - rowHeight: 26.0 + 7.0, + rowHeight: calendarStyle.rowHeight, calendarFormat: _calendarFormat, - availableCalendarFormats: const {CalendarFormat.month: 'Month'}, - daysOfWeekHeight: 17.0 + 8.0, + daysOfWeekHeight: calendarStyle.dowHeight, rangeSelectionMode: widget.isRange ? RangeSelectionMode.enforced : RangeSelectionMode.disabled, rangeStartDay: widget.isRange ? widget.startDay : null, rangeEndDay: widget.isRange ? widget.endDay : null, - headerStyle: HeaderStyle( - formatButtonVisible: false, - titleCentered: true, - titleTextStyle: textStyle, - leftChevronMargin: EdgeInsets.zero, - leftChevronPadding: EdgeInsets.zero, - leftChevronIcon: FlowySvg( - FlowySvgs.arrow_left_s, - color: Theme.of(context).iconTheme.color, - ), - rightChevronPadding: EdgeInsets.zero, - rightChevronMargin: EdgeInsets.zero, - rightChevronIcon: FlowySvg( - FlowySvgs.arrow_right_s, - color: Theme.of(context).iconTheme.color, - ), - headerMargin: EdgeInsets.zero, - headerPadding: const EdgeInsets.only(bottom: 8.0), - ), + availableGestures: calendarStyle.availableGestures, + availableCalendarFormats: const {CalendarFormat.month: 'Month'}, + onCalendarCreated: widget.onCalendarCreated, + headerVisible: calendarStyle.headerVisible, + headerStyle: calendarStyle.headerStyle, calendarStyle: CalendarStyle( cellMargin: const EdgeInsets.all(3.5), defaultDecoration: boxDecoration, selectedDecoration: boxDecoration.copyWith( - color: Theme.of(context).colorScheme.primary, + color: calendarStyle.selectedColor, ), todayDecoration: boxDecoration.copyWith( color: Colors.transparent, - border: Border.all(color: Theme.of(context).colorScheme.primary), + border: Border.all(color: calendarStyle.selectedColor), ), weekendDecoration: boxDecoration, outsideDecoration: boxDecoration, rangeStartDecoration: boxDecoration.copyWith( - color: Theme.of(context).colorScheme.primary, + color: calendarStyle.selectedColor, ), rangeEndDecoration: boxDecoration.copyWith( - color: Theme.of(context).colorScheme.primary, + color: calendarStyle.selectedColor, ), defaultTextStyle: textStyle, weekendTextStyle: textStyle, @@ -140,10 +146,7 @@ class _DatePickerState extends State { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Center( - child: Text( - label, - style: AFThemeExtension.of(context).caption, - ), + child: Text(label, style: calendarStyle.dowTextStyle), ), ); }, @@ -152,10 +155,71 @@ class _DatePickerState extends State { widget.isRange ? false : isSameDay(widget.selectedDay, day), onFormatChanged: (calendarFormat) => setState(() => _calendarFormat = calendarFormat), - onPageChanged: (focusedDay) => setState(() => _focusedDay = focusedDay), + onPageChanged: (focusedDay) { + widget.onPageChanged?.call(focusedDay); + setState(() => _focusedDay = focusedDay); + }, onDaySelected: widget.onDaySelected, onRangeSelected: widget.onRangeSelected, ), ); } } + +class _CalendarStyle { + _CalendarStyle({ + required this.rowHeight, + required this.dowHeight, + required this.headerVisible, + required this.headerStyle, + required this.dowTextStyle, + required this.selectedColor, + required this.availableGestures, + }); + + final double rowHeight; + final double dowHeight; + final bool headerVisible; + final HeaderStyle headerStyle; + final TextStyle dowTextStyle; + final Color selectedColor; + final AvailableGestures availableGestures; + + _CalendarStyle.mobile({ + required this.dowTextStyle, + }) : rowHeight = 48, + dowHeight = 48, + headerVisible = false, + headerStyle = const HeaderStyle(), + selectedColor = const Color(0xFF00BCF0), + availableGestures = AvailableGestures.horizontalSwipe; + + _CalendarStyle.desktop({ + required TextStyle textStyle, + required this.selectedColor, + required this.dowTextStyle, + Color? iconColor, + }) : rowHeight = 33, + dowHeight = 35, + headerVisible = true, + headerStyle = HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + titleTextStyle: textStyle, + leftChevronMargin: EdgeInsets.zero, + leftChevronPadding: EdgeInsets.zero, + leftChevronIcon: FlowySvg( + FlowySvgs.arrow_left_s, + color: iconColor, + ), + rightChevronPadding: EdgeInsets.zero, + rightChevronMargin: EdgeInsets.zero, + rightChevronIcon: FlowySvg( + FlowySvgs.arrow_right_s, + color: iconColor, + ), + headerMargin: EdgeInsets.zero, + headerPadding: const EdgeInsets.only(bottom: 8.0), + ), + availableGestures = AvailableGestures.all; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart index bd03e6af31..ce79c932b3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart @@ -1,13 +1,13 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; /// Provides arguemnts for [AppFlowyDatePicker] when showing @@ -21,15 +21,20 @@ class DatePickerOptions { this.firstDay, this.lastDay, this.timeStr, + this.endTimeStr, this.includeTime = false, this.isRange = false, this.enableRanges = true, this.dateFormat = UserDateFormatPB.Friendly, this.timeFormat = UserTimeFormatPB.TwentyFourHour, + this.selectedReminderOption, this.onDaySelected, - this.onIncludeTimeChanged, + required this.onIncludeTimeChanged, this.onStartTimeChanged, this.onEndTimeChanged, + this.onRangeSelected, + this.onIsRangeChanged, + this.onReminderSelected, }) : focusedDay = focusedDay ?? DateTime.now(); final DateTime focusedDay; @@ -38,33 +43,35 @@ class DatePickerOptions { final DateTime? firstDay; final DateTime? lastDay; final String? timeStr; + final String? endTimeStr; final bool includeTime; final bool isRange; final bool enableRanges; final UserDateFormatPB dateFormat; final UserTimeFormatPB timeFormat; + final ReminderOption? selectedReminderOption; final DaySelectedCallback? onDaySelected; - final IncludeTimeChangedCallback? onIncludeTimeChanged; + final IncludeTimeChangedCallback onIncludeTimeChanged; final TimeChangedCallback? onStartTimeChanged; final TimeChangedCallback? onEndTimeChanged; + final RangeSelectedCallback? onRangeSelected; + final Function(bool)? onIsRangeChanged; + final OnReminderSelected? onReminderSelected; } abstract class DatePickerService { - void show(Offset offset); + void show(Offset offset, {required DatePickerOptions options}); void dismiss(); } const double _datePickerWidth = 260; -const double _datePickerHeight = 355; -const double _includeTimeHeight = 40; +const double _datePickerHeight = 370; +const double _includeTimeHeight = 32; const double _ySpacing = 15; class DatePickerMenu extends DatePickerService { - DatePickerMenu({ - required this.context, - required this.editorState, - }); + DatePickerMenu({required this.context, required this.editorState}); final BuildContext context; final EditorState editorState; @@ -78,16 +85,10 @@ class DatePickerMenu extends DatePickerService { } @override - void show( - Offset offset, { - DatePickerOptions? options, - }) => + void show(Offset offset, {required DatePickerOptions options}) => _show(offset, options: options); - void _show( - Offset offset, { - DatePickerOptions? options, - }) { + void _show(Offset offset, {required DatePickerOptions options}) { dismiss(); final editorSize = editorState.renderBox!.size; @@ -112,37 +113,35 @@ class DatePickerMenu extends DatePickerService { } _menuEntry = OverlayEntry( - builder: (context) { - return Material( - type: MaterialType.transparency, - child: SizedBox( - height: editorSize.height, - width: editorSize.width, - child: RawKeyboardListener( - focusNode: FocusNode()..requestFocus(), - onKey: (event) { - if (event is RawKeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { - dismiss(); - } - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: dismiss, - child: Stack( - children: [ - _AnimatedDatePicker( - offset: Offset(offsetX, offsetY), - showBelow: showBelow, - options: options, - ), - ], - ), + builder: (_) => Material( + type: MaterialType.transparency, + child: SizedBox( + height: editorSize.height, + width: editorSize.width, + child: RawKeyboardListener( + focusNode: FocusNode()..requestFocus(), + onKey: (event) { + if (event is RawKeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + dismiss(); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + _AnimatedDatePicker( + offset: Offset(offsetX, offsetY), + showBelow: showBelow, + options: options, + ), + ], ), ), ), - ); - }, + ), + ), ); Overlay.of(context).insert(_menuEntry!); @@ -153,28 +152,28 @@ class _AnimatedDatePicker extends StatefulWidget { const _AnimatedDatePicker({ required this.offset, required this.showBelow, - this.options, + required this.options, }); final Offset offset; final bool showBelow; - final DatePickerOptions? options; + final DatePickerOptions options; @override State<_AnimatedDatePicker> createState() => _AnimatedDatePickerState(); } class _AnimatedDatePickerState extends State<_AnimatedDatePicker> { - late bool _includeTime = widget.options?.includeTime ?? false; + late bool _includeTime = widget.options.includeTime; @override Widget build(BuildContext context) { double dy = widget.offset.dy; if (!widget.showBelow && _includeTime) { - dy = dy - _includeTimeHeight; + dy -= _includeTimeHeight; } - dy = dy + (widget.showBelow ? _ySpacing : -_ySpacing); + dy += (widget.showBelow ? _ySpacing : -_ySpacing); return AnimatedPositioned( duration: const Duration(milliseconds: 200), @@ -185,30 +184,31 @@ class _AnimatedDatePickerState extends State<_AnimatedDatePicker> { Theme.of(context).cardColor, Theme.of(context).colorScheme.shadow, ), - constraints: BoxConstraints.loose( - const Size(_datePickerWidth, 465), - ), + constraints: BoxConstraints.loose(const Size(_datePickerWidth, 465)), child: AppFlowyDatePicker( - popoverMutex: widget.options?.popoverMutex, includeTime: _includeTime, - enableRanges: widget.options?.enableRanges ?? false, - isRange: widget.options?.isRange ?? false, - onIsRangeChanged: (_) {}, - timeStr: widget.options?.timeStr, - dateFormat: - widget.options?.dateFormat.simplified ?? DateFormatPB.Friendly, - timeFormat: widget.options?.timeFormat.simplified ?? - TimeFormatPB.TwentyFourHour, - selectedDay: widget.options?.selectedDay, onIncludeTimeChanged: (includeTime) { - widget.options?.onIncludeTimeChanged?.call(!includeTime); + widget.options.onIncludeTimeChanged.call(!includeTime); setState(() => _includeTime = !includeTime); }, - onStartTimeSubmitted: widget.options?.onStartTimeChanged, - onDaySelected: widget.options?.onDaySelected, - focusedDay: widget.options?.focusedDay ?? DateTime.now(), - firstDay: widget.options?.firstDay, - lastDay: widget.options?.lastDay, + enableRanges: widget.options.enableRanges, + isRange: widget.options.isRange, + onIsRangeChanged: widget.options.onIsRangeChanged, + dateFormat: widget.options.dateFormat.simplified, + timeFormat: widget.options.timeFormat.simplified, + selectedDay: widget.options.selectedDay, + focusedDay: widget.options.focusedDay, + firstDay: widget.options.firstDay, + lastDay: widget.options.lastDay, + timeStr: widget.options.timeStr, + endTimeStr: widget.options.endTimeStr, + popoverMutex: widget.options.popoverMutex, + selectedReminderOption: + widget.options.selectedReminderOption ?? ReminderOption.none, + onStartTimeSubmitted: widget.options.onStartTimeChanged, + onDaySelected: widget.options.onDaySelected, + onRangeSelected: widget.options.onRangeSelected, + onReminderSelected: widget.options.onReminderSelected, ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart index 8945d19754..7472045672 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart @@ -1,10 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + import 'package:appflowy/plugins/database/grid/presentation/widgets/header/type_option/date/date_time_format.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/widgets.dart'; - -import '../utils/layout.dart'; class DateTimeSetting extends StatefulWidget { const DateTimeSetting({ @@ -35,7 +36,7 @@ class _DateTimeSettingState extends State { mutex: timeSettingPopoverMutex, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), - popupBuilder: (BuildContext context) => DateFormatList( + popupBuilder: (_) => DateFormatList( selectedFormat: widget.dateFormat, onSelected: _onDateFormatChanged, ), @@ -48,7 +49,7 @@ class _DateTimeSettingState extends State { mutex: timeSettingPopoverMutex, triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, offset: const Offset(8, 0), - popupBuilder: (BuildContext context) => TimeFormatList( + popupBuilder: (_) => TimeFormatList( selectedFormat: widget.timeFormat, onSelected: _onTimeFormatChanged, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart index 8f48126488..fae8782af5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_text_field.dart @@ -32,7 +32,7 @@ class EndTextField extends StatelessWidget { child: TimeTextField( isEndTime: true, timeFormat: timeFormat, - timeStr: endTimeStr, + endTimeStr: endTimeStr, popoverMutex: popoverMutex, onSubmitted: onSubmitted, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart new file mode 100644 index 0000000000..60476712e6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class MobileDatePicker extends StatefulWidget { + const MobileDatePicker({ + super.key, + this.selectedDay, + required this.isRange, + this.onDaySelected, + this.rebuildOnDaySelected = false, + this.onRangeSelected, + this.firstDay, + this.lastDay, + this.startDay, + this.endDay, + }); + + final DateTime? selectedDay; + + final bool isRange; + + final DaySelectedCallback? onDaySelected; + + final bool rebuildOnDaySelected; + final RangeSelectedCallback? onRangeSelected; + + final DateTime? firstDay; + final DateTime? lastDay; + final DateTime? startDay; + final DateTime? endDay; + + @override + State createState() => _MobileDatePickerState(); +} + +class _MobileDatePickerState extends State { + PageController? _pageController; + + late DateTime _focusedDay = widget.selectedDay ?? DateTime.now(); + late DateTime? _selectedDay = widget.selectedDay; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const VSpace(8.0), + _buildHeader(context), + const VSpace(8.0), + _buildCalendar(context), + const VSpace(16.0), + ], + ); + } + + Widget _buildCalendar(BuildContext context) { + return DatePicker( + isRange: widget.isRange, + onDaySelected: (selectedDay, focusedDay) { + widget.onDaySelected?.call(selectedDay, focusedDay); + + if (widget.rebuildOnDaySelected) { + setState(() => _selectedDay = selectedDay); + } + }, + onRangeSelected: widget.onRangeSelected, + selectedDay: + widget.rebuildOnDaySelected ? _selectedDay : widget.selectedDay, + firstDay: widget.firstDay, + lastDay: widget.lastDay, + startDay: widget.startDay, + endDay: widget.endDay, + onCalendarCreated: (pageController) => _pageController = pageController, + onPageChanged: (focusedDay) => setState(() => _focusedDay = focusedDay), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + children: [ + const HSpace(16.0), + FlowyText( + DateFormat.yMMMM().format(_focusedDay), + ), + const Spacer(), + FlowyButton( + useIntrinsicWidth: true, + text: FlowySvg( + FlowySvgs.arrow_left_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(24.0), + ), + onTap: () => _pageController?.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ), + ), + const HSpace(24.0), + FlowyButton( + useIntrinsicWidth: true, + text: FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(24.0), + ), + onTap: () => _pageController?.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ), + ), + const HSpace(8.0), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart new file mode 100644 index 0000000000..833400c63f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart @@ -0,0 +1,45 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +const _iconWidth = 30.0; +const _height = 44.0; + +class MobileDateHeader extends StatelessWidget { + const MobileDateHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: FlowyIconButton( + icon: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(_height), + ), + width: _iconWidth, + iconPadding: EdgeInsets.zero, + onPressed: () => context.pop(), + ), + ), + Align( + alignment: Alignment.center, + child: FlowyText.medium( + LocaleKeys.grid_field_dateFieldName.tr(), + fontSize: 16, + ), + ), + ].map((e) => SizedBox(height: _height, child: e)).toList(), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart new file mode 100644 index 0000000000..4043264aee --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +typedef OnReminderSelected = void Function(ReminderOption option); + +class ReminderSelector extends StatelessWidget { + const ReminderSelector({ + super.key, + required this.mutex, + required this.selectedOption, + required this.onOptionSelected, + }); + + final PopoverMutex? mutex; + final ReminderOption selectedOption; + final OnReminderSelected? onOptionSelected; + + @override + Widget build(BuildContext context) { + final options = ReminderOption.values.toList(); + if (selectedOption != ReminderOption.custom) { + options.remove(ReminderOption.custom); + } + + final optionWidgets = options + .map( + (o) => SizedBox( + height: DatePickerSize.itemHeight, + child: FlowyButton( + text: FlowyText.medium(o.label), + rightIcon: o == selectedOption + ? const FlowySvg(FlowySvgs.check_s) + : null, + onTap: () { + if (o != selectedOption) { + onOptionSelected?.call(o); + mutex?.close(); + } + }, + ), + ), + ) + .toList(); + + return AppFlowyPopover( + mutex: mutex, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, -155), + margin: EdgeInsets.zero, + constraints: BoxConstraints.loose(const Size(150, 310)), + popupBuilder: (_) => Padding( + padding: const EdgeInsets.all(6.0), + child: ListView.separated( + itemCount: options.length, + separatorBuilder: (_, __) => VSpace(DatePickerSize.seperatorHeight), + itemBuilder: (_, index) => optionWidgets[index], + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SizedBox( + height: DatePickerSize.itemHeight, + child: FlowyButton( + text: FlowyText.medium(LocaleKeys.datePicker_reminderLabel.tr()), + rightIcon: Row( + children: [ + FlowyText.regular(selectedOption.label), + const FlowySvg(FlowySvgs.more_s), + ], + ), + ), + ), + ), + ); + } +} + +enum ReminderOption { + none(time: Duration()), + atTimeOfEvent(time: Duration()), + fiveMinsBefore(time: Duration(minutes: 5)), + tenMinsBefore(time: Duration(minutes: 10)), + fifteenMinsBefore(time: Duration(minutes: 15)), + thirtyMinsBefore(time: Duration(minutes: 30)), + oneHourBefore(time: Duration(hours: 1)), + twoHoursBefore(time: Duration(hours: 2)), + oneDayBefore(time: Duration(days: 1)), + twoDaysBefore(time: Duration(days: 2)), + custom(time: Duration()); + + const ReminderOption({required this.time}); + + final Duration time; + + String get label => switch (this) { + ReminderOption.none => LocaleKeys.datePicker_reminderOptions_none.tr(), + ReminderOption.atTimeOfEvent => + LocaleKeys.datePicker_reminderOptions_atTimeOfEvent.tr(), + ReminderOption.fiveMinsBefore => + LocaleKeys.datePicker_reminderOptions_fiveMinsBefore.tr(), + ReminderOption.tenMinsBefore => + LocaleKeys.datePicker_reminderOptions_tenMinsBefore.tr(), + ReminderOption.fifteenMinsBefore => + LocaleKeys.datePicker_reminderOptions_fifteenMinsBefore.tr(), + ReminderOption.thirtyMinsBefore => + LocaleKeys.datePicker_reminderOptions_thirtyMinsBefore.tr(), + ReminderOption.oneHourBefore => + LocaleKeys.datePicker_reminderOptions_oneHourBefore.tr(), + ReminderOption.twoHoursBefore => + LocaleKeys.datePicker_reminderOptions_twoHoursBefore.tr(), + ReminderOption.oneDayBefore => + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ReminderOption.twoDaysBefore => + LocaleKeys.datePicker_reminderOptions_twoDaysBefore.tr(), + ReminderOption.custom => + LocaleKeys.datePicker_reminderOptions_custom.tr(), + }; + + static ReminderOption fromDateDifference( + DateTime eventDate, + DateTime reminderDate, + ) => + fromMinutes(eventDate.difference(reminderDate).inMinutes); + + static ReminderOption fromMinutes(int minutes) => switch (minutes) { + 0 => ReminderOption.atTimeOfEvent, + 5 => ReminderOption.fiveMinsBefore, + 10 => ReminderOption.tenMinsBefore, + 15 => ReminderOption.fifteenMinsBefore, + 30 => ReminderOption.thirtyMinsBefore, + 60 => ReminderOption.oneHourBefore, + 120 => ReminderOption.twoHoursBefore, + 1440 => ReminderOption.oneDayBefore, + 2880 => ReminderOption.twoDaysBefore, + _ => ReminderOption.custom, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart index 1ce1164af4..d0c5d74b66 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/time_text_field.dart @@ -45,11 +45,8 @@ class _TimeTextFieldState extends State { void initState() { super.initState(); - if (widget.isEndTime) { - _textController.text = widget.endTimeStr ?? ""; - } else { - _textController.text = widget.timeStr ?? ""; - } + _textController.text = + (widget.isEndTime ? widget.endTimeStr : widget.timeStr) ?? ""; if (!widget.isEndTime && widget.timeStr != null) { text = widget.timeStr!; @@ -89,6 +86,7 @@ class _TimeTextFieldState extends State { child: FlowyTextField( text: text, focusNode: _focusNode, + autoFocus: false, controller: _textController, submitOnLeave: true, hintText: widget.timeHintText, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 5b4e3230a2..aa94f0cc02 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; -import 'package:flutter/material.dart'; class AppFlowyPopover extends StatelessWidget { final Widget child; @@ -58,12 +59,11 @@ class AppFlowyPopover extends StatelessWidget { offset: offset, clickHandler: clickHandler, popupBuilder: (context) { - final child = popupBuilder(context); return _PopoverContainer( constraints: constraints, margin: margin, decoration: decoration, - child: child, + child: popupBuilder(context), ); }, child: child, @@ -72,18 +72,17 @@ class AppFlowyPopover extends StatelessWidget { } class _PopoverContainer extends StatelessWidget { - final Widget child; - final BoxConstraints constraints; - final EdgeInsets margin; - final Decoration? decoration; - const _PopoverContainer({ required this.child, required this.margin, required this.constraints, required this.decoration, - Key? key, - }) : super(key: key); + }); + + final Widget child; + final BoxConstraints constraints; + final EdgeInsets margin; + final Decoration? decoration; @override Widget build(BuildContext context) { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 80d7265667..135278d400 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -581,7 +581,8 @@ "newProperty": "New property", "deleteFieldPromptMessage": "Are you sure? This property will be deleted", "newColumn": "New Column", - "format": "Format" + "format": "Format", + "reminderOnDateTooltip": "This cell has a scheduled reminder" }, "rowPage": { "newField": "Add a new field", @@ -1025,7 +1026,21 @@ "includeTime": "Include time", "isRange": "End date", "timeFormat": "Time format", - "clearDate": "Clear date" + "clearDate": "Clear date", + "reminderLabel": "Reminder", + "reminderOptions": { + "none": "None", + "atTimeOfEvent": "Time of event", + "fiveMinsBefore": "5 mins before", + "tenMinsBefore": "10 mins before", + "fifteenMinsBefore": "15 mins before", + "thirtyMinsBefore": "30 mins before", + "oneHourBefore": "1 hour before", + "twoHoursBefore": "2 hours before", + "oneDayBefore": "1 day before", + "twoDaysBefore": "2 days before", + "custom": "Custom" + } }, "relativeDates": { "yesterday": "Yesterday", @@ -1089,6 +1104,7 @@ "highlight": "Highlight", "color": "Color", "image": "Image", + "date": "Date", "italic": "Italic", "link": "Link", "numberedList": "Numbered List", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index d9ce2ce106..3ecb7658fc 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -34,7 +34,7 @@ resolver = "2" [workspace.dependencies] lib-dispatch = { workspace = true, path = "lib-dispatch" } lib-log = { workspace = true, path = "lib-log" } -lib-infra= { workspace = true, path = "lib-infra" } +lib-infra = { workspace = true, path = "lib-infra" } flowy-ast = { workspace = true, path = "build-tool/flowy-ast" } flowy-codegen = { workspace = true, path = "build-tool/flowy-codegen" } flowy-derive = { workspace = true, path = "build-tool/flowy-derive" } @@ -73,7 +73,7 @@ futures = "0.3.29" tokio = "1.34.0" tokio-stream = "0.1.14" async-trait = "0.1.74" -chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +chrono = { version = "0.4.31", default-features = false, features = ["clock"] } lru = "0.12.0" [profile.dev] diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs index a3106fbec3..2f2c1453da 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/date_entities.rs @@ -32,6 +32,9 @@ pub struct DateCellDataPB { #[pb(index = 8)] pub is_range: bool, + + #[pb(index = 9)] + pub reminder_id: String, } #[derive(Clone, Debug, Default, ProtoBuf)] @@ -59,6 +62,9 @@ pub struct DateChangesetPB { #[pb(index = 8, one_of)] pub clear_flag: Option, + + #[pb(index = 9, one_of)] + pub reminder_id: Option, } // Date @@ -94,7 +100,7 @@ impl From for DateTypeOption { } } -#[derive(Clone, Debug, Copy, EnumIter, ProtoBuf_Enum, Default)] +#[derive(Clone, Debug, Copy, ProtoBuf_Enum, Default)] pub enum DateFormatPB { Local = 0, US = 1, diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index eb447fc29e..5f6ec480b1 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -586,7 +586,9 @@ pub(crate) async fn update_date_cell_handler( include_time: data.include_time, is_range: data.is_range, clear_flag: data.clear_flag, + reminder_id: data.reminder_id, }; + let database_editor = manager.get_database_with_view_id(&cell_id.view_id).await?; database_editor .update_cell_with_changeset( diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs index 773ddaf2e0..28921f1a2a 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs @@ -79,6 +79,8 @@ impl TypeOptionCellDataSerde for DateTypeOption { let end_timestamp = cell_data.end_timestamp; let (end_date, end_time) = self.formatted_date_time_from_timestamp(&end_timestamp); + let reminder_id = cell_data.reminder_id; + DateCellDataPB { date, time, @@ -88,6 +90,7 @@ impl TypeOptionCellDataSerde for DateTypeOption { end_timestamp: end_timestamp.unwrap_or_default(), include_time, is_range, + reminder_id, } } @@ -257,18 +260,20 @@ impl CellDataChangeset for DateTypeOption { cell: Option, ) -> FlowyResult<(Cell, ::CellData)> { // old date cell data - let (previous_timestamp, previous_end_timestamp, include_time, is_range) = match cell { - Some(cell) => { - let cell_data = DateCellData::from(&cell); - ( - cell_data.timestamp, - cell_data.end_timestamp, - cell_data.include_time, - cell_data.is_range, - ) - }, - None => (None, None, false, false), - }; + let (previous_timestamp, previous_end_timestamp, include_time, is_range, reminder_id) = + match cell { + Some(cell) => { + let cell_data = DateCellData::from(&cell); + ( + cell_data.timestamp, + cell_data.end_timestamp, + cell_data.include_time, + cell_data.is_range, + cell_data.reminder_id, + ) + }, + None => (None, None, false, false, String::new()), + }; if changeset.clear_flag == Some(true) { let cell_data = DateCellData { @@ -276,6 +281,7 @@ impl CellDataChangeset for DateTypeOption { end_timestamp: None, include_time, is_range, + reminder_id: String::new(), }; return Ok((Cell::from(&cell_data), cell_data)); @@ -284,6 +290,7 @@ impl CellDataChangeset for DateTypeOption { // update include_time and is_range if necessary let include_time = changeset.include_time.unwrap_or(include_time); let is_range = changeset.is_range.unwrap_or(is_range); + let reminder_id = changeset.reminder_id.unwrap_or(reminder_id); // Calculate the timestamp in the time zone specified in type option. If // a new timestamp is included in the changeset without an accompanying @@ -323,6 +330,7 @@ impl CellDataChangeset for DateTypeOption { end_timestamp, include_time, is_range, + reminder_id, }; Ok((Cell::from(&cell_data), cell_data)) diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs index 6d36e22758..0c08073060 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs @@ -25,6 +25,7 @@ pub struct DateCellChangeset { pub include_time: Option, pub is_range: Option, pub clear_flag: Option, + pub reminder_id: Option, } impl FromCellChangeset for DateCellChangeset { @@ -50,15 +51,17 @@ pub struct DateCellData { pub include_time: bool, #[serde(default)] pub is_range: bool, + pub reminder_id: String, } impl DateCellData { - pub fn new(timestamp: i64, include_time: bool, is_range: bool) -> Self { + pub fn new(timestamp: i64, include_time: bool, is_range: bool, reminder_id: String) -> Self { Self { timestamp: Some(timestamp), end_timestamp: None, include_time, is_range, + reminder_id, } } } @@ -79,11 +82,14 @@ impl From<&Cell> for DateCellData { .and_then(|data| data.parse::().ok()); let include_time = cell.get_bool_value("include_time").unwrap_or_default(); let is_range = cell.get_bool_value("is_range").unwrap_or_default(); + let reminder_id = cell.get_str_value("reminder_id").unwrap_or_default(); + Self { timestamp, end_timestamp, include_time, is_range, + reminder_id, } } } @@ -95,6 +101,7 @@ impl From<&DateCellDataPB> for DateCellData { end_timestamp: Some(data.end_timestamp), include_time: data.include_time, is_range: data.is_range, + reminder_id: data.reminder_id.to_owned(), } } } @@ -116,6 +123,7 @@ impl From<&DateCellData> for Cell { .insert_str_value("end_timestamp", end_timestamp_string) .insert_bool_value("include_time", cell_data.include_time) .insert_bool_value("is_range", cell_data.is_range) + .insert_str_value("reminder_id", cell_data.reminder_id.to_owned()) .build() } } @@ -145,6 +153,7 @@ impl<'de> serde::Deserialize<'de> for DateCellData { end_timestamp: None, include_time: false, is_range: false, + reminder_id: String::new(), }) } @@ -163,6 +172,7 @@ impl<'de> serde::Deserialize<'de> for DateCellData { let mut end_timestamp: Option = None; let mut include_time: Option = None; let mut is_range: Option = None; + let mut reminder_id: Option = None; while let Some(key) = map.next_key()? { match key { @@ -178,18 +188,23 @@ impl<'de> serde::Deserialize<'de> for DateCellData { "is_range" => { is_range = map.next_value()?; }, + "reminder_id" => { + reminder_id = map.next_value()?; + }, _ => {}, } } let include_time = include_time.unwrap_or_default(); let is_range = is_range.unwrap_or_default(); + let reminder_id = reminder_id.unwrap_or_default(); Ok(DateCellData { timestamp, end_timestamp, include_time, is_range, + reminder_id, }) } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs index 98f4d90b77..d12d95b4f0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_tests.rs @@ -29,6 +29,7 @@ mod tests { end_timestamp: None, include_time: true, is_range: false, + reminder_id: String::new(), }; assert_eq!( @@ -41,6 +42,7 @@ mod tests { end_timestamp: Some(1648533809), include_time: true, is_range: false, + reminder_id: String::new(), }; assert_eq!( @@ -53,6 +55,7 @@ mod tests { end_timestamp: Some(1648533809), include_time: true, is_range: true, + reminder_id: String::new(), }; assert_eq!( diff --git a/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs b/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs index d4482a5e4c..2520aa5e65 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/field_test/util.rs @@ -115,6 +115,7 @@ pub fn make_date_cell_string(timestamp: i64) -> String { include_time: Some(false), is_range: Some(false), clear_flag: None, + reminder_id: Some(String::new()), }) .unwrap() }