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