From 4a433a31764f661e7a32ef9341e518989a0baa9e Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Mon, 2 Oct 2023 09:12:24 +0200 Subject: [PATCH] feat: reminder (#3374) --- .github/workflows/flutter_ci.yaml | 2 +- .github/workflows/integration_test.yml | 2 +- .github/workflows/release.yml | 2 +- .../reminder/document_reminder_test.dart | 118 ++++++ .../util/editor_test_operations.dart | 3 +- .../lib/date/date_service.dart | 19 + .../type_option/select_option_editor.dart | 2 +- .../row/cells/date_cell/date_editor.dart | 28 +- .../document/presentation/editor_page.dart | 66 ++-- .../editor_plugins/header/cover_editor.dart | 8 +- .../header/custom_cover_picker.dart | 4 +- .../inline_page/inline_page_reference.dart | 155 -------- .../editor_plugins/mention/mention_block.dart | 73 +++- .../mention/mention_date_block.dart | 181 ++++++++++ .../outline/outline_block_component.dart | 2 +- .../document/presentation/editor_style.dart | 45 ++- .../handlers/date_reference.dart | 187 ++++++++++ .../handlers/inline_page_reference.dart | 113 ++++++ .../handlers/reminder_reference.dart | 216 +++++++++++ .../inline_actions_command.dart | 64 ++++ .../inline_actions/inline_actions_menu.dart | 237 ++++++++++++ .../inline_actions/inline_actions_result.dart | 48 +++ .../inline_actions_service.dart | 32 ++ .../widgets/inline_actions_handler.dart | 338 ++++++++++++++++++ .../widgets/inline_actions_menu_group.dart | 135 +++++++ .../lib/startup/deps_resolver.dart | 6 + .../lib/startup/tasks/app_widget.dart | 25 +- .../application/reminder/reminder_bloc.dart | 232 ++++++++++++ .../reminder/reminder_service.dart | 58 +++ .../application/user_settings_service.dart | 25 +- .../lib/workspace/application/appearance.dart | 39 ++ .../notification_action.dart | 19 + .../notification_action_bloc.dart | 38 ++ .../notification_service.dart | 43 +++ .../settings/date_time/date_format_ext.dart | 41 +++ .../settings/date_time/time_patterns.dart | 18 + .../home/desktop_home_screen.dart | 4 + .../home/menu/sidebar/sidebar.dart | 42 ++- .../home/menu/sidebar/sidebar_user.dart | 6 + .../workspace/presentation/home/toast.dart | 2 +- .../notifications/notification_button.dart | 67 ++++ .../notifications/notification_dialog.dart | 123 +++++++ .../notifications/notification_item.dart | 195 ++++++++++ .../date_format_setting.dart | 72 ++++ .../time_format_setting.dart | 63 ++++ .../widgets/settings_appearance_view.dart | 17 +- .../date_picker/appflowy_calendar.dart | 277 ++++++++++++++ .../widgets/date_picker_dialog.dart | 182 ++++++++++ .../widgets/include_time_button.dart | 197 ++++++++++ .../lib/dispatch/dispatch.dart | 5 +- .../lib/style_widget/color_picker.dart | 3 +- frontend/appflowy_flutter/pubspec.lock | 8 + frontend/appflowy_flutter/pubspec.yaml | 5 + .../app_setting_test/appearance_test.dart | 7 + frontend/appflowy_tauri/src-tauri/Cargo.lock | 52 ++- frontend/appflowy_tauri/src-tauri/Cargo.toml | 37 +- .../src/services/backend/index.ts | 14 +- frontend/resources/translations/en.json | 39 ++ frontend/rust-lib/Cargo.lock | 54 ++- frontend/rust-lib/Cargo.toml | 20 +- frontend/rust-lib/flowy-core/Cargo.toml | 7 +- .../src/integrate/collab_interact.rs | 65 ++++ .../rust-lib/flowy-core/src/integrate/log.rs | 48 +++ .../rust-lib/flowy-core/src/integrate/mod.rs | 3 + .../rust-lib/flowy-core/src/integrate/user.rs | 187 ++++++++++ frontend/rust-lib/flowy-core/src/lib.rs | 257 +------------ frontend/rust-lib/flowy-core/src/module.rs | 2 + .../src/services/group/configuration.rs | 2 +- frontend/rust-lib/flowy-date/Cargo.toml | 25 ++ frontend/rust-lib/flowy-date/Flowy.toml | 3 + frontend/rust-lib/flowy-date/build.rs | 10 + frontend/rust-lib/flowy-date/src/entities.rs | 13 + .../rust-lib/flowy-date/src/event_handler.rs | 36 ++ frontend/rust-lib/flowy-date/src/event_map.rs | 19 + frontend/rust-lib/flowy-date/src/lib.rs | 4 + frontend/rust-lib/flowy-document2/src/lib.rs | 1 + .../rust-lib/flowy-document2/src/manager.rs | 10 + .../rust-lib/flowy-document2/src/reminder.rs | 23 ++ frontend/rust-lib/flowy-error/Cargo.toml | 21 +- frontend/rust-lib/flowy-error/src/errors.rs | 6 + .../user/local_test/user_awareness_test.rs | 10 +- frontend/rust-lib/flowy-user/Cargo.toml | 13 +- .../flowy-user/src/entities/date_time.rs | 79 ++++ .../rust-lib/flowy-user/src/entities/mod.rs | 1 + .../flowy-user/src/entities/reminder.rs | 35 +- .../flowy-user/src/entities/user_setting.rs | 29 +- .../rust-lib/flowy-user/src/event_handler.rs | 64 ++++ frontend/rust-lib/flowy-user/src/event_map.rs | 25 +- frontend/rust-lib/flowy-user/src/manager.rs | 10 +- .../src/services/collab_interact.rs | 25 ++ .../rust-lib/flowy-user/src/services/mod.rs | 1 + .../flowy-user/src/services/user_awareness.rs | 46 ++- shared-lib/lib-infra/src/lib.rs | 1 - shared-lib/lib-infra/src/retry/future.rs | 218 ----------- shared-lib/lib-infra/src/retry/mod.rs | 5 - .../src/retry/strategy/exponential_backoff.rs | 127 ------- .../src/retry/strategy/fixed_interval.rs | 39 -- .../lib-infra/src/retry/strategy/jitter.rs | 5 - .../lib-infra/src/retry/strategy/mod.rs | 7 - 99 files changed, 4599 insertions(+), 998 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart create mode 100644 frontend/appflowy_flutter/lib/date/date_service.dart delete mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart create mode 100644 frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_service.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_calendar.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart create mode 100644 frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs create mode 100644 frontend/rust-lib/flowy-core/src/integrate/log.rs create mode 100644 frontend/rust-lib/flowy-core/src/integrate/user.rs create mode 100644 frontend/rust-lib/flowy-date/Cargo.toml create mode 100644 frontend/rust-lib/flowy-date/Flowy.toml create mode 100644 frontend/rust-lib/flowy-date/build.rs create mode 100644 frontend/rust-lib/flowy-date/src/entities.rs create mode 100644 frontend/rust-lib/flowy-date/src/event_handler.rs create mode 100644 frontend/rust-lib/flowy-date/src/event_map.rs create mode 100644 frontend/rust-lib/flowy-date/src/lib.rs create mode 100644 frontend/rust-lib/flowy-document2/src/reminder.rs create mode 100644 frontend/rust-lib/flowy-user/src/entities/date_time.rs create mode 100644 frontend/rust-lib/flowy-user/src/services/collab_interact.rs delete mode 100644 shared-lib/lib-infra/src/retry/future.rs delete mode 100644 shared-lib/lib-infra/src/retry/mod.rs delete mode 100644 shared-lib/lib-infra/src/retry/strategy/exponential_backoff.rs delete mode 100644 shared-lib/lib-infra/src/retry/strategy/fixed_interval.rs delete mode 100644 shared-lib/lib-infra/src/retry/strategy/jitter.rs delete mode 100644 shared-lib/lib-infra/src/retry/strategy/mod.rs diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 75181cdf94..ad9a6069c1 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -88,7 +88,7 @@ jobs: sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 + sudo apt-get install keybinder-3.0 libnotify-dev elif [ "$RUNNER_OS" == "Windows" ]; then vcpkg integrate install elif [ "$RUNNER_OS" == "macOS" ]; then diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 974406e8d0..d8d8725690 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -85,7 +85,7 @@ jobs: sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 + sudo apt-get install keybinder-3.0 libnotify-dev elif [ "$RUNNER_OS" == "Windows" ]; then vcpkg integrate install elif [ "$RUNNER_OS" == "macOS" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a2bc32529..c8009aeb86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -388,7 +388,7 @@ jobs: sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo apt-get update sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 + sudo apt-get install keybinder-3.0 libnotify-dev sudo apt-get -y install alien source $HOME/.cargo/env cargo install --force cargo-make diff --git a/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart b/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart new file mode 100644 index 0000000000..fa79f9820f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/reminder/document_reminder_test.dart @@ -0,0 +1,118 @@ +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/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/keyboard.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Reminder in Document', () { + testWidgets('Add reminder for tomorrow, and include time', (tester) async { + const time = "23:59"; + + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + final dateTimeSettings = + await UserSettingsBackendService().getDateTimeSettings(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.getCurrentEditorState().insertNewLine(); + + await tester.pumpAndSettle(); + + // Trigger iline action menu and type 'remind tomorrow' + final tomorrow = await _insertReminderTomorrow(tester); + + Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; + Map mentionAttr = + node.delta!.first.attributes![MentionBlockKeys.mention]; + + expect(node.type, 'paragraph'); + expect(mentionAttr['type'], MentionType.reminder.name); + expect(mentionAttr['date'], tomorrow.toIso8601String()); + + await tester.tap( + find.text(dateTimeSettings.dateFormat.formatDate(tomorrow, false)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Toggle)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(FlowyTextField), time); + + // Leave text field to submit + await tester.tap(find.text(LocaleKeys.grid_field_includeTime.tr())); + await tester.pumpAndSettle(); + + node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; + mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention]; + + final tomorrowWithTime = + _dateWithTime(dateTimeSettings.timeFormat, tomorrow, time); + + expect(node.type, 'paragraph'); + expect(mentionAttr['type'], MentionType.reminder.name); + expect(mentionAttr['date'], tomorrowWithTime.toIso8601String()); + }); + }); +} + +Future _insertReminderTomorrow(WidgetTester tester) async { + await tester.editor.showAtMenu(); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyT, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyW, + ], + tester: tester, + ); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [LogicalKeyboardKey.enter], + tester: tester, + ); + + return DateTime.now().add(const Duration(days: 1)).withoutTime; +} + +DateTime _dateWithTime(UserTimeFormatPB format, DateTime date, String time) { + final t = format == UserTimeFormatPB.TwelveHour + ? DateFormat.jm().parse(time) + : DateFormat.Hm().parse(time); + + return DateTime.parse( + '${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(t.hour)}:${_padZeroLeft(t.minute)}', + ); +} + +String _padZeroLeft(int a) => a.toString().padLeft(2, '0'); diff --git a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart index 97ea1cdaf4..5b3e8c0777 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart @@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cus 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/plugins/document/presentation/editor_plugins/header/emoji_popover.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -160,7 +161,7 @@ class EditorOperations { /// Must call [showAtMenu] first. Future tapAtMenuItemWithName(String name) async { final atMenuItem = find.descendant( - of: find.byType(SelectionMenuWidget), + of: find.byType(InlineActionsHandler), matching: find.text(name, findRichText: true), ); await tester.tapButton(atMenuItem); diff --git a/frontend/appflowy_flutter/lib/date/date_service.dart b/frontend/appflowy_flutter/lib/date/date_service.dart new file mode 100644 index 0000000000..bfd5a825ce --- /dev/null +++ b/frontend/appflowy_flutter/lib/date/date_service.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-date/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:dartz/dartz.dart'; + +class DateService { + static Future> queryDate(String search) async { + final query = DateQueryPB.create()..query = search; + final result = (await DateEventQueryDate(query).send()).swap(); + return result.fold((l) => left(l), (r) { + final date = DateTime.tryParse(r.date); + if (date != null) { + return right(date); + } + + return left(FlowyError(msg: 'Could not parse Date (NLP) from String')); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart index f58401a313..79a420c0ae 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/grid/presentation/widgets/header/type_option/select_option_editor.dart @@ -224,7 +224,7 @@ class _SelectOptionColorCell extends StatelessWidget { final colorIcon = SizedBox.square( dimension: 16, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: color.toColor(context), shape: BoxShape.circle, diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart index 56c7a7633b..63e1c2465e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart @@ -3,17 +3,17 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart'; import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_calendar.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.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/errors.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:dartz/dartz.dart' show Either; import 'package:easy_localization/easy_localization.dart'; - import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/time/duration.dart'; +import 'package:flowy_infra/time/prelude.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,9 +24,6 @@ import '../../../../grid/presentation/widgets/common/type_option_separator.dart' import '../../../../grid/presentation/widgets/header/type_option/date.dart'; import 'date_cal_bloc.dart'; -final kFirstDay = DateTime.utc(1970, 1, 1); -final kLastDay = DateTime.utc(2100, 1, 1); - class DateCellEditor extends StatefulWidget { final VoidCallback onDismissed; final DateCellController cellController; @@ -51,9 +48,9 @@ class _DateCellEditor extends State { builder: (BuildContext context, snapshot) { if (snapshot.hasData) { return _buildWidget(snapshot); - } else { - return const SizedBox.shrink(); } + + return const SizedBox.shrink(); }, ); } @@ -81,22 +78,14 @@ class _CellCalendarWidget extends StatefulWidget { const _CellCalendarWidget({ required this.cellContext, required this.dateTypeOptionPB, - Key? key, - }) : super(key: key); + }); @override State<_CellCalendarWidget> createState() => _CellCalendarWidgetState(); } class _CellCalendarWidgetState extends State<_CellCalendarWidget> { - late PopoverMutex popoverMutex; - - @override - void initState() { - popoverMutex = PopoverMutex(); - - super.initState(); - } + final PopoverMutex popoverMutex = PopoverMutex(); @override Widget build(BuildContext context) { @@ -387,8 +376,7 @@ class _TimeTextField extends StatefulWidget { required this.timeStr, required this.popoverMutex, required this.isEndTime, - Key? key, - }) : super(key: key); + }); @override State<_TimeTextField> createState() => _TimeTextFieldState(); 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 1f8f9ad6a3..e050af080f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -1,7 +1,11 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; +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/workspace/application/appearance.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -11,6 +15,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +final List commandShortcutEvents = [ + toggleToggleListCommand, + ...codeBlockCommands, + customCopyCommand, + customPasteCommand, + customCutCommand, + ...standardCommandShortcutEvents, +]; + +final List defaultCommandShortcutEvents = [ + ...commandShortcutEvents.map((e) => e.copyWith()).toList(), +]; + /// Wrapper for the appflowy editor. class AppFlowyEditorPage extends StatefulWidget { const AppFlowyEditorPage({ @@ -34,19 +51,17 @@ class AppFlowyEditorPage extends StatefulWidget { State createState() => _AppFlowyEditorPageState(); } -final List commandShortcutEvents = [ - ...codeBlockCommands, - ...standardCommandShortcutEvents, -]; - -final List defaultCommandShortcutEvents = [ - ...commandShortcutEvents.map((e) => e.copyWith()).toList(), -]; - class _AppFlowyEditorPageState extends State { late final ScrollController effectiveScrollController; - final inlinePageReferenceService = InlinePageReferenceService(); + late final InlineActionsService inlineActionsService = InlineActionsService( + context: context, + handlers: [ + InlinePageReferenceService().inlinePageReferenceDelegate, + DateReferenceService(context).dateReferenceDelegate, + ReminderReferenceService(context).reminderReferenceDelegate, + ], + ); late final List commandShortcutEvents = [ toggleToggleListCommand, @@ -85,9 +100,6 @@ class _AppFlowyEditorPageState extends State { _customAppFlowyBlockComponentBuilders(); List get characterShortcutEvents => [ - // inline page reference list - ...inlinePageReferenceShortcuts, - // code block ...codeBlockCharacterEvents, @@ -105,19 +117,15 @@ class _AppFlowyEditorPageState extends State { ..removeWhere( (element) => element == slashCommand, ), // remove the default slash command. - ]; - late final inlinePageReferenceShortcuts = [ - inlinePageReferenceService.customPageLinkMenu( - character: '@', - style: styleCustomizer.selectionMenuStyleBuilder(), - ), - // uncomment this to enable the inline page reference list - // inlinePageReferenceService.customPageLinkMenu( - // character: '+', - // style: styleCustomizer.selectionMenuStyleBuilder(), - // ), - ]; + /// Inline Actions + /// - Reminder + /// - Inline-page reference + inlineActionsCommand( + inlineActionsService, + style: styleCustomizer.inlineActionsMenuStyleBuilder(), + ), + ]; late final showSlashMenu = customSlashCommand( slashMenuItems, @@ -147,6 +155,7 @@ class _AppFlowyEditorPageState extends State { if (widget.scrollController == null) { effectiveScrollController.dispose(); } + inlineActionsService.dispose(); widget.editorState.dispose(); @@ -221,6 +230,7 @@ class _AppFlowyEditorPageState extends State { final configuration = BlockComponentConfiguration( padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), ); + final customBlockComponentBuilderMap = { PageBlockKeys.type: PageBlockComponentBuilder(), ParagraphBlockKeys.type: TextBlockComponentBuilder( @@ -462,13 +472,13 @@ class _AppFlowyEditorPageState extends State { } Future _initializeShortcuts() async { - //TODO(Xazin): Refactor lazy initialization + // TODO(Xazin): Refactor lazy initialization defaultCommandShortcutEvents; final settingsShortcutService = SettingsShortcutService(); final customizeShortcuts = await settingsShortcutService.getCustomizeShortcuts(); await settingsShortcutService.updateCommandShortcuts( - standardCommandShortcutEvents, + commandShortcutEvents, customizeShortcuts, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart index b8411141b9..b74eb4470b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart @@ -180,7 +180,7 @@ class _ChangeCoverPopoverState extends State { builtInAssetImages[index], ); }, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: AssetImage(builtInAssetImages[index]), @@ -299,7 +299,7 @@ class NewCustomCoverButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return DecoratedBox( decoration: BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.primary, @@ -484,7 +484,7 @@ class _ImageGridItemState extends State { children: [ InkWell( onTap: widget.onImageSelect, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: FileImage(File(widget.imagePath)), @@ -544,7 +544,7 @@ class ColorItem extends StatelessWidget { padding: const EdgeInsets.only(right: 10.0), child: SizedBox.square( dimension: 25, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: option.colorHex.tryToColor(), shape: BoxShape.circle, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart index 088869f180..83d39b3bef 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart @@ -201,7 +201,7 @@ class CoverImagePreviewWidget extends StatefulWidget { class _CoverImagePreviewWidgetState extends State { _buildFilePickerWidget(BuildContext ctx) { - return Container( + return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: Corners.s6Border, @@ -263,7 +263,7 @@ class _CoverImagePreviewWidgetState extends State { onTap: () { ctx.read().add(const DeleteImage()); }, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).colorScheme.onPrimary, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart deleted file mode 100644 index d3104cccf1..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; -import 'package:appflowy/workspace/application/view/view_ext.dart'; -import 'package:appflowy/workspace/application/view/view_service.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -enum MentionType { - page; - - static MentionType fromString(String value) { - switch (value) { - case 'page': - return page; - default: - throw UnimplementedError(); - } - } -} - -class MentionBlockKeys { - const MentionBlockKeys._(); - - static const mention = 'mention'; - static const type = 'type'; // MentionType, String - static const pageId = 'page_id'; -} - -class InlinePageReferenceService { - customPageLinkMenu({ - bool shouldInsertKeyword = false, - SelectionMenuStyle style = SelectionMenuStyle.light, - String character = '@', - }) { - return CharacterShortcutEvent( - key: 'show page link menu', - character: character, - handler: (editorState) async { - final items = await generatePageItems(character); - return _showPageSelectionMenu( - editorState, - items, - shouldInsertKeyword: shouldInsertKeyword, - style: style, - character: character, - ); - }, - ); - } - - SelectionMenuService? _selectionMenuService; - Future _showPageSelectionMenu( - EditorState editorState, - List items, { - bool shouldInsertKeyword = true, - SelectionMenuStyle style = SelectionMenuStyle.light, - String character = '@', - }) async { - if (PlatformExtension.isMobile) { - return false; - } - - final selection = editorState.selection; - if (selection == null) { - return false; - } - - // delete the selection - await editorState.deleteSelection(selection); - - final afterSelection = editorState.selection; - if (afterSelection == null || !afterSelection.isCollapsed) { - assert(false, 'the selection should be collapsed'); - return true; - } - await editorState.insertTextAtPosition( - character, - position: selection.start, - ); - - () { - final context = editorState.getNodeAtPath(selection.start.path)?.context; - if (context != null) { - _selectionMenuService = SelectionMenu( - context: context, - editorState: editorState, - selectionMenuItems: items, - deleteSlashByDefault: false, - style: style, - itemCountFilter: 5, - ); - _selectionMenuService?.show(); - } - }(); - - return true; - } - - Future> generatePageItems(String character) async { - final service = ViewBackendService(); - final views = await service.fetchViews(); - if (views.isEmpty) { - return []; - } - final List pages = []; - views.sort(((a, b) => b.createTime.compareTo(a.createTime))); - - for (final view in views) { - final SelectionMenuItem pageSelectionMenuItem = SelectionMenuItem( - icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: view.iconData, - isSelected: isSelected, - style: style, - ), - keywords: [ - view.name.toLowerCase(), - ], - name: view.name, - handler: (editorState, menuService, context) async { - final selection = editorState.selection; - if (selection == null || !selection.isCollapsed) { - return; - } - final node = editorState.getNodeAtPath(selection.end.path); - final delta = node?.delta; - if (node == null || delta == null) { - return; - } - final index = selection.endIndex; - final lastKeywordIndex = - delta.toPlainText().substring(0, index).lastIndexOf(character); - // @page name -> $ - // preload the page infos - pageMemorizer[view.id] = view; - final transaction = editorState.transaction - ..replaceText( - node, - lastKeywordIndex, - index - lastKeywordIndex, - '\$', - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: MentionType.page.name, - MentionBlockKeys.pageId: view.id, - } - }, - ); - await editorState.apply(transaction); - }, - ); - pages.add(pageSelectionMenuItem); - } - - return pages; - } -} 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 f88e0bdf43..c8ef4f8ca2 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,22 +1,83 @@ -import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.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_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum MentionType { + page, + date, + reminder; + + static MentionType fromString(String value) { + switch (value) { + case 'page': + return page; + case 'date': + return date; + case 'reminder': + return reminder; + default: + throw UnimplementedError(); + } + } +} + +class MentionBlockKeys { + const MentionBlockKeys._(); + + static const uid = 'uid'; // UniqueID + 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 includeTime = 'include_time'; +} class MentionBlock extends StatelessWidget { const MentionBlock({ super.key, required this.mention, + required this.node, + required this.index, }); - final Map mention; + final Map mention; + final Node node; + final int index; @override Widget build(BuildContext context) { final type = MentionType.fromString(mention[MentionBlockKeys.type]); - if (type == MentionType.page) { - final pageId = mention[MentionBlockKeys.pageId]; - return MentionPageBlock(key: ValueKey(pageId), pageId: pageId); + + switch (type) { + case MentionType.page: + final String pageId = mention[MentionBlockKeys.pageId]; + return MentionPageBlock( + key: ValueKey(pageId), + pageId: pageId, + ); + case MentionType.reminder: + case MentionType.date: + final String date = mention[MentionBlockKeys.date]; + final BuildContext editorContext = + context.read().document.root.context!; + return MentionDateBlock( + key: ValueKey(date), + editorContext: editorContext, + date: date, + node: node, + index: index, + isReminder: type == MentionType.reminder, + reminderId: type == MentionType.reminder + ? mention[MentionBlockKeys.uid] + : null, + includeTime: mention[MentionBlockKeys.includeTime] ?? false, + ); + default: + return const SizedBox.shrink(); } - throw UnimplementedError(); } } 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 new file mode 100644 index 0000000000..afbc8f625e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -0,0 +1,181 @@ +import 'package:appflowy/generated/flowy_svgs.g.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/workspace/application/appearance.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MentionDateBlock extends StatelessWidget { + const MentionDateBlock({ + super.key, + required this.editorContext, + required this.date, + required this.index, + required this.node, + this.isReminder = false, + this.reminderId, + this.includeTime = false, + }); + + final BuildContext editorContext; + 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 bool includeTime; + + @override + Widget build(BuildContext context) { + DateTime? parsedDate = DateTime.tryParse(date); + if (parsedDate == null) { + return const SizedBox.shrink(); + } + + final fontSize = context.read().state.fontSize; + + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value( + value: context.read(), + ), + ], + child: BlocBuilder( + buildWhen: (previous, current) => + previous.dateFormat != current.dateFormat || + previous.timeFormat != current.timeFormat, + builder: (context, appearance) => + BlocBuilder( + builder: (context, state) { + final reminder = + state.reminders.firstWhereOrNull((r) => r.id == reminderId); + final noReminder = reminder == null && isReminder; + + final formattedDate = appearance.dateFormat + .formatDate(parsedDate!, includeTime, appearance.timeFormat); + + final options = DatePickerOptions( + selectedDay: parsedDate, + focusedDay: parsedDate, + firstDay: isReminder + ? noReminder + ? parsedDate + : DateTime.now() + : null, + lastDay: noReminder ? parsedDate : null, + includeTime: includeTime, + timeFormat: appearance.timeFormat, + onIncludeTimeChanged: (includeTime) { + _updateBlock(parsedDate!.withoutTime, includeTime); + + // We can remove time from the date/reminder + // block when toggled off. + if (!includeTime && isReminder) { + _updateScheduledAt( + reminderId: reminderId!, + selectedDay: parsedDate!.withoutTime, + ); + } + }, + onDaySelected: (selectedDay, focusedDay, includeTime) { + parsedDate = selectedDay; + + _updateBlock(selectedDay, includeTime); + + if (isReminder && date != selectedDay.toIso8601String()) { + _updateScheduledAt( + reminderId: reminderId!, + selectedDay: selectedDay, + ); + } + }, + ); + + return GestureDetector( + onTapDown: (details) => DatePickerMenu( + context: context, + editorState: context.read(), + ).show(details.globalPosition, options: options), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + isReminder ? FlowySvgs.clock_alarm_s : FlowySvgs.date_s, + size: const Size.square(18.0), + color: isReminder && reminder?.isAck == true + ? Theme.of(context).colorScheme.error + : null, + ), + const HSpace(2), + FlowyText( + formattedDate, + fontSize: fontSize, + color: isReminder && reminder?.isAck == true + ? Theme.of(context).colorScheme.error + : null, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } + + void _updateBlock( + DateTime date, [ + bool includeTime = false, + ]) { + final editorState = editorContext.read(); + final transaction = editorState.transaction + ..formatText(node, index, 1, { + MentionBlockKeys.mention: { + MentionBlockKeys.type: + isReminder ? MentionType.reminder.name : MentionType.date.name, + MentionBlockKeys.date: date.toIso8601String(), + MentionBlockKeys.uid: reminderId, + MentionBlockKeys.includeTime: includeTime, + }, + }); + + editorState.apply(transaction, withUpdateSelection: false); + + // Length of rendered block changes, this synchronizes + // the cursor with the new block render + editorState.updateSelectionWithReason( + editorState.selection, + reason: SelectionUpdateReason.transaction, + ); + } + + void _updateScheduledAt({ + required String reminderId, + required DateTime selectedDay, + }) { + editorContext.read().add( + ReminderEvent.update( + ReminderUpdate(id: reminderId, scheduledAt: selectedDay), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart index 46389d0a06..6c77edb4d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -135,7 +135,7 @@ class _OutlineBlockWidgetState extends State ), ); } - return Container( + return DecoratedBox( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8.0)), color: backgroundColor, 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 1f37d666b5..8742a48c7c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,8 +1,11 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.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/plugins/inline_actions/inline_actions_menu.dart'; + import 'package:appflowy/util/google_font_family_extension.dart'; + import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -161,6 +164,17 @@ class EditorStyleCustomizer { ); } + InlineActionsMenuStyle inlineActionsMenuStyleBuilder() { + final theme = Theme.of(context); + return InlineActionsMenuStyle( + backgroundColor: theme.cardColor, + groupTextColor: theme.colorScheme.onBackground.withOpacity(.8), + menuItemTextColor: theme.colorScheme.onBackground, + menuItemSelectedColor: theme.hoverColor, + menuItemSelectedTextColor: theme.colorScheme.onSurface, + ); + } + FloatingToolbarStyle floatingToolbarStyleBuilder() { final theme = Theme.of(context); return FloatingToolbarStyle( @@ -203,19 +217,28 @@ class EditorStyleCustomizer { } } - // customize the inline mention block, like inline page - final mention = attributes[MentionBlockKeys.mention] as Map?; + // Inline Mentions (Page Reference, Date, Reminder, etc.) + final mention = + attributes[MentionBlockKeys.mention] as Map?; if (mention != null) { final type = mention[MentionBlockKeys.type]; - if (type == MentionType.page.name) { - return WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: MentionBlock( - key: ValueKey(mention[MentionBlockKeys.pageId]), - mention: mention, + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: MentionBlock( + key: ValueKey( + switch (type) { + MentionType.page => mention[MentionBlockKeys.pageId], + MentionType.date || + MentionType.reminder => + mention[MentionBlockKeys.date], + _ => MentionBlockKeys.mention, + }, ), - ); - } + node: node, + index: index, + mention: mention, + ), + ); } // customize the inline math equation block diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart new file mode 100644 index 0000000000..95243f8a65 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/date/date_service.dart'; +import 'package:appflowy/generated/locale_keys.g.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_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +final _keywords = [ + LocaleKeys.inlineActions_date.tr().toLowerCase(), +]; + +class DateReferenceService { + DateReferenceService(this.context) { + // Initialize locale + _locale = context.locale.toLanguageTag(); + + // Initializes options + _setOptions(); + } + + final BuildContext context; + + late String _locale; + late List _allOptions; + + List options = []; + + Future dateReferenceDelegate([ + String? search, + ]) async { + // Checks if Locale has changed since last + _setLocale(); + + // Filters static options + _filterOptions(search); + + // Searches for date by pattern + _searchDate(search); + + // Searches for date by natural language prompt + await _searchDateNLP(search); + + return InlineActionsResult( + title: LocaleKeys.inlineActions_date.tr(), + results: options, + ); + } + + void _filterOptions(String? search) { + if (search == null || search.isEmpty) { + options = _allOptions; + return; + } + + options = _allOptions + .where( + (option) => + option.keywords != null && + option.keywords!.isNotEmpty && + option.keywords!.any( + (keyword) => keyword.contains(search.toLowerCase()), + ), + ) + .toList(); + + if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) { + options = _allOptions; + } + } + + void _searchDate(String? search) { + if (search == null || search.isEmpty) { + return; + } + + try { + final date = DateFormat.yMd(_locale).parse(search); + options.insert(0, _itemFromDate(date)); + } catch (_) { + return; + } + } + + Future _searchDateNLP(String? search) async { + if (search == null || search.isEmpty) { + return; + } + + final result = await DateService.queryDate(search); + + result.fold( + (l) {}, + (date) => options.insert(0, _itemFromDate(date)), + ); + } + + Future _insertDateReference( + EditorState editorState, + DateTime date, + int start, + int end, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = editorState.transaction + ..replaceText( + node, + start, + end, + '\$', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date.toIso8601String(), + } + }, + ); + + await editorState.apply(transaction); + } + + void _setOptions() { + final today = DateTime.now(); + final tomorrow = today.add(const Duration(days: 1)); + final yesterday = today.subtract(const Duration(days: 1)); + + _allOptions = [ + _itemFromDate( + today, + LocaleKeys.relativeDates_today.tr(), + [DateFormat.yMd(_locale).format(today)], + ), + _itemFromDate( + tomorrow, + LocaleKeys.relativeDates_tomorrow.tr(), + [DateFormat.yMd(_locale).format(tomorrow)], + ), + _itemFromDate( + yesterday, + LocaleKeys.relativeDates_yesterday.tr(), + [DateFormat.yMd(_locale).format(yesterday)], + ), + ]; + } + + /// Sets Locale on each search to make sure + /// keywords are localized + void _setLocale() { + final locale = context.locale.toLanguageTag(); + + if (locale != _locale) { + _locale = locale; + _setOptions(); + } + } + + InlineActionsMenuItem _itemFromDate( + DateTime date, [ + String? label, + List? keywords, + ]) { + final labelStr = label ?? DateFormat.yMd(_locale).format(date); + + return InlineActionsMenuItem( + label: labelStr.capitalize(), + keywords: [labelStr.toLowerCase(), ...?keywords], + onSelected: (context, editorState, menuService, replace) => + _insertDateReference( + editorState, + date, + replace.$1, + replace.$2, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart new file mode 100644 index 0000000000..228739c748 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class InlinePageReferenceService { + InlinePageReferenceService() { + init(); + } + + final Completer _initCompleter = Completer(); + + late final ViewBackendService service; + List _items = []; + List _filtered = []; + + Future init() async { + service = ViewBackendService(); + + _generatePageItems().then((value) { + _items = value; + _filtered = value; + _initCompleter.complete(); + }); + } + + Future> _filterItems(String? search) async { + await _initCompleter.future; + + if (search == null || search.isEmpty) { + return _items; + } + + return _items + .where( + (item) => + item.keywords != null && + item.keywords!.isNotEmpty && + item.keywords!.any( + (keyword) => keyword.contains(search.toLowerCase()), + ), + ) + .toList(); + } + + Future inlinePageReferenceDelegate([ + String? search, + ]) async { + _filtered = await _filterItems(search); + + return InlineActionsResult( + title: LocaleKeys.inlineActions_pageReference.tr(), + results: _filtered, + ); + } + + Future> _generatePageItems() async { + final views = await service.fetchViews(); + if (views.isEmpty) { + return []; + } + + final List pages = []; + views.sort(((a, b) => b.createTime.compareTo(a.createTime))); + + for (final view in views) { + final pageSelectionMenuItem = InlineActionsMenuItem( + keywords: [view.name.toLowerCase()], + label: view.name, + onSelected: (context, editorState, menuService, replace) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + // @page name -> $ + // preload the page infos + pageMemorizer[view.id] = view; + final transaction = editorState.transaction + ..replaceText( + node, + replace.$1, + replace.$2, + '\$', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: view.id, + } + }, + ); + + await editorState.apply(transaction); + }, + ); + + pages.add(pageSelectionMenuItem); + } + + return pages; + } +} 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 new file mode 100644 index 0000000000..c2ca8a7b8c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -0,0 +1,216 @@ +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_bloc.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'; + +final _keywords = [ + LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(), + LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), +]; + +class ReminderReferenceService { + ReminderReferenceService(this.context) { + // Initialize locale + _locale = context.locale.toLanguageTag(); + + // Initializes options + _setOptions(); + } + + final BuildContext context; + + late String _locale; + late List _allOptions; + + List options = []; + + Future reminderReferenceDelegate([ + String? search, + ]) async { + // Checks if Locale has changed since last + _setLocale(); + + // Filters static options + _filterOptions(search); + + // Searches for date by pattern + _searchDate(search); + + // Searches for date by natural language prompt + await _searchDateNLP(search); + + return _groupFromResults(options); + } + + InlineActionsResult _groupFromResults([ + List? options, + ]) => + InlineActionsResult( + title: LocaleKeys.inlineActions_reminder_groupTitle.tr(), + results: options ?? [], + startsWithKeywords: [ + LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(), + LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), + ], + ); + + void _filterOptions(String? search) { + if (search == null || search.isEmpty) { + options = _allOptions; + return; + } + + options = _allOptions + .where( + (option) => + option.keywords != null && + option.keywords!.isNotEmpty && + option.keywords!.any( + (keyword) => keyword.contains(search.toLowerCase()), + ), + ) + .toList(); + + if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) { + options = _allOptions; + } + } + + void _searchDate(String? search) { + if (search == null || search.isEmpty) { + return; + } + + try { + final date = DateFormat.yMd(_locale).parse(search); + options.insert(0, _itemFromDate(date)); + } catch (_) { + return; + } + } + + Future _searchDateNLP(String? search) async { + if (search == null || search.isEmpty) { + return; + } + + final result = await DateService.queryDate(search); + + result.fold( + (l) {}, + (date) { + // Only insert dates in the future + if (DateTime.now().isBefore(date)) { + options.insert(0, _itemFromDate(date)); + } + }, + ); + } + + Future _insertReminderReference( + EditorState editorState, + DateTime date, + int start, + int end, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final viewId = context.read().view.id; + final reminder = _reminderFromDate(date, viewId); + + context.read().add(ReminderEvent.add(reminder: reminder)); + + final transaction = editorState.transaction + ..replaceText( + node, + start, + end, + '\$', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.reminder.name, + MentionBlockKeys.date: date.toIso8601String(), + MentionBlockKeys.uid: reminder.id, + } + }, + ); + + await editorState.apply(transaction); + } + + void _setOptions() { + final today = DateTime.now(); + final tomorrow = today.add(const Duration(days: 1)); + final oneWeek = today.add(const Duration(days: 7)); + + _allOptions = [ + _itemFromDate( + tomorrow, + LocaleKeys.relativeDates_tomorrow.tr(), + [DateFormat.yMd(_locale).format(tomorrow)], + ), + _itemFromDate( + oneWeek, + LocaleKeys.relativeDates_oneWeek.tr(), + [DateFormat.yMd(_locale).format(oneWeek)], + ), + ]; + } + + /// Sets Locale on each search to make sure + /// keywords are localized + void _setLocale() { + final locale = context.locale.toLanguageTag(); + + if (locale != _locale) { + _locale = locale; + _setOptions(); + } + } + + InlineActionsMenuItem _itemFromDate( + DateTime date, [ + String? label, + List? keywords, + ]) { + final labelStr = label ?? DateFormat.yMd(_locale).format(date); + + return InlineActionsMenuItem( + label: labelStr.capitalize(), + keywords: [labelStr.toLowerCase(), ...?keywords], + onSelected: (context, editorState, menuService, replace) => + _insertReminderReference(editorState, date, replace.$1, replace.$2), + ); + } + + ReminderPB _reminderFromDate(DateTime date, String viewId) { + return ReminderPB( + id: nanoid(), + objectId: viewId, + title: LocaleKeys.reminderNotification_title.tr(), + message: LocaleKeys.reminderNotification_message.tr(), + meta: {"document_id": viewId}, + scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000), + isAck: date.isBefore(DateTime.now()), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart new file mode 100644 index 0000000000..c1004d63f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +const inlineActionCharacter = '@'; + +CharacterShortcutEvent inlineActionsCommand( + InlineActionsService inlineActionsService, { + InlineActionsMenuStyle style = const InlineActionsMenuStyle.light(), +}) => + CharacterShortcutEvent( + key: 'Opens Inline Actions Menu', + character: inlineActionCharacter, + handler: (editorState) => inlineActionsCommandHandler( + editorState, + inlineActionsService, + style, + ), + ); + +InlineActionsMenuService? selectionMenuService; +Future inlineActionsCommandHandler( + EditorState editorState, + InlineActionsService service, + InlineActionsMenuStyle style, +) async { + final selection = editorState.selection; + if (PlatformExtension.isMobile || selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + await editorState.insertTextAtPosition( + inlineActionCharacter, + position: selection.start, + ); + + final List initialResults = []; + for (final handler in service.handlers) { + final group = await handler(); + + if (group.results.isNotEmpty) { + initialResults.add(group); + } + } + + if (service.context != null) { + selectionMenuService = InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ); + + selectionMenuService?.show(); + } + + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart new file mode 100644 index 0000000000..1dffa7edd0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -0,0 +1,237 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +abstract class InlineActionsMenuService { + InlineActionsMenuStyle get style; + + void show(); + void dismiss(); +} + +class InlineActionsMenu extends InlineActionsMenuService { + InlineActionsMenu({ + required this.context, + required this.editorState, + required this.service, + required this.initialResults, + required this.style, + }); + + final BuildContext context; + final EditorState editorState; + final InlineActionsService service; + final List initialResults; + + @override + final InlineActionsMenuStyle style; + + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _menuEntry?.remove(); + _menuEntry = null; + + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + selectionService.currentSelection.removeListener(_onSelectionChange); + } + } + + void _onSelectionUpdate() => selectionChangedByMenu = true; + + @override + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + dismiss(); + + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + + const double menuHeight = 200.0; + const Offset menuOffset = Offset(0, 10); + final Offset editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final Size editorSize = editorState.renderBox!.size; + + // Default to opening the overlay below + Alignment alignment = Alignment.topLeft; + + final firstRect = selectionRects.first; + Offset offset = firstRect.bottomRight + menuOffset; + + // Show above + if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { + offset = firstRect.topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + offset.dx, + MediaQuery.of(context).size.height - offset.dy, + ); + } + + // Show on the left + if (offset.dx > editorSize.width / 2) { + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorSize.width - offset.dx, + offset.dy, + ); + } + + final (left, top, right, bottom) = _getPosition(alignment, offset); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + + // GestureDetector handles clicks outside of the context menu, + // to dismiss the context menu. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: InlineActionsHandler( + service: service, + results: initialResults, + editorState: editorState, + menuService: this, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + style: style, + ), + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + selectionService.currentSelection.addListener(_onSelectionChange); + } + + void _onSelectionChange() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + if (selectionService.currentSelection.value == null) { + return; + } + } + + if (!selectionChangedByMenu) { + return dismiss(); + } + + selectionChangedByMenu = false; + } + + (double? left, double? top, double? right, double? bottom) _getPosition( + Alignment alignment, + Offset offset, + ) { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } +} + +class InlineActionsMenuStyle { + InlineActionsMenuStyle({ + required this.backgroundColor, + required this.groupTextColor, + required this.menuItemTextColor, + required this.menuItemSelectedColor, + required this.menuItemSelectedTextColor, + }); + + const InlineActionsMenuStyle.light() + : backgroundColor = Colors.white, + groupTextColor = const Color(0xFF555555), + menuItemTextColor = const Color(0xFF333333), + menuItemSelectedColor = const Color(0xFFE0F8FF), + menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247); + + const InlineActionsMenuStyle.dark() + : backgroundColor = const Color(0xFF282E3A), + groupTextColor = const Color(0xFFBBC3CD), + menuItemTextColor = const Color(0xFFBBC3CD), + menuItemSelectedColor = const Color(0xFF00BCF0), + menuItemSelectedTextColor = const Color(0xFF131720); + + /// The background color of the context menu itself + /// + final Color backgroundColor; + + /// The color of the [InlineActionsGroup]'s title text + /// + final Color groupTextColor; + + /// The text color of an [InlineActionsMenuItem] + /// + final Color menuItemTextColor; + + /// The background of the currently selected [InlineActionsMenuItem] + /// + final Color menuItemSelectedColor; + + /// The text color of the currently selected [InlineActionsMenuItem] + /// + final Color menuItemSelectedTextColor; +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart new file mode 100644 index 0000000000..c1f1a9e237 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +typedef SelectItemHandler = void Function( + BuildContext context, + EditorState editorState, + InlineActionsMenuService menuService, + (int start, int end) replacement, +); + +class InlineActionsMenuItem { + InlineActionsMenuItem({ + required this.label, + this.icon, + this.keywords, + this.onSelected, + }); + + final String label; + final Widget Function(bool onSelected)? icon; + final List? keywords; + final SelectItemHandler? onSelected; +} + +class InlineActionsResult { + InlineActionsResult({ + required this.title, + required this.results, + this.startsWithKeywords, + }); + + /// Localized title to be displayed above the results + /// of the current group. + /// + final String title; + + /// List of results that will be displayed for this group + /// made up of [SelectionMenuItem]s. + /// + final List results; + + /// If the search term start with one of these keyword, + /// the results will be reordered such that these results + /// will be above. + /// + final List? startsWithKeywords; +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart new file mode 100644 index 0000000000..d3a5623d2f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:flutter/material.dart'; + +typedef InlineActionsDelegate = Future Function([ + String? search, +]); + +abstract class _InlineActionsProvider { + void dispose(); +} + +class InlineActionsService extends _InlineActionsProvider { + InlineActionsService({ + required this.context, + required this.handlers, + }); + + /// The [BuildContext] in which to show the [InlineActionsMenu] + /// + BuildContext? context; + + final List handlers; + + /// This is a workaround for not having a mounted check. + /// Thus when the widget that uses the service is disposed, + /// we set the [BuildContext] to null. + /// + @override + void dispose() { + context = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart new file mode 100644 index 0000000000..9aa6f7ba01 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart @@ -0,0 +1,338 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_menu_group.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +extension _StartWithsSort on List { + void sortByStartsWithKeyword(String search) => sort( + (a, b) { + final aCount = a.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + final bCount = b.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + if (aCount > bCount) { + return -1; + } else if (bCount > aCount) { + return 1; + } + + return 0; + }, + ); +} + +const _invalidSearchesAmount = 20; + +class InlineActionsHandler extends StatefulWidget { + const InlineActionsHandler({ + super.key, + required this.service, + required this.results, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.onSelectionUpdate, + required this.style, + }); + + final InlineActionsService service; + final List results; + final EditorState editorState; + final InlineActionsMenuService menuService; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + final InlineActionsMenuStyle style; + + @override + State createState() => _InlineActionsHandlerState(); +} + +class _InlineActionsHandlerState extends State { + final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler'); + + late List results = widget.results; + int invalidCounter = 0; + late int startOffset; + + String _search = ''; + set search(String search) { + _search = search; + _doSearch(); + } + + Future _doSearch() async { + final List newResults = []; + for (final handler in widget.service.handlers) { + final group = await handler.call(_search); + + if (group.results.isNotEmpty) { + newResults.add(group); + } + } + + invalidCounter = results.every((group) => group.results.isEmpty) + ? invalidCounter + 1 + : 0; + + if (invalidCounter >= _invalidSearchesAmount) { + return widget.onDismiss(); + } + + _resetSelection(); + + newResults.sortByStartsWithKeyword(_search); + + setState(() { + results = newResults; + }); + } + + void _resetSelection() { + _selectedGroup = 0; + _selectedIndex = 0; + } + + int _selectedGroup = 0; + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + + startOffset = widget.editorState.selection?.endIndex ?? 0; + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + onKey: onKey, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.style.backgroundColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: noResults + ? SizedBox( + width: 150, + child: FlowyText.regular( + LocaleKeys.inlineActions_noResults.tr(), + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: results + .where((g) => g.results.isNotEmpty) + .mapIndexed( + (index, group) => InlineActionsGroup( + result: group, + editorState: widget.editorState, + menuService: widget.menuService, + style: widget.style, + isGroupSelected: _selectedGroup == index, + selectedIndex: _selectedIndex, + onSelected: widget.onDismiss, + ), + ) + .toList(), + ), + ), + ), + ); + } + + bool get noResults => + results.isEmpty || results.every((e) => e.results.isEmpty); + + int get groupLength => results.length; + + int lengthOfGroup(int index) => results[index].results.length; + + InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) => + results[groupIndex].results[handlerIndex]; + + KeyEventResult onKey(focus, event) { + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + + const moveKeys = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.tab, + ]; + + if (event.logicalKey == LogicalKeyboardKey.enter) { + if (_selectedGroup <= groupLength && + _selectedIndex <= lengthOfGroup(_selectedGroup)) { + handlerOf(_selectedGroup, _selectedIndex).onSelected?.call( + context, + widget.editorState, + widget.menuService, + (startOffset - 1, _search.length + 1), + ); + + widget.onDismiss(); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onDismiss(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_search.isEmpty) { + widget.onDismiss(); + widget.editorState.deleteBackward(); // Delete '@' + } else { + widget.onSelectionUpdate(); + widget.editorState.deleteBackward(); + _deleteCharacterAtSelection(); + } + + return KeyEventResult.handled; + } else if (event.character != null && + ![ + ...moveKeys, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight + ].contains(event.logicalKey)) { + /// Prevents dismissal of context menu by notifying the parent + /// that the selection change occurred from the handler. + widget.onSelectionUpdate(); + + // Interpolation to avoid having a getter for private variable + _insertCharacter(event.character!); + return KeyEventResult.handled; + } + + if (moveKeys.contains(event.logicalKey)) { + _moveSelection(event.logicalKey); + return KeyEventResult.handled; + } + + if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight] + .contains(event.logicalKey)) { + widget.onSelectionUpdate(); + + event.logicalKey == LogicalKeyboardKey.arrowLeft + ? widget.editorState.moveCursorForward(SelectionMoveRange.character) + : widget.editorState.moveCursorBackward(SelectionMoveRange.character); + + /// If cursor moves before @ then dismiss menu + /// If cursor moves after @search.length then dismiss menu + final selection = widget.editorState.selection; + if (selection != null && + (selection.endIndex < startOffset || + selection.endIndex > (startOffset + _search.length))) { + widget.onDismiss(); + } + + /// Workaround: When using the move cursor methods, it seems the + /// focus goes back to the editor, this makes sure this handler + /// receives the next keypress. + /// + _focusNode.requestFocus(); + + return KeyEventResult.handled; + } + + return KeyEventResult.handled; + } + + void _insertCharacter(String character) { + widget.editorState.insertTextAtCurrentSelection(character); + + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta; + if (delta == null) { + return; + } + + /// Grab index of the first character in command (right after @) + final startIndex = + delta.toPlainText().lastIndexOf(inlineActionCharacter) + 1; + + search = widget.editorState + .getTextInSelection( + selection.copyWith( + start: selection.start.copyWith(offset: startIndex), + end: selection.start + .copyWith(offset: startIndex + _search.length + 1), + ), + ) + .join(); + } + + void _moveSelection(LogicalKeyboardKey key) { + if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab].contains(key)) { + if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) { + _selectedIndex += 1; + } else if (_selectedGroup < groupLength - 1) { + _selectedGroup += 1; + _selectedIndex = 0; + } + } else if (key == LogicalKeyboardKey.arrowUp) { + if (_selectedIndex == 0 && _selectedGroup > 0) { + _selectedGroup -= 1; + _selectedIndex = lengthOfGroup(_selectedGroup) - 1; + } else if (_selectedIndex > 0) { + _selectedIndex -= 1; + } + } + + if (mounted) { + setState(() {}); + } + } + + void _deleteCharacterAtSelection() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = widget.editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + search = delta + .toPlainText() + .substring(startOffset, startOffset - 1 + _search.length); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart new file mode 100644 index 0000000000..1f568e8f3e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class InlineActionsGroup extends StatelessWidget { + const InlineActionsGroup({ + super.key, + required this.result, + required this.editorState, + required this.menuService, + required this.style, + required this.onSelected, + this.isGroupSelected = false, + this.selectedIndex = 0, + }); + + final InlineActionsResult result; + final EditorState editorState; + final InlineActionsMenuService menuService; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + + final bool isGroupSelected; + final int selectedIndex; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium(result.title, color: style.groupTextColor), + const SizedBox(height: 4), + ...result.results.mapIndexed( + (index, item) => InlineActionsWidget( + item: item, + editorState: editorState, + menuService: menuService, + isSelected: isGroupSelected && index == selectedIndex, + style: style, + onSelected: onSelected, + ), + ), + ], + ), + ); + } +} + +class InlineActionsWidget extends StatefulWidget { + const InlineActionsWidget({ + super.key, + required this.item, + required this.editorState, + required this.menuService, + required this.isSelected, + required this.style, + required this.onSelected, + }); + + final InlineActionsMenuItem item; + final EditorState editorState; + final InlineActionsMenuService menuService; + final bool isSelected; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + + @override + State createState() => _InlineActionsWidgetState(); +} + +class _InlineActionsWidgetState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SizedBox( + width: 200, + child: widget.item.icon != null + ? TextButton.icon( + onPressed: _onPressed, + style: ButtonStyle( + alignment: Alignment.centerLeft, + backgroundColor: widget.isSelected + ? MaterialStateProperty.all( + widget.style.menuItemSelectedColor, + ) + : MaterialStateProperty.all(Colors.transparent), + ), + icon: widget.item.icon!.call(widget.isSelected || isHovering), + label: FlowyText.regular( + widget.item.label, + color: widget.isSelected + ? widget.style.menuItemSelectedTextColor + : widget.style.menuItemTextColor, + ), + ) + : TextButton( + onPressed: _onPressed, + style: ButtonStyle( + alignment: Alignment.centerLeft, + backgroundColor: widget.isSelected + ? MaterialStateProperty.all( + widget.style.menuItemSelectedColor, + ) + : MaterialStateProperty.all(Colors.transparent), + ), + onHover: (value) => setState(() => isHovering = value), + child: FlowyText.regular( + widget.item.label, + color: widget.isSelected + ? widget.style.menuItemSelectedTextColor + : widget.style.menuItemTextColor, + ), + ), + ), + ); + } + + void _onPressed() { + widget.onSelected(); + widget.item.onSelected?.call( + context, + widget.editorState, + widget.menuService, + (0, 0), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 66c53b288f..22de7bb050 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -15,11 +15,13 @@ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/mock_auth_service.dart'; import 'package:appflowy/user/application/auth/supabase_auth_service.dart'; import 'package:appflowy/user/application/prelude.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/user/application/user_listener.dart'; import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/user/prelude.dart'; @@ -133,7 +135,11 @@ void _resolveHomeDeps(GetIt getIt) { (view, _) => DocShareBloc(view: view), ); + getIt.registerSingleton(NotificationActionBloc()); + getIt.registerLazySingleton(() => TabsBloc()); + + getIt.registerSingleton(ReminderBloc()); } void _resolveFolderDeps(GetIt getIt) { diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index 5ec3ab6e76..2c2db1bcdd 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -1,4 +1,5 @@ import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_service.dart'; import 'package:appflowy/startup/tasks/prelude.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; @@ -22,15 +23,22 @@ class InitAppWidgetTask extends LaunchTask { @override Future initialize(LaunchContext context) async { + WidgetsFlutterBinding.ensureInitialized(); + + await NotificationService.initialize(); + final widget = context.getIt().create(context.config); final appearanceSetting = await UserSettingsBackendService().getAppearanceSetting(); + final dateTimeSettings = + await UserSettingsBackendService().getDateTimeSettings(); // If the passed-in context is not the same as the context of the // application widget, the application widget will be rebuilt. final app = ApplicationWidget( key: ValueKey(context), appearanceSetting: appearanceSetting, + dateTimeSettings: dateTimeSettings, appTheme: await appTheme(appearanceSetting.theme), child: widget, ); @@ -71,21 +79,23 @@ class InitAppWidgetTask extends LaunchTask { ), ); - return Future(() => {}); + return; } } class ApplicationWidget extends StatefulWidget { - final Widget child; - final AppearanceSettingsPB appearanceSetting; - final AppTheme appTheme; - const ApplicationWidget({ - Key? key, + super.key, required this.child, required this.appTheme, required this.appearanceSetting, - }) : super(key: key); + required this.dateTimeSettings, + }); + + final Widget child; + final AppearanceSettingsPB appearanceSetting; + final AppTheme appTheme; + final DateTimeSettingsPB dateTimeSettings; @override State createState() => _ApplicationWidgetState(); @@ -109,6 +119,7 @@ class _ApplicationWidgetState extends State { BlocProvider( create: (_) => AppearanceSettingsCubit( widget.appearanceSetting, + widget.dateTimeSettings, widget.appTheme, )..readLocaleWhenAppLaunch(context), ), diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart new file mode 100644 index 0000000000..7469b8f967 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -0,0 +1,232 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_service.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_action.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +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 Timer timer; + + ReminderBloc() : super(ReminderState()) { + actionBloc = getIt(); + reminderService = const ReminderService(); + timer = _periodicCheck(); + + on((event, emit) async { + await event.when( + started: () async { + final remindersOrFailure = await reminderService.fetchReminders(); + + remindersOrFailure.fold( + (error) => Log.error(error), + (reminders) => _updateState(emit, reminders), + ); + }, + remove: (reminderId) async { + final unitOrFailure = + await reminderService.removeReminder(reminderId: reminderId); + + unitOrFailure.fold( + (error) => Log.error(error), + (_) { + final reminders = [...state.reminders]; + reminders.removeWhere((e) => e.id == reminderId); + _updateState(emit, reminders); + }, + ); + }, + add: (reminder) async { + final unitOrFailure = + await reminderService.addReminder(reminder: reminder); + + return unitOrFailure.fold( + (error) => Log.error(error), + (_) { + state.reminders.add(reminder); + _updateState(emit, state.reminders); + }, + ); + }, + update: (updateObject) async { + final reminder = + state.reminders.firstWhereOrNull((r) => r.id == updateObject.id); + + if (reminder == null) { + return; + } + + final newReminder = updateObject.merge(a: reminder); + final failureOrUnit = await reminderService.updateReminder( + reminder: updateObject.merge(a: reminder), + ); + + failureOrUnit.fold( + (error) => Log.error(error), + (_) { + final index = + state.reminders.indexWhere((r) => r.id == reminder.id); + final reminders = [...state.reminders]; + reminders.replaceRange(index, index + 1, [newReminder]); + _updateState(emit, reminders); + }, + ); + }, + pressReminder: (reminderId) { + final reminder = + state.reminders.firstWhereOrNull((r) => r.id == reminderId); + + if (reminder == null) { + return; + } + + add( + ReminderEvent.update(ReminderUpdate(id: reminderId, isRead: true)), + ); + + actionBloc.add( + NotificationActionEvent.performAction( + action: NotificationAction(objectId: reminder.objectId), + ), + ); + }, + ); + }); + } + + void _updateState(Emitter emit, List reminders) { + final now = DateTime.now(); + final hasUnreads = reminders.any( + (r) => + DateTime.fromMillisecondsSinceEpoch(r.scheduledAt.toInt() * 1000) + .isBefore(now) && + !r.isRead, + ); + emit(state.copyWith(reminders: reminders, hasUnreads: hasUnreads)); + } + + Timer _periodicCheck() { + return Timer.periodic( + const Duration(minutes: 1), + (_) { + final now = DateTime.now(); + for (final reminder in state.reminders) { + if (reminder.isAck) { + continue; + } + + final scheduledAt = DateTime.fromMillisecondsSinceEpoch( + reminder.scheduledAt.toInt() * 1000, + ); + + if (scheduledAt.isBefore(now)) { + NotificationMessage( + identifier: reminder.id, + title: LocaleKeys.reminderNotification_title.tr(), + body: LocaleKeys.reminderNotification_message.tr(), + onClick: () => actionBloc.add( + NotificationActionEvent.performAction( + action: NotificationAction(objectId: reminder.objectId), + ), + ), + ); + + add( + ReminderEvent.update( + ReminderUpdate(id: reminder.id, isAck: true), + ), + ); + } + } + }, + ); + } +} + +@freezed +class ReminderEvent with _$ReminderEvent { + // On startup we fetch all reminders and upcoming ones + const factory ReminderEvent.started() = _Started; + + // Remove a reminder + const factory ReminderEvent.remove({required String reminderId}) = _Remove; + + // Add a reminder + const factory ReminderEvent.add({required ReminderPB reminder}) = _Add; + + // Update a reminder (eg. isAck, isRead, etc.) + const factory ReminderEvent.update(ReminderUpdate update) = _Update; + + const factory ReminderEvent.pressReminder({required String reminderId}) = + _PressReminder; +} + +/// Object used to merge updates with +/// a [ReminderPB] +/// +class ReminderUpdate { + final String id; + final bool? isAck; + final bool? isRead; + final DateTime? scheduledAt; + + ReminderUpdate({ + required this.id, + this.isAck, + this.isRead, + this.scheduledAt, + }); + + ReminderPB merge({required ReminderPB a}) { + final isAcknowledged = isAck == null && scheduledAt != null + ? scheduledAt!.isBefore(DateTime.now()) + : a.isAck; + + return ReminderPB( + id: a.id, + objectId: a.objectId, + scheduledAt: scheduledAt != null + ? Int64(scheduledAt!.millisecondsSinceEpoch ~/ 1000) + : a.scheduledAt, + isAck: isAcknowledged, + isRead: isRead ?? a.isRead, + title: a.title, + message: a.message, + meta: a.meta, + ); + } +} + +class ReminderState { + ReminderState({ + List? reminders, + bool? hasUnreads, + }) : reminders = reminders ?? [], + hasUnreads = hasUnreads ?? false; + + final List reminders; + final bool hasUnreads; + + ReminderState copyWith({ + List? reminders, + bool? hasUnreads, + }) => + ReminderState( + reminders: reminders ?? this.reminders, + hasUnreads: hasUnreads ?? this.hasUnreads, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart new file mode 100644 index 0000000000..4b9aaf1fb3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart @@ -0,0 +1,58 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:dartz/dartz.dart'; + +/// Interface for a Reminder Service that handles +/// communication to the backend +/// +abstract class IReminderService { + Future>> fetchReminders(); + + Future> removeReminder({required String reminderId}); + + Future> addReminder({required ReminderPB reminder}); + + Future> updateReminder({ + required ReminderPB reminder, + }); +} + +class ReminderService implements IReminderService { + const ReminderService(); + + @override + Future> addReminder({ + required ReminderPB reminder, + }) async { + final unitOrFailure = await UserEventCreateReminder(reminder).send(); + + return unitOrFailure.swap(); + } + + @override + Future> updateReminder({ + required ReminderPB reminder, + }) async { + final unitOrFailure = await UserEventUpdateReminder(reminder).send(); + + return unitOrFailure.swap(); + } + + @override + Future>> fetchReminders() async { + final resultOrFailure = await UserEventGetAllReminders().send(); + + return resultOrFailure.swap().fold((l) => left(l), (r) => right(r.items)); + } + + @override + Future> removeReminder({ + required String reminderId, + }) async { + final request = ReminderIdentifierPB(id: reminderId); + final unitOrFailure = await UserEventRemoveReminder(request).send(); + + return unitOrFailure.swap(); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart b/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart index ae1debfc5b..718d37b42e 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart @@ -10,12 +10,9 @@ class UserSettingsBackendService { final result = await UserEventGetAppearanceSetting().send(); return result.fold( - (AppearanceSettingsPB setting) { - return setting; - }, - (error) { - throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty); - }, + (AppearanceSettingsPB setting) => setting, + (error) => + throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), ); } @@ -28,4 +25,20 @@ class UserSettingsBackendService { ) { return UserEventSetAppearanceSetting(setting).send(); } + + Future getDateTimeSettings() async { + final result = await UserEventGetDateTimeSettings().send(); + + return result.fold( + (DateTimeSettingsPB setting) => setting, + (error) => + throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), + ); + } + + Future> setDateTimeSettings( + DateTimeSettingsPB settings, + ) async { + return (await UserEventSetDateTimeSettings(settings).send()).swap(); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance.dart index f68bb70739..2551c0d880 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance.dart @@ -5,6 +5,7 @@ import 'package:appflowy/util/platform_extension.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/mobile/application/mobile_theme_data.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; @@ -23,11 +24,14 @@ const _white = Color(0xFFFFFFFF); /// It includes the [AppTheme], [ThemeMode], [TextStyles] and [Locale]. class AppearanceSettingsCubit extends Cubit { final AppearanceSettingsPB _setting; + final DateTimeSettingsPB _dateTimeSettings; AppearanceSettingsCubit( AppearanceSettingsPB setting, + DateTimeSettingsPB dateTimeSettings, AppTheme appTheme, ) : _setting = setting, + _dateTimeSettings = dateTimeSettings, super( AppearanceSettingsState.initial( appTheme, @@ -39,6 +43,9 @@ class AppearanceSettingsCubit extends Cubit { setting.locale, setting.isMenuCollapsed, setting.menuOffset, + dateTimeSettings.dateFormat, + dateTimeSettings.timeFormat, + dateTimeSettings.timezoneId, ), ); @@ -173,6 +180,29 @@ class AppearanceSettingsCubit extends Cubit { setLocale(context, state.locale); } + void setDateFormat(UserDateFormatPB format) { + _dateTimeSettings.dateFormat = format; + _saveDateTimeSettings(); + emit(state.copyWith(dateFormat: format)); + } + + void setTimeFormat(UserTimeFormatPB format) { + _dateTimeSettings.timeFormat = format; + _saveDateTimeSettings(); + emit(state.copyWith(timeFormat: format)); + } + + Future _saveDateTimeSettings() async { + UserSettingsBackendService() + .setDateTimeSettings(_dateTimeSettings) + .then((result) { + result.fold( + (error) => Log.error(error), + (_) => null, + ); + }); + } + Future _saveAppearanceSettings() async { UserSettingsBackendService().setAppearanceSetting(_setting).then((result) { result.fold( @@ -271,6 +301,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState { required Locale locale, required bool isMenuCollapsed, required double menuOffset, + required UserDateFormatPB dateFormat, + required UserTimeFormatPB timeFormat, + required String timezoneId, }) = _AppearanceSettingsState; factory AppearanceSettingsState.initial( @@ -283,6 +316,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState { LocaleSettingsPB localePB, bool isMenuCollapsed, double menuOffset, + UserDateFormatPB dateFormat, + UserTimeFormatPB timeFormat, + String timezoneId, ) { return AppearanceSettingsState( appTheme: appTheme, @@ -294,6 +330,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState { locale: Locale(localePB.languageCode, localePB.countryCode), isMenuCollapsed: isMenuCollapsed, menuOffset: menuOffset, + dateFormat: dateFormat, + timeFormat: timeFormat, + timezoneId: timezoneId, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action.dart b/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action.dart new file mode 100644 index 0000000000..82b10c1c7e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action.dart @@ -0,0 +1,19 @@ +enum ActionType { + openView, +} + +/// A [NotificationAction] is used to communicate with the +/// [NotificationActionBloc] to perform actions based on an event +/// triggered by pressing a notification, such as opening a specific +/// view and jumping to a specific block. +/// +class NotificationAction { + const NotificationAction({ + this.type = ActionType.openView, + required this.objectId, + }); + + final ActionType type; + + final String objectId; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action_bloc.dart new file mode 100644 index 0000000000..e3af97103c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_action_bloc.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/workspace/application/local_notifications/notification_action.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notification_action_bloc.freezed.dart'; + +class NotificationActionBloc + extends Bloc { + NotificationActionBloc() : super(const NotificationActionState.initial()) { + on((event, emit) async { + event.when( + performAction: (action) { + emit(state.copyWith(action: action)); + }, + ); + }); + } +} + +@freezed +class NotificationActionEvent with _$NotificationActionEvent { + const factory NotificationActionEvent.performAction({ + required NotificationAction action, + }) = _PerformAction; +} + +class NotificationActionState { + const NotificationActionState({required this.action}); + + final NotificationAction? action; + + const NotificationActionState.initial() : action = null; + + NotificationActionState copyWith({ + NotificationAction? action, + }) => + NotificationActionState(action: action ?? this.action); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_service.dart b/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_service.dart new file mode 100644 index 0000000000..d85b644f78 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/local_notifications/notification_service.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:local_notifier/local_notifier.dart'; + +const _appName = "AppFlowy"; + +/// Manages Local Notifications +/// +/// Currently supports: +/// - MacOS +/// - Windows +/// - Linux +/// +class NotificationService { + static Future initialize() async { + await localNotifier.setup( + appName: _appName, + shortcutPolicy: ShortcutPolicy.requireCreate, // Windows Specific + ); + } +} + +/// Creates and shows a Notification +/// +class NotificationMessage { + NotificationMessage({ + required String title, + required String body, + String? identifier, + VoidCallback? onClick, + }) { + _notification = LocalNotification( + identifier: identifier, + title: title, + body: body, + )..onClick = onClick; + + _show(); + } + + late final LocalNotification _notification; + + void _show() => _notification.show(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart new file mode 100644 index 0000000000..4e1e6c5ff6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart @@ -0,0 +1,41 @@ +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; + +const _localFmt = 'M/d/y'; +const _usFmt = 'y/M/d'; +const _isoFmt = 'ymd'; +const _friendlyFmt = 'MMM d, y'; +const _dmyFmt = 'd/M/y'; + +extension DateFormatter on UserDateFormatPB { + DateFormat get toFormat => DateFormat(_toFormat[this] ?? _friendlyFmt); + + String formatDate( + DateTime date, + bool includeTime, [ + UserTimeFormatPB? timeFormat, + ]) { + final format = toFormat; + + if (includeTime) { + switch (timeFormat) { + case UserTimeFormatPB.TwentyFourHour: + return format.add_Hm().format(date); + case UserTimeFormatPB.TwelveHour: + return format.add_jm().format(date); + default: + return format.format(date); + } + } + + return format.format(date); + } +} + +final _toFormat = { + UserDateFormatPB.Locally: _localFmt, + UserDateFormatPB.US: _usFmt, + UserDateFormatPB.ISO: _isoFmt, + UserDateFormatPB.Friendly: _friendlyFmt, + UserDateFormatPB.DayMonthYear: _dmyFmt, +}; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart new file mode 100644 index 0000000000..57443b23f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_patterns.dart @@ -0,0 +1,18 @@ +/// RegExp to match Twelve Hour formats +/// Source: https://stackoverflow.com/a/33906224 +/// +/// Matches eg: "05:05 PM", "5:50 Pm", "10:59 am", etc. +/// +final _twelveHourTimePattern = + RegExp(r'\b((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))'); +bool isTwelveHourTime(String? time) => + _twelveHourTimePattern.hasMatch(time ?? ''); + +/// RegExp to match Twenty Four Hour formats +/// Source: https://stackoverflow.com/a/7536768 +/// +/// Matches eg: "0:01", "04:59", "16:30", etc. +/// +final _twentyFourHourtimePattern = RegExp(r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'); +bool isTwentyFourHourTime(String? time) => + _twentyFourHourtimePattern.hasMatch(time ?? ''); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart index f2e13dddf8..7de744024a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -2,6 +2,7 @@ import 'package:appflowy/plugins/blank/blank.dart'; import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/appearance.dart'; import 'package:appflowy/workspace/application/home/home_bloc.dart'; import 'package:appflowy/workspace/application/home/home_service.dart'; @@ -57,6 +58,9 @@ class DesktopHomeScreen extends StatelessWidget { return MultiBlocProvider( key: ValueKey(userProfile!.id), providers: [ + BlocProvider.value( + value: getIt()..add(const ReminderEvent.started()), + ), BlocProvider.value(value: getIt()), BlocProvider( create: (context) { 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 0c4dcddcd4..3bb4a5e1b6 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,4 +1,7 @@ +import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_action.dart'; +import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart'; import 'package:appflowy/workspace/application/menu/menu_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart'; @@ -10,6 +13,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' show UserProfilePB; +import 'package:collection/collection.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -36,6 +40,9 @@ class HomeSideBar extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ + BlocProvider( + create: (_) => getIt(), + ), BlocProvider( create: (_) => MenuBloc( user: user, @@ -46,11 +53,34 @@ class HomeSideBar extends StatelessWidget { create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), ) ], - child: BlocListener( - listenWhen: (p, c) => p.plugin.id != c.plugin.id, - listener: (context, state) => context - .read() - .add(TabsEvent.openPlugin(plugin: state.plugin)), + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => p.plugin.id != c.plugin.id, + listener: (context, state) => context + .read() + .add(TabsEvent.openPlugin(plugin: state.plugin)), + ), + BlocListener( + listener: (context, state) { + final action = state.action; + if (action != null) { + switch (action.type) { + case ActionType.openView: + final view = context + .read() + .state + .views + .firstWhereOrNull((view) => action.objectId == view.id); + + if (view != null) { + context.read().openPlugin(view); + } + } + } + }, + ), + ], child: Builder( builder: (context) { final menuState = context.watch().state; @@ -88,7 +118,7 @@ class HomeSideBar extends StatelessWidget { // top menu const SidebarTopMenu(), // user, setting - SidebarUser(user: user), + SidebarUser(user: user, views: views), const VSpace(20), // scrollable document list Expanded( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart index 36b3ff0c38..6726886e95 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart @@ -2,7 +2,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/notifications/notification_button.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; @@ -17,9 +19,11 @@ class SidebarUser extends StatelessWidget { const SidebarUser({ super.key, required this.user, + required this.views, }); final UserProfilePB user; + final List views; @override Widget build(BuildContext context) { @@ -41,6 +45,8 @@ class SidebarUser extends StatelessWidget { child: _buildUserName(context, state), ), _buildSettingsButton(context, state), + const HSpace(4), + NotificationButton(views: views), ], ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart index 995f8afbc2..4a91985fb9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart @@ -10,7 +10,7 @@ class FlowyMessageToast extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return DecoratedBox( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), color: Theme.of(context).colorScheme.surface, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart new file mode 100644 index 0000000000..34d1b67639 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_button.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/notifications/notification_dialog.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +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/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NotificationButton extends StatelessWidget { + const NotificationButton({super.key, required this.views}); + + final List views; + + @override + Widget build(BuildContext context) { + final mutex = PopoverMutex(); + + return BlocProvider.value( + value: getIt(), + child: BlocBuilder( + builder: (context, state) => Tooltip( + message: LocaleKeys.notificationHub_title.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: AppFlowyPopover( + mutex: mutex, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(maxHeight: 250, maxWidth: 300), + popupBuilder: (_) => + NotificationDialog(views: views, mutex: mutex), + child: _buildNotificationIcon(context, state.hasUnreads), + ), + ), + ), + ), + ); + } + + Widget _buildNotificationIcon(BuildContext context, bool hasUnreads) { + return Stack( + children: [ + FlowySvg( + FlowySvgs.clock_alarm_s, + size: const Size.square(24), + color: Theme.of(context).colorScheme.tertiary, + ), + if (hasUnreads) + Positioned( + bottom: 2, + right: 2, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AFThemeExtension.of(context).warning, + ), + child: const SizedBox(height: 8, width: 8), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart new file mode 100644 index 0000000000..f61df0111c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -0,0 +1,123 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/notifications/notification_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +extension _ReminderReady on ReminderPB { + DateTime get scheduledDate => + DateTime.fromMillisecondsSinceEpoch(scheduledAt.toInt() * 1000); + + bool isBefore(DateTime date) => scheduledDate.isBefore(date); +} + +class NotificationDialog extends StatelessWidget { + const NotificationDialog({ + super.key, + required this.views, + required this.mutex, + }); + + final List views; + final PopoverMutex mutex; + + @override + Widget build(BuildContext context) { + final reminderBloc = getIt(); + + return BlocProvider.value( + value: reminderBloc, + child: BlocBuilder( + builder: (context, state) { + final shownReminders = state.reminders + .where((reminder) => reminder.isBefore(DateTime.now())) + .sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt)); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 10, + ), + child: FlowyText.semibold( + LocaleKeys.notificationHub_title.tr(), + fontSize: 16, + ), + ), + ), + ), + ], + ), + const VSpace(4), + if (shownReminders.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: FlowyText.regular( + LocaleKeys.notificationHub_empty.tr(), + ), + ), + ) + else + ...shownReminders.map((reminder) { + return NotificationItem( + reminderId: reminder.id, + key: ValueKey(reminder.id), + title: reminder.title, + scheduled: reminder.scheduledAt, + body: reminder.message, + isRead: reminder.isRead, + onReadChanged: (isRead) => reminderBloc.add( + ReminderEvent.update( + ReminderUpdate(id: reminder.id, isRead: isRead), + ), + ), + onDelete: () => reminderBloc + .add(ReminderEvent.remove(reminderId: reminder.id)), + onAction: () { + final view = views.firstWhereOrNull( + (view) => view.id == reminder.objectId, + ); + + if (view == null) { + return; + } + + reminderBloc.add( + ReminderEvent.pressReminder(reminderId: reminder.id), + ); + + mutex.close(); + }, + ); + }), + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart new file mode 100644 index 0000000000..6dee6a819b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_item.dart @@ -0,0 +1,195 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +DateFormat _dateFormat(BuildContext context) => DateFormat('MMM d, y'); + +class NotificationItem extends StatefulWidget { + const NotificationItem({ + super.key, + required this.reminderId, + required this.title, + required this.scheduled, + required this.body, + required this.isRead, + this.onAction, + this.onDelete, + this.onReadChanged, + }); + + final String reminderId; + final String title; + final Int64 scheduled; + final String body; + final bool isRead; + + final VoidCallback? onAction; + final VoidCallback? onDelete; + final void Function(bool isRead)? onReadChanged; + + @override + State createState() => _NotificationItemState(); +} + +class _NotificationItemState extends State { + final PopoverMutex mutex = PopoverMutex(); + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => _onHover(true), + onExit: (_) => _onHover(false), + cursor: widget.onAction != null + ? SystemMouseCursors.click + : MouseCursor.defer, + child: Stack( + children: [ + GestureDetector( + onTap: widget.onAction, + child: Opacity( + opacity: widget.isRead ? 0.5 : 1, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6)), + color: _isHovering && widget.onAction != null + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + const FlowySvg(FlowySvgs.time_s, size: Size.square(20)), + if (!widget.isRead) + Positioned( + bottom: 1, + right: 1, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AFThemeExtension.of(context).warning, + ), + child: const SizedBox(height: 8, width: 8), + ), + ), + ], + ), + const HSpace(10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: FlowyText.semibold(widget.title), + ), + FlowyText.regular( + _scheduledString(widget.scheduled), + fontSize: 10, + ), + ], + ), + const VSpace(5), + FlowyText.regular(widget.body, maxLines: 4), + ], + ), + ), + ], + ), + ), + ), + ), + if (_isHovering) + Positioned( + right: 4, + top: 4, + child: NotificationItemActions( + isRead: widget.isRead, + onDelete: widget.onDelete, + onReadChanged: widget.onReadChanged, + ), + ), + ], + ), + ); + } + + String _scheduledString(Int64 secondsSinceEpoch) => + _dateFormat(context).format( + DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch.toInt() * 1000), + ); + + void _onHover(bool isHovering) => setState(() => _isHovering = isHovering); +} + +class NotificationItemActions extends StatelessWidget { + const NotificationItemActions({ + super.key, + required this.isRead, + this.onDelete, + this.onReadChanged, + }); + + final bool isRead; + final VoidCallback? onDelete; + final void Function(bool isRead)? onReadChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 30, + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(6), + ), + child: IntrinsicHeight( + child: Row( + children: [ + if (isRead) ...[ + FlowyIconButton( + height: 28, + tooltipText: + LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), + icon: const FlowySvg(FlowySvgs.restore_s), + onPressed: () => onReadChanged?.call(false), + ), + ] else ...[ + FlowyIconButton( + height: 28, + tooltipText: + LocaleKeys.reminderNotification_tooltipMarkRead.tr(), + icon: const FlowySvg(FlowySvgs.messages_s), + onPressed: () => onReadChanged?.call(true), + ), + ], + VerticalDivider( + width: 3, + thickness: 1, + indent: 2, + endIndent: 2, + color: Theme.of(context).dividerColor, + ), + FlowyIconButton( + height: 28, + tooltipText: LocaleKeys.reminderNotification_tooltipDelete.tr(), + icon: const FlowySvg(FlowySvgs.delete_s), + onPressed: onDelete, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart new file mode 100644 index 0000000000..89b0758314 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart @@ -0,0 +1,72 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/appearance.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.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'; + +import 'theme_setting_entry_template.dart'; + +class DateFormatSetting extends StatelessWidget { + const DateFormatSetting({ + super.key, + required this.currentFormat, + }); + + final UserDateFormatPB currentFormat; + + @override + Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget( + label: LocaleKeys.settings_appearance_dateFormat_label.tr(), + trailing: [ + ThemeValueDropDown( + currentValue: _formatLabel(currentFormat), + popupBuilder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _formatItem(context, UserDateFormatPB.Locally), + _formatItem(context, UserDateFormatPB.US), + _formatItem(context, UserDateFormatPB.ISO), + _formatItem(context, UserDateFormatPB.Friendly), + _formatItem(context, UserDateFormatPB.DayMonthYear), + ], + ), + ), + ], + ); + + Widget _formatItem(BuildContext context, UserDateFormatPB format) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium(_formatLabel(format)), + rightIcon: + currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null, + onTap: () { + if (currentFormat != format) { + context.read().setDateFormat(format); + } + }, + ), + ); + } + + String _formatLabel(UserDateFormatPB format) { + switch (format) { + case (UserDateFormatPB.Locally): + return LocaleKeys.settings_appearance_dateFormat_local.tr(); + case (UserDateFormatPB.US): + return LocaleKeys.settings_appearance_dateFormat_us.tr(); + case (UserDateFormatPB.ISO): + return LocaleKeys.settings_appearance_dateFormat_iso.tr(); + case (UserDateFormatPB.Friendly): + return LocaleKeys.settings_appearance_dateFormat_friendly.tr(); + case (UserDateFormatPB.DayMonthYear): + return LocaleKeys.settings_appearance_dateFormat_dmy.tr(); + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart new file mode 100644 index 0000000000..462d63e7e9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart @@ -0,0 +1,63 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/appearance.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.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'; + +import 'theme_setting_entry_template.dart'; + +class TimeFormatSetting extends StatelessWidget { + const TimeFormatSetting({ + super.key, + required this.currentFormat, + }); + + final UserTimeFormatPB currentFormat; + + @override + Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget( + label: LocaleKeys.settings_appearance_timeFormat_label.tr(), + trailing: [ + ThemeValueDropDown( + currentValue: _formatLabel(currentFormat), + popupBuilder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _formatItem(context, UserTimeFormatPB.TwentyFourHour), + _formatItem(context, UserTimeFormatPB.TwelveHour), + ], + ), + ), + ], + ); + + Widget _formatItem(BuildContext context, UserTimeFormatPB format) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium(_formatLabel(format)), + rightIcon: + currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null, + onTap: () { + if (currentFormat != format) { + context.read().setTimeFormat(format); + } + }, + ), + ); + } + + String _formatLabel(UserTimeFormatPB format) { + switch (format) { + case (UserTimeFormatPB.TwentyFourHour): + return LocaleKeys.settings_appearance_timeFormat_twentyFourHour.tr(); + case (UserTimeFormatPB.TwelveHour): + return LocaleKeys.settings_appearance_timeFormat_twelveHour.tr(); + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart index 20e03115c6..c4f533a2b1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart @@ -1,5 +1,7 @@ import 'package:appflowy/workspace/application/appearance.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart'; import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -23,20 +25,17 @@ class SettingsAppearanceView extends StatelessWidget { currentTheme: state.appTheme.themeName, bloc: context.read(), ), - BrightnessSetting( - currentThemeMode: state.themeMode, - ), + BrightnessSetting(currentThemeMode: state.themeMode), const Divider(), - ThemeFontFamilySetting( - currentFontFamily: state.font, - ), + ThemeFontFamilySetting(currentFontFamily: state.font), const Divider(), LayoutDirectionSetting( currentLayoutDirection: state.layoutDirection, ), - TextDirectionSetting( - currentTextDirection: state.textDirection, - ), + TextDirectionSetting(currentTextDirection: state.textDirection), + const Divider(), + DateFormatSetting(currentFormat: state.dateFormat), + TimeFormatSetting(currentFormat: state.timeFormat), const Divider(), CreateFileSettings(), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_calendar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_calendar.dart new file mode 100644 index 0000000000..16d2de99ea --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_calendar.dart @@ -0,0 +1,277 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +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/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:table_calendar/table_calendar.dart'; + +final kFirstDay = DateTime.utc(1970, 1, 1); +final kLastDay = DateTime.utc(2100, 1, 1); + +typedef DaySelectedCallback = void Function( + DateTime selectedDay, + DateTime focusedDay, + bool includeTime, +); +typedef IncludeTimeChangedCallback = void Function(bool includeTime); +typedef FormatChangedCallback = void Function(CalendarFormat format); +typedef PageChangedCallback = void Function(DateTime focusedDay); +typedef TimeChangedCallback = void Function(String? time); + +class AppFlowyCalendar extends StatefulWidget { + const AppFlowyCalendar({ + super.key, + this.popoverMutex, + this.firstDay, + this.lastDay, + this.selectedDate, + required this.focusedDay, + this.format = CalendarFormat.month, + this.onDaySelected, + this.onFormatChanged, + this.onPageChanged, + this.onIncludeTimeChanged, + this.onTimeChanged, + this.includeTime = false, + this.timeFormat = UserTimeFormatPB.TwentyFourHour, + }); + + final PopoverMutex? popoverMutex; + + /// Disallows choosing dates before this date + final DateTime? firstDay; + + /// Disallows choosing dates after this date + final DateTime? lastDay; + + final DateTime? selectedDate; + final DateTime focusedDay; + final CalendarFormat format; + + final DaySelectedCallback? onDaySelected; + final IncludeTimeChangedCallback? onIncludeTimeChanged; + final FormatChangedCallback? onFormatChanged; + final PageChangedCallback? onPageChanged; + final TimeChangedCallback? onTimeChanged; + + final bool includeTime; + + // Timeformat for time selector + final UserTimeFormatPB timeFormat; + + @override + State createState() => _AppFlowyCalendarState(); +} + +class _AppFlowyCalendarState extends State + with AutomaticKeepAliveClientMixin { + String? _time; + + late DateTime? _selectedDay = widget.selectedDate; + late DateTime _focusedDay = widget.focusedDay; + late bool _includeTime = widget.includeTime; + + @override + void initState() { + super.initState(); + if (widget.includeTime) { + final hour = widget.focusedDay.hour; + final minute = widget.focusedDay.minute; + _time = '$hour:$minute'; + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final textStyle = Theme.of(context).textTheme.bodyMedium!; + final boxDecoration = BoxDecoration( + color: Theme.of(context).cardColor, + shape: BoxShape.circle, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: TableCalendar( + currentDay: DateTime.now(), + firstDay: widget.firstDay ?? kFirstDay, + lastDay: widget.lastDay ?? kLastDay, + focusedDay: _focusedDay, + rowHeight: GridSize.popoverItemHeight, + calendarFormat: widget.format, + daysOfWeekHeight: GridSize.popoverItemHeight, + 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), + ), + calendarStyle: CalendarStyle( + cellMargin: const EdgeInsets.all(3.5), + defaultDecoration: boxDecoration, + selectedDecoration: boxDecoration.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + todayDecoration: boxDecoration.copyWith( + color: Colors.transparent, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + ), + ), + weekendDecoration: boxDecoration, + outsideDecoration: boxDecoration, + rangeStartDecoration: boxDecoration.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + rangeEndDecoration: boxDecoration.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + 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: AFThemeExtension.of(context).caption, + ), + ), + ); + }, + ), + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + onDaySelected: (selectedDay, focusedDay) { + if (!_includeTime) { + widget.onDaySelected?.call( + selectedDay, + focusedDay, + _includeTime, + ); + } + + setState(() { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + }); + + _updateSelectedDay(selectedDay, focusedDay, _includeTime); + }, + onFormatChanged: widget.onFormatChanged, + onPageChanged: widget.onPageChanged, + ), + ), + const TypeOptionSeparator(spacing: 12.0), + IncludeTimeButton( + initialTime: widget.selectedDate != null + ? _initialTime(widget.selectedDate!) + : null, + includeTime: widget.includeTime, + timeFormat: widget.timeFormat, + popoverMutex: widget.popoverMutex, + onChanged: (includeTime) { + setState(() => _includeTime = includeTime); + + widget.onIncludeTimeChanged?.call(includeTime); + }, + onSubmitted: (time) { + _time = time; + + if (widget.selectedDate != null && widget.onTimeChanged == null) { + _updateSelectedDay( + widget.selectedDate!, + widget.selectedDate!, + _includeTime, + ); + } + + widget.onTimeChanged?.call(time); + }, + ), + const VSpace(6.0), + ], + ); + } + + DateTime _dateWithTime(DateTime date, DateTime time) { + return DateTime.parse( + '${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(time.hour)}:${_padZeroLeft(time.minute)}', + ); + } + + String _initialTime(DateTime selectedDay) => switch (widget.timeFormat) { + UserTimeFormatPB.TwelveHour => DateFormat.jm().format(selectedDay), + UserTimeFormatPB.TwentyFourHour => DateFormat.Hm().format(selectedDay), + _ => '00:00', + }; + + String _padZeroLeft(int a) => a.toString().padLeft(2, '0'); + + void _updateSelectedDay( + DateTime selectedDay, + DateTime focusedDay, + bool includeTime, + ) { + late DateTime timeOfDay; + switch (widget.timeFormat) { + case UserTimeFormatPB.TwelveHour: + timeOfDay = DateFormat.jm().parse(_time ?? '12:00 AM'); + break; + case UserTimeFormatPB.TwentyFourHour: + timeOfDay = DateFormat.Hm().parse(_time ?? '00:00'); + break; + } + + widget.onDaySelected?.call( + _dateWithTime(selectedDay, timeOfDay), + focusedDay, + _includeTime, + ); + } + + @override + bool get wantKeepAlive => true; +} 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 new file mode 100644 index 0000000000..8fc83567ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart @@ -0,0 +1,182 @@ +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_calendar.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/style_widget/decoration.dart'; +import 'package:flutter/material.dart'; + +/// Provides arguemnts for [AppFlowyCalender] when showing +/// a [DatePickerMenu] +/// +class DatePickerOptions { + DatePickerOptions({ + DateTime? focusedDay, + this.selectedDay, + this.firstDay, + this.lastDay, + this.includeTime = false, + this.timeFormat = UserTimeFormatPB.TwentyFourHour, + this.onDaySelected, + this.onIncludeTimeChanged, + this.onFormatChanged, + this.onPageChanged, + this.onTimeChanged, + }) : focusedDay = focusedDay ?? DateTime.now(); + + final DateTime focusedDay; + final DateTime? selectedDay; + final DateTime? firstDay; + final DateTime? lastDay; + final bool includeTime; + final UserTimeFormatPB timeFormat; + + final DaySelectedCallback? onDaySelected; + final IncludeTimeChangedCallback? onIncludeTimeChanged; + final FormatChangedCallback? onFormatChanged; + final PageChangedCallback? onPageChanged; + final TimeChangedCallback? onTimeChanged; +} + +abstract class DatePickerService { + void show(Offset offset); + void dismiss(); +} + +const double _datePickerWidth = 260; +const double _datePickerHeight = 325; +const double _includeTimeHeight = 60; +const double _ySpacing = 15; + +class DatePickerMenu extends DatePickerService { + DatePickerMenu({ + required this.context, + required this.editorState, + }); + + final BuildContext context; + final EditorState editorState; + + OverlayEntry? _menuEntry; + + @override + void dismiss() { + _menuEntry?.remove(); + _menuEntry = null; + } + + @override + void show( + Offset offset, { + DatePickerOptions? options, + }) => + _show(offset, options: options); + + void _show( + Offset offset, { + DatePickerOptions? options, + }) { + dismiss(); + + final editorSize = editorState.renderBox!.size; + + double offsetX = offset.dx; + double offsetY = offset.dy; + + final showRight = (offset.dx + _datePickerWidth) < editorSize.width; + if (!showRight) { + offsetX = offset.dx - _datePickerWidth; + } + + final showBelow = (offset.dy + _datePickerHeight) < editorSize.height; + if (!showBelow) { + offsetY = offset.dy - _datePickerHeight; + } + + _menuEntry = OverlayEntry( + builder: (context) { + return Material( + type: MaterialType.transparency, + child: SizedBox( + height: editorSize.height, + width: editorSize.width, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + _AnimatedDatePicker( + offset: Offset(offsetX, offsetY), + showBelow: showBelow, + options: options, + ), + ], + ), + ), + ), + ); + }, + ); + + Overlay.of(context).insert(_menuEntry!); + } +} + +class _AnimatedDatePicker extends StatefulWidget { + const _AnimatedDatePicker({ + required this.offset, + required this.showBelow, + this.options, + }); + + final Offset offset; + final bool showBelow; + final DatePickerOptions? options; + + @override + State<_AnimatedDatePicker> createState() => _AnimatedDatePickerState(); +} + +class _AnimatedDatePickerState extends State<_AnimatedDatePicker> { + late bool _includeTime = widget.options?.includeTime ?? false; + + @override + Widget build(BuildContext context) { + double dy = widget.offset.dy; + if (!widget.showBelow && _includeTime) { + dy = dy - _includeTimeHeight; + } + + dy = dy + (widget.showBelow ? _ySpacing : -_ySpacing); + + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + top: dy, + left: widget.offset.dx, + child: Container( + decoration: FlowyDecoration.decoration( + Theme.of(context).cardColor, + Theme.of(context).colorScheme.shadow, + ), + constraints: BoxConstraints.loose( + const Size(_datePickerWidth, 465), + ), + child: AppFlowyCalendar( + focusedDay: widget.options?.focusedDay ?? DateTime.now(), + selectedDate: widget.options?.selectedDay, + firstDay: widget.options?.firstDay, + lastDay: widget.options?.lastDay, + includeTime: widget.options?.includeTime ?? false, + timeFormat: + widget.options?.timeFormat ?? UserTimeFormatPB.TwentyFourHour, + onDaySelected: widget.options?.onDaySelected, + onFormatChanged: widget.options?.onFormatChanged, + onPageChanged: widget.options?.onPageChanged, + onIncludeTimeChanged: (includeTime) { + widget.options?.onIncludeTimeChanged?.call(includeTime); + setState(() => _includeTime = includeTime); + }, + onTimeChanged: widget.options?.onTimeChanged, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart new file mode 100644 index 0000000000..d231e2b304 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/include_time_button.dart @@ -0,0 +1,197 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/workspace/application/settings/date_time/time_patterns.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class IncludeTimeButton extends StatefulWidget { + const IncludeTimeButton({ + super.key, + this.initialTime, + required this.popoverMutex, + this.includeTime = false, + this.onChanged, + this.onSubmitted, + this.timeFormat = UserTimeFormatPB.TwentyFourHour, + }); + + final String? initialTime; + final PopoverMutex? popoverMutex; + final bool includeTime; + final Function(bool includeTime)? onChanged; + final Function(String? time)? onSubmitted; + final UserTimeFormatPB timeFormat; + + @override + State createState() => _IncludeTimeButtonState(); +} + +class _IncludeTimeButtonState extends State { + late bool _includeTime = widget.includeTime; + String? _timeString; + + @override + void initState() { + super.initState(); + _timeString = widget.initialTime; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (_includeTime) ...[ + _TimeTextField( + timeStr: _timeString, + popoverMutex: widget.popoverMutex, + timeFormat: widget.timeFormat, + onSubmitted: (value) { + setState(() => _timeString = value); + widget.onSubmitted?.call(_timeString); + }, + ), + const TypeOptionSeparator(spacing: 12.0), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: GridSize.typeOptionContentInsets - + const EdgeInsets.only(top: 4), + child: Row( + children: [ + FlowySvg( + FlowySvgs.clock_alarm_s, + color: Theme.of(context).iconTheme.color, + ), + const HSpace(6), + FlowyText.medium(LocaleKeys.grid_field_includeTime.tr()), + const Spacer(), + Toggle( + value: _includeTime, + onChanged: (value) { + widget.onChanged?.call(!value); + setState(() => _includeTime = !value); + }, + style: ToggleStyle.big, + padding: EdgeInsets.zero, + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _TimeTextField extends StatefulWidget { + const _TimeTextField({ + required this.timeStr, + required this.popoverMutex, + this.onSubmitted, + this.timeFormat = UserTimeFormatPB.TwentyFourHour, + }); + + final String? timeStr; + final PopoverMutex? popoverMutex; + final Function(String? value)? onSubmitted; + final UserTimeFormatPB timeFormat; + + @override + State<_TimeTextField> createState() => _TimeTextFieldState(); +} + +class _TimeTextFieldState extends State<_TimeTextField> { + late final FocusNode _focusNode; + late final TextEditingController _textController; + + late String? _timeString; + + String? errorText; + + @override + void initState() { + super.initState(); + + _timeString = widget.timeStr; + _focusNode = FocusNode(); + _textController = TextEditingController()..text = _timeString ?? ""; + + _focusNode.addListener(() { + if (_focusNode.hasFocus) { + widget.popoverMutex?.close(); + } + }); + + widget.popoverMutex?.listenOnPopoverChanged(() { + if (_focusNode.hasFocus) { + _focusNode.unfocus(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: FlowyTextField( + text: _timeString ?? "", + focusNode: _focusNode, + controller: _textController, + submitOnLeave: true, + hintText: hintText, + errorText: errorText, + onSubmitted: (value) { + setState(() { + errorText = _validate(value); + }); + + if (errorText == null) { + widget.onSubmitted?.call(value); + } + }, + ), + ), + ], + ); + } + + String? _validate(String value) { + final msg = LocaleKeys.grid_field_invalidTimeFormat.tr(); + + switch (widget.timeFormat) { + case UserTimeFormatPB.TwentyFourHour: + if (!isTwentyFourHourTime(value)) { + return "$msg. e.g. 13:00"; + } + case UserTimeFormatPB.TwelveHour: + if (!isTwelveHourTime(value)) { + return "$msg. e.g. 01:00 PM"; + } + } + + return null; + } + + String get hintText => switch (widget.timeFormat) { + UserTimeFormatPB.TwentyFourHour => + LocaleKeys.document_date_timeHintTextInTwentyFourHour.tr(), + UserTimeFormatPB.TwelveHour => + LocaleKeys.document_date_timeHintTextInTwelveHour.tr(), + _ => "", + }; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart index 8f782e3dc9..7881d8ab02 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -17,18 +17,21 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; -// ignore: unused_import import 'package:protobuf/protobuf.dart'; import 'dart:convert' show utf8; import '../protobuf/flowy-config/entities.pb.dart'; import '../protobuf/flowy-config/event_map.pb.dart'; import 'error.dart'; +import '../protobuf/flowy-date/entities.pb.dart'; +import '../protobuf/flowy-date/event_map.pb.dart'; + part 'dart_event/flowy-folder2/dart_event.dart'; part 'dart_event/flowy-user/dart_event.dart'; part 'dart_event/flowy-database2/dart_event.dart'; part 'dart_event/flowy-document2/dart_event.dart'; part 'dart_event/flowy-config/dart_event.dart'; +part 'dart_event/flowy-date/dart_event.dart'; enum FFIException { RequestIsEmpty, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart index 55354d7713..1d9795aa50 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart @@ -59,11 +59,10 @@ class FlowyColorPicker extends StatelessWidget { final colorIcon = SizedBox.square( dimension: iconSize, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: option.color, shape: BoxShape.circle, - // border: border, ), ), ); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index f8899d7954..ce7844e01a 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -825,6 +825,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + local_notifier: + dependency: "direct main" + description: + name: local_notifier + sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 + url: "https://pub.dev" + source: hosted + version: "0.1.5" logger: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index c4f8bad814..6e922bc22a 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -109,6 +109,11 @@ dependencies: super_clipboard: ^0.6.3 go_router: ^10.1.2 + # Notifications + # TODO: Consider implementing custom package + # to gather notification handling for all platforms + local_notifier: ^0.1.5 + dev_dependencies: flutter_lints: ^2.0.1 diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart index 22a5ed2e4e..0e97df8c20 100644 --- a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart +++ b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart @@ -17,9 +17,12 @@ void main() { group('$AppearanceSettingsCubit', () { late AppearanceSettingsPB appearanceSetting; + late DateTimeSettingsPB dateTimeSettings; setUp(() async { appearanceSetting = await UserSettingsBackendService().getAppearanceSetting(); + dateTimeSettings = + await UserSettingsBackendService().getDateTimeSettings(); await blocResponseFuture(); }); @@ -27,6 +30,7 @@ void main() { 'default theme', build: () => AppearanceSettingsCubit( appearanceSetting, + dateTimeSettings, AppTheme.fallback, ), verify: (bloc) { @@ -41,6 +45,7 @@ void main() { 'save key/value', build: () => AppearanceSettingsCubit( appearanceSetting, + dateTimeSettings, AppTheme.fallback, ), act: (bloc) { @@ -55,6 +60,7 @@ void main() { 'remove key/value', build: () => AppearanceSettingsCubit( appearanceSetting, + dateTimeSettings, AppTheme.fallback, ), act: (bloc) { @@ -69,6 +75,7 @@ void main() { 'initial state uses fallback theme', build: () => AppearanceSettingsCubit( appearanceSetting, + dateTimeSettings, AppTheme.fallback, ), verify: (bloc) { diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 34ab186016..ff4ba0b78f 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -798,7 +798,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "async-trait", @@ -817,7 +817,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "async-trait", @@ -847,7 +847,7 @@ dependencies = [ [[package]] name = "collab-define" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "collab", @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "collab-derive" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "proc-macro2", "quote", @@ -871,7 +871,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "collab", @@ -891,7 +891,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "chrono", @@ -931,7 +931,7 @@ dependencies = [ [[package]] name = "collab-persistence" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "async-trait", "bincode", @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "async-trait", @@ -980,7 +980,7 @@ dependencies = [ [[package]] name = "collab-sync-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "bytes", "collab", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "collab", @@ -1344,6 +1344,16 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "date_time_parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a" +dependencies = [ + "chrono", + "regex", +] + [[package]] name = "derivative" version = "2.2.0" @@ -1774,6 +1784,7 @@ dependencies = [ "flowy-config", "flowy-database-deps", "flowy-database2", + "flowy-date", "flowy-document-deps", "flowy-document2", "flowy-error", @@ -1853,6 +1864,23 @@ dependencies = [ "url", ] +[[package]] +name = "flowy-date" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "date_time_parser", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "lib-dispatch", + "protobuf", + "strum_macros 0.21.1", + "tracing", +] + [[package]] name = "flowy-derive" version = "0.1.0" @@ -1932,6 +1960,7 @@ dependencies = [ "client-api", "collab-database", "collab-document", + "fancy-regex 0.11.0", "flowy-codegen", "flowy-derive", "flowy-sqlite", @@ -2134,7 +2163,8 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "strum_macros 0.21.1", + "strum", + "strum_macros 0.25.2", "tokio", "tracing", "unicode-segmentation", diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index b51d43fd0c..044a450871 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -20,9 +20,16 @@ tauri = { version = "1.2", features = ["fs-all", "shell-open"] } tauri-utils = "1.2" bytes = { version = "1.4" } tracing = { version = "0.1", features = ["log"] } -lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = ["use_serde"] } -flowy-core = { path = "../../rust-lib/flowy-core", features = ["rev-sqlite", "ts"] } -flowy-notification = { path = "../../rust-lib/flowy-notification", features = ["ts"] } +lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ + "use_serde", +] } +flowy-core = { path = "../../rust-lib/flowy-core", features = [ + "rev-sqlite", + "ts", +] } +flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ + "ts", +] } [features] # by default Tauri runs in production mode @@ -40,21 +47,17 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "8f8 # Working directory: frontend # # To update the commit ID, run: -# scripts/tool/update_collab_rev.sh new_rev_id +# scripts/tool/update_collab_rev.sh e37ee7 # # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } - - - - +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } diff --git a/frontend/appflowy_tauri/src/services/backend/index.ts b/frontend/appflowy_tauri/src/services/backend/index.ts index 3db0877743..e4977d4665 100644 --- a/frontend/appflowy_tauri/src/services/backend/index.ts +++ b/frontend/appflowy_tauri/src/services/backend/index.ts @@ -1,7 +1,7 @@ -export * from "./models/flowy-user"; -export * from "./models/flowy-database2"; -export * from "./models/flowy-folder2"; -export * from "./models/flowy-document2"; -export * from "./models/flowy-error"; -export * from "./models/flowy-config"; - +export * from './models/flowy-user'; +export * from './models/flowy-database2'; +export * from './models/flowy-folder2'; +export * from './models/flowy-document2'; +export * from './models/flowy-error'; +export * from './models/flowy-config'; +export * from './models/flowy-date'; diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 40369ee438..c86a8ed566 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -291,6 +291,19 @@ "theme": "Theme", "builtInsLabel": "Built-in Themes", "pluginsLabel": "Plugins", + "dateFormat": { + "label": "Date format", + "local": "Local", + "us": "US", + "iso": "ISO", + "friendly": "Friendly", + "dmy": "D/M/Y" + }, + "timeFormat": { + "label": "Time format", + "twelveHour": "Twelve hour", + "twentyFourHour": "Twenty four hour" + }, "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page" }, "files": { @@ -755,6 +768,32 @@ "frequentlyUsed": "Frequently Used" } }, + "inlineActions": { + "noResults": "No results", + "pageReference": "Page reference", + "date": "Date", + "reminder": { + "groupTitle": "Reminder", + "shortKeyword": "remind" + } + }, + "relativeDates": { + "yesterday": "Yesterday", + "today": "Today", + "tomorrow": "Tomorrow", + "oneWeek": "1 week" + }, + "notificationHub": { + "title": "Notifications", + "empty": "Nothing to see here!" + }, + "reminderNotification": { + "title": "Reminder", + "message": "Remember to check this before you forget!", + "tooltipDelete": "Delete", + "tooltipMarkRead": "Mark as read", + "tooltipMarkUnread": "Mark as unread" + }, "findAndReplace": { "find": "Find", "previousMatch": "Previous match", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 414bdb1647..6e0db240ec 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -421,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" dependencies = [ "borsh-derive", - "hashbrown 0.13.2", + "hashbrown 0.12.3", ] [[package]] @@ -672,7 +672,7 @@ dependencies = [ [[package]] name = "collab" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "async-trait", @@ -691,7 +691,7 @@ dependencies = [ [[package]] name = "collab-database" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "async-trait", @@ -721,7 +721,7 @@ dependencies = [ [[package]] name = "collab-define" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "collab", @@ -733,7 +733,7 @@ dependencies = [ [[package]] name = "collab-derive" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "proc-macro2", "quote", @@ -745,7 +745,7 @@ dependencies = [ [[package]] name = "collab-document" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "collab", @@ -765,7 +765,7 @@ dependencies = [ [[package]] name = "collab-folder" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "chrono", @@ -805,7 +805,7 @@ dependencies = [ [[package]] name = "collab-persistence" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "async-trait", "bincode", @@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "collab-plugins" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "async-trait", @@ -854,7 +854,7 @@ dependencies = [ [[package]] name = "collab-sync-protocol" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "bytes", "collab", @@ -869,7 +869,7 @@ dependencies = [ [[package]] name = "collab-user" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32" dependencies = [ "anyhow", "collab", @@ -1168,6 +1168,16 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "date_time_parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a" +dependencies = [ + "chrono", + "regex", +] + [[package]] name = "deranged" version = "0.3.8" @@ -1517,6 +1527,7 @@ dependencies = [ "flowy-config", "flowy-database-deps", "flowy-database2", + "flowy-date", "flowy-document-deps", "flowy-document2", "flowy-error", @@ -1597,6 +1608,23 @@ dependencies = [ "url", ] +[[package]] +name = "flowy-date" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "date_time_parser", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "lib-dispatch", + "protobuf", + "strum_macros 0.21.1", + "tracing", +] + [[package]] name = "flowy-derive" version = "0.1.0" @@ -1678,6 +1706,7 @@ dependencies = [ "client-api", "collab-database", "collab-document", + "fancy-regex 0.11.0", "flowy-codegen", "flowy-derive", "flowy-sqlite", @@ -1943,7 +1972,8 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "strum_macros 0.21.1", + "strum", + "strum_macros 0.25.2", "tokio", "tracing", "unicode-segmentation", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index 7731d6b95e..f61ea33168 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -24,6 +24,7 @@ members = [ "flowy-storage", "collab-integrate", "flowy-ai", + "flowy-date", ] [workspace.dependencies] @@ -50,6 +51,7 @@ flowy-encrypt = { workspace = true, path = "flowy-encrypt" } flowy-storage = { workspace = true, path = "flowy-storage" } collab-integrate = { workspace = true, path = "collab-integrate" } flowy-ai = { workspace = true, path = "flowy-ai" } +flowy-date = { workspace = true, path = "flowy-date" } [profile.dev] opt-level = 0 @@ -87,12 +89,12 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "8f8 # To switch to the local path, run: # scripts/tool/update_collab_source.sh # ⚠️⚠️⚠️️ -collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } -collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } +collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } +collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" } diff --git a/frontend/rust-lib/flowy-core/Cargo.toml b/frontend/rust-lib/flowy-core/Cargo.toml index 4af2d7ae2e..38e59635d4 100644 --- a/frontend/rust-lib/flowy-core/Cargo.toml +++ b/frontend/rust-lib/flowy-core/Cargo.toml @@ -22,6 +22,7 @@ flowy-task = { workspace = true } flowy-server = { workspace = true } flowy-server-config = { workspace = true } flowy-config = { workspace = true } +flowy-date = { workspace = true } collab-integrate = { workspace = true, features = ["supabase_integrate", "appflowy_cloud_integrate", "snapshot_plugin"] } flowy-ai = { workspace = true } collab-define = { version = "0.1.0" } @@ -52,6 +53,7 @@ native_sync = [] use_bunyan = ["lib-log/use_bunyan"] dart = [ "flowy-user/dart", + "flowy-date/dart", "flowy-folder2/dart", "flowy-database2/dart", "flowy-document2/dart", @@ -59,13 +61,12 @@ dart = [ ] ts = [ "flowy-user/ts", + "flowy-date/ts", "flowy-folder2/ts", "flowy-database2/ts", "flowy-document2/ts", "flowy-config/ts", ] -rev-sqlite = [ - "flowy-user/rev-sqlite", -] +rev-sqlite = ["flowy-user/rev-sqlite"] openssl_vendored = ["flowy-sqlite/openssl_vendored"] diff --git a/frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs b/frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs new file mode 100644 index 0000000000..18434ffcb8 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/integrate/collab_interact.rs @@ -0,0 +1,65 @@ +use collab_define::reminder::Reminder; +use std::convert::TryFrom; +use std::sync::Weak; + +use flowy_database2::DatabaseManager; +use flowy_document2::manager::DocumentManager; +use flowy_document2::reminder::{DocumentReminder, DocumentReminderAction}; +use flowy_folder_deps::cloud::Error; +use flowy_user::services::collab_interact::CollabInteract; +use lib_infra::future::FutureResult; + +pub struct CollabInteractImpl { + #[allow(dead_code)] + pub(crate) database_manager: Weak, + #[allow(dead_code)] + pub(crate) document_manager: Weak, +} + +impl CollabInteract for CollabInteractImpl { + fn add_reminder(&self, reminder: Reminder) -> FutureResult<(), Error> { + let cloned_document_manager = self.document_manager.clone(); + FutureResult::new(async move { + if let Some(document_manager) = cloned_document_manager.upgrade() { + match DocumentReminder::try_from(reminder) { + Ok(reminder) => { + document_manager + .handle_reminder_action(DocumentReminderAction::Add { reminder }) + .await; + }, + Err(e) => tracing::error!("Failed to convert reminder: {:?}", e), + } + } + Ok(()) + }) + } + + fn remove_reminder(&self, reminder_id: &str) -> FutureResult<(), Error> { + let reminder_id = reminder_id.to_string(); + let cloned_document_manager = self.document_manager.clone(); + FutureResult::new(async move { + if let Some(document_manager) = cloned_document_manager.upgrade() { + let action = DocumentReminderAction::Remove { reminder_id }; + document_manager.handle_reminder_action(action).await; + } + Ok(()) + }) + } + + fn update_reminder(&self, reminder: Reminder) -> FutureResult<(), Error> { + let cloned_document_manager = self.document_manager.clone(); + FutureResult::new(async move { + if let Some(document_manager) = cloned_document_manager.upgrade() { + match DocumentReminder::try_from(reminder) { + Ok(reminder) => { + document_manager + .handle_reminder_action(DocumentReminderAction::Update { reminder }) + .await; + }, + Err(e) => tracing::error!("Failed to convert reminder: {:?}", e), + } + } + Ok(()) + }) + } +} diff --git a/frontend/rust-lib/flowy-core/src/integrate/log.rs b/frontend/rust-lib/flowy-core/src/integrate/log.rs new file mode 100644 index 0000000000..248b5c9b30 --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/integrate/log.rs @@ -0,0 +1,48 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::AppFlowyCoreConfig; + +static INIT_LOG: AtomicBool = AtomicBool::new(false); +pub(crate) fn init_log(config: &AppFlowyCoreConfig) { + if !INIT_LOG.load(Ordering::SeqCst) { + INIT_LOG.store(true, Ordering::SeqCst); + + let _ = lib_log::Builder::new("AppFlowy-Client", &config.storage_path) + .env_filter(&config.log_filter) + .build(); + } +} +pub(crate) fn create_log_filter(level: String, with_crates: Vec) -> String { + let level = std::env::var("RUST_LOG").unwrap_or(level); + let mut filters = with_crates + .into_iter() + .map(|crate_name| format!("{}={}", crate_name, level)) + .collect::>(); + filters.push(format!("flowy_core={}", level)); + filters.push(format!("flowy_folder2={}", level)); + filters.push(format!("collab_sync={}", level)); + filters.push(format!("collab_folder={}", level)); + filters.push(format!("collab_persistence={}", level)); + filters.push(format!("collab_database={}", level)); + filters.push(format!("collab_plugins={}", level)); + filters.push(format!("appflowy_integrate={}", level)); + filters.push(format!("collab={}", level)); + filters.push(format!("flowy_user={}", level)); + filters.push(format!("flowy_document2={}", level)); + filters.push(format!("flowy_database2={}", level)); + filters.push(format!("flowy_server={}", level)); + filters.push(format!("flowy_notification={}", "info")); + filters.push(format!("lib_infra={}", level)); + filters.push(format!("flowy_task={}", level)); + + filters.push(format!("dart_ffi={}", "info")); + filters.push(format!("flowy_sqlite={}", "info")); + filters.push(format!("flowy_net={}", level)); + #[cfg(feature = "profiling")] + filters.push(format!("tokio={}", level)); + + #[cfg(feature = "profiling")] + filters.push(format!("runtime={}", level)); + + filters.join(",") +} diff --git a/frontend/rust-lib/flowy-core/src/integrate/mod.rs b/frontend/rust-lib/flowy-core/src/integrate/mod.rs index 3df4bdcc6e..7484472f5a 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/mod.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/mod.rs @@ -1,2 +1,5 @@ +pub(crate) mod collab_interact; +pub(crate) mod log; pub(crate) mod server; mod trait_impls; +pub(crate) mod user; diff --git a/frontend/rust-lib/flowy-core/src/integrate/user.rs b/frontend/rust-lib/flowy-core/src/integrate/user.rs new file mode 100644 index 0000000000..fa31007c8d --- /dev/null +++ b/frontend/rust-lib/flowy-core/src/integrate/user.rs @@ -0,0 +1,187 @@ +use std::sync::Arc; + +use collab_integrate::collab_builder::AppFlowyCollabBuilder; +use flowy_database2::DatabaseManager; +use flowy_document2::manager::DocumentManager; +use flowy_error::FlowyResult; +use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager}; +use flowy_user::event_map::{UserCloudServiceProvider, UserStatusCallback}; +use flowy_user_deps::cloud::UserCloudConfig; +use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace}; +use lib_infra::future::{to_fut, Fut}; + +use crate::integrate::server::ServerProvider; +use crate::AppFlowyCoreConfig; + +pub(crate) struct UserStatusCallbackImpl { + pub(crate) collab_builder: Arc, + pub(crate) folder_manager: Arc, + pub(crate) database_manager: Arc, + pub(crate) document_manager: Arc, + pub(crate) server_provider: Arc, + #[allow(dead_code)] + pub(crate) config: AppFlowyCoreConfig, +} + +impl UserStatusCallback for UserStatusCallbackImpl { + fn auth_type_did_changed(&self, _auth_type: AuthType) {} + + fn did_init( + &self, + user_id: i64, + cloud_config: &Option, + user_workspace: &UserWorkspace, + _device_id: &str, + ) -> Fut> { + let user_id = user_id.to_owned(); + let user_workspace = user_workspace.clone(); + let collab_builder = self.collab_builder.clone(); + let folder_manager = self.folder_manager.clone(); + let database_manager = self.database_manager.clone(); + let document_manager = self.document_manager.clone(); + + if let Some(cloud_config) = cloud_config { + self + .server_provider + .set_enable_sync(user_id, cloud_config.enable_sync); + if cloud_config.enable_encrypt() { + self + .server_provider + .set_encrypt_secret(cloud_config.encrypt_secret.clone()); + } + } + + to_fut(async move { + collab_builder.initialize(user_workspace.id.clone()); + folder_manager + .initialize( + user_id, + &user_workspace.id, + FolderInitializeDataSource::LocalDisk { + create_if_not_exist: false, + }, + ) + .await?; + database_manager + .initialize( + user_id, + user_workspace.id.clone(), + user_workspace.database_views_aggregate_id, + ) + .await?; + document_manager + .initialize(user_id, user_workspace.id) + .await?; + Ok(()) + }) + } + + fn did_sign_in( + &self, + user_id: i64, + user_workspace: &UserWorkspace, + _device_id: &str, + ) -> Fut> { + let user_id = user_id.to_owned(); + let user_workspace = user_workspace.clone(); + let folder_manager = self.folder_manager.clone(); + let database_manager = self.database_manager.clone(); + let document_manager = self.document_manager.clone(); + + to_fut(async move { + folder_manager + .initialize_with_workspace_id(user_id, &user_workspace.id) + .await?; + database_manager + .initialize( + user_id, + user_workspace.id.clone(), + user_workspace.database_views_aggregate_id, + ) + .await?; + document_manager + .initialize(user_id, user_workspace.id) + .await?; + Ok(()) + }) + } + + fn did_sign_up( + &self, + is_new_user: bool, + user_profile: &UserProfile, + user_workspace: &UserWorkspace, + _device_id: &str, + ) -> Fut> { + let user_profile = user_profile.clone(); + let folder_manager = self.folder_manager.clone(); + let database_manager = self.database_manager.clone(); + let user_workspace = user_workspace.clone(); + let document_manager = self.document_manager.clone(); + + to_fut(async move { + folder_manager + .initialize_with_new_user( + user_profile.uid, + &user_profile.token, + is_new_user, + FolderInitializeDataSource::LocalDisk { + create_if_not_exist: true, + }, + &user_workspace.id, + ) + .await?; + database_manager + .initialize_with_new_user( + user_profile.uid, + user_workspace.id.clone(), + user_workspace.database_views_aggregate_id, + ) + .await?; + + document_manager + .initialize_with_new_user(user_profile.uid, user_workspace.id) + .await?; + Ok(()) + }) + } + + fn did_expired(&self, _token: &str, user_id: i64) -> Fut> { + let folder_manager = self.folder_manager.clone(); + to_fut(async move { + folder_manager.clear(user_id).await; + Ok(()) + }) + } + + fn open_workspace(&self, user_id: i64, user_workspace: &UserWorkspace) -> Fut> { + let user_workspace = user_workspace.clone(); + let collab_builder = self.collab_builder.clone(); + let folder_manager = self.folder_manager.clone(); + let database_manager = self.database_manager.clone(); + let document_manager = self.document_manager.clone(); + + to_fut(async move { + collab_builder.initialize(user_workspace.id.clone()); + folder_manager + .initialize_with_workspace_id(user_id, &user_workspace.id) + .await?; + + database_manager + .initialize( + user_id, + user_workspace.id.clone(), + user_workspace.database_views_aggregate_id, + ) + .await?; + document_manager + .initialize(user_id, user_workspace.id) + .await?; + Ok(()) + }) + } + + fn did_update_network(&self, reachable: bool) { + self.collab_builder.update_network(reachable); + } +} diff --git a/frontend/rust-lib/flowy-core/src/lib.rs b/frontend/rust-lib/flowy-core/src/lib.rs index f430f005bd..b6e02a74c5 100644 --- a/frontend/rust-lib/flowy-core/src/lib.rs +++ b/frontend/rust-lib/flowy-core/src/lib.rs @@ -2,43 +2,34 @@ use std::sync::Weak; use std::time::Duration; -use std::{ - fmt, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, -}; +use std::{fmt, sync::Arc}; use tokio::sync::RwLock; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabSource}; use flowy_database2::DatabaseManager; use flowy_document2::manager::DocumentManager; -use flowy_error::FlowyResult; -use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager}; +use flowy_folder2::manager::FolderManager; use flowy_sqlite::kv::StorePreferences; use flowy_storage::FileStorageService; use flowy_task::{TaskDispatcher, TaskRunner}; -use flowy_user::event_map::{UserCloudServiceProvider, UserStatusCallback}; +use flowy_user::event_map::UserCloudServiceProvider; use flowy_user::manager::{UserManager, UserSessionConfig}; -use flowy_user_deps::cloud::UserCloudConfig; -use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace}; use lib_dispatch::prelude::*; use lib_dispatch::runtime::tokio_default_runtime; -use lib_infra::future::{to_fut, Fut}; use module::make_plugins; pub use module::*; use crate::deps_resolve::*; +use crate::integrate::collab_interact::CollabInteractImpl; +use crate::integrate::log::{create_log_filter, init_log}; use crate::integrate::server::{current_server_provider, ServerProvider, ServerType}; +use crate::integrate::user::UserStatusCallbackImpl; mod deps_resolve; mod integrate; pub mod module; -static INIT_LOG: AtomicBool = AtomicBool::new(false); - /// This name will be used as to identify the current [AppFlowyCore] instance. /// Don't change this. pub const DEFAULT_NAME: &str = "appflowy"; @@ -75,41 +66,6 @@ impl AppFlowyCoreConfig { } } -fn create_log_filter(level: String, with_crates: Vec) -> String { - let level = std::env::var("RUST_LOG").unwrap_or(level); - let mut filters = with_crates - .into_iter() - .map(|crate_name| format!("{}={}", crate_name, level)) - .collect::>(); - filters.push(format!("flowy_core={}", level)); - filters.push(format!("flowy_folder2={}", level)); - filters.push(format!("collab_sync={}", level)); - filters.push(format!("collab_folder={}", level)); - filters.push(format!("collab_persistence={}", level)); - filters.push(format!("collab_database={}", level)); - filters.push(format!("collab_plugins={}", level)); - filters.push(format!("appflowy_integrate={}", level)); - filters.push(format!("collab={}", level)); - filters.push(format!("flowy_user={}", level)); - filters.push(format!("flowy_document2={}", level)); - filters.push(format!("flowy_database2={}", level)); - filters.push(format!("flowy_server={}", level)); - filters.push(format!("flowy_notification={}", "info")); - filters.push(format!("lib_infra={}", level)); - filters.push(format!("flowy_task={}", level)); - - filters.push(format!("dart_ffi={}", "info")); - filters.push(format!("flowy_sqlite={}", "info")); - filters.push(format!("flowy_net={}", level)); - #[cfg(feature = "profiling")] - filters.push(format!("tokio={}", level)); - - #[cfg(feature = "profiling")] - filters.push(format!("runtime={}", level)); - - filters.join(",") -} - #[derive(Clone)] pub struct AppFlowyCore { #[allow(dead_code)] @@ -162,7 +118,7 @@ impl AppFlowyCore { /// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded /// on demand based on the [CollabPluginConfig]. let collab_builder = Arc::new(AppFlowyCollabBuilder::new(server_provider.clone())); - let user_manager = mk_user_session( + let user_manager = init_user_manager( &config, &store_preference, server_provider.clone(), @@ -206,7 +162,7 @@ impl AppFlowyCore { ) }); - let user_status_listener = UserStatusCallbackImpl { + let user_status_callback = UserStatusCallbackImpl { collab_builder, folder_manager: folder_manager.clone(), database_manager: database_manager.clone(), @@ -215,10 +171,17 @@ impl AppFlowyCore { config: config.clone(), }; + let collab_interact_impl = CollabInteractImpl { + database_manager: Arc::downgrade(&database_manager), + document_manager: Arc::downgrade(&document_manager), + }; + let cloned_user_session = Arc::downgrade(&user_manager); runtime.block_on(async move { if let Some(user_session) = cloned_user_session.upgrade() { - user_session.init(user_status_listener).await; + user_session + .init(user_status_callback, collab_interact_impl) + .await; } }); @@ -250,17 +213,7 @@ impl AppFlowyCore { } } -fn init_log(config: &AppFlowyCoreConfig) { - if !INIT_LOG.load(Ordering::SeqCst) { - INIT_LOG.store(true, Ordering::SeqCst); - - let _ = lib_log::Builder::new("AppFlowy-Client", &config.storage_path) - .env_filter(&config.log_filter) - .build(); - } -} - -fn mk_user_session( +fn init_user_manager( config: &AppFlowyCoreConfig, storage_preference: &Arc, user_cloud_service_provider: Arc, @@ -275,181 +228,9 @@ fn mk_user_session( ) } -struct UserStatusCallbackImpl { - collab_builder: Arc, - folder_manager: Arc, - database_manager: Arc, - document_manager: Arc, - server_provider: Arc, - #[allow(dead_code)] - config: AppFlowyCoreConfig, -} - -impl UserStatusCallback for UserStatusCallbackImpl { - fn auth_type_did_changed(&self, _auth_type: AuthType) {} - - fn did_init( - &self, - user_id: i64, - cloud_config: &Option, - user_workspace: &UserWorkspace, - _device_id: &str, - ) -> Fut> { - let user_workspace = user_workspace.clone(); - self.collab_builder.initialize(user_workspace.id.clone()); - - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let document_manager = self.document_manager.clone(); - - if let Some(cloud_config) = cloud_config { - self - .server_provider - .set_enable_sync(user_id, cloud_config.enable_sync); - if cloud_config.enable_encrypt() { - self - .server_provider - .set_encrypt_secret(cloud_config.encrypt_secret.clone()); - } - } - - to_fut(async move { - folder_manager - .initialize( - user_id, - &user_workspace.id, - FolderInitializeDataSource::LocalDisk { - create_if_not_exist: false, - }, - ) - .await?; - database_manager - .initialize( - user_id, - user_workspace.id.clone(), - user_workspace.database_views_aggregate_id, - ) - .await?; - document_manager - .initialize(user_id, user_workspace.id) - .await?; - Ok(()) - }) - } - - fn did_sign_in( - &self, - user_id: i64, - user_workspace: &UserWorkspace, - _device_id: &str, - ) -> Fut> { - let user_id = user_id.to_owned(); - let user_workspace = user_workspace.clone(); - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let document_manager = self.document_manager.clone(); - - to_fut(async move { - folder_manager - .initialize_with_workspace_id(user_id, &user_workspace.id) - .await?; - database_manager - .initialize( - user_id, - user_workspace.id.clone(), - user_workspace.database_views_aggregate_id, - ) - .await?; - document_manager - .initialize(user_id, user_workspace.id) - .await?; - Ok(()) - }) - } - - fn did_sign_up( - &self, - is_new_user: bool, - user_profile: &UserProfile, - user_workspace: &UserWorkspace, - _device_id: &str, - ) -> Fut> { - let user_profile = user_profile.clone(); - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let user_workspace = user_workspace.clone(); - let document_manager = self.document_manager.clone(); - - to_fut(async move { - folder_manager - .initialize_with_new_user( - user_profile.uid, - &user_profile.token, - is_new_user, - FolderInitializeDataSource::LocalDisk { - create_if_not_exist: true, - }, - &user_workspace.id, - ) - .await?; - database_manager - .initialize_with_new_user( - user_profile.uid, - user_workspace.id.clone(), - user_workspace.database_views_aggregate_id, - ) - .await?; - - document_manager - .initialize_with_new_user(user_profile.uid, user_workspace.id) - .await?; - Ok(()) - }) - } - - fn did_expired(&self, _token: &str, user_id: i64) -> Fut> { - let folder_manager = self.folder_manager.clone(); - to_fut(async move { - folder_manager.clear(user_id).await; - Ok(()) - }) - } - - fn open_workspace(&self, user_id: i64, user_workspace: &UserWorkspace) -> Fut> { - let user_workspace = user_workspace.clone(); - self.collab_builder.initialize(user_workspace.id.clone()); - - let folder_manager = self.folder_manager.clone(); - let database_manager = self.database_manager.clone(); - let document_manager = self.document_manager.clone(); - - to_fut(async move { - folder_manager - .initialize_with_workspace_id(user_id, &user_workspace.id) - .await?; - - database_manager - .initialize( - user_id, - user_workspace.id.clone(), - user_workspace.database_views_aggregate_id, - ) - .await?; - document_manager - .initialize(user_id, user_workspace.id) - .await?; - Ok(()) - }) - } - - fn did_update_network(&self, reachable: bool) { - self.collab_builder.update_network(reachable); - } -} - impl From for CollabSource { - fn from(server_provider: ServerType) -> Self { - match server_provider { + fn from(server_type: ServerType) -> Self { + match server_type { ServerType::Local => CollabSource::Local, ServerType::AppFlowyCloud => CollabSource::Local, ServerType::Supabase => CollabSource::Supabase, diff --git a/frontend/rust-lib/flowy-core/src/module.rs b/frontend/rust-lib/flowy-core/src/module.rs index 1229cf90d4..3699d74b85 100644 --- a/frontend/rust-lib/flowy-core/src/module.rs +++ b/frontend/rust-lib/flowy-core/src/module.rs @@ -21,11 +21,13 @@ pub fn make_plugins( let database_plugin = flowy_database2::event_map::init(database_manager); let document_plugin2 = flowy_document2::event_map::init(document_manager2); let config_plugin = flowy_config::event_map::init(store_preferences); + let date_plugin = flowy_date::event_map::init(); vec![ user_plugin, folder_plugin, database_plugin, document_plugin2, config_plugin, + date_plugin, ] } diff --git a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs index 3a65482174..89b69e1acb 100644 --- a/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs +++ b/frontend/rust-lib/flowy-database2/src/services/group/configuration.rs @@ -301,7 +301,7 @@ where is_changed = true; }, Some(pos) => { - let mut old_group = configuration.groups.get_mut(pos).unwrap(); + let old_group = configuration.groups.get_mut(pos).unwrap(); // Take the old group setting group.visible = old_group.visible; if !is_changed { diff --git a/frontend/rust-lib/flowy-date/Cargo.toml b/frontend/rust-lib/flowy-date/Cargo.toml new file mode 100644 index 0000000000..6211fd9bb7 --- /dev/null +++ b/frontend/rust-lib/flowy-date/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "flowy-date" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lib-dispatch = { path = "../lib-dispatch" } +flowy-error = { path = "../flowy-error" } +flowy-derive = { path = "../../../shared-lib/flowy-derive" } +protobuf = { version = "2.28.0" } +bytes = { version = "1.4" } +strum_macros = "0.21" +tracing = { version = "0.1" } +date_time_parser = { version = "0.2.0" } +chrono = { version = "0.4.26" } +fancy-regex = { version = "0.11.0" } + +[features] +dart = ["flowy-codegen/dart"] +ts = ["flowy-codegen/ts"] + +[build-dependencies] +flowy-codegen = { path = "../../../shared-lib/flowy-codegen" } diff --git a/frontend/rust-lib/flowy-date/Flowy.toml b/frontend/rust-lib/flowy-date/Flowy.toml new file mode 100644 index 0000000000..9735bb9b6f --- /dev/null +++ b/frontend/rust-lib/flowy-date/Flowy.toml @@ -0,0 +1,3 @@ +# Check out the FlowyConfig (located in flowy_toml.rs) for more details. +proto_input = ["src/event_map.rs", "src/entities.rs"] +event_files = ["src/event_map.rs"] diff --git a/frontend/rust-lib/flowy-date/build.rs b/frontend/rust-lib/flowy-date/build.rs new file mode 100644 index 0000000000..06388d2a02 --- /dev/null +++ b/frontend/rust-lib/flowy-date/build.rs @@ -0,0 +1,10 @@ +fn main() { + let crate_name = env!("CARGO_PKG_NAME"); + flowy_codegen::protobuf_file::gen(crate_name); + + #[cfg(feature = "dart")] + flowy_codegen::dart_event::gen(crate_name); + + #[cfg(feature = "ts")] + flowy_codegen::ts_event::gen(crate_name); +} diff --git a/frontend/rust-lib/flowy-date/src/entities.rs b/frontend/rust-lib/flowy-date/src/entities.rs new file mode 100644 index 0000000000..75d0ee61e0 --- /dev/null +++ b/frontend/rust-lib/flowy-date/src/entities.rs @@ -0,0 +1,13 @@ +use flowy_derive::ProtoBuf; + +#[derive(ProtoBuf, Debug, Default, Clone)] +pub struct DateQueryPB { + #[pb(index = 1)] + pub query: String, +} + +#[derive(ProtoBuf, Debug, Default, Clone)] +pub struct DateResultPB { + #[pb(index = 1)] + pub date: String, +} diff --git a/frontend/rust-lib/flowy-date/src/event_handler.rs b/frontend/rust-lib/flowy-date/src/event_handler.rs new file mode 100644 index 0000000000..12712ec8fb --- /dev/null +++ b/frontend/rust-lib/flowy-date/src/event_handler.rs @@ -0,0 +1,36 @@ +use chrono::{Datelike, NaiveDate}; +use date_time_parser::DateParser; +use fancy_regex::Regex; +use flowy_error::FlowyError; +use lib_dispatch::prelude::{data_result_ok, AFPluginData, DataResult}; +use std::sync::OnceLock; + +use crate::entities::*; + +static YEAR_REGEX: OnceLock = OnceLock::new(); + +fn year_regex() -> &'static Regex { + YEAR_REGEX.get_or_init(|| Regex::new(r"\b\d{4}\b").unwrap()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn query_date_handler( + data: AFPluginData, +) -> DataResult { + let query: String = data.into_inner().query; + let date = DateParser::parse(&query); + + match date { + Some(naive_date) => { + let year_match = year_regex().find(&query).unwrap(); + let formatted = year_match + .and_then(|capture| capture.as_str().parse::().ok()) + .and_then(|year| NaiveDate::from_ymd_opt(year, naive_date.month0(), naive_date.day0())) + .map(|date| date.to_string()) + .unwrap_or_else(|| naive_date.to_string()); + + data_result_ok(DateResultPB { date: formatted }) + }, + None => Err(FlowyError::internal().with_context("Failed to parse date from")), + } +} diff --git a/frontend/rust-lib/flowy-date/src/event_map.rs b/frontend/rust-lib/flowy-date/src/event_map.rs new file mode 100644 index 0000000000..2f4e939469 --- /dev/null +++ b/frontend/rust-lib/flowy-date/src/event_map.rs @@ -0,0 +1,19 @@ +use strum_macros::Display; + +use flowy_derive::{Flowy_Event, ProtoBuf_Enum}; +use lib_dispatch::prelude::AFPlugin; + +use crate::event_handler::query_date_handler; + +pub fn init() -> AFPlugin { + AFPlugin::new() + .name(env!("CARGO_PKG_NAME")) + .event(DateEvent::QueryDate, query_date_handler) +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, ProtoBuf_Enum, Flowy_Event)] +#[event_err = "FlowyError"] +pub enum DateEvent { + #[event(input = "DateQueryPB", output = "DateResultPB")] + QueryDate = 0, +} diff --git a/frontend/rust-lib/flowy-date/src/lib.rs b/frontend/rust-lib/flowy-date/src/lib.rs new file mode 100644 index 0000000000..8c06875312 --- /dev/null +++ b/frontend/rust-lib/flowy-date/src/lib.rs @@ -0,0 +1,4 @@ +pub mod entities; +pub mod event_handler; +pub mod event_map; +pub mod protobuf; diff --git a/frontend/rust-lib/flowy-document2/src/lib.rs b/frontend/rust-lib/flowy-document2/src/lib.rs index 6dd7fd1213..365ba63da7 100644 --- a/frontend/rust-lib/flowy-document2/src/lib.rs +++ b/frontend/rust-lib/flowy-document2/src/lib.rs @@ -10,3 +10,4 @@ pub mod protobuf; pub mod deps; pub mod notification; mod parse; +pub mod reminder; diff --git a/frontend/rust-lib/flowy-document2/src/manager.rs b/frontend/rust-lib/flowy-document2/src/manager.rs index dd4cb5af77..c63e3c9514 100644 --- a/frontend/rust-lib/flowy-document2/src/manager.rs +++ b/frontend/rust-lib/flowy-document2/src/manager.rs @@ -17,6 +17,7 @@ use flowy_storage::FileStorageService; use crate::document::MutexDocument; use crate::entities::DocumentSnapshotPB; +use crate::reminder::DocumentReminderAction; pub trait DocumentUser: Send + Sync { fn user_id(&self) -> Result; @@ -58,6 +59,15 @@ impl DocumentManager { self.initialize(uid, workspace_id).await?; Ok(()) } + + pub async fn handle_reminder_action(&self, action: DocumentReminderAction) { + match action { + DocumentReminderAction::Add { reminder: _ } => {}, + DocumentReminderAction::Remove { reminder_id: _ } => {}, + DocumentReminderAction::Update { reminder: _ } => {}, + } + } + /// Create a new document. /// /// if the document already exists, return the existing document. diff --git a/frontend/rust-lib/flowy-document2/src/reminder.rs b/frontend/rust-lib/flowy-document2/src/reminder.rs new file mode 100644 index 0000000000..03fa68b579 --- /dev/null +++ b/frontend/rust-lib/flowy-document2/src/reminder.rs @@ -0,0 +1,23 @@ +use collab_define::reminder::Reminder; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DocumentReminderAction { + Add { reminder: DocumentReminder }, + Remove { reminder_id: String }, + Update { reminder: DocumentReminder }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentReminder { + document_id: String, // defines the necessary fields for a reminder +} + +impl TryFrom for DocumentReminder { + type Error = serde_json::Error; + + fn try_from(value: Reminder) -> Result { + serde_json::from_value(json!(value.meta.into_inner())) + } +} diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index 0298ebc383..dee71d6de6 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -7,18 +7,21 @@ edition = "2018" [dependencies] flowy-derive = { path = "../../../shared-lib/flowy-derive" } -protobuf = {version = "2.28.0"} +protobuf = { version = "2.28.0" } bytes = "1.4" anyhow = "1.0" thiserror = "1.0" +fancy-regex = { version = "0.11.0" } lib-dispatch = { workspace = true, optional = true } -serde_json = {version = "1.0", optional = true} +serde_json = { version = "1.0", optional = true } serde_repr = { version = "0.1" } serde = "1.0" -reqwest = { version = "0.11.14", optional = true, features = ["native-tls-vendored"] } -flowy-sqlite = { workspace = true, optional = true} -r2d2 = { version = "0.8", optional = true} +reqwest = { version = "0.11.14", optional = true, features = [ + "native-tls-vendored", +] } +flowy-sqlite = { workspace = true, optional = true } +r2d2 = { version = "0.8", optional = true } url = { version = "2.2", optional = true } collab-database = { version = "0.1.0", optional = true } collab-document = { version = "0.1.0", optional = true } @@ -33,11 +36,13 @@ impl_from_reqwest = ["reqwest"] impl_from_sqlite = ["flowy-sqlite", "r2d2"] impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"] impl_from_postgres = ["tokio-postgres"] -impl_from_tokio= ["tokio"] -impl_from_url= ["url"] +impl_from_tokio = ["tokio"] +impl_from_url = ["url"] impl_from_appflowy_cloud = ["client-api"] dart = ["flowy-codegen/dart"] ts = ["flowy-codegen/ts"] [build-dependencies] -flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = ["proto_gen"]} +flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = [ + "proto_gen", +] } diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index dc99416125..8e8295f79d 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -137,3 +137,9 @@ impl From for FlowyError { .unwrap_or_else(|err| FlowyError::new(ErrorCode::Internal, err)) } } + +impl From for FlowyError { + fn from(e: fancy_regex::Error) -> Self { + FlowyError::internal().with_context(e) + } +} diff --git a/frontend/rust-lib/flowy-test/tests/user/local_test/user_awareness_test.rs b/frontend/rust-lib/flowy-test/tests/user/local_test/user_awareness_test.rs index df980677b0..01802b74a8 100644 --- a/frontend/rust-lib/flowy-test/tests/user/local_test/user_awareness_test.rs +++ b/frontend/rust-lib/flowy-test/tests/user/local_test/user_awareness_test.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use flowy_test::event_builder::EventBuilder; use flowy_test::FlowyCoreTest; use flowy_user::entities::{ReminderPB, RepeatedReminderPB}; @@ -7,14 +9,18 @@ use flowy_user::event_map::UserEvent::*; async fn user_update_with_name() { let sdk = FlowyCoreTest::new(); let _ = sdk.sign_up_as_guest().await; + let mut meta = HashMap::new(); + meta.insert("object_id".to_string(), "".to_string()); + let payload = ReminderPB { id: "".to_string(), scheduled_at: 0, is_ack: false, - ty: 0, + is_read: false, title: "".to_string(), message: "".to_string(), - reminder_object_id: "".to_string(), + object_id: "".to_string(), + meta, }; let _ = EventBuilder::new(sdk.clone()) .event(CreateReminder) diff --git a/frontend/rust-lib/flowy-user/Cargo.toml b/frontend/rust-lib/flowy-user/Cargo.toml index 2141c3840b..46e3809862 100644 --- a/frontend/rust-lib/flowy-user/Cargo.toml +++ b/frontend/rust-lib/flowy-user/Cargo.toml @@ -28,16 +28,17 @@ anyhow = "1.0.75" tracing = { version = "0.1", features = ["log"] } bytes = "1.4" serde = { version = "1.0", features = ["derive"] } -serde_json = {version = "1.0"} +serde_json = { version = "1.0" } serde_repr = "0.1" log = "0.4.17" -protobuf = {version = "2.28.0"} +protobuf = { version = "2.28.0" } lazy_static = "1.4.0" -diesel = {version = "1.4.8", features = ["sqlite"]} -diesel_derives = {version = "1.4.1", features = ["sqlite"]} +diesel = { version = "1.4.8", features = ["sqlite"] } +diesel_derives = { version = "1.4.1", features = ["sqlite"] } once_cell = "1.17.1" parking_lot = "0.12.1" -strum_macros = "0.21" +strum = "0.25" +strum_macros = "0.25.2" tokio = { version = "1.26", features = ["rt"] } validator = "0.16.0" unicode-segmentation = "1.10" @@ -61,4 +62,4 @@ dart = ["flowy-codegen/dart", "flowy-notification/dart"] ts = ["flowy-codegen/ts", "flowy-notification/ts"] [build-dependencies] -flowy-codegen = { path = "../../../shared-lib/flowy-codegen"} +flowy-codegen = { path = "../../../shared-lib/flowy-codegen" } diff --git a/frontend/rust-lib/flowy-user/src/entities/date_time.rs b/frontend/rust-lib/flowy-user/src/entities/date_time.rs new file mode 100644 index 0000000000..7fc3ae35e0 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/entities/date_time.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +use flowy_derive::ProtoBuf_Enum; + +#[derive(ProtoBuf_Enum, Serialize, Deserialize, Debug, Clone, Default, Copy)] +pub enum UserDateFormatPB { + Locally = 0, + US = 1, + ISO = 2, + #[default] + Friendly = 3, + DayMonthYear = 4, +} + +impl std::convert::From for UserDateFormatPB { + fn from(value: i64) -> Self { + match value { + 0 => UserDateFormatPB::Locally, + 1 => UserDateFormatPB::US, + 2 => UserDateFormatPB::ISO, + 3 => UserDateFormatPB::Friendly, + 4 => UserDateFormatPB::DayMonthYear, + _ => { + tracing::error!("Unsupported date format, fallback to friendly"); + UserDateFormatPB::Friendly + }, + } + } +} + +impl UserDateFormatPB { + pub fn value(&self) -> i64 { + *self as i64 + } + // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html + pub fn format_str(&self) -> &'static str { + match self { + UserDateFormatPB::Locally => "%m/%d/%Y", + UserDateFormatPB::US => "%Y/%m/%d", + UserDateFormatPB::ISO => "%Y-%m-%d", + UserDateFormatPB::Friendly => "%b %d, %Y", + UserDateFormatPB::DayMonthYear => "%d/%m/%Y", + } + } +} + +#[derive(ProtoBuf_Enum, Serialize, Deserialize, Debug, Clone, Default, Copy)] +pub enum UserTimeFormatPB { + TwelveHour = 0, + #[default] + TwentyFourHour = 1, +} + +impl std::convert::From for UserTimeFormatPB { + fn from(value: i64) -> Self { + match value { + 0 => UserTimeFormatPB::TwelveHour, + 1 => UserTimeFormatPB::TwentyFourHour, + _ => { + tracing::error!("Unsupported time format, fallback to TwentyFourHour"); + UserTimeFormatPB::TwentyFourHour + }, + } + } +} + +impl UserTimeFormatPB { + pub fn value(&self) -> i64 { + *self as i64 + } + + // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html + pub fn format_str(&self) -> &'static str { + match self { + UserTimeFormatPB::TwelveHour => "%I:%M %p", + UserTimeFormatPB::TwentyFourHour => "%R", + } + } +} diff --git a/frontend/rust-lib/flowy-user/src/entities/mod.rs b/frontend/rust-lib/flowy-user/src/entities/mod.rs index 0ff506f994..9568679078 100644 --- a/frontend/rust-lib/flowy-user/src/entities/mod.rs +++ b/frontend/rust-lib/flowy-user/src/entities/mod.rs @@ -5,6 +5,7 @@ pub use user_profile::*; pub use user_setting::*; pub mod auth; +pub mod date_time; pub mod parser; pub mod realtime; mod reminder; diff --git a/frontend/rust-lib/flowy-user/src/entities/reminder.rs b/frontend/rust-lib/flowy-user/src/entities/reminder.rs index bc7eccd97d..887a76796b 100644 --- a/frontend/rust-lib/flowy-user/src/entities/reminder.rs +++ b/frontend/rust-lib/flowy-user/src/entities/reminder.rs @@ -1,6 +1,6 @@ -use collab_define::reminder::{ObjectType, Reminder}; - +use collab_define::reminder::{ObjectType, Reminder, ReminderMeta}; use flowy_derive::ProtoBuf; +use std::collections::HashMap; #[derive(ProtoBuf, Default, Clone)] pub struct ReminderPB { @@ -8,22 +8,25 @@ pub struct ReminderPB { pub id: String, #[pb(index = 2)] - pub scheduled_at: i64, + pub object_id: String, #[pb(index = 3)] - pub is_ack: bool, + pub scheduled_at: i64, #[pb(index = 4)] - pub ty: i64, + pub is_ack: bool, #[pb(index = 5)] - pub title: String, + pub is_read: bool, #[pb(index = 6)] - pub message: String, + pub title: String, #[pb(index = 7)] - pub reminder_object_id: String, + pub message: String, + + #[pb(index = 8)] + pub meta: HashMap, } #[derive(ProtoBuf, Default, Clone)] @@ -38,11 +41,12 @@ impl From for Reminder { id: value.id, scheduled_at: value.scheduled_at, is_ack: value.is_ack, + is_read: value.is_read, ty: ObjectType::Document, title: value.title, message: value.message, - meta: Default::default(), - object_id: value.reminder_object_id, + meta: ReminderMeta::from(value.meta), + object_id: value.object_id, } } } @@ -51,12 +55,13 @@ impl From for ReminderPB { fn from(value: Reminder) -> Self { Self { id: value.id, + object_id: value.object_id, scheduled_at: value.scheduled_at, is_ack: value.is_ack, - ty: value.ty as i64, + is_read: value.is_read, title: value.title, message: value.message, - reminder_object_id: value.object_id, + meta: value.meta.into_inner(), } } } @@ -66,3 +71,9 @@ impl From> for RepeatedReminderPB { Self { items: value } } } + +#[derive(ProtoBuf, Default, Clone)] +pub struct ReminderIdentifierPB { + #[pb(index = 1)] + pub id: String, +} diff --git a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs index db0e6a17ec..e961fca155 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs @@ -7,6 +7,8 @@ use flowy_user_deps::cloud::UserCloudConfig; use crate::entities::EncryptionTypePB; +use super::date_time::{UserDateFormatPB, UserTimeFormatPB}; + #[derive(ProtoBuf, Default, Debug, Clone)] pub struct UserPreferencesPB { #[pb(index = 1)] @@ -14,6 +16,9 @@ pub struct UserPreferencesPB { #[pb(index = 2)] appearance_setting: AppearanceSettingsPB, + + #[pb(index = 3)] + date_time_settings: DateTimeSettingsPB, } #[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)] @@ -106,7 +111,7 @@ impl std::default::Default for LocaleSettingsPB { } } -pub const APPEARANCE_DEFAULT_THEME: &str = "light"; +pub const APPEARANCE_DEFAULT_THEME: &str = "Default"; pub const APPEARANCE_DEFAULT_FONT: &str = "Poppins"; pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono"; const APPEARANCE_RESET_AS_DEFAULT: bool = true; @@ -210,3 +215,25 @@ pub struct NetworkStatePB { #[pb(index = 1)] pub ty: NetworkTypePB, } + +#[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)] +pub struct DateTimeSettingsPB { + #[pb(index = 1)] + pub date_format: UserDateFormatPB, + + #[pb(index = 2)] + pub time_format: UserTimeFormatPB, + + #[pb(index = 3)] + pub timezone_id: String, +} + +impl std::default::Default for DateTimeSettingsPB { + fn default() -> Self { + DateTimeSettingsPB { + date_format: UserDateFormatPB::Friendly, + time_format: UserTimeFormatPB::TwentyFourHour, + timezone_id: "".to_owned(), + } + } +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index 823d444d5f..9948b606af 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -166,6 +166,46 @@ pub async fn get_appearance_setting( } } +const DATE_TIME_SETTINGS_CACHE_KEY: &str = "date_time_settings"; + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn set_date_time_settings( + store_preferences: AFPluginState>, + data: AFPluginData, +) -> Result<(), FlowyError> { + let store_preferences = upgrade_store_preferences(store_preferences)?; + let mut setting = data.into_inner(); + if setting.timezone_id.is_empty() { + setting.timezone_id = "".to_string(); + } + + store_preferences.set_object(DATE_TIME_SETTINGS_CACHE_KEY, setting)?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn get_date_time_settings( + store_preferences: AFPluginState>, +) -> DataResult { + let store_preferences = upgrade_store_preferences(store_preferences)?; + match store_preferences.get_str(DATE_TIME_SETTINGS_CACHE_KEY) { + None => data_result_ok(DateTimeSettingsPB::default()), + Some(s) => { + let setting = match serde_json::from_str(&s) { + Ok(setting) => setting, + Err(e) => { + tracing::error!( + "Deserialize AppearanceSettings failed: {:?}, fallback to default", + e + ); + DateTimeSettingsPB::default() + }, + }; + data_result_ok(setting) + }, + } +} + #[tracing::instrument(level = "debug", skip_all, err)] pub async fn get_user_setting( manager: AFPluginState>, @@ -457,3 +497,27 @@ pub async fn reset_workspace_handler( manager.reset_workspace(reset_pb, session.device_id).await?; Ok(()) } + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn remove_reminder_event_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + + let params = data.into_inner(); + let _ = manager.remove_reminder(params.id.as_str()).await; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all, err)] +pub async fn update_reminder_event_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> Result<(), FlowyError> { + let manager = upgrade_manager(manager)?; + let params = data.into_inner(); + manager.update_reminder(params).await?; + Ok(()) +} diff --git a/frontend/rust-lib/flowy-user/src/event_map.rs b/frontend/rust-lib/flowy-user/src/event_map.rs index 74eb30b03d..ab7b76cfef 100644 --- a/frontend/rust-lib/flowy-user/src/event_map.rs +++ b/frontend/rust-lib/flowy-user/src/event_map.rs @@ -54,7 +54,11 @@ pub fn init(user_session: Weak) -> AFPlugin { .event(UserEvent::PushRealtimeEvent, push_realtime_event_handler) .event(UserEvent::CreateReminder, create_reminder_event_handler) .event(UserEvent::GetAllReminders, get_all_reminder_event_handler) + .event(UserEvent::RemoveReminder, remove_reminder_event_handler) + .event(UserEvent::UpdateReminder, update_reminder_event_handler) .event(UserEvent::ResetWorkspace, reset_workspace_handler) + .event(UserEvent::SetDateTimeSettings, set_date_time_settings) + .event(UserEvent::GetDateTimeSettings, get_date_time_settings) } pub struct SignUpContext { @@ -262,8 +266,9 @@ pub enum UserEvent { #[event(input = "HistoricalUserPB")] OpenHistoricalUser = 26, - /// Push a realtime event to the user. Currently, the realtime event is only used - /// when the auth type is: [AuthType::Supabase]. + /// Push a realtime event to the user. Currently, the realtime event + /// is only used when the auth type is: [AuthType::Supabase]. + /// #[event(input = "RealtimePayloadPB")] PushRealtimeEvent = 27, @@ -273,6 +278,20 @@ pub enum UserEvent { #[event(output = "RepeatedReminderPB")] GetAllReminders = 29, + #[event(input = "ReminderIdentifierPB")] + RemoveReminder = 30, + + #[event(input = "ReminderPB")] + UpdateReminder = 31, + #[event(input = "ResetWorkspacePB")] - ResetWorkspace = 30, + ResetWorkspace = 32, + + /// Change the Date/Time formats globally + #[event(input = "DateTimeSettingsPB")] + SetDateTimeSettings = 33, + + /// Retrieve the Date/Time formats + #[event(output = "DateTimeSettingsPB")] + GetDateTimeSettings = 34, } diff --git a/frontend/rust-lib/flowy-user/src/manager.rs b/frontend/rust-lib/flowy-user/src/manager.rs index 386e7d82ca..998482c904 100644 --- a/frontend/rust-lib/flowy-user/src/manager.rs +++ b/frontend/rust-lib/flowy-user/src/manager.rs @@ -25,6 +25,7 @@ use crate::migrations::migration::UserLocalDataMigration; use crate::migrations::sync_new_user::sync_user_data_to_cloud; use crate::migrations::MigrationUser; use crate::services::cloud_config::get_cloud_config; +use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract}; use crate::services::database::UserDB; use crate::services::entities::{ResumableSignUp, Session}; use crate::services::user_awareness::UserAwarenessDataSource; @@ -59,6 +60,7 @@ pub struct UserManager { pub(crate) user_awareness: Arc>>, pub(crate) user_status_callback: RwLock>, pub(crate) collab_builder: Weak, + pub(crate) collab_interact: RwLock>, resumable_sign_up: Mutex>, current_session: parking_lot::RwLock>, } @@ -82,6 +84,7 @@ impl UserManager { user_awareness: Arc::new(Default::default()), user_status_callback, collab_builder, + collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)), resumable_sign_up: Default::default(), current_session: Default::default(), }); @@ -114,7 +117,11 @@ impl UserManager { /// it will attempt a local data migration for the user. After ensuring the user's data is migrated and up-to-date, /// the function will set up the collaboration configuration and initialize the user's awareness. Upon successful /// completion, a user status callback is invoked to signify that the initialization process is complete. - pub async fn init(&self, user_status_callback: C) { + pub async fn init( + &self, + user_status_callback: C, + collab_interact: I, + ) { if let Ok(session) = self.get_session() { // Do the user data migration if needed match ( @@ -155,6 +162,7 @@ impl UserManager { } } *self.user_status_callback.write().await = Arc::new(user_status_callback); + *self.collab_interact.write().await = Arc::new(collab_interact); } pub fn db_connection(&self, uid: i64) -> Result { diff --git a/frontend/rust-lib/flowy-user/src/services/collab_interact.rs b/frontend/rust-lib/flowy-user/src/services/collab_interact.rs new file mode 100644 index 0000000000..10b87fdad5 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/services/collab_interact.rs @@ -0,0 +1,25 @@ +use anyhow::Error; +use collab_define::reminder::Reminder; + +use lib_infra::future::FutureResult; + +pub trait CollabInteract: Send + Sync + 'static { + fn add_reminder(&self, reminder: Reminder) -> FutureResult<(), Error>; + fn remove_reminder(&self, reminder_id: &str) -> FutureResult<(), Error>; + fn update_reminder(&self, reminder: Reminder) -> FutureResult<(), Error>; +} + +pub struct DefaultCollabInteract; +impl CollabInteract for DefaultCollabInteract { + fn add_reminder(&self, _reminder: Reminder) -> FutureResult<(), Error> { + FutureResult::new(async { Ok(()) }) + } + + fn remove_reminder(&self, _reminder_id: &str) -> FutureResult<(), Error> { + FutureResult::new(async { Ok(()) }) + } + + fn update_reminder(&self, _reminder: Reminder) -> FutureResult<(), Error> { + FutureResult::new(async { Ok(()) }) + } +} diff --git a/frontend/rust-lib/flowy-user/src/services/mod.rs b/frontend/rust-lib/flowy-user/src/services/mod.rs index e632fc6e65..2b69162896 100644 --- a/frontend/rust-lib/flowy-user/src/services/mod.rs +++ b/frontend/rust-lib/flowy-user/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod cloud_config; +pub mod collab_interact; pub mod database; pub mod entities; pub(crate) mod historical_user; diff --git a/frontend/rust-lib/flowy-user/src/services/user_awareness.rs b/frontend/rust-lib/flowy-user/src/services/user_awareness.rs index 47cbc65947..e71a9e1c29 100644 --- a/frontend/rust-lib/flowy-user/src/services/user_awareness.rs +++ b/frontend/rust-lib/flowy-user/src/services/user_awareness.rs @@ -30,9 +30,53 @@ impl UserManager { let reminder = Reminder::from(reminder_pb); self .with_awareness((), |user_awareness| { - user_awareness.add_reminder(reminder); + user_awareness.add_reminder(reminder.clone()); }) .await; + self + .collab_interact + .read() + .await + .add_reminder(reminder) + .await?; + Ok(()) + } + + /// Removes a specific reminder for the user by its id + /// + pub async fn remove_reminder(&self, reminder_id: &str) -> FlowyResult<()> { + self + .with_awareness((), |user_awareness| { + user_awareness.remove_reminder(reminder_id); + }) + .await; + self + .collab_interact + .read() + .await + .remove_reminder(reminder_id) + .await?; + Ok(()) + } + + /// Updates an existing reminder + /// + pub async fn update_reminder(&self, reminder_pb: ReminderPB) -> FlowyResult<()> { + let reminder = Reminder::from(reminder_pb); + self + .with_awareness((), |user_awareness| { + user_awareness.update_reminder(&reminder.id, |new_reminder| { + new_reminder.clone_from(&reminder) + }); + }) + .await; + self + .collab_interact + .read() + .await + .update_reminder(reminder) + .await?; + Ok(()) } diff --git a/shared-lib/lib-infra/src/lib.rs b/shared-lib/lib-infra/src/lib.rs index f2cf594209..aab12d4872 100644 --- a/shared-lib/lib-infra/src/lib.rs +++ b/shared-lib/lib-infra/src/lib.rs @@ -3,5 +3,4 @@ pub use async_trait; pub mod box_any; pub mod future; pub mod ref_map; -pub mod retry; pub mod util; diff --git a/shared-lib/lib-infra/src/retry/future.rs b/shared-lib/lib-infra/src/retry/future.rs deleted file mode 100644 index 670b02a982..0000000000 --- a/shared-lib/lib-infra/src/retry/future.rs +++ /dev/null @@ -1,218 +0,0 @@ -#![allow(clippy::large_enum_variant)] -#![allow(clippy::type_complexity)] -use crate::retry::FixedInterval; -use pin_project::pin_project; -use std::{ - future::Future, - iter::{IntoIterator, Iterator}, - pin::Pin, - task::{Context, Poll}, -}; -use tokio::time::{sleep_until, Duration, Instant, Sleep}; - -#[pin_project(project = RetryStateProj)] -enum RetryState -where - A: Action, -{ - Running(#[pin] A::Future), - Sleeping(#[pin] Sleep), -} - -impl RetryState { - fn poll(self: Pin<&mut Self>, cx: &mut Context) -> RetryFuturePoll { - match self.project() { - RetryStateProj::Running(future) => RetryFuturePoll::Running(future.poll(cx)), - RetryStateProj::Sleeping(future) => RetryFuturePoll::Sleeping(future.poll(cx)), - } - } -} - -enum RetryFuturePoll -where - A: Action, -{ - Running(Poll>), - Sleeping(Poll<()>), -} - -/// Future that drives multiple attempts at an action via a retry strategy. -#[pin_project] -pub struct Retry -where - I: Iterator, - A: Action, -{ - #[pin] - retry_if: RetryIf bool>, -} - -impl Retry -where - I: Iterator, - A: Action, -{ - pub fn new>( - strategy: T, - action: A, - ) -> Retry { - Retry { - retry_if: RetryIf::spawn(strategy, action, (|_| true) as fn(&A::Error) -> bool), - } - } -} - -impl Future for Retry -where - I: Iterator, - A: Action, -{ - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let this = self.project(); - this.retry_if.poll(cx) - } -} - -/// Future that drives multiple attempts at an action via a retry strategy. -/// Retries are only attempted if the `Error` returned by the future satisfies a -/// given condition. -#[pin_project] -pub struct RetryIf -where - I: Iterator, - A: Action, - C: Condition, -{ - strategy: I, - #[pin] - state: RetryState, - action: A, - condition: C, -} - -impl RetryIf -where - I: Iterator, - A: Action, - C: Condition, -{ - pub fn spawn>( - strategy: T, - mut action: A, - condition: C, - ) -> RetryIf { - RetryIf { - strategy: strategy.into_iter(), - state: RetryState::Running(action.run()), - action, - condition, - } - } - - fn attempt(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll> { - let future = { - let this = self.as_mut().project(); - this.action.run() - }; - self - .as_mut() - .project() - .state - .set(RetryState::Running(future)); - self.poll(cx) - } - - fn retry( - mut self: Pin<&mut Self>, - err: A::Error, - cx: &mut Context, - ) -> Result>, A::Error> { - match self.as_mut().project().strategy.next() { - None => Err(err), - Some(duration) => { - let deadline = Instant::now() + duration; - let future = sleep_until(deadline); - self - .as_mut() - .project() - .state - .set(RetryState::Sleeping(future)); - Ok(self.poll(cx)) - }, - } - } -} - -impl Future for RetryIf -where - I: Iterator, - A: Action, - C: Condition, -{ - type Output = Result; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll { - match self.as_mut().project().state.poll(cx) { - RetryFuturePoll::Running(poll_result) => match poll_result { - Poll::Ready(Ok(ok)) => Poll::Ready(Ok(ok)), - Poll::Pending => Poll::Pending, - Poll::Ready(Err(err)) => { - if self.as_mut().project().condition.should_retry(&err) { - match self.retry(err, cx) { - Ok(poll) => poll, - Err(err) => Poll::Ready(Err(err)), - } - } else { - Poll::Ready(Err(err)) - } - }, - }, - RetryFuturePoll::Sleeping(poll_result) => match poll_result { - Poll::Pending => Poll::Pending, - Poll::Ready(_) => self.attempt(cx), - }, - } - } -} - -/// An action can be run multiple times and produces a future. -pub trait Action: Send + Sync { - type Future: Future>; - type Item; - type Error; - - fn run(&mut self) -> Self::Future; -} -// impl>, F: FnMut() -> T + Send + Sync> -// Action for F { type Future = T; -// type Item = R; -// type Error = E; -// -// fn run(&mut self) -> Self::Future { self() } -// } - -pub trait Condition { - fn should_retry(&mut self, error: &E) -> bool; -} - -impl bool> Condition for F { - fn should_retry(&mut self, error: &E) -> bool { - self(error) - } -} - -pub fn spawn_retry( - retry_count: usize, - retry_per_millis: u64, - action: A, -) -> impl Future> -where - A::Item: Send + Sync, - A::Error: Send + Sync, - ::Future: Send + Sync, -{ - let strategy = FixedInterval::from_millis(retry_per_millis).take(retry_count); - Retry::new(strategy, action) -} diff --git a/shared-lib/lib-infra/src/retry/mod.rs b/shared-lib/lib-infra/src/retry/mod.rs deleted file mode 100644 index 617a22f34e..0000000000 --- a/shared-lib/lib-infra/src/retry/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod future; -mod strategy; - -pub use future::*; -pub use strategy::*; diff --git a/shared-lib/lib-infra/src/retry/strategy/exponential_backoff.rs b/shared-lib/lib-infra/src/retry/strategy/exponential_backoff.rs deleted file mode 100644 index 37796353d5..0000000000 --- a/shared-lib/lib-infra/src/retry/strategy/exponential_backoff.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::{iter::Iterator, time::Duration}; -/// A retry strategy driven by exponential back-off. -/// -/// The power corresponds to the number of past attempts. -#[derive(Debug, Clone)] -pub struct ExponentialBackoff { - current: u64, - base: u64, - factor: u64, - max_delay: Option, -} - -impl ExponentialBackoff { - /// Constructs a new exponential back-off strategy, - /// given a base duration in milliseconds. - /// - /// The resulting duration is calculated by taking the base to the `n`-th - /// power, where `n` denotes the number of past attempts. - pub fn from_millis(base: u64) -> ExponentialBackoff { - ExponentialBackoff { - current: base, - base, - factor: 1u64, - max_delay: None, - } - } - - /// A multiplicative factor that will be applied to the retry delay. - /// - /// For example, using a factor of `1000` will make each delay in units of - /// seconds. - /// - /// Default factor is `1`. - pub fn factor(mut self, factor: u64) -> ExponentialBackoff { - self.factor = factor; - self - } - - /// Apply a maximum delay. No retry delay will be longer than this - /// `Duration`. - pub fn max_delay(mut self, duration: Duration) -> ExponentialBackoff { - self.max_delay = Some(duration); - self - } -} - -impl Iterator for ExponentialBackoff { - type Item = Duration; - - fn next(&mut self) -> Option { - // set delay duration by applying factor - let duration = if let Some(duration) = self.current.checked_mul(self.factor) { - Duration::from_millis(duration) - } else { - Duration::from_millis(u64::MAX) - }; - - // check if we reached max delay - if let Some(ref max_delay) = self.max_delay { - if duration > *max_delay { - return Some(*max_delay); - } - } - - if let Some(next) = self.current.checked_mul(self.base) { - self.current = next; - } else { - self.current = u64::MAX; - } - - Some(duration) - } -} - -#[test] -fn returns_some_exponential_base_10() { - let mut s = ExponentialBackoff::from_millis(10); - - assert_eq!(s.next(), Some(Duration::from_millis(10))); - assert_eq!(s.next(), Some(Duration::from_millis(100))); - assert_eq!(s.next(), Some(Duration::from_millis(1000))); -} - -#[test] -fn returns_some_exponential_base_2() { - let mut s = ExponentialBackoff::from_millis(2); - - assert_eq!(s.next(), Some(Duration::from_millis(2))); - assert_eq!(s.next(), Some(Duration::from_millis(4))); - assert_eq!(s.next(), Some(Duration::from_millis(8))); -} - -#[test] -fn saturates_at_maximum_value() { - let mut s = ExponentialBackoff::from_millis(u64::MAX - 1); - - assert_eq!(s.next(), Some(Duration::from_millis(u64::MAX - 1))); - assert_eq!(s.next(), Some(Duration::from_millis(u64::MAX))); - assert_eq!(s.next(), Some(Duration::from_millis(u64::MAX))); -} - -#[test] -fn can_use_factor_to_get_seconds() { - let factor = 1000; - let mut s = ExponentialBackoff::from_millis(2).factor(factor); - - assert_eq!(s.next(), Some(Duration::from_secs(2))); - assert_eq!(s.next(), Some(Duration::from_secs(4))); - assert_eq!(s.next(), Some(Duration::from_secs(8))); -} - -#[test] -fn stops_increasing_at_max_delay() { - let mut s = ExponentialBackoff::from_millis(2).max_delay(Duration::from_millis(4)); - - assert_eq!(s.next(), Some(Duration::from_millis(2))); - assert_eq!(s.next(), Some(Duration::from_millis(4))); - assert_eq!(s.next(), Some(Duration::from_millis(4))); -} - -#[test] -fn returns_max_when_max_less_than_base() { - let mut s = ExponentialBackoff::from_millis(20).max_delay(Duration::from_millis(10)); - - assert_eq!(s.next(), Some(Duration::from_millis(10))); - assert_eq!(s.next(), Some(Duration::from_millis(10))); -} diff --git a/shared-lib/lib-infra/src/retry/strategy/fixed_interval.rs b/shared-lib/lib-infra/src/retry/strategy/fixed_interval.rs deleted file mode 100644 index b2340deeb2..0000000000 --- a/shared-lib/lib-infra/src/retry/strategy/fixed_interval.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::{iter::Iterator, time::Duration}; - -/// A retry strategy driven by a fixed interval. -#[derive(Debug, Clone)] -pub struct FixedInterval { - duration: Duration, -} - -impl FixedInterval { - /// Constructs a new fixed interval strategy. - pub fn new(duration: Duration) -> FixedInterval { - FixedInterval { duration } - } - - /// Constructs a new fixed interval strategy, - /// given a duration in milliseconds. - pub fn from_millis(millis: u64) -> FixedInterval { - FixedInterval { - duration: Duration::from_millis(millis), - } - } -} - -impl Iterator for FixedInterval { - type Item = Duration; - - fn next(&mut self) -> Option { - Some(self.duration) - } -} - -#[test] -fn returns_some_fixed() { - let mut s = FixedInterval::new(Duration::from_millis(123)); - - assert_eq!(s.next(), Some(Duration::from_millis(123))); - assert_eq!(s.next(), Some(Duration::from_millis(123))); - assert_eq!(s.next(), Some(Duration::from_millis(123))); -} diff --git a/shared-lib/lib-infra/src/retry/strategy/jitter.rs b/shared-lib/lib-infra/src/retry/strategy/jitter.rs deleted file mode 100644 index 179bd4372e..0000000000 --- a/shared-lib/lib-infra/src/retry/strategy/jitter.rs +++ /dev/null @@ -1,5 +0,0 @@ -use std::time::Duration; - -pub fn jitter(duration: Duration) -> Duration { - duration.mul_f64(rand::random::()) -} diff --git a/shared-lib/lib-infra/src/retry/strategy/mod.rs b/shared-lib/lib-infra/src/retry/strategy/mod.rs deleted file mode 100644 index 8f3a618457..0000000000 --- a/shared-lib/lib-infra/src/retry/strategy/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod exponential_backoff; -mod fixed_interval; -mod jitter; - -pub use exponential_backoff::*; -pub use fixed_interval::*; -pub use jitter::*;