mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: reminder (#3374)
This commit is contained in:
parent
f7749bdccc
commit
4a433a3176
2
.github/workflows/flutter_ci.yaml
vendored
2
.github/workflows/flutter_ci.yaml
vendored
@ -88,7 +88,7 @@ jobs:
|
||||
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
elif [ "$RUNNER_OS" == "Windows" ]; then
|
||||
vcpkg integrate install
|
||||
elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
|
2
.github/workflows/integration_test.yml
vendored
2
.github/workflows/integration_test.yml
vendored
@ -85,7 +85,7 @@ jobs:
|
||||
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
elif [ "$RUNNER_OS" == "Windows" ]; then
|
||||
vcpkg integrate install
|
||||
elif [ "$RUNNER_OS" == "macOS" ]; then
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -388,7 +388,7 @@ jobs:
|
||||
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install keybinder-3.0 libnotify-dev
|
||||
sudo apt-get -y install alien
|
||||
source $HOME/.cargo/env
|
||||
cargo install --force cargo-make
|
||||
|
@ -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');
|
@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cus
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -160,7 +161,7 @@ class EditorOperations {
|
||||
/// Must call [showAtMenu] first.
|
||||
Future<void> tapAtMenuItemWithName(String name) async {
|
||||
final atMenuItem = find.descendant(
|
||||
of: find.byType(SelectionMenuWidget),
|
||||
of: find.byType(InlineActionsHandler),
|
||||
matching: find.text(name, findRichText: true),
|
||||
);
|
||||
await tester.tapButton(atMenuItem);
|
||||
|
19
frontend/appflowy_flutter/lib/date/date_service.dart
Normal file
19
frontend/appflowy_flutter/lib/date/date_service.dart
Normal 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'));
|
||||
});
|
||||
}
|
||||
}
|
@ -224,7 +224,7 @@ class _SelectOptionColorCell extends StatelessWidget {
|
||||
|
||||
final colorIcon = SizedBox.square(
|
||||
dimension: 16,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color.toColor(context),
|
||||
shape: BoxShape.circle,
|
||||
|
@ -3,17 +3,17 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
|
||||
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
|
||||
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_calendar.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:dartz/dartz.dart' show Either;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/time/duration.dart';
|
||||
import 'package:flowy_infra/time/prelude.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -24,9 +24,6 @@ import '../../../../grid/presentation/widgets/common/type_option_separator.dart'
|
||||
import '../../../../grid/presentation/widgets/header/type_option/date.dart';
|
||||
import 'date_cal_bloc.dart';
|
||||
|
||||
final kFirstDay = DateTime.utc(1970, 1, 1);
|
||||
final kLastDay = DateTime.utc(2100, 1, 1);
|
||||
|
||||
class DateCellEditor extends StatefulWidget {
|
||||
final VoidCallback onDismissed;
|
||||
final DateCellController cellController;
|
||||
@ -51,9 +48,9 @@ class _DateCellEditor extends State<DateCellEditor> {
|
||||
builder: (BuildContext context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return _buildWidget(snapshot);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -81,22 +78,14 @@ class _CellCalendarWidget extends StatefulWidget {
|
||||
const _CellCalendarWidget({
|
||||
required this.cellContext,
|
||||
required this.dateTypeOptionPB,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CellCalendarWidget> createState() => _CellCalendarWidgetState();
|
||||
}
|
||||
|
||||
class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
|
||||
late PopoverMutex popoverMutex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
popoverMutex = PopoverMutex();
|
||||
|
||||
super.initState();
|
||||
}
|
||||
final PopoverMutex popoverMutex = PopoverMutex();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -387,8 +376,7 @@ class _TimeTextField extends StatefulWidget {
|
||||
required this.timeStr,
|
||||
required this.popoverMutex,
|
||||
required this.isEndTime,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TimeTextField> createState() => _TimeTextFieldState();
|
||||
|
@ -1,7 +1,11 @@
|
||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
|
||||
import 'package:appflowy/workspace/application/appearance.dart';
|
||||
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@ -11,6 +15,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
toggleToggleListCommand,
|
||||
...codeBlockCommands,
|
||||
customCopyCommand,
|
||||
customPasteCommand,
|
||||
customCutCommand,
|
||||
...standardCommandShortcutEvents,
|
||||
];
|
||||
|
||||
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
|
||||
...commandShortcutEvents.map((e) => e.copyWith()).toList(),
|
||||
];
|
||||
|
||||
/// Wrapper for the appflowy editor.
|
||||
class AppFlowyEditorPage extends StatefulWidget {
|
||||
const AppFlowyEditorPage({
|
||||
@ -34,19 +51,17 @@ class AppFlowyEditorPage extends StatefulWidget {
|
||||
State<AppFlowyEditorPage> createState() => _AppFlowyEditorPageState();
|
||||
}
|
||||
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
...codeBlockCommands,
|
||||
...standardCommandShortcutEvents,
|
||||
];
|
||||
|
||||
final List<CommandShortcutEvent> defaultCommandShortcutEvents = [
|
||||
...commandShortcutEvents.map((e) => e.copyWith()).toList(),
|
||||
];
|
||||
|
||||
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
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 = [
|
||||
toggleToggleListCommand,
|
||||
@ -85,9 +100,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
_customAppFlowyBlockComponentBuilders();
|
||||
|
||||
List<CharacterShortcutEvent> get characterShortcutEvents => [
|
||||
// inline page reference list
|
||||
...inlinePageReferenceShortcuts,
|
||||
|
||||
// code block
|
||||
...codeBlockCharacterEvents,
|
||||
|
||||
@ -105,19 +117,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
..removeWhere(
|
||||
(element) => element == slashCommand,
|
||||
), // remove the default slash command.
|
||||
];
|
||||
|
||||
late final inlinePageReferenceShortcuts = [
|
||||
inlinePageReferenceService.customPageLinkMenu(
|
||||
character: '@',
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
),
|
||||
// uncomment this to enable the inline page reference list
|
||||
// inlinePageReferenceService.customPageLinkMenu(
|
||||
// character: '+',
|
||||
// style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
// ),
|
||||
];
|
||||
/// Inline Actions
|
||||
/// - Reminder
|
||||
/// - Inline-page reference
|
||||
inlineActionsCommand(
|
||||
inlineActionsService,
|
||||
style: styleCustomizer.inlineActionsMenuStyleBuilder(),
|
||||
),
|
||||
];
|
||||
|
||||
late final showSlashMenu = customSlashCommand(
|
||||
slashMenuItems,
|
||||
@ -147,6 +155,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
if (widget.scrollController == null) {
|
||||
effectiveScrollController.dispose();
|
||||
}
|
||||
inlineActionsService.dispose();
|
||||
|
||||
widget.editorState.dispose();
|
||||
|
||||
@ -221,6 +230,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
final configuration = BlockComponentConfiguration(
|
||||
padding: (_) => const EdgeInsets.symmetric(vertical: 5.0),
|
||||
);
|
||||
|
||||
final customBlockComponentBuilderMap = {
|
||||
PageBlockKeys.type: PageBlockComponentBuilder(),
|
||||
ParagraphBlockKeys.type: TextBlockComponentBuilder(
|
||||
@ -462,13 +472,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
}
|
||||
|
||||
Future<void> _initializeShortcuts() async {
|
||||
//TODO(Xazin): Refactor lazy initialization
|
||||
// TODO(Xazin): Refactor lazy initialization
|
||||
defaultCommandShortcutEvents;
|
||||
final settingsShortcutService = SettingsShortcutService();
|
||||
final customizeShortcuts =
|
||||
await settingsShortcutService.getCustomizeShortcuts();
|
||||
await settingsShortcutService.updateCommandShortcuts(
|
||||
standardCommandShortcutEvents,
|
||||
commandShortcutEvents,
|
||||
customizeShortcuts,
|
||||
);
|
||||
}
|
||||
|
@ -180,7 +180,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
builtInAssetImages[index],
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(builtInAssetImages[index]),
|
||||
@ -299,7 +299,7 @@ class NewCustomCoverButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
@ -484,7 +484,7 @@ class _ImageGridItemState extends State<ImageGridItem> {
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: widget.onImageSelect,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(widget.imagePath)),
|
||||
@ -544,7 +544,7 @@ class ColorItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: SizedBox.square(
|
||||
dimension: 25,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: option.colorHex.tryToColor(),
|
||||
shape: BoxShape.circle,
|
||||
|
@ -201,7 +201,7 @@ class CoverImagePreviewWidget extends StatefulWidget {
|
||||
|
||||
class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
_buildFilePickerWidget(BuildContext ctx) {
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: Corners.s6Border,
|
||||
@ -263,7 +263,7 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
onTap: () {
|
||||
ctx.read<CoverImagePickerBloc>().add(const DeleteImage());
|
||||
},
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,22 +1,83 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
enum MentionType {
|
||||
page,
|
||||
date,
|
||||
reminder;
|
||||
|
||||
static MentionType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'page':
|
||||
return page;
|
||||
case 'date':
|
||||
return date;
|
||||
case 'reminder':
|
||||
return reminder;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MentionBlockKeys {
|
||||
const MentionBlockKeys._();
|
||||
|
||||
static const uid = 'uid'; // UniqueID
|
||||
static const mention = 'mention';
|
||||
static const type = 'type'; // MentionType, String
|
||||
static const pageId = 'page_id';
|
||||
|
||||
// Related to Reminder and Date blocks
|
||||
static const date = 'date';
|
||||
static const includeTime = 'include_time';
|
||||
}
|
||||
|
||||
class MentionBlock extends StatelessWidget {
|
||||
const MentionBlock({
|
||||
super.key,
|
||||
required this.mention,
|
||||
required this.node,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final Map mention;
|
||||
final Map<String, dynamic> mention;
|
||||
final Node node;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = MentionType.fromString(mention[MentionBlockKeys.type]);
|
||||
if (type == MentionType.page) {
|
||||
final pageId = mention[MentionBlockKeys.pageId];
|
||||
return MentionPageBlock(key: ValueKey(pageId), pageId: pageId);
|
||||
|
||||
switch (type) {
|
||||
case MentionType.page:
|
||||
final String pageId = mention[MentionBlockKeys.pageId];
|
||||
return MentionPageBlock(
|
||||
key: ValueKey(pageId),
|
||||
pageId: pageId,
|
||||
);
|
||||
case MentionType.reminder:
|
||||
case MentionType.date:
|
||||
final String date = mention[MentionBlockKeys.date];
|
||||
final BuildContext editorContext =
|
||||
context.read<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();
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -135,7 +135,7 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: backgroundColor,
|
||||
|
@ -1,8 +1,11 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
|
||||
import 'package:appflowy/util/google_font_family_extension.dart';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -161,6 +164,17 @@ class EditorStyleCustomizer {
|
||||
);
|
||||
}
|
||||
|
||||
InlineActionsMenuStyle inlineActionsMenuStyleBuilder() {
|
||||
final theme = Theme.of(context);
|
||||
return InlineActionsMenuStyle(
|
||||
backgroundColor: theme.cardColor,
|
||||
groupTextColor: theme.colorScheme.onBackground.withOpacity(.8),
|
||||
menuItemTextColor: theme.colorScheme.onBackground,
|
||||
menuItemSelectedColor: theme.hoverColor,
|
||||
menuItemSelectedTextColor: theme.colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
|
||||
FloatingToolbarStyle floatingToolbarStyleBuilder() {
|
||||
final theme = Theme.of(context);
|
||||
return FloatingToolbarStyle(
|
||||
@ -203,19 +217,28 @@ class EditorStyleCustomizer {
|
||||
}
|
||||
}
|
||||
|
||||
// customize the inline mention block, like inline page
|
||||
final mention = attributes[MentionBlockKeys.mention] as Map?;
|
||||
// Inline Mentions (Page Reference, Date, Reminder, etc.)
|
||||
final mention =
|
||||
attributes[MentionBlockKeys.mention] as Map<String, dynamic>?;
|
||||
if (mention != null) {
|
||||
final type = mention[MentionBlockKeys.type];
|
||||
if (type == MentionType.page.name) {
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: MentionBlock(
|
||||
key: ValueKey(mention[MentionBlockKeys.pageId]),
|
||||
mention: mention,
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: MentionBlock(
|
||||
key: ValueKey(
|
||||
switch (type) {
|
||||
MentionType.page => mention[MentionBlockKeys.pageId],
|
||||
MentionType.date ||
|
||||
MentionType.reminder =>
|
||||
mention[MentionBlockKeys.date],
|
||||
_ => MentionBlockKeys.mention,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
node: node,
|
||||
index: index,
|
||||
mention: mention,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// customize the inline math equation block
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
@ -15,11 +15,13 @@ import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/mock_auth_service.dart';
|
||||
import 'package:appflowy/user/application/auth/supabase_auth_service.dart';
|
||||
import 'package:appflowy/user/application/prelude.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/user/application/user_listener.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/user/presentation/router.dart';
|
||||
import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
@ -133,7 +135,11 @@ void _resolveHomeDeps(GetIt getIt) {
|
||||
(view, _) => DocShareBloc(view: view),
|
||||
);
|
||||
|
||||
getIt.registerSingleton<NotificationActionBloc>(NotificationActionBloc());
|
||||
|
||||
getIt.registerLazySingleton<TabsBloc>(() => TabsBloc());
|
||||
|
||||
getIt.registerSingleton<ReminderBloc>(ReminderBloc());
|
||||
}
|
||||
|
||||
void _resolveFolderDeps(GetIt getIt) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_service.dart';
|
||||
import 'package:appflowy/startup/tasks/prelude.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
@ -22,15 +23,22 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
|
||||
@override
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await NotificationService.initialize();
|
||||
|
||||
final widget = context.getIt<EntryPoint>().create(context.config);
|
||||
final appearanceSetting =
|
||||
await UserSettingsBackendService().getAppearanceSetting();
|
||||
final dateTimeSettings =
|
||||
await UserSettingsBackendService().getDateTimeSettings();
|
||||
|
||||
// If the passed-in context is not the same as the context of the
|
||||
// application widget, the application widget will be rebuilt.
|
||||
final app = ApplicationWidget(
|
||||
key: ValueKey(context),
|
||||
appearanceSetting: appearanceSetting,
|
||||
dateTimeSettings: dateTimeSettings,
|
||||
appTheme: await appTheme(appearanceSetting.theme),
|
||||
child: widget,
|
||||
);
|
||||
@ -71,21 +79,23 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
),
|
||||
);
|
||||
|
||||
return Future(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
class ApplicationWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final AppearanceSettingsPB appearanceSetting;
|
||||
final AppTheme appTheme;
|
||||
|
||||
const ApplicationWidget({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.appTheme,
|
||||
required this.appearanceSetting,
|
||||
}) : super(key: key);
|
||||
required this.dateTimeSettings,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final AppearanceSettingsPB appearanceSetting;
|
||||
final AppTheme appTheme;
|
||||
final DateTimeSettingsPB dateTimeSettings;
|
||||
|
||||
@override
|
||||
State<ApplicationWidget> createState() => _ApplicationWidgetState();
|
||||
@ -109,6 +119,7 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
BlocProvider<AppearanceSettingsCubit>(
|
||||
create: (_) => AppearanceSettingsCubit(
|
||||
widget.appearanceSetting,
|
||||
widget.dateTimeSettings,
|
||||
widget.appTheme,
|
||||
)..readLocaleWhenAppLaunch(context),
|
||||
),
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -10,12 +10,9 @@ class UserSettingsBackendService {
|
||||
final result = await UserEventGetAppearanceSetting().send();
|
||||
|
||||
return result.fold(
|
||||
(AppearanceSettingsPB setting) {
|
||||
return setting;
|
||||
},
|
||||
(error) {
|
||||
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty);
|
||||
},
|
||||
(AppearanceSettingsPB setting) => setting,
|
||||
(error) =>
|
||||
throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty),
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,4 +25,20 @@ class UserSettingsBackendService {
|
||||
) {
|
||||
return UserEventSetAppearanceSetting(setting).send();
|
||||
}
|
||||
|
||||
Future<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();
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import 'package:appflowy/util/platform_extension.dart';
|
||||
import 'package:appflowy/workspace/application/appearance_defaults.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_theme_data.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
@ -23,11 +24,14 @@ const _white = Color(0xFFFFFFFF);
|
||||
/// It includes the [AppTheme], [ThemeMode], [TextStyles] and [Locale].
|
||||
class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
final AppearanceSettingsPB _setting;
|
||||
final DateTimeSettingsPB _dateTimeSettings;
|
||||
|
||||
AppearanceSettingsCubit(
|
||||
AppearanceSettingsPB setting,
|
||||
DateTimeSettingsPB dateTimeSettings,
|
||||
AppTheme appTheme,
|
||||
) : _setting = setting,
|
||||
_dateTimeSettings = dateTimeSettings,
|
||||
super(
|
||||
AppearanceSettingsState.initial(
|
||||
appTheme,
|
||||
@ -39,6 +43,9 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
setting.locale,
|
||||
setting.isMenuCollapsed,
|
||||
setting.menuOffset,
|
||||
dateTimeSettings.dateFormat,
|
||||
dateTimeSettings.timeFormat,
|
||||
dateTimeSettings.timezoneId,
|
||||
),
|
||||
);
|
||||
|
||||
@ -173,6 +180,29 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
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 {
|
||||
UserSettingsBackendService().setAppearanceSetting(_setting).then((result) {
|
||||
result.fold(
|
||||
@ -271,6 +301,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
|
||||
required Locale locale,
|
||||
required bool isMenuCollapsed,
|
||||
required double menuOffset,
|
||||
required UserDateFormatPB dateFormat,
|
||||
required UserTimeFormatPB timeFormat,
|
||||
required String timezoneId,
|
||||
}) = _AppearanceSettingsState;
|
||||
|
||||
factory AppearanceSettingsState.initial(
|
||||
@ -283,6 +316,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
|
||||
LocaleSettingsPB localePB,
|
||||
bool isMenuCollapsed,
|
||||
double menuOffset,
|
||||
UserDateFormatPB dateFormat,
|
||||
UserTimeFormatPB timeFormat,
|
||||
String timezoneId,
|
||||
) {
|
||||
return AppearanceSettingsState(
|
||||
appTheme: appTheme,
|
||||
@ -294,6 +330,9 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
|
||||
locale: Locale(localePB.languageCode, localePB.countryCode),
|
||||
isMenuCollapsed: isMenuCollapsed,
|
||||
menuOffset: menuOffset,
|
||||
dateFormat: dateFormat,
|
||||
timeFormat: timeFormat,
|
||||
timezoneId: timezoneId,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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();
|
||||
}
|
@ -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,
|
||||
};
|
@ -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 ?? '');
|
@ -2,6 +2,7 @@ import 'package:appflowy/plugins/blank/blank.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/appearance.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_service.dart';
|
||||
@ -57,6 +58,9 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
return MultiBlocProvider(
|
||||
key: ValueKey(userProfile!.id),
|
||||
providers: [
|
||||
BlocProvider<ReminderBloc>.value(
|
||||
value: getIt<ReminderBloc>()..add(const ReminderEvent.started()),
|
||||
),
|
||||
BlocProvider<TabsBloc>.value(value: getIt<TabsBloc>()),
|
||||
BlocProvider<HomeBloc>(
|
||||
create: (context) {
|
||||
|
@ -1,4 +1,7 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_action.dart';
|
||||
import 'package:appflowy/workspace/application/local_notifications/notification_action_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_folder.dart';
|
||||
@ -10,6 +13,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -36,6 +40,9 @@ class HomeSideBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => getIt<NotificationActionBloc>(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => MenuBloc(
|
||||
user: user,
|
||||
@ -46,11 +53,34 @@ class HomeSideBar extends StatelessWidget {
|
||||
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
|
||||
)
|
||||
],
|
||||
child: BlocListener<MenuBloc, MenuState>(
|
||||
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
|
||||
listener: (context, state) => context
|
||||
.read<TabsBloc>()
|
||||
.add(TabsEvent.openPlugin(plugin: state.plugin)),
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<MenuBloc, MenuState>(
|
||||
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
|
||||
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(
|
||||
builder: (context) {
|
||||
final menuState = context.watch<MenuBloc>().state;
|
||||
@ -88,7 +118,7 @@ class HomeSideBar extends StatelessWidget {
|
||||
// top menu
|
||||
const SidebarTopMenu(),
|
||||
// user, setting
|
||||
SidebarUser(user: user),
|
||||
SidebarUser(user: user, views: views),
|
||||
const VSpace(20),
|
||||
// scrollable document list
|
||||
Expanded(
|
||||
|
@ -2,7 +2,9 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/notifications/notification_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
@ -17,9 +19,11 @@ class SidebarUser extends StatelessWidget {
|
||||
const SidebarUser({
|
||||
super.key,
|
||||
required this.user,
|
||||
required this.views,
|
||||
});
|
||||
|
||||
final UserProfilePB user;
|
||||
final List<ViewPB> views;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -41,6 +45,8 @@ class SidebarUser extends StatelessWidget {
|
||||
child: _buildUserName(context, state),
|
||||
),
|
||||
_buildSettingsButton(context, state),
|
||||
const HSpace(4),
|
||||
NotificationButton(views: views),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -10,7 +10,7 @@ class FlowyMessageToast extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import 'package:appflowy/workspace/application/appearance.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart';
|
||||
import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@ -23,20 +25,17 @@ class SettingsAppearanceView extends StatelessWidget {
|
||||
currentTheme: state.appTheme.themeName,
|
||||
bloc: context.read<DynamicPluginBloc>(),
|
||||
),
|
||||
BrightnessSetting(
|
||||
currentThemeMode: state.themeMode,
|
||||
),
|
||||
BrightnessSetting(currentThemeMode: state.themeMode),
|
||||
const Divider(),
|
||||
ThemeFontFamilySetting(
|
||||
currentFontFamily: state.font,
|
||||
),
|
||||
ThemeFontFamilySetting(currentFontFamily: state.font),
|
||||
const Divider(),
|
||||
LayoutDirectionSetting(
|
||||
currentLayoutDirection: state.layoutDirection,
|
||||
),
|
||||
TextDirectionSetting(
|
||||
currentTextDirection: state.textDirection,
|
||||
),
|
||||
TextDirectionSetting(currentTextDirection: state.textDirection),
|
||||
const Divider(),
|
||||
DateFormatSetting(currentFormat: state.dateFormat),
|
||||
TimeFormatSetting(currentFormat: state.timeFormat),
|
||||
const Divider(),
|
||||
CreateFileSettings(),
|
||||
],
|
||||
|
@ -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;
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
_ => "",
|
||||
};
|
||||
}
|
@ -17,18 +17,21 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
|
||||
|
||||
// ignore: unused_import
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
import 'dart:convert' show utf8;
|
||||
import '../protobuf/flowy-config/entities.pb.dart';
|
||||
import '../protobuf/flowy-config/event_map.pb.dart';
|
||||
import 'error.dart';
|
||||
|
||||
import '../protobuf/flowy-date/entities.pb.dart';
|
||||
import '../protobuf/flowy-date/event_map.pb.dart';
|
||||
|
||||
part 'dart_event/flowy-folder2/dart_event.dart';
|
||||
part 'dart_event/flowy-user/dart_event.dart';
|
||||
part 'dart_event/flowy-database2/dart_event.dart';
|
||||
part 'dart_event/flowy-document2/dart_event.dart';
|
||||
part 'dart_event/flowy-config/dart_event.dart';
|
||||
part 'dart_event/flowy-date/dart_event.dart';
|
||||
|
||||
enum FFIException {
|
||||
RequestIsEmpty,
|
||||
|
@ -59,11 +59,10 @@ class FlowyColorPicker extends StatelessWidget {
|
||||
|
||||
final colorIcon = SizedBox.square(
|
||||
dimension: iconSize,
|
||||
child: Container(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: option.color,
|
||||
shape: BoxShape.circle,
|
||||
// border: border,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -825,6 +825,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
local_notifier:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: local_notifier
|
||||
sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.5"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -109,6 +109,11 @@ dependencies:
|
||||
super_clipboard: ^0.6.3
|
||||
go_router: ^10.1.2
|
||||
|
||||
# Notifications
|
||||
# TODO: Consider implementing custom package
|
||||
# to gather notification handling for all platforms
|
||||
local_notifier: ^0.1.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.1
|
||||
|
||||
|
@ -17,9 +17,12 @@ void main() {
|
||||
|
||||
group('$AppearanceSettingsCubit', () {
|
||||
late AppearanceSettingsPB appearanceSetting;
|
||||
late DateTimeSettingsPB dateTimeSettings;
|
||||
setUp(() async {
|
||||
appearanceSetting =
|
||||
await UserSettingsBackendService().getAppearanceSetting();
|
||||
dateTimeSettings =
|
||||
await UserSettingsBackendService().getDateTimeSettings();
|
||||
await blocResponseFuture();
|
||||
});
|
||||
|
||||
@ -27,6 +30,7 @@ void main() {
|
||||
'default theme',
|
||||
build: () => AppearanceSettingsCubit(
|
||||
appearanceSetting,
|
||||
dateTimeSettings,
|
||||
AppTheme.fallback,
|
||||
),
|
||||
verify: (bloc) {
|
||||
@ -41,6 +45,7 @@ void main() {
|
||||
'save key/value',
|
||||
build: () => AppearanceSettingsCubit(
|
||||
appearanceSetting,
|
||||
dateTimeSettings,
|
||||
AppTheme.fallback,
|
||||
),
|
||||
act: (bloc) {
|
||||
@ -55,6 +60,7 @@ void main() {
|
||||
'remove key/value',
|
||||
build: () => AppearanceSettingsCubit(
|
||||
appearanceSetting,
|
||||
dateTimeSettings,
|
||||
AppTheme.fallback,
|
||||
),
|
||||
act: (bloc) {
|
||||
@ -69,6 +75,7 @@ void main() {
|
||||
'initial state uses fallback theme',
|
||||
build: () => AppearanceSettingsCubit(
|
||||
appearanceSetting,
|
||||
dateTimeSettings,
|
||||
AppTheme.fallback,
|
||||
),
|
||||
verify: (bloc) {
|
||||
|
52
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
52
frontend/appflowy_tauri/src-tauri/Cargo.lock
generated
@ -798,7 +798,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -817,7 +817,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-database"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -847,7 +847,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-define"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -859,7 +859,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -871,7 +871,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-document"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -891,7 +891,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-folder"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -931,7 +931,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-persistence"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bincode",
|
||||
@ -952,7 +952,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-plugins"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -980,7 +980,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-sync-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab",
|
||||
@ -995,7 +995,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-user"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -1344,6 +1344,16 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "date_time_parser"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derivative"
|
||||
version = "2.2.0"
|
||||
@ -1774,6 +1784,7 @@ dependencies = [
|
||||
"flowy-config",
|
||||
"flowy-database-deps",
|
||||
"flowy-database2",
|
||||
"flowy-date",
|
||||
"flowy-document-deps",
|
||||
"flowy-document2",
|
||||
"flowy-error",
|
||||
@ -1853,6 +1864,23 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flowy-date"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"date_time_parser",
|
||||
"fancy-regex 0.11.0",
|
||||
"flowy-codegen",
|
||||
"flowy-derive",
|
||||
"flowy-error",
|
||||
"lib-dispatch",
|
||||
"protobuf",
|
||||
"strum_macros 0.21.1",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flowy-derive"
|
||||
version = "0.1.0"
|
||||
@ -1932,6 +1960,7 @@ dependencies = [
|
||||
"client-api",
|
||||
"collab-database",
|
||||
"collab-document",
|
||||
"fancy-regex 0.11.0",
|
||||
"flowy-codegen",
|
||||
"flowy-derive",
|
||||
"flowy-sqlite",
|
||||
@ -2134,7 +2163,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"strum_macros 0.21.1",
|
||||
"strum",
|
||||
"strum_macros 0.25.2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
|
@ -20,9 +20,16 @@ tauri = { version = "1.2", features = ["fs-all", "shell-open"] }
|
||||
tauri-utils = "1.2"
|
||||
bytes = { version = "1.4" }
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = ["use_serde"] }
|
||||
flowy-core = { path = "../../rust-lib/flowy-core", features = ["rev-sqlite", "ts"] }
|
||||
flowy-notification = { path = "../../rust-lib/flowy-notification", features = ["ts"] }
|
||||
lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [
|
||||
"use_serde",
|
||||
] }
|
||||
flowy-core = { path = "../../rust-lib/flowy-core", features = [
|
||||
"rev-sqlite",
|
||||
"ts",
|
||||
] }
|
||||
flowy-notification = { path = "../../rust-lib/flowy-notification", features = [
|
||||
"ts",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
@ -40,21 +47,17 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "8f8
|
||||
# Working directory: frontend
|
||||
#
|
||||
# To update the commit ID, run:
|
||||
# scripts/tool/update_collab_rev.sh new_rev_id
|
||||
# scripts/tool/update_collab_rev.sh e37ee7
|
||||
#
|
||||
# To switch to the local path, run:
|
||||
# scripts/tool/update_collab_source.sh
|
||||
# ⚠️⚠️⚠️️
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
|
||||
|
||||
|
||||
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
|
@ -1,7 +1,7 @@
|
||||
export * from "./models/flowy-user";
|
||||
export * from "./models/flowy-database2";
|
||||
export * from "./models/flowy-folder2";
|
||||
export * from "./models/flowy-document2";
|
||||
export * from "./models/flowy-error";
|
||||
export * from "./models/flowy-config";
|
||||
|
||||
export * from './models/flowy-user';
|
||||
export * from './models/flowy-database2';
|
||||
export * from './models/flowy-folder2';
|
||||
export * from './models/flowy-document2';
|
||||
export * from './models/flowy-error';
|
||||
export * from './models/flowy-config';
|
||||
export * from './models/flowy-date';
|
||||
|
@ -291,6 +291,19 @@
|
||||
"theme": "Theme",
|
||||
"builtInsLabel": "Built-in Themes",
|
||||
"pluginsLabel": "Plugins",
|
||||
"dateFormat": {
|
||||
"label": "Date format",
|
||||
"local": "Local",
|
||||
"us": "US",
|
||||
"iso": "ISO",
|
||||
"friendly": "Friendly",
|
||||
"dmy": "D/M/Y"
|
||||
},
|
||||
"timeFormat": {
|
||||
"label": "Time format",
|
||||
"twelveHour": "Twelve hour",
|
||||
"twentyFourHour": "Twenty four hour"
|
||||
},
|
||||
"showNamingDialogWhenCreatingPage": "Show naming dialog when creating a page"
|
||||
},
|
||||
"files": {
|
||||
@ -755,6 +768,32 @@
|
||||
"frequentlyUsed": "Frequently Used"
|
||||
}
|
||||
},
|
||||
"inlineActions": {
|
||||
"noResults": "No results",
|
||||
"pageReference": "Page reference",
|
||||
"date": "Date",
|
||||
"reminder": {
|
||||
"groupTitle": "Reminder",
|
||||
"shortKeyword": "remind"
|
||||
}
|
||||
},
|
||||
"relativeDates": {
|
||||
"yesterday": "Yesterday",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"oneWeek": "1 week"
|
||||
},
|
||||
"notificationHub": {
|
||||
"title": "Notifications",
|
||||
"empty": "Nothing to see here!"
|
||||
},
|
||||
"reminderNotification": {
|
||||
"title": "Reminder",
|
||||
"message": "Remember to check this before you forget!",
|
||||
"tooltipDelete": "Delete",
|
||||
"tooltipMarkRead": "Mark as read",
|
||||
"tooltipMarkUnread": "Mark as unread"
|
||||
},
|
||||
"findAndReplace": {
|
||||
"find": "Find",
|
||||
"previousMatch": "Previous match",
|
||||
|
54
frontend/rust-lib/Cargo.lock
generated
54
frontend/rust-lib/Cargo.lock
generated
@ -421,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"hashbrown 0.13.2",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -672,7 +672,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -691,7 +691,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-database"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -721,7 +721,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-define"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -733,7 +733,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -745,7 +745,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-document"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -765,7 +765,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-folder"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -805,7 +805,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-persistence"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bincode",
|
||||
@ -826,7 +826,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-plugins"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -854,7 +854,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-sync-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab",
|
||||
@ -869,7 +869,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-user"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=bf3a19#bf3a1935bead461cc521cc4931a2fc3e7d8adbef"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e37ee7#e37ee7ea66e27da7ef4ec1128adba7f4af8ede32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@ -1168,6 +1168,16 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "date_time_parser"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.8"
|
||||
@ -1517,6 +1527,7 @@ dependencies = [
|
||||
"flowy-config",
|
||||
"flowy-database-deps",
|
||||
"flowy-database2",
|
||||
"flowy-date",
|
||||
"flowy-document-deps",
|
||||
"flowy-document2",
|
||||
"flowy-error",
|
||||
@ -1597,6 +1608,23 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flowy-date"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"date_time_parser",
|
||||
"fancy-regex 0.11.0",
|
||||
"flowy-codegen",
|
||||
"flowy-derive",
|
||||
"flowy-error",
|
||||
"lib-dispatch",
|
||||
"protobuf",
|
||||
"strum_macros 0.21.1",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flowy-derive"
|
||||
version = "0.1.0"
|
||||
@ -1678,6 +1706,7 @@ dependencies = [
|
||||
"client-api",
|
||||
"collab-database",
|
||||
"collab-document",
|
||||
"fancy-regex 0.11.0",
|
||||
"flowy-codegen",
|
||||
"flowy-derive",
|
||||
"flowy-sqlite",
|
||||
@ -1943,7 +1972,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"strum_macros 0.21.1",
|
||||
"strum",
|
||||
"strum_macros 0.25.2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
|
@ -24,6 +24,7 @@ members = [
|
||||
"flowy-storage",
|
||||
"collab-integrate",
|
||||
"flowy-ai",
|
||||
"flowy-date",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@ -50,6 +51,7 @@ flowy-encrypt = { workspace = true, path = "flowy-encrypt" }
|
||||
flowy-storage = { workspace = true, path = "flowy-storage" }
|
||||
collab-integrate = { workspace = true, path = "collab-integrate" }
|
||||
flowy-ai = { workspace = true, path = "flowy-ai" }
|
||||
flowy-date = { workspace = true, path = "flowy-date" }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
@ -87,12 +89,12 @@ client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "8f8
|
||||
# To switch to the local path, run:
|
||||
# scripts/tool/update_collab_source.sh
|
||||
# ⚠️⚠️⚠️️
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "bf3a19" }
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-user = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-define = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-sync-protocol = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e37ee7" }
|
||||
|
@ -22,6 +22,7 @@ flowy-task = { workspace = true }
|
||||
flowy-server = { workspace = true }
|
||||
flowy-server-config = { workspace = true }
|
||||
flowy-config = { workspace = true }
|
||||
flowy-date = { workspace = true }
|
||||
collab-integrate = { workspace = true, features = ["supabase_integrate", "appflowy_cloud_integrate", "snapshot_plugin"] }
|
||||
flowy-ai = { workspace = true }
|
||||
collab-define = { version = "0.1.0" }
|
||||
@ -52,6 +53,7 @@ native_sync = []
|
||||
use_bunyan = ["lib-log/use_bunyan"]
|
||||
dart = [
|
||||
"flowy-user/dart",
|
||||
"flowy-date/dart",
|
||||
"flowy-folder2/dart",
|
||||
"flowy-database2/dart",
|
||||
"flowy-document2/dart",
|
||||
@ -59,13 +61,12 @@ dart = [
|
||||
]
|
||||
ts = [
|
||||
"flowy-user/ts",
|
||||
"flowy-date/ts",
|
||||
"flowy-folder2/ts",
|
||||
"flowy-database2/ts",
|
||||
"flowy-document2/ts",
|
||||
"flowy-config/ts",
|
||||
]
|
||||
rev-sqlite = [
|
||||
"flowy-user/rev-sqlite",
|
||||
]
|
||||
rev-sqlite = ["flowy-user/rev-sqlite"]
|
||||
openssl_vendored = ["flowy-sqlite/openssl_vendored"]
|
||||
|
||||
|
@ -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(())
|
||||
})
|
||||
}
|
||||
}
|
48
frontend/rust-lib/flowy-core/src/integrate/log.rs
Normal file
48
frontend/rust-lib/flowy-core/src/integrate/log.rs
Normal 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(",")
|
||||
}
|
@ -1,2 +1,5 @@
|
||||
pub(crate) mod collab_interact;
|
||||
pub(crate) mod log;
|
||||
pub(crate) mod server;
|
||||
mod trait_impls;
|
||||
pub(crate) mod user;
|
||||
|
187
frontend/rust-lib/flowy-core/src/integrate/user.rs
Normal file
187
frontend/rust-lib/flowy-core/src/integrate/user.rs
Normal 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);
|
||||
}
|
||||
}
|
@ -2,43 +2,34 @@
|
||||
|
||||
use std::sync::Weak;
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
fmt,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use collab_integrate::collab_builder::{AppFlowyCollabBuilder, CollabSource};
|
||||
use flowy_database2::DatabaseManager;
|
||||
use flowy_document2::manager::DocumentManager;
|
||||
use flowy_error::FlowyResult;
|
||||
use flowy_folder2::manager::{FolderInitializeDataSource, FolderManager};
|
||||
use flowy_folder2::manager::FolderManager;
|
||||
use flowy_sqlite::kv::StorePreferences;
|
||||
use flowy_storage::FileStorageService;
|
||||
use flowy_task::{TaskDispatcher, TaskRunner};
|
||||
use flowy_user::event_map::{UserCloudServiceProvider, UserStatusCallback};
|
||||
use flowy_user::event_map::UserCloudServiceProvider;
|
||||
use flowy_user::manager::{UserManager, UserSessionConfig};
|
||||
use flowy_user_deps::cloud::UserCloudConfig;
|
||||
use flowy_user_deps::entities::{AuthType, UserProfile, UserWorkspace};
|
||||
use lib_dispatch::prelude::*;
|
||||
use lib_dispatch::runtime::tokio_default_runtime;
|
||||
use lib_infra::future::{to_fut, Fut};
|
||||
use module::make_plugins;
|
||||
pub use module::*;
|
||||
|
||||
use crate::deps_resolve::*;
|
||||
use crate::integrate::collab_interact::CollabInteractImpl;
|
||||
use crate::integrate::log::{create_log_filter, init_log};
|
||||
use crate::integrate::server::{current_server_provider, ServerProvider, ServerType};
|
||||
use crate::integrate::user::UserStatusCallbackImpl;
|
||||
|
||||
mod deps_resolve;
|
||||
mod integrate;
|
||||
pub mod module;
|
||||
|
||||
static INIT_LOG: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// This name will be used as to identify the current [AppFlowyCore] instance.
|
||||
/// Don't change this.
|
||||
pub const DEFAULT_NAME: &str = "appflowy";
|
||||
@ -75,41 +66,6 @@ impl AppFlowyCoreConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_log_filter(level: String, with_crates: Vec<String>) -> 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)]
|
||||
pub struct AppFlowyCore {
|
||||
#[allow(dead_code)]
|
||||
@ -162,7 +118,7 @@ impl AppFlowyCore {
|
||||
/// The shared collab builder is used to build the [Collab] instance. The plugins will be loaded
|
||||
/// on demand based on the [CollabPluginConfig].
|
||||
let collab_builder = Arc::new(AppFlowyCollabBuilder::new(server_provider.clone()));
|
||||
let user_manager = mk_user_session(
|
||||
let user_manager = init_user_manager(
|
||||
&config,
|
||||
&store_preference,
|
||||
server_provider.clone(),
|
||||
@ -206,7 +162,7 @@ impl AppFlowyCore {
|
||||
)
|
||||
});
|
||||
|
||||
let user_status_listener = UserStatusCallbackImpl {
|
||||
let user_status_callback = UserStatusCallbackImpl {
|
||||
collab_builder,
|
||||
folder_manager: folder_manager.clone(),
|
||||
database_manager: database_manager.clone(),
|
||||
@ -215,10 +171,17 @@ impl AppFlowyCore {
|
||||
config: config.clone(),
|
||||
};
|
||||
|
||||
let collab_interact_impl = CollabInteractImpl {
|
||||
database_manager: Arc::downgrade(&database_manager),
|
||||
document_manager: Arc::downgrade(&document_manager),
|
||||
};
|
||||
|
||||
let cloned_user_session = Arc::downgrade(&user_manager);
|
||||
runtime.block_on(async move {
|
||||
if let Some(user_session) = cloned_user_session.upgrade() {
|
||||
user_session.init(user_status_listener).await;
|
||||
user_session
|
||||
.init(user_status_callback, collab_interact_impl)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
@ -250,17 +213,7 @@ impl AppFlowyCore {
|
||||
}
|
||||
}
|
||||
|
||||
fn init_log(config: &AppFlowyCoreConfig) {
|
||||
if !INIT_LOG.load(Ordering::SeqCst) {
|
||||
INIT_LOG.store(true, Ordering::SeqCst);
|
||||
|
||||
let _ = lib_log::Builder::new("AppFlowy-Client", &config.storage_path)
|
||||
.env_filter(&config.log_filter)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
fn mk_user_session(
|
||||
fn init_user_manager(
|
||||
config: &AppFlowyCoreConfig,
|
||||
storage_preference: &Arc<StorePreferences>,
|
||||
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 {
|
||||
fn from(server_provider: ServerType) -> Self {
|
||||
match server_provider {
|
||||
fn from(server_type: ServerType) -> Self {
|
||||
match server_type {
|
||||
ServerType::Local => CollabSource::Local,
|
||||
ServerType::AppFlowyCloud => CollabSource::Local,
|
||||
ServerType::Supabase => CollabSource::Supabase,
|
||||
|
@ -21,11 +21,13 @@ pub fn make_plugins(
|
||||
let database_plugin = flowy_database2::event_map::init(database_manager);
|
||||
let document_plugin2 = flowy_document2::event_map::init(document_manager2);
|
||||
let config_plugin = flowy_config::event_map::init(store_preferences);
|
||||
let date_plugin = flowy_date::event_map::init();
|
||||
vec![
|
||||
user_plugin,
|
||||
folder_plugin,
|
||||
database_plugin,
|
||||
document_plugin2,
|
||||
config_plugin,
|
||||
date_plugin,
|
||||
]
|
||||
}
|
||||
|
@ -301,7 +301,7 @@ where
|
||||
is_changed = true;
|
||||
},
|
||||
Some(pos) => {
|
||||
let mut old_group = configuration.groups.get_mut(pos).unwrap();
|
||||
let old_group = configuration.groups.get_mut(pos).unwrap();
|
||||
// Take the old group setting
|
||||
group.visible = old_group.visible;
|
||||
if !is_changed {
|
||||
|
25
frontend/rust-lib/flowy-date/Cargo.toml
Normal file
25
frontend/rust-lib/flowy-date/Cargo.toml
Normal 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" }
|
3
frontend/rust-lib/flowy-date/Flowy.toml
Normal file
3
frontend/rust-lib/flowy-date/Flowy.toml
Normal 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"]
|
10
frontend/rust-lib/flowy-date/build.rs
Normal file
10
frontend/rust-lib/flowy-date/build.rs
Normal 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);
|
||||
}
|
13
frontend/rust-lib/flowy-date/src/entities.rs
Normal file
13
frontend/rust-lib/flowy-date/src/entities.rs
Normal 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,
|
||||
}
|
36
frontend/rust-lib/flowy-date/src/event_handler.rs
Normal file
36
frontend/rust-lib/flowy-date/src/event_handler.rs
Normal 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")),
|
||||
}
|
||||
}
|
19
frontend/rust-lib/flowy-date/src/event_map.rs
Normal file
19
frontend/rust-lib/flowy-date/src/event_map.rs
Normal 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,
|
||||
}
|
4
frontend/rust-lib/flowy-date/src/lib.rs
Normal file
4
frontend/rust-lib/flowy-date/src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod entities;
|
||||
pub mod event_handler;
|
||||
pub mod event_map;
|
||||
pub mod protobuf;
|
@ -10,3 +10,4 @@ pub mod protobuf;
|
||||
pub mod deps;
|
||||
pub mod notification;
|
||||
mod parse;
|
||||
pub mod reminder;
|
||||
|
@ -17,6 +17,7 @@ use flowy_storage::FileStorageService;
|
||||
|
||||
use crate::document::MutexDocument;
|
||||
use crate::entities::DocumentSnapshotPB;
|
||||
use crate::reminder::DocumentReminderAction;
|
||||
|
||||
pub trait DocumentUser: Send + Sync {
|
||||
fn user_id(&self) -> Result<i64, FlowyError>;
|
||||
@ -58,6 +59,15 @@ impl DocumentManager {
|
||||
self.initialize(uid, workspace_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_reminder_action(&self, action: DocumentReminderAction) {
|
||||
match action {
|
||||
DocumentReminderAction::Add { reminder: _ } => {},
|
||||
DocumentReminderAction::Remove { reminder_id: _ } => {},
|
||||
DocumentReminderAction::Update { reminder: _ } => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new document.
|
||||
///
|
||||
/// if the document already exists, return the existing document.
|
||||
|
23
frontend/rust-lib/flowy-document2/src/reminder.rs
Normal file
23
frontend/rust-lib/flowy-document2/src/reminder.rs
Normal 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()))
|
||||
}
|
||||
}
|
@ -7,18 +7,21 @@ edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
flowy-derive = { path = "../../../shared-lib/flowy-derive" }
|
||||
protobuf = {version = "2.28.0"}
|
||||
protobuf = { version = "2.28.0" }
|
||||
bytes = "1.4"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
fancy-regex = { version = "0.11.0" }
|
||||
lib-dispatch = { workspace = true, optional = true }
|
||||
serde_json = {version = "1.0", optional = true}
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
serde_repr = { version = "0.1" }
|
||||
serde = "1.0"
|
||||
reqwest = { version = "0.11.14", optional = true, features = ["native-tls-vendored"] }
|
||||
flowy-sqlite = { workspace = true, optional = true}
|
||||
r2d2 = { version = "0.8", optional = true}
|
||||
reqwest = { version = "0.11.14", optional = true, features = [
|
||||
"native-tls-vendored",
|
||||
] }
|
||||
flowy-sqlite = { workspace = true, optional = true }
|
||||
r2d2 = { version = "0.8", optional = true }
|
||||
url = { version = "2.2", optional = true }
|
||||
collab-database = { version = "0.1.0", optional = true }
|
||||
collab-document = { version = "0.1.0", optional = true }
|
||||
@ -33,11 +36,13 @@ impl_from_reqwest = ["reqwest"]
|
||||
impl_from_sqlite = ["flowy-sqlite", "r2d2"]
|
||||
impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"]
|
||||
impl_from_postgres = ["tokio-postgres"]
|
||||
impl_from_tokio= ["tokio"]
|
||||
impl_from_url= ["url"]
|
||||
impl_from_tokio = ["tokio"]
|
||||
impl_from_url = ["url"]
|
||||
impl_from_appflowy_cloud = ["client-api"]
|
||||
dart = ["flowy-codegen/dart"]
|
||||
ts = ["flowy-codegen/ts"]
|
||||
|
||||
[build-dependencies]
|
||||
flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = ["proto_gen"]}
|
||||
flowy-codegen = { path = "../../../shared-lib/flowy-codegen", features = [
|
||||
"proto_gen",
|
||||
] }
|
||||
|
@ -137,3 +137,9 @@ impl From<anyhow::Error> for FlowyError {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use flowy_test::event_builder::EventBuilder;
|
||||
use flowy_test::FlowyCoreTest;
|
||||
use flowy_user::entities::{ReminderPB, RepeatedReminderPB};
|
||||
@ -7,14 +9,18 @@ use flowy_user::event_map::UserEvent::*;
|
||||
async fn user_update_with_name() {
|
||||
let sdk = FlowyCoreTest::new();
|
||||
let _ = sdk.sign_up_as_guest().await;
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert("object_id".to_string(), "".to_string());
|
||||
|
||||
let payload = ReminderPB {
|
||||
id: "".to_string(),
|
||||
scheduled_at: 0,
|
||||
is_ack: false,
|
||||
ty: 0,
|
||||
is_read: false,
|
||||
title: "".to_string(),
|
||||
message: "".to_string(),
|
||||
reminder_object_id: "".to_string(),
|
||||
object_id: "".to_string(),
|
||||
meta,
|
||||
};
|
||||
let _ = EventBuilder::new(sdk.clone())
|
||||
.event(CreateReminder)
|
||||
|
@ -28,16 +28,17 @@ anyhow = "1.0.75"
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
bytes = "1.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = {version = "1.0"}
|
||||
serde_json = { version = "1.0" }
|
||||
serde_repr = "0.1"
|
||||
log = "0.4.17"
|
||||
protobuf = {version = "2.28.0"}
|
||||
protobuf = { version = "2.28.0" }
|
||||
lazy_static = "1.4.0"
|
||||
diesel = {version = "1.4.8", features = ["sqlite"]}
|
||||
diesel_derives = {version = "1.4.1", features = ["sqlite"]}
|
||||
diesel = { version = "1.4.8", features = ["sqlite"] }
|
||||
diesel_derives = { version = "1.4.1", features = ["sqlite"] }
|
||||
once_cell = "1.17.1"
|
||||
parking_lot = "0.12.1"
|
||||
strum_macros = "0.21"
|
||||
strum = "0.25"
|
||||
strum_macros = "0.25.2"
|
||||
tokio = { version = "1.26", features = ["rt"] }
|
||||
validator = "0.16.0"
|
||||
unicode-segmentation = "1.10"
|
||||
@ -61,4 +62,4 @@ dart = ["flowy-codegen/dart", "flowy-notification/dart"]
|
||||
ts = ["flowy-codegen/ts", "flowy-notification/ts"]
|
||||
|
||||
[build-dependencies]
|
||||
flowy-codegen = { path = "../../../shared-lib/flowy-codegen"}
|
||||
flowy-codegen = { path = "../../../shared-lib/flowy-codegen" }
|
||||
|
79
frontend/rust-lib/flowy-user/src/entities/date_time.rs
Normal file
79
frontend/rust-lib/flowy-user/src/entities/date_time.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ pub use user_profile::*;
|
||||
pub use user_setting::*;
|
||||
|
||||
pub mod auth;
|
||||
pub mod date_time;
|
||||
pub mod parser;
|
||||
pub mod realtime;
|
||||
mod reminder;
|
||||
|
@ -1,6 +1,6 @@
|
||||
use collab_define::reminder::{ObjectType, Reminder};
|
||||
|
||||
use collab_define::reminder::{ObjectType, Reminder, ReminderMeta};
|
||||
use flowy_derive::ProtoBuf;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(ProtoBuf, Default, Clone)]
|
||||
pub struct ReminderPB {
|
||||
@ -8,22 +8,25 @@ pub struct ReminderPB {
|
||||
pub id: String,
|
||||
|
||||
#[pb(index = 2)]
|
||||
pub scheduled_at: i64,
|
||||
pub object_id: String,
|
||||
|
||||
#[pb(index = 3)]
|
||||
pub is_ack: bool,
|
||||
pub scheduled_at: i64,
|
||||
|
||||
#[pb(index = 4)]
|
||||
pub ty: i64,
|
||||
pub is_ack: bool,
|
||||
|
||||
#[pb(index = 5)]
|
||||
pub title: String,
|
||||
pub is_read: bool,
|
||||
|
||||
#[pb(index = 6)]
|
||||
pub message: String,
|
||||
pub title: String,
|
||||
|
||||
#[pb(index = 7)]
|
||||
pub reminder_object_id: String,
|
||||
pub message: String,
|
||||
|
||||
#[pb(index = 8)]
|
||||
pub meta: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf, Default, Clone)]
|
||||
@ -38,11 +41,12 @@ impl From<ReminderPB> for Reminder {
|
||||
id: value.id,
|
||||
scheduled_at: value.scheduled_at,
|
||||
is_ack: value.is_ack,
|
||||
is_read: value.is_read,
|
||||
ty: ObjectType::Document,
|
||||
title: value.title,
|
||||
message: value.message,
|
||||
meta: Default::default(),
|
||||
object_id: value.reminder_object_id,
|
||||
meta: ReminderMeta::from(value.meta),
|
||||
object_id: value.object_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,12 +55,13 @@ impl From<Reminder> for ReminderPB {
|
||||
fn from(value: Reminder) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
object_id: value.object_id,
|
||||
scheduled_at: value.scheduled_at,
|
||||
is_ack: value.is_ack,
|
||||
ty: value.ty as i64,
|
||||
is_read: value.is_read,
|
||||
title: value.title,
|
||||
message: value.message,
|
||||
reminder_object_id: value.object_id,
|
||||
meta: value.meta.into_inner(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,3 +71,9 @@ impl From<Vec<ReminderPB>> for RepeatedReminderPB {
|
||||
Self { items: value }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf, Default, Clone)]
|
||||
pub struct ReminderIdentifierPB {
|
||||
#[pb(index = 1)]
|
||||
pub id: String,
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ use flowy_user_deps::cloud::UserCloudConfig;
|
||||
|
||||
use crate::entities::EncryptionTypePB;
|
||||
|
||||
use super::date_time::{UserDateFormatPB, UserTimeFormatPB};
|
||||
|
||||
#[derive(ProtoBuf, Default, Debug, Clone)]
|
||||
pub struct UserPreferencesPB {
|
||||
#[pb(index = 1)]
|
||||
@ -14,6 +16,9 @@ pub struct UserPreferencesPB {
|
||||
|
||||
#[pb(index = 2)]
|
||||
appearance_setting: AppearanceSettingsPB,
|
||||
|
||||
#[pb(index = 3)]
|
||||
date_time_settings: DateTimeSettingsPB,
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)]
|
||||
@ -106,7 +111,7 @@ impl std::default::Default for LocaleSettingsPB {
|
||||
}
|
||||
}
|
||||
|
||||
pub const APPEARANCE_DEFAULT_THEME: &str = "light";
|
||||
pub const APPEARANCE_DEFAULT_THEME: &str = "Default";
|
||||
pub const APPEARANCE_DEFAULT_FONT: &str = "Poppins";
|
||||
pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono";
|
||||
const APPEARANCE_RESET_AS_DEFAULT: bool = true;
|
||||
@ -210,3 +215,25 @@ pub struct NetworkStatePB {
|
||||
#[pb(index = 1)]
|
||||
pub ty: NetworkTypePB,
|
||||
}
|
||||
|
||||
#[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DateTimeSettingsPB {
|
||||
#[pb(index = 1)]
|
||||
pub date_format: UserDateFormatPB,
|
||||
|
||||
#[pb(index = 2)]
|
||||
pub time_format: UserTimeFormatPB,
|
||||
|
||||
#[pb(index = 3)]
|
||||
pub timezone_id: String,
|
||||
}
|
||||
|
||||
impl std::default::Default for DateTimeSettingsPB {
|
||||
fn default() -> Self {
|
||||
DateTimeSettingsPB {
|
||||
date_format: UserDateFormatPB::Friendly,
|
||||
time_format: UserTimeFormatPB::TwentyFourHour,
|
||||
timezone_id: "".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)]
|
||||
pub async fn get_user_setting(
|
||||
manager: AFPluginState<Weak<UserManager>>,
|
||||
@ -457,3 +497,27 @@ pub async fn reset_workspace_handler(
|
||||
manager.reset_workspace(reset_pb, session.device_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, err)]
|
||||
pub async fn remove_reminder_event_handler(
|
||||
data: AFPluginData<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(())
|
||||
}
|
||||
|
@ -54,7 +54,11 @@ pub fn init(user_session: Weak<UserManager>) -> AFPlugin {
|
||||
.event(UserEvent::PushRealtimeEvent, push_realtime_event_handler)
|
||||
.event(UserEvent::CreateReminder, create_reminder_event_handler)
|
||||
.event(UserEvent::GetAllReminders, get_all_reminder_event_handler)
|
||||
.event(UserEvent::RemoveReminder, remove_reminder_event_handler)
|
||||
.event(UserEvent::UpdateReminder, update_reminder_event_handler)
|
||||
.event(UserEvent::ResetWorkspace, reset_workspace_handler)
|
||||
.event(UserEvent::SetDateTimeSettings, set_date_time_settings)
|
||||
.event(UserEvent::GetDateTimeSettings, get_date_time_settings)
|
||||
}
|
||||
|
||||
pub struct SignUpContext {
|
||||
@ -262,8 +266,9 @@ pub enum UserEvent {
|
||||
#[event(input = "HistoricalUserPB")]
|
||||
OpenHistoricalUser = 26,
|
||||
|
||||
/// Push a realtime event to the user. Currently, the realtime event is only used
|
||||
/// when the auth type is: [AuthType::Supabase].
|
||||
/// Push a realtime event to the user. Currently, the realtime event
|
||||
/// is only used when the auth type is: [AuthType::Supabase].
|
||||
///
|
||||
#[event(input = "RealtimePayloadPB")]
|
||||
PushRealtimeEvent = 27,
|
||||
|
||||
@ -273,6 +278,20 @@ pub enum UserEvent {
|
||||
#[event(output = "RepeatedReminderPB")]
|
||||
GetAllReminders = 29,
|
||||
|
||||
#[event(input = "ReminderIdentifierPB")]
|
||||
RemoveReminder = 30,
|
||||
|
||||
#[event(input = "ReminderPB")]
|
||||
UpdateReminder = 31,
|
||||
|
||||
#[event(input = "ResetWorkspacePB")]
|
||||
ResetWorkspace = 30,
|
||||
ResetWorkspace = 32,
|
||||
|
||||
/// Change the Date/Time formats globally
|
||||
#[event(input = "DateTimeSettingsPB")]
|
||||
SetDateTimeSettings = 33,
|
||||
|
||||
/// Retrieve the Date/Time formats
|
||||
#[event(output = "DateTimeSettingsPB")]
|
||||
GetDateTimeSettings = 34,
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ use crate::migrations::migration::UserLocalDataMigration;
|
||||
use crate::migrations::sync_new_user::sync_user_data_to_cloud;
|
||||
use crate::migrations::MigrationUser;
|
||||
use crate::services::cloud_config::get_cloud_config;
|
||||
use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract};
|
||||
use crate::services::database::UserDB;
|
||||
use crate::services::entities::{ResumableSignUp, Session};
|
||||
use crate::services::user_awareness::UserAwarenessDataSource;
|
||||
@ -59,6 +60,7 @@ pub struct UserManager {
|
||||
pub(crate) user_awareness: Arc<Mutex<Option<MutexUserAwareness>>>,
|
||||
pub(crate) user_status_callback: RwLock<Arc<dyn UserStatusCallback>>,
|
||||
pub(crate) collab_builder: Weak<AppFlowyCollabBuilder>,
|
||||
pub(crate) collab_interact: RwLock<Arc<dyn CollabInteract>>,
|
||||
resumable_sign_up: Mutex<Option<ResumableSignUp>>,
|
||||
current_session: parking_lot::RwLock<Option<Session>>,
|
||||
}
|
||||
@ -82,6 +84,7 @@ impl UserManager {
|
||||
user_awareness: Arc::new(Default::default()),
|
||||
user_status_callback,
|
||||
collab_builder,
|
||||
collab_interact: RwLock::new(Arc::new(DefaultCollabInteract)),
|
||||
resumable_sign_up: Default::default(),
|
||||
current_session: Default::default(),
|
||||
});
|
||||
@ -114,7 +117,11 @@ impl UserManager {
|
||||
/// it will attempt a local data migration for the user. After ensuring the user's data is migrated and up-to-date,
|
||||
/// the function will set up the collaboration configuration and initialize the user's awareness. Upon successful
|
||||
/// completion, a user status callback is invoked to signify that the initialization process is complete.
|
||||
pub async fn init<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() {
|
||||
// Do the user data migration if needed
|
||||
match (
|
||||
@ -155,6 +162,7 @@ impl UserManager {
|
||||
}
|
||||
}
|
||||
*self.user_status_callback.write().await = Arc::new(user_status_callback);
|
||||
*self.collab_interact.write().await = Arc::new(collab_interact);
|
||||
}
|
||||
|
||||
pub fn db_connection(&self, uid: i64) -> Result<DBConnection, FlowyError> {
|
||||
|
25
frontend/rust-lib/flowy-user/src/services/collab_interact.rs
Normal file
25
frontend/rust-lib/flowy-user/src/services/collab_interact.rs
Normal 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(()) })
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
pub mod cloud_config;
|
||||
pub mod collab_interact;
|
||||
pub mod database;
|
||||
pub mod entities;
|
||||
pub(crate) mod historical_user;
|
||||
|
@ -30,9 +30,53 @@ impl UserManager {
|
||||
let reminder = Reminder::from(reminder_pb);
|
||||
self
|
||||
.with_awareness((), |user_awareness| {
|
||||
user_awareness.add_reminder(reminder);
|
||||
user_awareness.add_reminder(reminder.clone());
|
||||
})
|
||||
.await;
|
||||
self
|
||||
.collab_interact
|
||||
.read()
|
||||
.await
|
||||
.add_reminder(reminder)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a specific reminder for the user by its id
|
||||
///
|
||||
pub async fn remove_reminder(&self, reminder_id: &str) -> FlowyResult<()> {
|
||||
self
|
||||
.with_awareness((), |user_awareness| {
|
||||
user_awareness.remove_reminder(reminder_id);
|
||||
})
|
||||
.await;
|
||||
self
|
||||
.collab_interact
|
||||
.read()
|
||||
.await
|
||||
.remove_reminder(reminder_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates an existing reminder
|
||||
///
|
||||
pub async fn update_reminder(&self, reminder_pb: ReminderPB) -> FlowyResult<()> {
|
||||
let reminder = Reminder::from(reminder_pb);
|
||||
self
|
||||
.with_awareness((), |user_awareness| {
|
||||
user_awareness.update_reminder(&reminder.id, |new_reminder| {
|
||||
new_reminder.clone_from(&reminder)
|
||||
});
|
||||
})
|
||||
.await;
|
||||
self
|
||||
.collab_interact
|
||||
.read()
|
||||
.await
|
||||
.update_reminder(reminder)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -3,5 +3,4 @@ pub use async_trait;
|
||||
pub mod box_any;
|
||||
pub mod future;
|
||||
pub mod ref_map;
|
||||
pub mod retry;
|
||||
pub mod util;
|
||||
|
@ -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)
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
mod future;
|
||||
mod strategy;
|
||||
|
||||
pub use future::*;
|
||||
pub use strategy::*;
|
@ -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)));
|
||||
}
|
@ -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)));
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn jitter(duration: Duration) -> Duration {
|
||||
duration.mul_f64(rand::random::<f64>())
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
mod exponential_backoff;
|
||||
mod fixed_interval;
|
||||
mod jitter;
|
||||
|
||||
pub use exponential_backoff::*;
|
||||
pub use fixed_interval::*;
|
||||
pub use jitter::*;
|
Loading…
Reference in New Issue
Block a user