Explorar el Código

Fix release v0.1.1 known issues. (#2025)

* feat: optimize the smart edit user-experience

* chore: update language files

* feat: show discard dialog when users tap blank are that out of ai plugins

* fix: toolbar_service test fail
Lucas.Xu hace 2 años
padre
commit
61704f6d4a

+ 5 - 2
frontend/appflowy_flutter/assets/translations/en.json

@@ -347,11 +347,13 @@
       "autoGeneratorTitleName": "OpenAI: Ask AI to write anything...",
       "autoGeneratorLearnMore": "Learn more",
       "autoGeneratorGenerate": "Generate",
-      "autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
+      "autoGeneratorHintText": "Ask OpenAI ...",
       "autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key",
       "smartEdit": "Smart Edit",
       "smartEditTitleName": "OpenAI: Smart Edit",
+      "openAI": "OpenAI",
       "smartEditFixSpelling": "Fix spelling",
+      "warning": "⚠️ AI responses can be inaccurate or misleading.",
       "smartEditSummarize": "Summarize",
       "smartEditCouldNotFetchResult": "Could not fetch result from OpenAI",
       "smartEditCouldNotFetchKey": "Could not fetch OpenAI key",
@@ -363,7 +365,8 @@
         "abstract": "Abstract",
         "addCover": "Add Cover",
         "addLocalImage": "Add local image"
-      }
+      },
+      "discardResponse": "Do you want to discard the AI responses?"
     }
   },
   "board": {

+ 37 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart

@@ -2,6 +2,7 @@ import 'dart:convert';
 
 import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/util/learn_more_action.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/loading.dart';
 import 'package:appflowy/user/application/user_service.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
@@ -10,6 +11,7 @@ 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';
+import 'package:flutter/rendering.dart';
 import 'package:http/http.dart' as http;
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -56,6 +58,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
   final controller = TextEditingController();
   final focusNode = FocusNode();
   final textFieldFocusNode = FocusNode();
+  final interceptor = SelectionInterceptor();
 
   @override
   void initState() {
@@ -63,6 +66,34 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
 
     textFieldFocusNode.addListener(_onFocusChanged);
     textFieldFocusNode.requestFocus();
+    widget.editorState.service.selectionService.register(interceptor
+      ..canTap = (details) {
+        final renderBox = context.findRenderObject() as RenderBox?;
+        if (renderBox != null) {
+          if (!isTapDownDetailsInRenderBox(details, renderBox)) {
+            if (text.isNotEmpty || controller.text.isEmpty) {
+              showDialog(
+                context: context,
+                builder: (context) {
+                  return DiscardDialog(
+                    onConfirm: () => _onDiscard(),
+                    onCancel: () {},
+                  );
+                },
+              );
+            } else if (controller.text.isEmpty) {
+              _onExit();
+            }
+          }
+        }
+        return false;
+      });
+  }
+
+  bool isTapDownDetailsInRenderBox(TapDownDetails details, RenderBox box) {
+    var result = BoxHitTestResult();
+    box.hitTest(result, position: box.globalToLocal(details.globalPosition));
+    return result.path.any((entry) => entry.target == box);
   }
 
   @override
@@ -71,6 +102,7 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
     textFieldFocusNode.removeListener(_onFocusChanged);
     widget.editorState.service.selectionService.currentSelection
         .removeListener(_onCancelWhenSelectionChanged);
+    widget.editorState.service.selectionService.unRegister(interceptor);
 
     super.dispose();
   }
@@ -168,6 +200,11 @@ class _AutoCompletionInputState extends State<_AutoCompletionInput> {
           ),
           onPressed: () async => await _onExit(),
         ),
+        const Spacer(),
+        FlowyText.regular(
+          LocaleKeys.document_plugins_warning.tr(),
+          color: Theme.of(context).hintColor,
+        ),
       ],
     );
   }

+ 28 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart

@@ -0,0 +1,28 @@
+import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
+
+import 'package:flutter/material.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+
+import 'package:easy_localization/easy_localization.dart';
+
+class DiscardDialog extends StatelessWidget {
+  const DiscardDialog({
+    super.key,
+    required this.onConfirm,
+    required this.onCancel,
+  });
+
+  final VoidCallback onConfirm;
+  final VoidCallback onCancel;
+
+  @override
+  Widget build(BuildContext context) {
+    return NavigatorOkCancelDialog(
+      message: LocaleKeys.document_plugins_discardResponse.tr(),
+      okTitle: LocaleKeys.button_discard.tr(),
+      cancelTitle: LocaleKeys.button_Cancel.tr(),
+      onOkPressed: onConfirm,
+      onCancelPressed: onCancel,
+    );
+  }
+}

+ 10 - 6
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart

@@ -34,6 +34,15 @@ enum SmartEditAction {
     }
     return SmartEditAction.fixSpelling;
   }
+
+  String get name {
+    switch (this) {
+      case SmartEditAction.summarize:
+        return LocaleKeys.document_plugins_smartEditSummarize.tr();
+      case SmartEditAction.fixSpelling:
+        return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
+    }
+  }
 }
 
 class SmartEditActionWrapper extends ActionCell {
@@ -45,11 +54,6 @@ class SmartEditActionWrapper extends ActionCell {
 
   @override
   String get name {
-    switch (inner) {
-      case SmartEditAction.summarize:
-        return LocaleKeys.document_plugins_smartEditSummarize.tr();
-      case SmartEditAction.fixSpelling:
-        return LocaleKeys.document_plugins_smartEditFixSpelling.tr();
-    }
+    return inner.name;
   }
 }

+ 98 - 8
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/openai/widgets/smart_edit_node_widget.dart

@@ -1,11 +1,14 @@
+import 'dart:async';
+
 import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/util/learn_more_action.dart';
+import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/discard_dialog.dart';
 import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/smart_edit_action.dart';
 import 'package:appflowy/user/application/user_service.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:flowy_infra_ui/style_widget/button.dart';
-import 'package:flowy_infra_ui/style_widget/text.dart';
-import 'package:flowy_infra_ui/widget/spacing.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/decoration.dart';
 import 'package:flutter/material.dart';
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -26,7 +29,7 @@ class SmartEditInputBuilder extends NodeWidgetBuilder<Node> {
 
   @override
   Widget build(NodeWidgetContext<Node> context) {
-    return _SmartEditInput(
+    return _HoverSmartInput(
       key: context.node.key,
       node: context.node,
       editorState: context.editorState,
@@ -34,16 +37,98 @@ class SmartEditInputBuilder extends NodeWidgetBuilder<Node> {
   }
 }
 
-class _SmartEditInput extends StatefulWidget {
-  final Node node;
+class _HoverSmartInput extends StatefulWidget {
+  const _HoverSmartInput({
+    required super.key,
+    required this.node,
+    required this.editorState,
+  });
 
+  final Node node;
   final EditorState editorState;
+
+  @override
+  State<_HoverSmartInput> createState() => _HoverSmartInputState();
+}
+
+class _HoverSmartInputState extends State<_HoverSmartInput> {
+  final popoverController = PopoverController();
+  final key = GlobalKey(debugLabel: 'smart_edit_input');
+
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+      popoverController.show();
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final width = _maxWidth();
+
+    return AppFlowyPopover(
+      controller: popoverController,
+      direction: PopoverDirection.bottomWithLeftAligned,
+      triggerActions: PopoverTriggerFlags.none,
+      margin: EdgeInsets.zero,
+      constraints: BoxConstraints(maxWidth: width),
+      decoration: FlowyDecoration.decoration(
+        Colors.transparent,
+        Colors.transparent,
+      ),
+      child: const SizedBox(
+        width: double.infinity,
+      ),
+      canClose: () async {
+        final completer = Completer<bool>();
+        final state = key.currentState as _SmartEditInputState;
+        if (state.result.isEmpty) {
+          completer.complete(true);
+        } else {
+          showDialog(
+            context: context,
+            builder: (context) {
+              return DiscardDialog(
+                onConfirm: () => completer.complete(true),
+                onCancel: () => completer.complete(false),
+              );
+            },
+          );
+        }
+        return completer.future;
+      },
+      popupBuilder: (BuildContext popoverContext) {
+        return _SmartEditInput(
+          key: key,
+          node: widget.node,
+          editorState: widget.editorState,
+        );
+      },
+    );
+  }
+
+  double _maxWidth() {
+    var width = double.infinity;
+    final editorSize = widget.editorState.renderBox?.size;
+    final padding = widget.editorState.editorStyle.padding;
+    if (editorSize != null && padding != null) {
+      width = editorSize.width - padding.left - padding.right;
+    }
+    return width;
+  }
+}
+
+class _SmartEditInput extends StatefulWidget {
   const _SmartEditInput({
-    Key? key,
+    required super.key,
     required this.node,
     required this.editorState,
   });
 
+  final Node node;
+  final EditorState editorState;
+
   @override
   State<_SmartEditInput> createState() => _SmartEditInputState();
 }
@@ -108,7 +193,7 @@ class _SmartEditInputState extends State<_SmartEditInput> {
     return Row(
       children: [
         FlowyText.medium(
-          LocaleKeys.document_plugins_smartEditTitleName.tr(),
+          '${LocaleKeys.document_plugins_openAI.tr()}: ${action.name}',
           fontSize: 14,
         ),
         const Spacer(),
@@ -187,6 +272,11 @@ class _SmartEditInputState extends State<_SmartEditInput> {
           ),
           onPressed: () async => await _onExit(),
         ),
+        const Spacer(),
+        FlowyText.regular(
+          LocaleKeys.document_plugins_warning.tr(),
+          color: Theme.of(context).hintColor,
+        ),
       ],
     );
   }

+ 23 - 0
frontend/appflowy_flutter/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -82,6 +82,13 @@ abstract class AppFlowySelectionService {
 
   /// The current selection areas's rect in editor.
   List<Rect> get selectionRects;
+
+  void register(SelectionInterceptor interceptor);
+  void unRegister(SelectionInterceptor interceptor);
+}
+
+class SelectionInterceptor {
+  bool Function(TapDownDetails details)? canTap;
 }
 
 class AppFlowySelection extends StatefulWidget {
@@ -212,6 +219,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
 
     selectionRects.clear();
     clearSelection();
+    _clearToolbar();
 
     if (selection != null) {
       if (selection.isCollapsed) {
@@ -286,6 +294,10 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
   }
 
   void _onTapDown(TapDownDetails details) {
+    final canTap =
+        _interceptors.every((element) => element.canTap?.call(details) ?? true);
+    if (!canTap) return;
+
     // clear old state.
     _panStartOffset = null;
 
@@ -701,4 +713,15 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
     //   }
     // }
   }
+
+  final List<SelectionInterceptor> _interceptors = [];
+  @override
+  void register(SelectionInterceptor interceptor) {
+    _interceptors.add(interceptor);
+  }
+
+  @override
+  void unRegister(SelectionInterceptor interceptor) {
+    _interceptors.removeWhere((element) => element == interceptor);
+  }
 }

+ 3 - 0
frontend/appflowy_flutter/packages/appflowy_editor/test/service/toolbar_service_test.dart

@@ -94,6 +94,7 @@ void main() async {
       await editor.updateSelection(
         Selection.single(path: [1], startOffset: 0, endOffset: text.length * 2),
       );
+      await tester.pumpAndSettle(const Duration(milliseconds: 500));
       testHighlight(false);
 
       await editor.updateSelection(
@@ -103,6 +104,7 @@ void main() async {
           endOffset: text.length * 2,
         ),
       );
+      await tester.pumpAndSettle(const Duration(milliseconds: 500));
       testHighlight(true);
 
       await editor.updateSelection(
@@ -112,6 +114,7 @@ void main() async {
           endOffset: text.length * 2 - 2,
         ),
       );
+      await tester.pumpAndSettle(const Duration(milliseconds: 500));
       testHighlight(true);
     });
 

+ 8 - 1
frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart

@@ -72,6 +72,7 @@ class Popover extends StatefulWidget {
   final PopoverDirection direction;
 
   final void Function()? onClose;
+  final Future<bool> Function()? canClose;
 
   final bool asBarrier;
 
@@ -92,6 +93,7 @@ class Popover extends StatefulWidget {
     this.mutex,
     this.windowPadding,
     this.onClose,
+    this.canClose,
     this.asBarrier = false,
   }) : super(key: key);
 
@@ -122,7 +124,12 @@ class PopoverState extends State<Popover> {
         children.add(
           PopoverMask(
             decoration: widget.maskDecoration,
-            onTap: () => _removeRootOverlay(),
+            onTap: () async {
+              if (!(await widget.canClose?.call() ?? true)) {
+                return;
+              }
+              _removeRootOverlay();
+            },
             onExit: () => _removeRootOverlay(),
           ),
         );

+ 14 - 4
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart

@@ -10,11 +10,13 @@ class AppFlowyPopover extends StatelessWidget {
   final int triggerActions;
   final BoxConstraints constraints;
   final void Function()? onClose;
+  final Future<bool> Function()? canClose;
   final PopoverMutex? mutex;
   final Offset? offset;
   final bool asBarrier;
   final EdgeInsets margin;
   final EdgeInsets windowPadding;
+  final Decoration? decoration;
 
   const AppFlowyPopover({
     Key? key,
@@ -22,6 +24,7 @@ class AppFlowyPopover extends StatelessWidget {
     required this.popupBuilder,
     this.direction = PopoverDirection.rightWithTopAligned,
     this.onClose,
+    this.canClose,
     this.constraints = const BoxConstraints(maxWidth: 240, maxHeight: 600),
     this.mutex,
     this.triggerActions = PopoverTriggerFlags.click,
@@ -30,6 +33,7 @@ class AppFlowyPopover extends StatelessWidget {
     this.asBarrier = false,
     this.margin = const EdgeInsets.all(6),
     this.windowPadding = const EdgeInsets.all(8.0),
+    this.decoration,
   }) : super(key: key);
 
   @override
@@ -37,6 +41,7 @@ class AppFlowyPopover extends StatelessWidget {
     return Popover(
       controller: controller,
       onClose: onClose,
+      canClose: canClose,
       direction: direction,
       mutex: mutex,
       asBarrier: asBarrier,
@@ -49,6 +54,7 @@ class AppFlowyPopover extends StatelessWidget {
         return _PopoverContainer(
           constraints: constraints,
           margin: margin,
+          decoration: decoration,
           child: child,
         );
       },
@@ -61,19 +67,23 @@ class _PopoverContainer extends StatelessWidget {
   final Widget child;
   final BoxConstraints constraints;
   final EdgeInsets margin;
+  final Decoration? decoration;
+
   const _PopoverContainer({
     required this.child,
     required this.margin,
     required this.constraints,
+    required this.decoration,
     Key? key,
   }) : super(key: key);
 
   @override
   Widget build(BuildContext context) {
-    final decoration = FlowyDecoration.decoration(
-      Theme.of(context).colorScheme.surface,
-      Theme.of(context).colorScheme.shadow.withOpacity(0.15),
-    );
+    final decoration = this.decoration ??
+        FlowyDecoration.decoration(
+          Theme.of(context).colorScheme.surface,
+          Theme.of(context).colorScheme.shadow.withOpacity(0.15),
+        );
 
     return Material(
       type: MaterialType.transparency,