feat: reminder (#3374)

This commit is contained in:
Mathias Mogensen 2023-10-02 09:12:24 +02:00 committed by GitHub
parent f7749bdccc
commit 4a433a3176
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
99 changed files with 4599 additions and 998 deletions

View File

@ -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 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 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 -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 elif [ "$RUNNER_OS" == "Windows" ]; then
vcpkg integrate install vcpkg integrate install
elif [ "$RUNNER_OS" == "macOS" ]; then elif [ "$RUNNER_OS" == "macOS" ]; then

View File

@ -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 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 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 -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 elif [ "$RUNNER_OS" == "Windows" ]; then
vcpkg integrate install vcpkg integrate install
elif [ "$RUNNER_OS" == "macOS" ]; then elif [ "$RUNNER_OS" == "macOS" ]; then

View File

@ -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 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 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 -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 sudo apt-get -y install alien
source $HOME/.cargo/env source $HOME/.cargo/env
cargo install --force cargo-make cargo install --force cargo-make

View File

@ -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<String, dynamic> 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<DateTime> _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');

View File

@ -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/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_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.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:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -160,7 +161,7 @@ class EditorOperations {
/// Must call [showAtMenu] first. /// Must call [showAtMenu] first.
Future<void> tapAtMenuItemWithName(String name) async { Future<void> tapAtMenuItemWithName(String name) async {
final atMenuItem = find.descendant( final atMenuItem = find.descendant(
of: find.byType(SelectionMenuWidget), of: find.byType(InlineActionsHandler),
matching: find.text(name, findRichText: true), matching: find.text(name, findRichText: true),
); );
await tester.tapButton(atMenuItem); await tester.tapButton(atMenuItem);

View File

@ -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<Either<FlowyError, DateTime>> 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'));
});
}
}

View File

@ -224,7 +224,7 @@ class _SelectOptionColorCell extends StatelessWidget {
final colorIcon = SizedBox.square( final colorIcon = SizedBox.square(
dimension: 16, dimension: 16,
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.toColor(context), color: color.toColor(context),
shape: BoxShape.circle, shape: BoxShape.circle,

View File

@ -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/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/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/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.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.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:appflowy_popover/appflowy_popover.dart';
import 'package:dartz/dartz.dart' show Either; import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.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:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../../../grid/presentation/widgets/header/type_option/date.dart';
import 'date_cal_bloc.dart'; import 'date_cal_bloc.dart';
final kFirstDay = DateTime.utc(1970, 1, 1);
final kLastDay = DateTime.utc(2100, 1, 1);
class DateCellEditor extends StatefulWidget { class DateCellEditor extends StatefulWidget {
final VoidCallback onDismissed; final VoidCallback onDismissed;
final DateCellController cellController; final DateCellController cellController;
@ -51,9 +48,9 @@ class _DateCellEditor extends State<DateCellEditor> {
builder: (BuildContext context, snapshot) { builder: (BuildContext context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return _buildWidget(snapshot); return _buildWidget(snapshot);
} else {
return const SizedBox.shrink();
} }
return const SizedBox.shrink();
}, },
); );
} }
@ -81,22 +78,14 @@ class _CellCalendarWidget extends StatefulWidget {
const _CellCalendarWidget({ const _CellCalendarWidget({
required this.cellContext, required this.cellContext,
required this.dateTypeOptionPB, required this.dateTypeOptionPB,
Key? key, });
}) : super(key: key);
@override @override
State<_CellCalendarWidget> createState() => _CellCalendarWidgetState(); State<_CellCalendarWidget> createState() => _CellCalendarWidgetState();
} }
class _CellCalendarWidgetState extends State<_CellCalendarWidget> { class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
late PopoverMutex popoverMutex; final PopoverMutex popoverMutex = PopoverMutex();
@override
void initState() {
popoverMutex = PopoverMutex();
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -387,8 +376,7 @@ class _TimeTextField extends StatefulWidget {
required this.timeStr, required this.timeStr,
required this.popoverMutex, required this.popoverMutex,
required this.isEndTime, required this.isEndTime,
Key? key, });
}) : super(key: key);
@override @override
State<_TimeTextField> createState() => _TimeTextFieldState(); State<_TimeTextField> createState() => _TimeTextFieldState();

View File

@ -1,7 +1,11 @@
import 'package:appflowy/plugins/document/application/doc_bloc.dart'; 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_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.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/appearance.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy_editor/appflowy_editor.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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
final List<CommandShortcutEvent> commandShortcutEvents = [
toggleToggleListCommand,
...codeBlockCommands,
customCopyCommand,
customPasteCommand,
customCutCommand,
...standardCommandShortcutEvents,
];
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
...commandShortcutEvents.map((e) => e.copyWith()).toList(),
];
/// Wrapper for the appflowy editor. /// Wrapper for the appflowy editor.
class AppFlowyEditorPage extends StatefulWidget { class AppFlowyEditorPage extends StatefulWidget {
const AppFlowyEditorPage({ const AppFlowyEditorPage({
@ -34,19 +51,17 @@ class AppFlowyEditorPage extends StatefulWidget {
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState(); State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
} }
final List<CommandShortcutEvent> commandShortcutEvents = [
...codeBlockCommands,
...standardCommandShortcutEvents,
];
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
...commandShortcutEvents.map((e) => e.copyWith()).toList(),
];
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> { class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
late final ScrollController effectiveScrollController; 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<CommandShortcutEvent> commandShortcutEvents = [ late final List<CommandShortcutEvent> commandShortcutEvents = [
toggleToggleListCommand, toggleToggleListCommand,
@ -85,9 +100,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
_customAppFlowyBlockComponentBuilders(); _customAppFlowyBlockComponentBuilders();
List<CharacterShortcutEvent> get characterShortcutEvents => [ List<CharacterShortcutEvent> get characterShortcutEvents => [
// inline page reference list
...inlinePageReferenceShortcuts,
// code block // code block
...codeBlockCharacterEvents, ...codeBlockCharacterEvents,
@ -105,19 +117,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
..removeWhere( ..removeWhere(
(element) => element == slashCommand, (element) => element == slashCommand,
), // remove the default slash command. ), // remove the default slash command.
];
late final inlinePageReferenceShortcuts = [ /// Inline Actions
inlinePageReferenceService.customPageLinkMenu( /// - Reminder
character: '@', /// - Inline-page reference
style: styleCustomizer.selectionMenuStyleBuilder(), inlineActionsCommand(
), inlineActionsService,
// uncomment this to enable the inline page reference list style: styleCustomizer.inlineActionsMenuStyleBuilder(),
// inlinePageReferenceService.customPageLinkMenu( ),
// character: '+', ];
// style: styleCustomizer.selectionMenuStyleBuilder(),
// ),
];
late final showSlashMenu = customSlashCommand( late final showSlashMenu = customSlashCommand(
slashMenuItems, slashMenuItems,
@ -147,6 +155,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
if (widget.scrollController == null) { if (widget.scrollController == null) {
effectiveScrollController.dispose(); effectiveScrollController.dispose();
} }
inlineActionsService.dispose();
widget.editorState.dispose(); widget.editorState.dispose();
@ -221,6 +230,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final configuration = BlockComponentConfiguration( final configuration = BlockComponentConfiguration(
padding: (_) => const EdgeInsets.symmetric(vertical: 5.0), padding: (_) => const EdgeInsets.symmetric(vertical: 5.0),
); );
final customBlockComponentBuilderMap = { final customBlockComponentBuilderMap = {
PageBlockKeys.type: PageBlockComponentBuilder(), PageBlockKeys.type: PageBlockComponentBuilder(),
ParagraphBlockKeys.type: TextBlockComponentBuilder( ParagraphBlockKeys.type: TextBlockComponentBuilder(
@ -462,13 +472,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
} }
Future<void> _initializeShortcuts() async { Future<void> _initializeShortcuts() async {
//TODO(Xazin): Refactor lazy initialization // TODO(Xazin): Refactor lazy initialization
defaultCommandShortcutEvents; defaultCommandShortcutEvents;
final settingsShortcutService = SettingsShortcutService(); final settingsShortcutService = SettingsShortcutService();
final customizeShortcuts = final customizeShortcuts =
await settingsShortcutService.getCustomizeShortcuts(); await settingsShortcutService.getCustomizeShortcuts();
await settingsShortcutService.updateCommandShortcuts( await settingsShortcutService.updateCommandShortcuts(
standardCommandShortcutEvents, commandShortcutEvents,
customizeShortcuts, customizeShortcuts,
); );
} }

View File

@ -180,7 +180,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
builtInAssetImages[index], builtInAssetImages[index],
); );
}, },
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: AssetImage(builtInAssetImages[index]), image: AssetImage(builtInAssetImages[index]),
@ -299,7 +299,7 @@ class NewCustomCoverButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
@ -484,7 +484,7 @@ class _ImageGridItemState extends State<ImageGridItem> {
children: [ children: [
InkWell( InkWell(
onTap: widget.onImageSelect, onTap: widget.onImageSelect,
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: FileImage(File(widget.imagePath)), image: FileImage(File(widget.imagePath)),
@ -544,7 +544,7 @@ class ColorItem extends StatelessWidget {
padding: const EdgeInsets.only(right: 10.0), padding: const EdgeInsets.only(right: 10.0),
child: SizedBox.square( child: SizedBox.square(
dimension: 25, dimension: 25,
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: option.colorHex.tryToColor(), color: option.colorHex.tryToColor(),
shape: BoxShape.circle, shape: BoxShape.circle,

View File

@ -201,7 +201,7 @@ class CoverImagePreviewWidget extends StatefulWidget {
class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> { class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
_buildFilePickerWidget(BuildContext ctx) { _buildFilePickerWidget(BuildContext ctx) {
return Container( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
borderRadius: Corners.s6Border, borderRadius: Corners.s6Border,
@ -263,7 +263,7 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
onTap: () { onTap: () {
ctx.read<CoverImagePickerBloc>().add(const DeleteImage()); ctx.read<CoverImagePickerBloc>().add(const DeleteImage());
}, },
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,

View File

@ -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<bool> _showPageSelectionMenu(
EditorState editorState,
List<SelectionMenuItem> 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<List<SelectionMenuItem>> generatePageItems(String character) async {
final service = ViewBackendService();
final views = await service.fetchViews();
if (views.isEmpty) {
return [];
}
final List<SelectionMenuItem> 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;
}
}

View File

@ -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/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.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 { class MentionBlock extends StatelessWidget {
const MentionBlock({ const MentionBlock({
super.key, super.key,
required this.mention, required this.mention,
required this.node,
required this.index,
}); });
final Map mention; final Map<String, dynamic> mention;
final Node node;
final int index;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final type = MentionType.fromString(mention[MentionBlockKeys.type]); final type = MentionType.fromString(mention[MentionBlockKeys.type]);
if (type == MentionType.page) {
final pageId = mention[MentionBlockKeys.pageId]; switch (type) {
return MentionPageBlock(key: ValueKey(pageId), pageId: pageId); 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<EditorState>().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();
} }
} }

View File

@ -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<DocumentAppearanceCubit>().state.fontSize;
return MultiBlocProvider(
providers: [
BlocProvider<ReminderBloc>.value(value: context.read<ReminderBloc>()),
BlocProvider<AppearanceSettingsCubit>.value(
value: context.read<AppearanceSettingsCubit>(),
),
],
child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
buildWhen: (previous, current) =>
previous.dateFormat != current.dateFormat ||
previous.timeFormat != current.timeFormat,
builder: (context, appearance) =>
BlocBuilder<ReminderBloc, ReminderState>(
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<EditorState>(),
).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<EditorState>();
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<ReminderBloc>().add(
ReminderEvent.update(
ReminderUpdate(id: reminderId, scheduledAt: selectedDay),
),
);
}
}

View File

@ -135,7 +135,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
), ),
); );
} }
return Container( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)), borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor, color: backgroundColor,

View File

@ -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_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/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.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/util/google_font_family_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.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() { FloatingToolbarStyle floatingToolbarStyleBuilder() {
final theme = Theme.of(context); final theme = Theme.of(context);
return FloatingToolbarStyle( return FloatingToolbarStyle(
@ -203,19 +217,28 @@ class EditorStyleCustomizer {
} }
} }
// customize the inline mention block, like inline page // Inline Mentions (Page Reference, Date, Reminder, etc.)
final mention = attributes[MentionBlockKeys.mention] as Map?; final mention =
attributes[MentionBlockKeys.mention] as Map<String, dynamic>?;
if (mention != null) { if (mention != null) {
final type = mention[MentionBlockKeys.type]; final type = mention[MentionBlockKeys.type];
if (type == MentionType.page.name) { return WidgetSpan(
return WidgetSpan( alignment: PlaceholderAlignment.middle,
alignment: PlaceholderAlignment.middle, child: MentionBlock(
child: MentionBlock( key: ValueKey(
key: ValueKey(mention[MentionBlockKeys.pageId]), switch (type) {
mention: mention, 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 // customize the inline math equation block

View File

@ -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<InlineActionsMenuItem> _allOptions;
List<InlineActionsMenuItem> options = [];
Future<InlineActionsResult> 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<void> _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<void> _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<String>? 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,
),
);
}
}

View File

@ -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<void>();
late final ViewBackendService service;
List<InlineActionsMenuItem> _items = [];
List<InlineActionsMenuItem> _filtered = [];
Future<void> init() async {
service = ViewBackendService();
_generatePageItems().then((value) {
_items = value;
_filtered = value;
_initCompleter.complete();
});
}
Future<List<InlineActionsMenuItem>> _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<InlineActionsResult> inlinePageReferenceDelegate([
String? search,
]) async {
_filtered = await _filterItems(search);
return InlineActionsResult(
title: LocaleKeys.inlineActions_pageReference.tr(),
results: _filtered,
);
}
Future<List<InlineActionsMenuItem>> _generatePageItems() async {
final views = await service.fetchViews();
if (views.isEmpty) {
return [];
}
final List<InlineActionsMenuItem> 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;
}
}

View File

@ -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<InlineActionsMenuItem> _allOptions;
List<InlineActionsMenuItem> options = [];
Future<InlineActionsResult> 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<InlineActionsMenuItem>? 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<void> _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<void> _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<DocumentBloc>().view.id;
final reminder = _reminderFromDate(date, viewId);
context.read<ReminderBloc>().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<String>? 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()),
);
}
}

View File

@ -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<bool> 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<InlineActionsResult> 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;
}

View File

@ -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<InlineActionsResult> 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;
}

View File

@ -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<String>? 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<InlineActionsMenuItem> 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<String>? startsWithKeywords;
}

View File

@ -0,0 +1,32 @@
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:flutter/material.dart';
typedef InlineActionsDelegate = Future<InlineActionsResult> 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<InlineActionsDelegate> 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;
}
}

View File

@ -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<InlineActionsResult> {
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<InlineActionsResult> results;
final EditorState editorState;
final InlineActionsMenuService menuService;
final VoidCallback onDismiss;
final VoidCallback onSelectionUpdate;
final InlineActionsMenuStyle style;
@override
State<InlineActionsHandler> createState() => _InlineActionsHandlerState();
}
class _InlineActionsHandlerState extends State<InlineActionsHandler> {
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0;
late int startOffset;
String _search = '';
set search(String search) {
_search = search;
_doSearch();
}
Future<void> _doSearch() async {
final List<InlineActionsResult> 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);
}
}

View File

@ -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<InlineActionsWidget> createState() => _InlineActionsWidgetState();
}
class _InlineActionsWidgetState extends State<InlineActionsWidget> {
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),
);
}
}

View File

@ -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/mock_auth_service.dart';
import 'package:appflowy/user/application/auth/supabase_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/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_listener.dart';
import 'package:appflowy/user/application/user_service.dart'; import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/router.dart';
import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.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/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/settings/prelude.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart'; import 'package:appflowy/workspace/application/user/prelude.dart';
@ -133,7 +135,11 @@ void _resolveHomeDeps(GetIt getIt) {
(view, _) => DocShareBloc(view: view), (view, _) => DocShareBloc(view: view),
); );
getIt.registerSingleton<NotificationActionBloc>(NotificationActionBloc());
getIt.registerLazySingleton<TabsBloc>(() => TabsBloc()); getIt.registerLazySingleton<TabsBloc>(() => TabsBloc());
getIt.registerSingleton<ReminderBloc>(ReminderBloc());
} }
void _resolveFolderDeps(GetIt getIt) { void _resolveFolderDeps(GetIt getIt) {

View File

@ -1,4 +1,5 @@
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; 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/startup/tasks/prelude.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@ -22,15 +23,22 @@ class InitAppWidgetTask extends LaunchTask {
@override @override
Future<void> initialize(LaunchContext context) async { Future<void> initialize(LaunchContext context) async {
WidgetsFlutterBinding.ensureInitialized();
await NotificationService.initialize();
final widget = context.getIt<EntryPoint>().create(context.config); final widget = context.getIt<EntryPoint>().create(context.config);
final appearanceSetting = final appearanceSetting =
await UserSettingsBackendService().getAppearanceSetting(); await UserSettingsBackendService().getAppearanceSetting();
final dateTimeSettings =
await UserSettingsBackendService().getDateTimeSettings();
// If the passed-in context is not the same as the context of the // If the passed-in context is not the same as the context of the
// application widget, the application widget will be rebuilt. // application widget, the application widget will be rebuilt.
final app = ApplicationWidget( final app = ApplicationWidget(
key: ValueKey(context), key: ValueKey(context),
appearanceSetting: appearanceSetting, appearanceSetting: appearanceSetting,
dateTimeSettings: dateTimeSettings,
appTheme: await appTheme(appearanceSetting.theme), appTheme: await appTheme(appearanceSetting.theme),
child: widget, child: widget,
); );
@ -71,21 +79,23 @@ class InitAppWidgetTask extends LaunchTask {
), ),
); );
return Future(() => {}); return;
} }
} }
class ApplicationWidget extends StatefulWidget { class ApplicationWidget extends StatefulWidget {
final Widget child;
final AppearanceSettingsPB appearanceSetting;
final AppTheme appTheme;
const ApplicationWidget({ const ApplicationWidget({
Key? key, super.key,
required this.child, required this.child,
required this.appTheme, required this.appTheme,
required this.appearanceSetting, required this.appearanceSetting,
}) : super(key: key); required this.dateTimeSettings,
});
final Widget child;
final AppearanceSettingsPB appearanceSetting;
final AppTheme appTheme;
final DateTimeSettingsPB dateTimeSettings;
@override @override
State<ApplicationWidget> createState() => _ApplicationWidgetState(); State<ApplicationWidget> createState() => _ApplicationWidgetState();
@ -109,6 +119,7 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
BlocProvider<AppearanceSettingsCubit>( BlocProvider<AppearanceSettingsCubit>(
create: (_) => AppearanceSettingsCubit( create: (_) => AppearanceSettingsCubit(
widget.appearanceSetting, widget.appearanceSetting,
widget.dateTimeSettings,
widget.appTheme, widget.appTheme,
)..readLocaleWhenAppLaunch(context), )..readLocaleWhenAppLaunch(context),
), ),

View File

@ -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<ReminderEvent, ReminderState> {
late final NotificationActionBloc actionBloc;
late final ReminderService reminderService;
late final Timer timer;
ReminderBloc() : super(ReminderState()) {
actionBloc = getIt<NotificationActionBloc>();
reminderService = const ReminderService();
timer = _periodicCheck();
on<ReminderEvent>((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<ReminderPB> 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<ReminderPB>? reminders,
bool? hasUnreads,
}) : reminders = reminders ?? [],
hasUnreads = hasUnreads ?? false;
final List<ReminderPB> reminders;
final bool hasUnreads;
ReminderState copyWith({
List<ReminderPB>? reminders,
bool? hasUnreads,
}) =>
ReminderState(
reminders: reminders ?? this.reminders,
hasUnreads: hasUnreads ?? this.hasUnreads,
);
}

View File

@ -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<Either<FlowyError, List<ReminderPB>>> fetchReminders();
Future<Either<FlowyError, Unit>> removeReminder({required String reminderId});
Future<Either<FlowyError, Unit>> addReminder({required ReminderPB reminder});
Future<Either<FlowyError, Unit>> updateReminder({
required ReminderPB reminder,
});
}
class ReminderService implements IReminderService {
const ReminderService();
@override
Future<Either<FlowyError, Unit>> addReminder({
required ReminderPB reminder,
}) async {
final unitOrFailure = await UserEventCreateReminder(reminder).send();
return unitOrFailure.swap();
}
@override
Future<Either<FlowyError, Unit>> updateReminder({
required ReminderPB reminder,
}) async {
final unitOrFailure = await UserEventUpdateReminder(reminder).send();
return unitOrFailure.swap();
}
@override
Future<Either<FlowyError, List<ReminderPB>>> fetchReminders() async {
final resultOrFailure = await UserEventGetAllReminders().send();
return resultOrFailure.swap().fold((l) => left(l), (r) => right(r.items));
}
@override
Future<Either<FlowyError, Unit>> removeReminder({
required String reminderId,
}) async {
final request = ReminderIdentifierPB(id: reminderId);
final unitOrFailure = await UserEventRemoveReminder(request).send();
return unitOrFailure.swap();
}
}

View File

@ -10,12 +10,9 @@ class UserSettingsBackendService {
final result = await UserEventGetAppearanceSetting().send(); final result = await UserEventGetAppearanceSetting().send();
return result.fold( return result.fold(
(AppearanceSettingsPB setting) { (AppearanceSettingsPB setting) => setting,
return setting; (error) =>
}, throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
(error) {
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty);
},
); );
} }
@ -28,4 +25,20 @@ class UserSettingsBackendService {
) { ) {
return UserEventSetAppearanceSetting(setting).send(); return UserEventSetAppearanceSetting(setting).send();
} }
Future<DateTimeSettingsPB> getDateTimeSettings() async {
final result = await UserEventGetDateTimeSettings().send();
return result.fold(
(DateTimeSettingsPB setting) => setting,
(error) =>
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
);
}
Future<Either<FlowyError, Unit>> setDateTimeSettings(
DateTimeSettingsPB settings,
) async {
return (await UserEventSetDateTimeSettings(settings).send()).swap();
}
} }

View File

@ -5,6 +5,7 @@ import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart';
import 'package:appflowy/mobile/application/mobile_theme_data.dart'; import 'package:appflowy/mobile/application/mobile_theme_data.dart';
import 'package:appflowy_backend/log.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:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/size.dart';
@ -23,11 +24,14 @@ const _white = Color(0xFFFFFFFF);
/// It includes the [AppTheme], [ThemeMode], [TextStyles] and [Locale]. /// It includes the [AppTheme], [ThemeMode], [TextStyles] and [Locale].
class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> { class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
final AppearanceSettingsPB _setting; final AppearanceSettingsPB _setting;
final DateTimeSettingsPB _dateTimeSettings;
AppearanceSettingsCubit( AppearanceSettingsCubit(
AppearanceSettingsPB setting, AppearanceSettingsPB setting,
DateTimeSettingsPB dateTimeSettings,
AppTheme appTheme, AppTheme appTheme,
) : _setting = setting, ) : _setting = setting,
_dateTimeSettings = dateTimeSettings,
super( super(
AppearanceSettingsState.initial( AppearanceSettingsState.initial(
appTheme, appTheme,
@ -39,6 +43,9 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
setting.locale, setting.locale,
setting.isMenuCollapsed, setting.isMenuCollapsed,
setting.menuOffset, setting.menuOffset,
dateTimeSettings.dateFormat,
dateTimeSettings.timeFormat,
dateTimeSettings.timezoneId,
), ),
); );
@ -173,6 +180,29 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
setLocale(context, state.locale); 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<void> _saveDateTimeSettings() async {
UserSettingsBackendService()
.setDateTimeSettings(_dateTimeSettings)
.then((result) {
result.fold(
(error) => Log.error(error),
(_) => null,
);
});
}
Future<void> _saveAppearanceSettings() async { Future<void> _saveAppearanceSettings() async {
UserSettingsBackendService().setAppearanceSetting(_setting).then((result) { UserSettingsBackendService().setAppearanceSetting(_setting).then((result) {
result.fold( result.fold(
@ -271,6 +301,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
required Locale locale, required Locale locale,
required bool isMenuCollapsed, required bool isMenuCollapsed,
required double menuOffset, required double menuOffset,
required UserDateFormatPB dateFormat,
required UserTimeFormatPB timeFormat,
required String timezoneId,
}) = _AppearanceSettingsState; }) = _AppearanceSettingsState;
factory AppearanceSettingsState.initial( factory AppearanceSettingsState.initial(
@ -283,6 +316,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
LocaleSettingsPB localePB, LocaleSettingsPB localePB,
bool isMenuCollapsed, bool isMenuCollapsed,
double menuOffset, double menuOffset,
UserDateFormatPB dateFormat,
UserTimeFormatPB timeFormat,
String timezoneId,
) { ) {
return AppearanceSettingsState( return AppearanceSettingsState(
appTheme: appTheme, appTheme: appTheme,
@ -294,6 +330,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
locale: Locale(localePB.languageCode, localePB.countryCode), locale: Locale(localePB.languageCode, localePB.countryCode),
isMenuCollapsed: isMenuCollapsed, isMenuCollapsed: isMenuCollapsed,
menuOffset: menuOffset, menuOffset: menuOffset,
dateFormat: dateFormat,
timeFormat: timeFormat,
timezoneId: timezoneId,
); );
} }

View File

@ -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;
}

View File

@ -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<NotificationActionEvent, NotificationActionState> {
NotificationActionBloc() : super(const NotificationActionState.initial()) {
on<NotificationActionEvent>((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);
}

View File

@ -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<void> 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();
}

View File

@ -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,
};

View File

@ -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 ?? '');

View File

@ -2,6 +2,7 @@ import 'package:appflowy/plugins/blank/blank.dart';
import 'package:appflowy/startup/plugin/plugin.dart'; import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.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/appearance.dart';
import 'package:appflowy/workspace/application/home/home_bloc.dart'; import 'package:appflowy/workspace/application/home/home_bloc.dart';
import 'package:appflowy/workspace/application/home/home_service.dart'; import 'package:appflowy/workspace/application/home/home_service.dart';
@ -57,6 +58,9 @@ class DesktopHomeScreen extends StatelessWidget {
return MultiBlocProvider( return MultiBlocProvider(
key: ValueKey(userProfile!.id), key: ValueKey(userProfile!.id),
providers: [ providers: [
BlocProvider<ReminderBloc>.value(
value: getIt<ReminderBloc>()..add(const ReminderEvent.started()),
),
BlocProvider<TabsBloc>.value(value: getIt<TabsBloc>()), BlocProvider<TabsBloc>.value(value: getIt<TabsBloc>()),
BlocProvider<HomeBloc>( BlocProvider<HomeBloc>(
create: (context) { create: (context) {

View File

@ -1,4 +1,7 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.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/menu/menu_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.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-folder2/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB; show UserProfilePB;
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -36,6 +40,9 @@ class HomeSideBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider(
create: (_) => getIt<NotificationActionBloc>(),
),
BlocProvider( BlocProvider(
create: (_) => MenuBloc( create: (_) => MenuBloc(
user: user, user: user,
@ -46,11 +53,34 @@ class HomeSideBar extends StatelessWidget {
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
) )
], ],
child: BlocListener<MenuBloc, MenuState>( child: MultiBlocListener(
listenWhen: (p, c) => p.plugin.id != c.plugin.id, listeners: [
listener: (context, state) => context BlocListener<MenuBloc, MenuState>(
.read<TabsBloc>() listenWhen: (p, c) => p.plugin.id != c.plugin.id,
.add(TabsEvent.openPlugin(plugin: state.plugin)), listener: (context, state) => context
.read<TabsBloc>()
.add(TabsEvent.openPlugin(plugin: state.plugin)),
),
BlocListener<NotificationActionBloc, NotificationActionState>(
listener: (context, state) {
final action = state.action;
if (action != null) {
switch (action.type) {
case ActionType.openView:
final view = context
.read<MenuBloc>()
.state
.views
.firstWhereOrNull((view) => action.objectId == view.id);
if (view != null) {
context.read<TabsBloc>().openPlugin(view);
}
}
}
},
),
],
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final menuState = context.watch<MenuBloc>().state; final menuState = context.watch<MenuBloc>().state;
@ -88,7 +118,7 @@ class HomeSideBar extends StatelessWidget {
// top menu // top menu
const SidebarTopMenu(), const SidebarTopMenu(),
// user, setting // user, setting
SidebarUser(user: user), SidebarUser(user: user, views: views),
const VSpace(20), const VSpace(20),
// scrollable document list // scrollable document list
Expanded( Expanded(

View File

@ -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/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/menu/menu_user_bloc.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/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:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
@ -17,9 +19,11 @@ class SidebarUser extends StatelessWidget {
const SidebarUser({ const SidebarUser({
super.key, super.key,
required this.user, required this.user,
required this.views,
}); });
final UserProfilePB user; final UserProfilePB user;
final List<ViewPB> views;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -41,6 +45,8 @@ class SidebarUser extends StatelessWidget {
child: _buildUserName(context, state), child: _buildUserName(context, state),
), ),
_buildSettingsButton(context, state), _buildSettingsButton(context, state),
const HSpace(4),
NotificationButton(views: views),
], ],
), ),
), ),

View File

@ -10,7 +10,7 @@ class FlowyMessageToast extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)), borderRadius: const BorderRadius.all(Radius.circular(4)),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,

View File

@ -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<ViewPB> views;
@override
Widget build(BuildContext context) {
final mutex = PopoverMutex();
return BlocProvider<ReminderBloc>.value(
value: getIt<ReminderBloc>(),
child: BlocBuilder<ReminderBloc, ReminderState>(
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),
),
),
],
);
}
}

View File

@ -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<ViewPB> views;
final PopoverMutex mutex;
@override
Widget build(BuildContext context) {
final reminderBloc = getIt<ReminderBloc>();
return BlocProvider<ReminderBloc>.value(
value: reminderBloc,
child: BlocBuilder<ReminderBloc, ReminderState>(
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();
},
);
}),
],
),
);
},
),
);
}
}

View File

@ -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<NotificationItem> createState() => _NotificationItemState();
}
class _NotificationItemState extends State<NotificationItem> {
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,
),
],
),
),
);
}
}

View File

@ -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<AppearanceSettingsCubit>().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 "";
}
}
}

View File

@ -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<AppearanceSettingsCubit>().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 "";
}
}
}

View File

@ -1,5 +1,7 @@
import 'package:appflowy/workspace/application/appearance.dart'; 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/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:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -23,20 +25,17 @@ class SettingsAppearanceView extends StatelessWidget {
currentTheme: state.appTheme.themeName, currentTheme: state.appTheme.themeName,
bloc: context.read<DynamicPluginBloc>(), bloc: context.read<DynamicPluginBloc>(),
), ),
BrightnessSetting( BrightnessSetting(currentThemeMode: state.themeMode),
currentThemeMode: state.themeMode,
),
const Divider(), const Divider(),
ThemeFontFamilySetting( ThemeFontFamilySetting(currentFontFamily: state.font),
currentFontFamily: state.font,
),
const Divider(), const Divider(),
LayoutDirectionSetting( LayoutDirectionSetting(
currentLayoutDirection: state.layoutDirection, currentLayoutDirection: state.layoutDirection,
), ),
TextDirectionSetting( TextDirectionSetting(currentTextDirection: state.textDirection),
currentTextDirection: state.textDirection, const Divider(),
), DateFormatSetting(currentFormat: state.dateFormat),
TimeFormatSetting(currentFormat: state.timeFormat),
const Divider(), const Divider(),
CreateFileSettings(), CreateFileSettings(),
], ],

View File

@ -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<AppFlowyCalendar> createState() => _AppFlowyCalendarState();
}
class _AppFlowyCalendarState extends State<AppFlowyCalendar>
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;
}

View File

@ -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,
),
),
);
}
}

View File

@ -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<IncludeTimeButton> createState() => _IncludeTimeButtonState();
}
class _IncludeTimeButtonState extends State<IncludeTimeButton> {
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(),
_ => "",
};
}

View File

@ -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-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
// ignore: unused_import
import 'package:protobuf/protobuf.dart'; import 'package:protobuf/protobuf.dart';
import 'dart:convert' show utf8; import 'dart:convert' show utf8;
import '../protobuf/flowy-config/entities.pb.dart'; import '../protobuf/flowy-config/entities.pb.dart';
import '../protobuf/flowy-config/event_map.pb.dart'; import '../protobuf/flowy-config/event_map.pb.dart';
import 'error.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-folder2/dart_event.dart';
part 'dart_event/flowy-user/dart_event.dart'; part 'dart_event/flowy-user/dart_event.dart';
part 'dart_event/flowy-database2/dart_event.dart'; part 'dart_event/flowy-database2/dart_event.dart';
part 'dart_event/flowy-document2/dart_event.dart'; part 'dart_event/flowy-document2/dart_event.dart';
part 'dart_event/flowy-config/dart_event.dart'; part 'dart_event/flowy-config/dart_event.dart';
part 'dart_event/flowy-date/dart_event.dart';
enum FFIException { enum FFIException {
RequestIsEmpty, RequestIsEmpty,

View File

@ -59,11 +59,10 @@ class FlowyColorPicker extends StatelessWidget {
final colorIcon = SizedBox.square( final colorIcon = SizedBox.square(
dimension: iconSize, dimension: iconSize,
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: option.color, color: option.color,
shape: BoxShape.circle, shape: BoxShape.circle,
// border: border,
), ),
), ),
); );

View File

@ -825,6 +825,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" 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: logger:
dependency: transitive dependency: transitive
description: description:

View File

@ -109,6 +109,11 @@ dependencies:
super_clipboard: ^0.6.3 super_clipboard: ^0.6.3
go_router: ^10.1.2 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: dev_dependencies:
flutter_lints: ^2.0.1 flutter_lints: ^2.0.1

View File

@ -17,9 +17,12 @@ void main() {
group('$AppearanceSettingsCubit', () { group('$AppearanceSettingsCubit', () {
late AppearanceSettingsPB appearanceSetting; late AppearanceSettingsPB appearanceSetting;
late DateTimeSettingsPB dateTimeSettings;
setUp(() async { setUp(() async {
appearanceSetting = appearanceSetting =
await UserSettingsBackendService().getAppearanceSetting(); await UserSettingsBackendService().getAppearanceSetting();
dateTimeSettings =
await UserSettingsBackendService().getDateTimeSettings();
await blocResponseFuture(); await blocResponseFuture();
}); });
@ -27,6 +30,7 @@ void main() {
'default theme', 'default theme',
build: () => AppearanceSettingsCubit( build: () => AppearanceSettingsCubit(
appearanceSetting, appearanceSetting,
dateTimeSettings,
AppTheme.fallback, AppTheme.fallback,
), ),
verify: (bloc) { verify: (bloc) {
@ -41,6 +45,7 @@ void main() {
'save key/value', 'save key/value',
build: () => AppearanceSettingsCubit( build: () => AppearanceSettingsCubit(
appearanceSetting, appearanceSetting,
dateTimeSettings,
AppTheme.fallback, AppTheme.fallback,
), ),
act: (bloc) { act: (bloc) {
@ -55,6 +60,7 @@ void main() {
'remove key/value', 'remove key/value',
build: () => AppearanceSettingsCubit( build: () => AppearanceSettingsCubit(
appearanceSetting, appearanceSetting,
dateTimeSettings,
AppTheme.fallback, AppTheme.fallback,
), ),
act: (bloc) { act: (bloc) {
@ -69,6 +75,7 @@ void main() {
'initial state uses fallback theme', 'initial state uses fallback theme',
build: () => AppearanceSettingsCubit( build: () => AppearanceSettingsCubit(
appearanceSetting, appearanceSetting,
dateTimeSettings,
AppTheme.fallback, AppTheme.fallback,
), ),
verify: (bloc) { verify: (bloc) {

View File

@ -798,7 +798,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -817,7 +817,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-database" name = "collab-database"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -847,7 +847,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-define" name = "collab-define"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -859,7 +859,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-derive" name = "collab-derive"
version = "0.1.0" 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 = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -871,7 +871,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-document" name = "collab-document"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -891,7 +891,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-folder" name = "collab-folder"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -931,7 +931,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-persistence" name = "collab-persistence"
version = "0.1.0" 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 = [ dependencies = [
"async-trait", "async-trait",
"bincode", "bincode",
@ -952,7 +952,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-plugins" name = "collab-plugins"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -980,7 +980,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-sync-protocol" name = "collab-sync-protocol"
version = "0.1.0" 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 = [ dependencies = [
"bytes", "bytes",
"collab", "collab",
@ -995,7 +995,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-user" name = "collab-user"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -1344,6 +1344,16 @@ dependencies = [
"parking_lot_core", "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]] [[package]]
name = "derivative" name = "derivative"
version = "2.2.0" version = "2.2.0"
@ -1774,6 +1784,7 @@ dependencies = [
"flowy-config", "flowy-config",
"flowy-database-deps", "flowy-database-deps",
"flowy-database2", "flowy-database2",
"flowy-date",
"flowy-document-deps", "flowy-document-deps",
"flowy-document2", "flowy-document2",
"flowy-error", "flowy-error",
@ -1853,6 +1864,23 @@ dependencies = [
"url", "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]] [[package]]
name = "flowy-derive" name = "flowy-derive"
version = "0.1.0" version = "0.1.0"
@ -1932,6 +1960,7 @@ dependencies = [
"client-api", "client-api",
"collab-database", "collab-database",
"collab-document", "collab-document",
"fancy-regex 0.11.0",
"flowy-codegen", "flowy-codegen",
"flowy-derive", "flowy-derive",
"flowy-sqlite", "flowy-sqlite",
@ -2134,7 +2163,8 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"strum_macros 0.21.1", "strum",
"strum_macros 0.25.2",
"tokio", "tokio",
"tracing", "tracing",
"unicode-segmentation", "unicode-segmentation",

View File

@ -20,9 +20,16 @@ tauri = { version = "1.2", features = ["fs-all", "shell-open"] }
tauri-utils = "1.2" tauri-utils = "1.2"
bytes = { version = "1.4" } bytes = { version = "1.4" }
tracing = { version = "0.1", features = ["log"] } tracing = { version = "0.1", features = ["log"] }
lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = ["use_serde"] } lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [
flowy-core = { path = "../../rust-lib/flowy-core", features = ["rev-sqlite", "ts"] } "use_serde",
flowy-notification = { path = "../../rust-lib/flowy-notification", features = ["ts"] } ] }
flowy-core = { path = "../../rust-lib/flowy-core", features = [
"rev-sqlite",
"ts",
] }
flowy-notification = { path = "../../rust-lib/flowy-notification", features = [
"ts",
] }
[features] [features]
# by default Tauri runs in production mode # 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 # Working directory: frontend
# #
# To update the commit ID, run: # 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: # To switch to the local path, run:
# scripts/tool/update_collab_source.sh # scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
collab = { 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 = "bf3a19" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }

View File

@ -1,7 +1,7 @@
export * from "./models/flowy-user"; export * from './models/flowy-user';
export * from "./models/flowy-database2"; export * from './models/flowy-database2';
export * from "./models/flowy-folder2"; export * from './models/flowy-folder2';
export * from "./models/flowy-document2"; export * from './models/flowy-document2';
export * from "./models/flowy-error"; export * from './models/flowy-error';
export * from "./models/flowy-config"; export * from './models/flowy-config';
export * from './models/flowy-date';

View File

@ -291,6 +291,19 @@
"theme": "Theme", "theme": "Theme",
"builtInsLabel": "Built-in Themes", "builtInsLabel": "Built-in Themes",
"pluginsLabel": "Plugins", "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" "showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page"
}, },
"files": { "files": {
@ -755,6 +768,32 @@
"frequentlyUsed": "Frequently Used" "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": { "findAndReplace": {
"find": "Find", "find": "Find",
"previousMatch": "Previous match", "previousMatch": "Previous match",

View File

@ -421,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b"
dependencies = [ dependencies = [
"borsh-derive", "borsh-derive",
"hashbrown 0.13.2", "hashbrown 0.12.3",
] ]
[[package]] [[package]]
@ -672,7 +672,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -691,7 +691,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-database" name = "collab-database"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -721,7 +721,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-define" name = "collab-define"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -733,7 +733,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-derive" name = "collab-derive"
version = "0.1.0" 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 = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -745,7 +745,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-document" name = "collab-document"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -765,7 +765,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-folder" name = "collab-folder"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -805,7 +805,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-persistence" name = "collab-persistence"
version = "0.1.0" 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 = [ dependencies = [
"async-trait", "async-trait",
"bincode", "bincode",
@ -826,7 +826,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-plugins" name = "collab-plugins"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -854,7 +854,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-sync-protocol" name = "collab-sync-protocol"
version = "0.1.0" 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 = [ dependencies = [
"bytes", "bytes",
"collab", "collab",
@ -869,7 +869,7 @@ dependencies = [
[[package]] [[package]]
name = "collab-user" name = "collab-user"
version = "0.1.0" 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 = [ dependencies = [
"anyhow", "anyhow",
"collab", "collab",
@ -1168,6 +1168,16 @@ dependencies = [
"parking_lot_core", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.8" version = "0.3.8"
@ -1517,6 +1527,7 @@ dependencies = [
"flowy-config", "flowy-config",
"flowy-database-deps", "flowy-database-deps",
"flowy-database2", "flowy-database2",
"flowy-date",
"flowy-document-deps", "flowy-document-deps",
"flowy-document2", "flowy-document2",
"flowy-error", "flowy-error",
@ -1597,6 +1608,23 @@ dependencies = [
"url", "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]] [[package]]
name = "flowy-derive" name = "flowy-derive"
version = "0.1.0" version = "0.1.0"
@ -1678,6 +1706,7 @@ dependencies = [
"client-api", "client-api",
"collab-database", "collab-database",
"collab-document", "collab-document",
"fancy-regex 0.11.0",
"flowy-codegen", "flowy-codegen",
"flowy-derive", "flowy-derive",
"flowy-sqlite", "flowy-sqlite",
@ -1943,7 +1972,8 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"strum_macros 0.21.1", "strum",
"strum_macros 0.25.2",
"tokio", "tokio",
"tracing", "tracing",
"unicode-segmentation", "unicode-segmentation",

View File

@ -24,6 +24,7 @@ members = [
"flowy-storage", "flowy-storage",
"collab-integrate", "collab-integrate",
"flowy-ai", "flowy-ai",
"flowy-date",
] ]
[workspace.dependencies] [workspace.dependencies]
@ -50,6 +51,7 @@ flowy-encrypt = { workspace = true, path = "flowy-encrypt" }
flowy-storage = { workspace = true, path = "flowy-storage" } flowy-storage = { workspace = true, path = "flowy-storage" }
collab-integrate = { workspace = true, path = "collab-integrate" } collab-integrate = { workspace = true, path = "collab-integrate" }
flowy-ai = { workspace = true, path = "flowy-ai" } flowy-ai = { workspace = true, path = "flowy-ai" }
flowy-date = { workspace = true, path = "flowy-date" }
[profile.dev] [profile.dev]
opt-level = 0 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: # To switch to the local path, run:
# scripts/tool/update_collab_source.sh # scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
collab = { 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 = "bf3a19" } collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" } collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }

View File

@ -22,6 +22,7 @@ flowy-task = { workspace = true }
flowy-server = { workspace = true } flowy-server = { workspace = true }
flowy-server-config = { workspace = true } flowy-server-config = { workspace = true }
flowy-config = { workspace = true } flowy-config = { workspace = true }
flowy-date = { workspace = true }
collab-integrate = { workspace = true, features = ["supabase_integrate", "appflowy_cloud_integrate", "snapshot_plugin"] } collab-integrate = { workspace = true, features = ["supabase_integrate", "appflowy_cloud_integrate", "snapshot_plugin"] }
flowy-ai = { workspace = true } flowy-ai = { workspace = true }
collab-define = { version = "0.1.0" } collab-define = { version = "0.1.0" }
@ -52,6 +53,7 @@ native_sync = []
use_bunyan = ["lib-log/use_bunyan"] use_bunyan = ["lib-log/use_bunyan"]
dart = [ dart = [
"flowy-user/dart", "flowy-user/dart",
"flowy-date/dart",
"flowy-folder2/dart", "flowy-folder2/dart",
"flowy-database2/dart", "flowy-database2/dart",
"flowy-document2/dart", "flowy-document2/dart",
@ -59,13 +61,12 @@ dart = [
] ]
ts = [ ts = [
"flowy-user/ts", "flowy-user/ts",
"flowy-date/ts",
"flowy-folder2/ts", "flowy-folder2/ts",
"flowy-database2/ts", "flowy-database2/ts",
"flowy-document2/ts", "flowy-document2/ts",
"flowy-config/ts", "flowy-config/ts",
] ]
rev-sqlite = [ rev-sqlite = ["flowy-user/rev-sqlite"]
"flowy-user/rev-sqlite",
]
openssl_vendored = ["flowy-sqlite/openssl_vendored"] openssl_vendored = ["flowy-sqlite/openssl_vendored"]

View File

@ -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<DatabaseManager>,
#[allow(dead_code)]
pub(crate) document_manager: Weak<DocumentManager>,
}
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(())
})
}
}

View File

@ -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>) -> 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::<Vec<String>>();
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(",")
}

View File

@ -1,2 +1,5 @@
pub(crate) mod collab_interact;
pub(crate) mod log;
pub(crate) mod server; pub(crate) mod server;
mod trait_impls; mod trait_impls;
pub(crate) mod user;

View File

@ -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<AppFlowyCollabBuilder>,
pub(crate) folder_manager: Arc<FolderManager>,
pub(crate) database_manager: Arc<DatabaseManager>,
pub(crate) document_manager: Arc<DocumentManager>,
pub(crate) server_provider: Arc<ServerProvider>,
#[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<UserCloudConfig>,
user_workspace: &UserWorkspace,
_device_id: &str,
) -> Fut<FlowyResult<()>> {
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<FlowyResult<()>> {
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<FlowyResult<()>> {
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<FlowyResult<()>> {
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<FlowyResult<()>> {
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);
}
}

View File

@ -2,43 +2,34 @@
use std::sync::Weak; use std::sync::Weak;
use std::time::Duration; use std::time::Duration;
use std::{ use std::{fmt, sync::Arc};
fmt,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabSource}; use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabSource};
use flowy_database2::DatabaseManager; use flowy_database2::DatabaseManager;
use flowy_document2::manager::DocumentManager; use flowy_document2::manager::DocumentManager;
use flowy_error::FlowyResult; use flowy_folder2::manager::FolderManager;
use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager};
use flowy_sqlite::kv::StorePreferences; use flowy_sqlite::kv::StorePreferences;
use flowy_storage::FileStorageService; use flowy_storage::FileStorageService;
use flowy_task::{TaskDispatcher, TaskRunner}; 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::manager::{UserManager, UserSessionConfig};
use flowy_user_deps::cloud::UserCloudConfig;
use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace};
use lib_dispatch::prelude::*; use lib_dispatch::prelude::*;
use lib_dispatch::runtime::tokio_default_runtime; use lib_dispatch::runtime::tokio_default_runtime;
use lib_infra::future::{to_fut, Fut};
use module::make_plugins; use module::make_plugins;
pub use module::*; pub use module::*;
use crate::deps_resolve::*; 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::server::{current_server_provider, ServerProvider, ServerType};
use crate::integrate::user::UserStatusCallbackImpl;
mod deps_resolve; mod deps_resolve;
mod integrate; mod integrate;
pub mod module; pub mod module;
static INIT_LOG: AtomicBool = AtomicBool::new(false);
/// This name will be used as to identify the current [AppFlowyCore] instance. /// This name will be used as to identify the current [AppFlowyCore] instance.
/// Don't change this. /// Don't change this.
pub const DEFAULT_NAME: &str = "appflowy"; pub const DEFAULT_NAME: &str = "appflowy";
@ -75,41 +66,6 @@ impl AppFlowyCoreConfig {
} }
} }
fn create_log_filter(level: String, with_crates: Vec<String>) -> 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::<Vec<String>>();
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)] #[derive(Clone)]
pub struct AppFlowyCore { pub struct AppFlowyCore {
#[allow(dead_code)] #[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 /// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded
/// on demand based on the [CollabPluginConfig]. /// on demand based on the [CollabPluginConfig].
let collab_builder = Arc::new(AppFlowyCollabBuilder::new(server_provider.clone())); let collab_builder = Arc::new(AppFlowyCollabBuilder::new(server_provider.clone()));
let user_manager = mk_user_session( let user_manager = init_user_manager(
&config, &config,
&store_preference, &store_preference,
server_provider.clone(), server_provider.clone(),
@ -206,7 +162,7 @@ impl AppFlowyCore {
) )
}); });
let user_status_listener = UserStatusCallbackImpl { let user_status_callback = UserStatusCallbackImpl {
collab_builder, collab_builder,
folder_manager: folder_manager.clone(), folder_manager: folder_manager.clone(),
database_manager: database_manager.clone(), database_manager: database_manager.clone(),
@ -215,10 +171,17 @@ impl AppFlowyCore {
config: config.clone(), 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); let cloned_user_session = Arc::downgrade(&user_manager);
runtime.block_on(async move { runtime.block_on(async move {
if let Some(user_session) = cloned_user_session.upgrade() { 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) { fn init_user_manager(
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(
config: &AppFlowyCoreConfig, config: &AppFlowyCoreConfig,
storage_preference: &Arc<StorePreferences>, storage_preference: &Arc<StorePreferences>,
user_cloud_service_provider: Arc<dyn UserCloudServiceProvider>, user_cloud_service_provider: Arc<dyn UserCloudServiceProvider>,
@ -275,181 +228,9 @@ fn mk_user_session(
) )
} }
struct UserStatusCallbackImpl {
collab_builder: Arc<AppFlowyCollabBuilder>,
folder_manager: Arc<FolderManager>,
database_manager: Arc<DatabaseManager>,
document_manager: Arc<DocumentManager>,
server_provider: Arc<ServerProvider>,
#[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<UserCloudConfig>,
user_workspace: &UserWorkspace,
_device_id: &str,
) -> Fut<FlowyResult<()>> {
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<FlowyResult<()>> {
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<FlowyResult<()>> {
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<FlowyResult<()>> {
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<FlowyResult<()>> {
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<ServerType> for CollabSource { impl From<ServerType> for CollabSource {
fn from(server_provider: ServerType) -> Self { fn from(server_type: ServerType) -> Self {
match server_provider { match server_type {
ServerType::Local => CollabSource::Local, ServerType::Local => CollabSource::Local,
ServerType::AppFlowyCloud => CollabSource::Local, ServerType::AppFlowyCloud => CollabSource::Local,
ServerType::Supabase => CollabSource::Supabase, ServerType::Supabase => CollabSource::Supabase,

View File

@ -21,11 +21,13 @@ pub fn make_plugins(
let database_plugin = flowy_database2::event_map::init(database_manager); let database_plugin = flowy_database2::event_map::init(database_manager);
let document_plugin2 = flowy_document2::event_map::init(document_manager2); let document_plugin2 = flowy_document2::event_map::init(document_manager2);
let config_plugin = flowy_config::event_map::init(store_preferences); let config_plugin = flowy_config::event_map::init(store_preferences);
let date_plugin = flowy_date::event_map::init();
vec![ vec![
user_plugin, user_plugin,
folder_plugin, folder_plugin,
database_plugin, database_plugin,
document_plugin2, document_plugin2,
config_plugin, config_plugin,
date_plugin,
] ]
} }

View File

@ -301,7 +301,7 @@ where
is_changed = true; is_changed = true;
}, },
Some(pos) => { 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 // Take the old group setting
group.visible = old_group.visible; group.visible = old_group.visible;
if !is_changed { if !is_changed {

View File

@ -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" }

View File

@ -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"]

View File

@ -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);
}

View File

@ -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,
}

View File

@ -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<Regex> = 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<DateQueryPB>,
) -> DataResult<DateResultPB, FlowyError> {
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::<i32>().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")),
}
}

View File

@ -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,
}

View File

@ -0,0 +1,4 @@
pub mod entities;
pub mod event_handler;
pub mod event_map;
pub mod protobuf;

View File

@ -10,3 +10,4 @@ pub mod protobuf;
pub mod deps; pub mod deps;
pub mod notification; pub mod notification;
mod parse; mod parse;
pub mod reminder;

View File

@ -17,6 +17,7 @@ use flowy_storage::FileStorageService;
use crate::document::MutexDocument; use crate::document::MutexDocument;
use crate::entities::DocumentSnapshotPB; use crate::entities::DocumentSnapshotPB;
use crate::reminder::DocumentReminderAction;
pub trait DocumentUser: Send + Sync { pub trait DocumentUser: Send + Sync {
fn user_id(&self) -> Result<i64, FlowyError>; fn user_id(&self) -> Result<i64, FlowyError>;
@ -58,6 +59,15 @@ impl DocumentManager {
self.initialize(uid, workspace_id).await?; self.initialize(uid, workspace_id).await?;
Ok(()) 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. /// Create a new document.
/// ///
/// if the document already exists, return the existing document. /// if the document already exists, return the existing document.

View File

@ -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<Reminder> for DocumentReminder {
type Error = serde_json::Error;
fn try_from(value: Reminder) -> Result<Self, Self::Error> {
serde_json::from_value(json!(value.meta.into_inner()))
}
}

View File

@ -7,18 +7,21 @@ edition = "2018"
[dependencies] [dependencies]
flowy-derive = { path = "../../../shared-lib/flowy-derive" } flowy-derive = { path = "../../../shared-lib/flowy-derive" }
protobuf = {version = "2.28.0"} protobuf = { version = "2.28.0" }
bytes = "1.4" bytes = "1.4"
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
fancy-regex = { version = "0.11.0" }
lib-dispatch = { workspace = true, optional = true } 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_repr = { version = "0.1" }
serde = "1.0" serde = "1.0"
reqwest = { version = "0.11.14", optional = true, features = ["native-tls-vendored"] } reqwest = { version = "0.11.14", optional = true, features = [
flowy-sqlite = { workspace = true, optional = true} "native-tls-vendored",
r2d2 = { version = "0.8", optional = true} ] }
flowy-sqlite = { workspace = true, optional = true }
r2d2 = { version = "0.8", optional = true }
url = { version = "2.2", optional = true } url = { version = "2.2", optional = true }
collab-database = { version = "0.1.0", optional = true } collab-database = { version = "0.1.0", optional = true }
collab-document = { 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_sqlite = ["flowy-sqlite", "r2d2"]
impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"] impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"]
impl_from_postgres = ["tokio-postgres"] impl_from_postgres = ["tokio-postgres"]
impl_from_tokio= ["tokio"] impl_from_tokio = ["tokio"]
impl_from_url= ["url"] impl_from_url = ["url"]
impl_from_appflowy_cloud = ["client-api"] impl_from_appflowy_cloud = ["client-api"]
dart = ["flowy-codegen/dart"] dart = ["flowy-codegen/dart"]
ts = ["flowy-codegen/ts"] ts = ["flowy-codegen/ts"]
[build-dependencies] [build-dependencies]
flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = ["proto_gen"]} flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = [
"proto_gen",
] }

View File

@ -137,3 +137,9 @@ impl From<anyhow::Error> for FlowyError {
.unwrap_or_else(|err| FlowyError::new(ErrorCode::Internal, err)) .unwrap_or_else(|err| FlowyError::new(ErrorCode::Internal, err))
} }
} }
impl From<fancy_regex::Error> for FlowyError {
fn from(e: fancy_regex::Error) -> Self {
FlowyError::internal().with_context(e)
}
}

View File

@ -1,3 +1,5 @@
use std::collections::HashMap;
use flowy_test::event_builder::EventBuilder; use flowy_test::event_builder::EventBuilder;
use flowy_test::FlowyCoreTest; use flowy_test::FlowyCoreTest;
use flowy_user::entities::{ReminderPB, RepeatedReminderPB}; use flowy_user::entities::{ReminderPB, RepeatedReminderPB};
@ -7,14 +9,18 @@ use flowy_user::event_map::UserEvent::*;
async fn user_update_with_name() { async fn user_update_with_name() {
let sdk = FlowyCoreTest::new(); let sdk = FlowyCoreTest::new();
let _ = sdk.sign_up_as_guest().await; let _ = sdk.sign_up_as_guest().await;
let mut meta = HashMap::new();
meta.insert("object_id".to_string(), "".to_string());
let payload = ReminderPB { let payload = ReminderPB {
id: "".to_string(), id: "".to_string(),
scheduled_at: 0, scheduled_at: 0,
is_ack: false, is_ack: false,
ty: 0, is_read: false,
title: "".to_string(), title: "".to_string(),
message: "".to_string(), message: "".to_string(),
reminder_object_id: "".to_string(), object_id: "".to_string(),
meta,
}; };
let _ = EventBuilder::new(sdk.clone()) let _ = EventBuilder::new(sdk.clone())
.event(CreateReminder) .event(CreateReminder)

View File

@ -28,16 +28,17 @@ anyhow = "1.0.75"
tracing = { version = "0.1", features = ["log"] } tracing = { version = "0.1", features = ["log"] }
bytes = "1.4" bytes = "1.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = {version = "1.0"} serde_json = { version = "1.0" }
serde_repr = "0.1" serde_repr = "0.1"
log = "0.4.17" log = "0.4.17"
protobuf = {version = "2.28.0"} protobuf = { version = "2.28.0" }
lazy_static = "1.4.0" lazy_static = "1.4.0"
diesel = {version = "1.4.8", features = ["sqlite"]} diesel = { version = "1.4.8", features = ["sqlite"] }
diesel_derives = {version = "1.4.1", features = ["sqlite"]} diesel_derives = { version = "1.4.1", features = ["sqlite"] }
once_cell = "1.17.1" once_cell = "1.17.1"
parking_lot = "0.12.1" parking_lot = "0.12.1"
strum_macros = "0.21" strum = "0.25"
strum_macros = "0.25.2"
tokio = { version = "1.26", features = ["rt"] } tokio = { version = "1.26", features = ["rt"] }
validator = "0.16.0" validator = "0.16.0"
unicode-segmentation = "1.10" unicode-segmentation = "1.10"
@ -61,4 +62,4 @@ dart = ["flowy-codegen/dart", "flowy-notification/dart"]
ts = ["flowy-codegen/ts", "flowy-notification/ts"] ts = ["flowy-codegen/ts", "flowy-notification/ts"]
[build-dependencies] [build-dependencies]
flowy-codegen = { path = "../../../shared-lib/flowy-codegen"} flowy-codegen = { path = "../../../shared-lib/flowy-codegen" }

View File

@ -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<i64> 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<i64> 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",
}
}
}

View File

@ -5,6 +5,7 @@ pub use user_profile::*;
pub use user_setting::*; pub use user_setting::*;
pub mod auth; pub mod auth;
pub mod date_time;
pub mod parser; pub mod parser;
pub mod realtime; pub mod realtime;
mod reminder; mod reminder;

View File

@ -1,6 +1,6 @@
use collab_define::reminder::{ObjectType, Reminder}; use collab_define::reminder::{ObjectType, Reminder, ReminderMeta};
use flowy_derive::ProtoBuf; use flowy_derive::ProtoBuf;
use std::collections::HashMap;
#[derive(ProtoBuf, Default, Clone)] #[derive(ProtoBuf, Default, Clone)]
pub struct ReminderPB { pub struct ReminderPB {
@ -8,22 +8,25 @@ pub struct ReminderPB {
pub id: String, pub id: String,
#[pb(index = 2)] #[pb(index = 2)]
pub scheduled_at: i64, pub object_id: String,
#[pb(index = 3)] #[pb(index = 3)]
pub is_ack: bool, pub scheduled_at: i64,
#[pb(index = 4)] #[pb(index = 4)]
pub ty: i64, pub is_ack: bool,
#[pb(index = 5)] #[pb(index = 5)]
pub title: String, pub is_read: bool,
#[pb(index = 6)] #[pb(index = 6)]
pub message: String, pub title: String,
#[pb(index = 7)] #[pb(index = 7)]
pub reminder_object_id: String, pub message: String,
#[pb(index = 8)]
pub meta: HashMap<String, String>,
} }
#[derive(ProtoBuf, Default, Clone)] #[derive(ProtoBuf, Default, Clone)]
@ -38,11 +41,12 @@ impl From<ReminderPB> for Reminder {
id: value.id, id: value.id,
scheduled_at: value.scheduled_at, scheduled_at: value.scheduled_at,
is_ack: value.is_ack, is_ack: value.is_ack,
is_read: value.is_read,
ty: ObjectType::Document, ty: ObjectType::Document,
title: value.title, title: value.title,
message: value.message, message: value.message,
meta: Default::default(), meta: ReminderMeta::from(value.meta),
object_id: value.reminder_object_id, object_id: value.object_id,
} }
} }
} }
@ -51,12 +55,13 @@ impl From<Reminder> for ReminderPB {
fn from(value: Reminder) -> Self { fn from(value: Reminder) -> Self {
Self { Self {
id: value.id, id: value.id,
object_id: value.object_id,
scheduled_at: value.scheduled_at, scheduled_at: value.scheduled_at,
is_ack: value.is_ack, is_ack: value.is_ack,
ty: value.ty as i64, is_read: value.is_read,
title: value.title, title: value.title,
message: value.message, message: value.message,
reminder_object_id: value.object_id, meta: value.meta.into_inner(),
} }
} }
} }
@ -66,3 +71,9 @@ impl From<Vec<ReminderPB>> for RepeatedReminderPB {
Self { items: value } Self { items: value }
} }
} }
#[derive(ProtoBuf, Default, Clone)]
pub struct ReminderIdentifierPB {
#[pb(index = 1)]
pub id: String,
}

View File

@ -7,6 +7,8 @@ use flowy_user_deps::cloud::UserCloudConfig;
use crate::entities::EncryptionTypePB; use crate::entities::EncryptionTypePB;
use super::date_time::{UserDateFormatPB, UserTimeFormatPB};
#[derive(ProtoBuf, Default, Debug, Clone)] #[derive(ProtoBuf, Default, Debug, Clone)]
pub struct UserPreferencesPB { pub struct UserPreferencesPB {
#[pb(index = 1)] #[pb(index = 1)]
@ -14,6 +16,9 @@ pub struct UserPreferencesPB {
#[pb(index = 2)] #[pb(index = 2)]
appearance_setting: AppearanceSettingsPB, appearance_setting: AppearanceSettingsPB,
#[pb(index = 3)]
date_time_settings: DateTimeSettingsPB,
} }
#[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)] #[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_FONT: &str = "Poppins";
pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono"; pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono";
const APPEARANCE_RESET_AS_DEFAULT: bool = true; const APPEARANCE_RESET_AS_DEFAULT: bool = true;
@ -210,3 +215,25 @@ pub struct NetworkStatePB {
#[pb(index = 1)] #[pb(index = 1)]
pub ty: NetworkTypePB, 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(),
}
}
}

View File

@ -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<Weak<StorePreferences>>,
data: AFPluginData<DateTimeSettingsPB>,
) -> 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<Weak<StorePreferences>>,
) -> DataResult<DateTimeSettingsPB, FlowyError> {
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)] #[tracing::instrument(level = "debug", skip_all, err)]
pub async fn get_user_setting( pub async fn get_user_setting(
manager: AFPluginState<Weak<UserManager>>, manager: AFPluginState<Weak<UserManager>>,
@ -457,3 +497,27 @@ pub async fn reset_workspace_handler(
manager.reset_workspace(reset_pb, session.device_id).await?; manager.reset_workspace(reset_pb, session.device_id).await?;
Ok(()) Ok(())
} }
#[tracing::instrument(level = "debug", skip_all, err)]
pub async fn remove_reminder_event_handler(
data: AFPluginData<ReminderIdentifierPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> 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<ReminderPB>,
manager: AFPluginState<Weak<UserManager>>,
) -> Result<(), FlowyError> {
let manager = upgrade_manager(manager)?;
let params = data.into_inner();
manager.update_reminder(params).await?;
Ok(())
}

View File

@ -54,7 +54,11 @@ pub fn init(user_session: Weak<UserManager>) -> AFPlugin {
.event(UserEvent::PushRealtimeEvent, push_realtime_event_handler) .event(UserEvent::PushRealtimeEvent, push_realtime_event_handler)
.event(UserEvent::CreateReminder, create_reminder_event_handler) .event(UserEvent::CreateReminder, create_reminder_event_handler)
.event(UserEvent::GetAllReminders, get_all_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::ResetWorkspace, reset_workspace_handler)
.event(UserEvent::SetDateTimeSettings, set_date_time_settings)
.event(UserEvent::GetDateTimeSettings, get_date_time_settings)
} }
pub struct SignUpContext { pub struct SignUpContext {
@ -262,8 +266,9 @@ pub enum UserEvent {
#[event(input = "HistoricalUserPB")] #[event(input = "HistoricalUserPB")]
OpenHistoricalUser = 26, OpenHistoricalUser = 26,
/// Push a realtime event to the user. Currently, the realtime event is only used /// Push a realtime event to the user. Currently, the realtime event
/// when the auth type is: [AuthType::Supabase]. /// is only used when the auth type is: [AuthType::Supabase].
///
#[event(input = "RealtimePayloadPB")] #[event(input = "RealtimePayloadPB")]
PushRealtimeEvent = 27, PushRealtimeEvent = 27,
@ -273,6 +278,20 @@ pub enum UserEvent {
#[event(output = "RepeatedReminderPB")] #[event(output = "RepeatedReminderPB")]
GetAllReminders = 29, GetAllReminders = 29,
#[event(input = "ReminderIdentifierPB")]
RemoveReminder = 30,
#[event(input = "ReminderPB")]
UpdateReminder = 31,
#[event(input = "ResetWorkspacePB")] #[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,
} }

View File

@ -25,6 +25,7 @@ use crate::migrations::migration::UserLocalDataMigration;
use crate::migrations::sync_new_user::sync_user_data_to_cloud; use crate::migrations::sync_new_user::sync_user_data_to_cloud;
use crate::migrations::MigrationUser; use crate::migrations::MigrationUser;
use crate::services::cloud_config::get_cloud_config; use crate::services::cloud_config::get_cloud_config;
use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract};
use crate::services::database::UserDB; use crate::services::database::UserDB;
use crate::services::entities::{ResumableSignUp, Session}; use crate::services::entities::{ResumableSignUp, Session};
use crate::services::user_awareness::UserAwarenessDataSource; use crate::services::user_awareness::UserAwarenessDataSource;
@ -59,6 +60,7 @@ pub struct UserManager {
pub(crate) user_awareness: Arc<Mutex<Option<MutexUserAwareness>>>, pub(crate) user_awareness: Arc<Mutex<Option<MutexUserAwareness>>>,
pub(crate) user_status_callback: RwLock<Arc<dyn UserStatusCallback>>, pub(crate) user_status_callback: RwLock<Arc<dyn UserStatusCallback>>,
pub(crate) collab_builder: Weak<AppFlowyCollabBuilder>, pub(crate) collab_builder: Weak<AppFlowyCollabBuilder>,
pub(crate) collab_interact: RwLock<Arc<dyn CollabInteract>>,
resumable_sign_up: Mutex<Option<ResumableSignUp>>, resumable_sign_up: Mutex<Option<ResumableSignUp>>,
current_session: parking_lot::RwLock<Option<Session>>, current_session: parking_lot::RwLock<Option<Session>>,
} }
@ -82,6 +84,7 @@ impl UserManager {
user_awareness: Arc::new(Default::default()), user_awareness: Arc::new(Default::default()),
user_status_callback, user_status_callback,
collab_builder, collab_builder,
collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)),
resumable_sign_up: Default::default(), resumable_sign_up: Default::default(),
current_session: 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, /// 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 /// 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. /// completion, a user status callback is invoked to signify that the initialization process is complete.
pub async fn init<C: UserStatusCallback + 'static>(&self, user_status_callback: C) { pub async fn init<C: UserStatusCallback + 'static, I: CollabInteract>(
&self,
user_status_callback: C,
collab_interact: I,
) {
if let Ok(session) = self.get_session() { if let Ok(session) = self.get_session() {
// Do the user data migration if needed // Do the user data migration if needed
match ( match (
@ -155,6 +162,7 @@ impl UserManager {
} }
} }
*self.user_status_callback.write().await = Arc::new(user_status_callback); *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<DBConnection, FlowyError> { pub fn db_connection(&self, uid: i64) -> Result<DBConnection, FlowyError> {

View File

@ -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(()) })
}
}

View File

@ -1,4 +1,5 @@
pub mod cloud_config; pub mod cloud_config;
pub mod collab_interact;
pub mod database; pub mod database;
pub mod entities; pub mod entities;
pub(crate) mod historical_user; pub(crate) mod historical_user;

View File

@ -30,9 +30,53 @@ impl UserManager {
let reminder = Reminder::from(reminder_pb); let reminder = Reminder::from(reminder_pb);
self self
.with_awareness((), |user_awareness| { .with_awareness((), |user_awareness| {
user_awareness.add_reminder(reminder); user_awareness.add_reminder(reminder.clone());
}) })
.await; .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(()) Ok(())
} }

View File

@ -3,5 +3,4 @@ pub use async_trait;
pub mod box_any; pub mod box_any;
pub mod future; pub mod future;
pub mod ref_map; pub mod ref_map;
pub mod retry;
pub mod util; pub mod util;

View File

@ -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<A>
where
A: Action,
{
Running(#[pin] A::Future),
Sleeping(#[pin] Sleep),
}
impl<A: Action> RetryState<A> {
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> RetryFuturePoll<A> {
match self.project() {
RetryStateProj::Running(future) => RetryFuturePoll::Running(future.poll(cx)),
RetryStateProj::Sleeping(future) => RetryFuturePoll::Sleeping(future.poll(cx)),
}
}
}
enum RetryFuturePoll<A>
where
A: Action,
{
Running(Poll<Result<A::Item, A::Error>>),
Sleeping(Poll<()>),
}
/// Future that drives multiple attempts at an action via a retry strategy.
#[pin_project]
pub struct Retry<I, A>
where
I: Iterator<Item = Duration>,
A: Action,
{
#[pin]
retry_if: RetryIf<I, A, fn(&A::Error) -> bool>,
}
impl<I, A> Retry<I, A>
where
I: Iterator<Item = Duration>,
A: Action,
{
pub fn new<T: IntoIterator<IntoIter = I, Item = Duration>>(
strategy: T,
action: A,
) -> Retry<I, A> {
Retry {
retry_if: RetryIf::spawn(strategy, action, (|_| true) as fn(&A::Error) -> bool),
}
}
}
impl<I, A> Future for Retry<I, A>
where
I: Iterator<Item = Duration>,
A: Action,
{
type Output = Result<A::Item, A::Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
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<I, A, C>
where
I: Iterator<Item = Duration>,
A: Action,
C: Condition<A::Error>,
{
strategy: I,
#[pin]
state: RetryState<A>,
action: A,
condition: C,
}
impl<I, A, C> RetryIf<I, A, C>
where
I: Iterator<Item = Duration>,
A: Action,
C: Condition<A::Error>,
{
pub fn spawn<T: IntoIterator<IntoIter = I, Item = Duration>>(
strategy: T,
mut action: A,
condition: C,
) -> RetryIf<I, A, C> {
RetryIf {
strategy: strategy.into_iter(),
state: RetryState::Running(action.run()),
action,
condition,
}
}
fn attempt(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Result<A::Item, A::Error>> {
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<Poll<Result<A::Item, A::Error>>, 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<I, A, C> Future for RetryIf<I, A, C>
where
I: Iterator<Item = Duration>,
A: Action,
C: Condition<A::Error>,
{
type Output = Result<A::Item, A::Error>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
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<Output = Result<Self::Item, Self::Error>>;
type Item;
type Error;
fn run(&mut self) -> Self::Future;
}
// impl<R, E, T: Future<Output = Result<R, E>>, 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<E> {
fn should_retry(&mut self, error: &E) -> bool;
}
impl<E, F: FnMut(&E) -> bool> Condition<E> for F {
fn should_retry(&mut self, error: &E) -> bool {
self(error)
}
}
pub fn spawn_retry<A: Action + 'static>(
retry_count: usize,
retry_per_millis: u64,
action: A,
) -> impl Future<Output = Result<A::Item, A::Error>>
where
A::Item: Send + Sync,
A::Error: Send + Sync,
<A as Action>::Future: Send + Sync,
{
let strategy = FixedInterval::from_millis(retry_per_millis).take(retry_count);
Retry::new(strategy, action)
}

View File

@ -1,5 +0,0 @@
mod future;
mod strategy;
pub use future::*;
pub use strategy::*;

View File

@ -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<Duration>,
}
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<Duration> {
// 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)));
}

View File

@ -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<Duration> {
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)));
}

View File

@ -1,5 +0,0 @@
use std::time::Duration;
pub fn jitter(duration: Duration) -> Duration {
duration.mul_f64(rand::random::<f64>())
}

View File

@ -1,7 +0,0 @@
mod exponential_backoff;
mod fixed_interval;
mod jitter;
pub use exponential_backoff::*;
pub use fixed_interval::*;
pub use jitter::*;