Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74b0bf6e1 | ||
|
|
913924d8d3 | ||
|
|
7bc9ce4391 | ||
|
|
ed5334a7d6 | ||
|
|
120f22c6fc | ||
|
|
f8a17dac00 | ||
|
|
7f41feb959 | ||
|
|
da7f584da7 | ||
|
|
031a88f4c4 | ||
|
|
e2ff12415c | ||
|
|
edd59cf462 | ||
|
|
b52f681e3c | ||
|
|
5d33d723e9 | ||
|
|
5e1a8b1ec7 | ||
|
|
3c99105b23 | ||
|
|
0b298ea426 | ||
|
|
7ed36f8736 | ||
|
|
34d2b7f24e | ||
|
|
a2303d35e8 |
No files matched your search
@@ -1,4 +1,15 @@
|
||||
# Release Notes
|
||||
## Version 0.8.8 - 01/04/2025
|
||||
### New Features
|
||||
- Added support for selecting AI models in AI writer
|
||||
- Revamped link menu in toolbar
|
||||
- Added support for using ":" to add emojis in documents
|
||||
- Passed the history of past AI prompts and responses to AI writer
|
||||
### Bug Fixes
|
||||
- Improved AI writer scrolling user experience
|
||||
- Fixed issue where checklist items would disappear during reordering
|
||||
- Fixed numbered lists generated by AI to maintain the same index as the input
|
||||
|
||||
## Version 0.8.7 - 18/03/2025
|
||||
### New Features
|
||||
- Made local AI free and integrated with Ollama
|
||||
|
||||
+40
@@ -184,6 +184,45 @@ void main() {
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('toolbar will not rebuild after click item', (tester) async {
|
||||
const text = 'Test rebuilding';
|
||||
await prepareForToolbar(tester, text);
|
||||
Finder toolbar = find.byType(DesktopFloatingToolbar);
|
||||
Element toolbarElement = toolbar.evaluate().first;
|
||||
final elementHashcode = toolbarElement.hashCode;
|
||||
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
|
||||
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
|
||||
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
|
||||
|
||||
/// tap format buttons
|
||||
await tester.tapButton(boldButton);
|
||||
await tester.tapButton(underlineButton);
|
||||
await tester.tapButton(italicButton);
|
||||
toolbar = find.byType(DesktopFloatingToolbar);
|
||||
toolbarElement = toolbar.evaluate().first;
|
||||
|
||||
/// check if the toolbar is not rebuilt
|
||||
expect(elementHashcode, toolbarElement.hashCode);
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
|
||||
/// check text formats
|
||||
expect(
|
||||
editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('document toolbar: link', () {
|
||||
@@ -228,6 +267,7 @@ void main() {
|
||||
expect(toolbar, findsNothing);
|
||||
|
||||
/// show toolbar again
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await selectText(tester, text);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
|
||||
+4
-11
@@ -43,12 +43,12 @@ void main() {
|
||||
});
|
||||
|
||||
group('insert emoji by colon', () {
|
||||
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
|
||||
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(':');
|
||||
await tester.ime.insertText(':${search ?? 'a'}');
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
@@ -106,11 +106,10 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('insert with searching', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
await createNewDocumentAndShowEmojiList(tester, search: 's');
|
||||
|
||||
/// search for `smiling eyes`, IME is not working, use keyboard input
|
||||
final searchText = [
|
||||
LogicalKeyboardKey.keyS,
|
||||
LogicalKeyboardKey.keyM,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyL,
|
||||
@@ -138,16 +137,10 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('start searching with sapce', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
await createNewDocumentAndShowEmojiList(tester, search: ' ');
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsOneWidget);
|
||||
|
||||
/// input space
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.space);
|
||||
|
||||
/// emoji list is dismissed
|
||||
expect(emojiHandler, findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -307,9 +307,11 @@ class EditorOperations {
|
||||
Future<void> openTurnIntoMenu(Path path) async {
|
||||
await hoverAndClickOptionMenuButton(path);
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
|
||||
),
|
||||
find
|
||||
.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
|
||||
}
|
||||
|
||||
+17
-10
@@ -23,6 +23,7 @@ class DesktopPromptInput extends StatefulWidget {
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
this.hideDecoration = false,
|
||||
this.hideFormats = false,
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
@@ -34,6 +35,7 @@ class DesktopPromptInput extends StatefulWidget {
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
final bool hideDecoration;
|
||||
final bool hideFormats;
|
||||
final Widget? extraBottomActionButton;
|
||||
|
||||
@override
|
||||
@@ -139,11 +141,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: getTextFieldConstraints(
|
||||
state.showPredefinedFormats,
|
||||
state.showPredefinedFormats && !widget.hideFormats,
|
||||
),
|
||||
child: inputTextField(),
|
||||
),
|
||||
if (state.showPredefinedFormats)
|
||||
if (state.showPredefinedFormats && !widget.hideFormats)
|
||||
Positioned.fill(
|
||||
bottom: null,
|
||||
child: TextFieldTapRegion(
|
||||
@@ -168,8 +170,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
top: null,
|
||||
child: TextFieldTapRegion(
|
||||
child: _PromptBottomActions(
|
||||
showPredefinedFormats:
|
||||
showPredefinedFormatBar:
|
||||
state.showPredefinedFormats,
|
||||
showPredefinedFormatButton: !widget.hideFormats,
|
||||
onTogglePredefinedFormatSection: () =>
|
||||
context.read<AIPromptInputBloc>().add(
|
||||
AIPromptInputEvent
|
||||
@@ -571,7 +574,8 @@ class PromptInputTextField extends StatelessWidget {
|
||||
class _PromptBottomActions extends StatelessWidget {
|
||||
const _PromptBottomActions({
|
||||
required this.sendButtonState,
|
||||
required this.showPredefinedFormats,
|
||||
required this.showPredefinedFormatBar,
|
||||
required this.showPredefinedFormatButton,
|
||||
required this.onTogglePredefinedFormatSection,
|
||||
required this.onStartMention,
|
||||
required this.onSendPressed,
|
||||
@@ -581,7 +585,8 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
final bool showPredefinedFormats;
|
||||
final bool showPredefinedFormatBar;
|
||||
final bool showPredefinedFormatButton;
|
||||
final void Function() onTogglePredefinedFormatSection;
|
||||
final void Function() onStartMention;
|
||||
final SendButtonState sendButtonState;
|
||||
@@ -600,10 +605,12 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
children: [
|
||||
_predefinedFormatButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
if (showPredefinedFormatButton) ...[
|
||||
_predefinedFormatButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
],
|
||||
SelectModelMenu(
|
||||
aiModelStateNotifier:
|
||||
context.read<AIPromptInputBloc>().aiModelStateNotifier,
|
||||
@@ -641,7 +648,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
|
||||
Widget _predefinedFormatButton() {
|
||||
return PromptInputDesktopToggleFormatButton(
|
||||
showFormatBar: showPredefinedFormats,
|
||||
showFormatBar: showPredefinedFormatBar,
|
||||
onTap: onTogglePredefinedFormatSection,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,6 @@ class _ModelItem extends StatelessWidget {
|
||||
model.i18n,
|
||||
figmaLineHeight: 20,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
if (model.desc.isNotEmpty)
|
||||
FlowyText(
|
||||
|
||||
@@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0;
|
||||
|
||||
const double _kDefaultHorizontalPadding = 12.0;
|
||||
|
||||
typedef CompareFunction<T> = bool Function(T? left, T? right);
|
||||
|
||||
// Navigation shortcuts to move the selected menu items up or down.
|
||||
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
|
||||
<ShortcutActivator, Intent>{
|
||||
@@ -86,6 +88,7 @@ class AFDropdownMenu<T> extends StatefulWidget {
|
||||
this.requestFocusOnTap,
|
||||
this.expandedInsets,
|
||||
this.searchCallback,
|
||||
this.selectOptionCompare,
|
||||
required this.dropdownMenuEntries,
|
||||
});
|
||||
|
||||
@@ -267,6 +270,11 @@ class AFDropdownMenu<T> extends StatefulWidget {
|
||||
/// which contains the contents of the text input field.
|
||||
final SearchCallback<T>? searchCallback;
|
||||
|
||||
/// Defines the compare function for the menu items.
|
||||
///
|
||||
/// Defaults to null. If this is null, the menu items will be sorted by the label.
|
||||
final CompareFunction<T>? selectOptionCompare;
|
||||
|
||||
@override
|
||||
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
|
||||
}
|
||||
@@ -301,7 +309,16 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
|
||||
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
|
||||
|
||||
final int index = filteredEntries.indexWhere(
|
||||
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
|
||||
(DropdownMenuEntry<T> entry) {
|
||||
if (widget.selectOptionCompare != null) {
|
||||
return widget.selectOptionCompare!(
|
||||
entry.value,
|
||||
widget.initialSelection,
|
||||
);
|
||||
} else {
|
||||
return entry.value == widget.initialSelection;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (index != -1) {
|
||||
_textEditingController.value = TextEditingValue(
|
||||
|
||||
+15
-1
@@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem {
|
||||
members,
|
||||
trash,
|
||||
help,
|
||||
helpAndDocumentation,
|
||||
}
|
||||
|
||||
class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
@@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
|
||||
),
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
|
||||
svg: FlowySvgs.help_and_documentation_s,
|
||||
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
|
||||
),
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _MobileSettingsPopupMenuItem.help,
|
||||
svg: FlowySvgs.message_support_s,
|
||||
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
|
||||
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
|
||||
),
|
||||
],
|
||||
onSelected: (_MobileSettingsPopupMenuItem value) {
|
||||
@@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
case _MobileSettingsPopupMenuItem.help:
|
||||
_openHelpPage(context);
|
||||
break;
|
||||
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
|
||||
_openHelpAndDocumentationPage(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: const Padding(
|
||||
@@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
void _openSettingsPage(BuildContext context) {
|
||||
context.push(MobileHomeSettingPage.routeName);
|
||||
}
|
||||
|
||||
void _openHelpAndDocumentationPage(BuildContext context) {
|
||||
afLaunchUrlString('https://appflowy.com/guide');
|
||||
}
|
||||
}
|
||||
|
||||
class _PopupButton extends StatelessWidget {
|
||||
|
||||
+8
-12
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
|
||||
@@ -16,6 +14,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../editable_cell_skeleton/checklist.dart';
|
||||
|
||||
@@ -201,19 +200,16 @@ class _ChecklistItems extends StatelessWidget {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
proxyDecorator: (child, index, _) => Material(
|
||||
color: Colors.transparent,
|
||||
child: Stack(
|
||||
children: [
|
||||
BlocProvider.value(
|
||||
child: MouseRegion(
|
||||
cursor: UniversalPlatform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: IgnorePointer(
|
||||
child: BlocProvider.value(
|
||||
value: context.read<ChecklistCellBloc>(),
|
||||
child: child,
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: Platform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
buildDefaultDragHandles: false,
|
||||
|
||||
+8
-10
@@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../application/cell/bloc/checklist_cell_bloc.dart';
|
||||
import 'checklist_cell_textfield.dart';
|
||||
@@ -125,19 +126,16 @@ class ChecklistItemList extends StatelessWidget {
|
||||
shrinkWrap: true,
|
||||
proxyDecorator: (child, index, _) => Material(
|
||||
color: Colors.transparent,
|
||||
child: Stack(
|
||||
children: [
|
||||
BlocProvider.value(
|
||||
child: MouseRegion(
|
||||
cursor: UniversalPlatform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: IgnorePointer(
|
||||
child: BlocProvider.value(
|
||||
value: context.read<ChecklistCellBloc>(),
|
||||
child: child,
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: Platform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
buildDefaultDragHandles: false,
|
||||
|
||||
@@ -272,7 +272,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
}
|
||||
|
||||
if (options.inMemoryUpdate) {
|
||||
Log.trace('skip transaction for in-memory update');
|
||||
if (enableDocumentInternalLog) {
|
||||
Log.trace('skip transaction for in-memory update');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -432,44 +432,50 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: FloatingToolbar(
|
||||
floatingToolbarHeight: 40,
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
style: FloatingToolbarStyle(
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
toolbarActiveColor: Color(0xffe0f8fd),
|
||||
),
|
||||
items: toolbarItems,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: Offset(0, 4),
|
||||
blurRadius: 24,
|
||||
color: themeV2.shadow_medium,
|
||||
child: BlocProvider.value(
|
||||
value: context.read<DocumentBloc>(),
|
||||
child: FloatingToolbar(
|
||||
floatingToolbarHeight: 40,
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
style: FloatingToolbarStyle(
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
toolbarActiveColor: Color(0xffe0f8fd),
|
||||
),
|
||||
items: toolbarItems,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: Offset(0, 4),
|
||||
blurRadius: 24,
|
||||
color: themeV2.shadow_medium,
|
||||
),
|
||||
],
|
||||
),
|
||||
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
|
||||
BlocProvider.value(
|
||||
value: context.read<DocumentBloc>(),
|
||||
child: DesktopFloatingToolbar(
|
||||
editorState: editorState,
|
||||
onDismiss: onDismiss,
|
||||
enableAnimation: !isMetricsChanged,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
toolbarBuilder: (context, child, onDismiss, isMetricsChanged) =>
|
||||
DesktopFloatingToolbar(
|
||||
),
|
||||
placeHolderBuilder: (_) => customPlaceholderItem,
|
||||
editorState: editorState,
|
||||
onDismiss: onDismiss,
|
||||
enableAnimation: !isMetricsChanged,
|
||||
child: child,
|
||||
editorScrollController: editorScrollController,
|
||||
textDirection: textDirection,
|
||||
tooltipBuilder: (context, id, message, child) =>
|
||||
widget.styleCustomizer.buildToolbarItemTooltip(
|
||||
context,
|
||||
id,
|
||||
message,
|
||||
child,
|
||||
),
|
||||
child: editor,
|
||||
),
|
||||
placeHolderBuilder: (_) => customPlaceholderItem,
|
||||
editorState: editorState,
|
||||
editorScrollController: editorScrollController,
|
||||
textDirection: textDirection,
|
||||
tooltipBuilder: (context, id, message, child) =>
|
||||
widget.styleCustomizer.buildToolbarItemTooltip(
|
||||
context,
|
||||
id,
|
||||
message,
|
||||
child,
|
||||
),
|
||||
child: editor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+6
@@ -490,6 +490,12 @@ class MainContentArea extends StatelessWidget {
|
||||
return DesktopPromptInput(
|
||||
isStreaming: false,
|
||||
hideDecoration: true,
|
||||
hideFormats: [
|
||||
AiWriterCommand.fixSpellingAndGrammar,
|
||||
AiWriterCommand.improveWriting,
|
||||
AiWriterCommand.makeLonger,
|
||||
AiWriterCommand.makeShorter,
|
||||
].contains(state.command),
|
||||
textController: textController,
|
||||
onSubmitted: (message, format, _) {
|
||||
cubit.runCommand(state.command, message, format);
|
||||
|
||||
+19
-4
@@ -208,11 +208,26 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
|
||||
throttler.call(() {
|
||||
if (aiWriterCubit.aiWriterNode != null) {
|
||||
final path = aiWriterCubit.aiWriterNode!.path;
|
||||
if (path.isNotEmpty) {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: path)),
|
||||
);
|
||||
|
||||
if (path.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.previous.isNotEmpty) {
|
||||
final node = widget.editorState.getNodeAtPath(path.previous);
|
||||
if (node != null && node.delta != null && node.delta!.isNotEmpty) {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(
|
||||
Position(path: path, offset: node.delta!.length),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: path)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+50
-12
@@ -1,10 +1,9 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'toolbar_animation.dart';
|
||||
import 'toolbar_cubit.dart';
|
||||
|
||||
class DesktopFloatingToolbar extends StatefulWidget {
|
||||
const DesktopFloatingToolbar({
|
||||
@@ -28,6 +27,7 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
_Position? position;
|
||||
final toolbarController = getIt<FloatingToolbarController>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,24 +39,32 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
|
||||
final selectionRect = editorState.selectionRects();
|
||||
if (selectionRect.isEmpty) return;
|
||||
position = calculateSelectionMenuOffset(selectionRect.first);
|
||||
toolbarController._addCallback(dismiss);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
toolbarController._removeCallback(dismiss);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (position == null) return Container();
|
||||
return BlocProvider<ToolbarCubit>(
|
||||
create: (_) => ToolbarCubit(widget.onDismiss),
|
||||
child: Positioned(
|
||||
left: position!.left,
|
||||
top: position!.top,
|
||||
right: position!.right,
|
||||
child: widget.enableAnimation
|
||||
? ToolbarAnimationWidget(child: widget.child)
|
||||
: widget.child,
|
||||
),
|
||||
return Positioned(
|
||||
left: position!.left,
|
||||
top: position!.top,
|
||||
right: position!.right,
|
||||
child: widget.enableAnimation
|
||||
? ToolbarAnimationWidget(child: widget.child)
|
||||
: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void dismiss() {
|
||||
widget.onDismiss.call();
|
||||
}
|
||||
|
||||
_Position calculateSelectionMenuOffset(
|
||||
Rect rect,
|
||||
) {
|
||||
@@ -92,3 +100,33 @@ class _Position {
|
||||
final double? top;
|
||||
final double? right;
|
||||
}
|
||||
|
||||
class FloatingToolbarController {
|
||||
final Set<VoidCallback> _dismissCallbacks = {};
|
||||
final Set<VoidCallback> _displayListeners = {};
|
||||
|
||||
void _addCallback(VoidCallback callback) {
|
||||
_dismissCallbacks.add(callback);
|
||||
for (final listener in Set.of(_displayListeners)) {
|
||||
listener.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _removeCallback(VoidCallback callback) =>
|
||||
_dismissCallbacks.remove(callback);
|
||||
|
||||
bool get isToolbarShowing => _dismissCallbacks.isNotEmpty;
|
||||
|
||||
void addDisplayListener(VoidCallback listener) =>
|
||||
_displayListeners.add(listener);
|
||||
|
||||
void removeDisplayListener(VoidCallback listener) =>
|
||||
_displayListeners.remove(listener);
|
||||
|
||||
void hideToolbar() {
|
||||
if (_dismissCallbacks.isEmpty) return;
|
||||
for (final callback in _dismissCallbacks) {
|
||||
callback.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-21
@@ -19,11 +19,15 @@ class LinkCreateMenu extends StatefulWidget {
|
||||
required this.onSubmitted,
|
||||
required this.onDismiss,
|
||||
required this.alignment,
|
||||
required this.currentViewId,
|
||||
required this.initialText,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final void Function(String link, bool isPage) onSubmitted;
|
||||
final VoidCallback onDismiss;
|
||||
final String currentViewId;
|
||||
final String initialText;
|
||||
final LinkMenuAlignment alignment;
|
||||
|
||||
@override
|
||||
@@ -32,6 +36,8 @@ class LinkCreateMenu extends StatefulWidget {
|
||||
|
||||
class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
late LinkSearchTextField searchTextField = LinkSearchTextField(
|
||||
currentViewId: widget.currentViewId,
|
||||
initialSearchText: widget.initialText,
|
||||
onEnter: () {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: () => onSubmittedLink(),
|
||||
@@ -48,17 +54,28 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
},
|
||||
);
|
||||
|
||||
bool get isButtonEnable => searchText.isNotEmpty;
|
||||
bool get isTextfieldEnable => searchTextField.isTextfieldEnable;
|
||||
|
||||
String get searchText => searchTextField.searchText;
|
||||
|
||||
bool get showAtTop => widget.alignment.isTop;
|
||||
|
||||
bool showErrorText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
searchTextField.requestFocus();
|
||||
searchTextField.searchRecentViews();
|
||||
final focusNode = searchTextField.focusNode;
|
||||
bool hasFocus = focusNode.hasFocus;
|
||||
focusNode.addListener(() {
|
||||
if (hasFocus != focusNode.hasFocus && mounted) {
|
||||
setState(() {
|
||||
hasFocus = focusNode.hasFocus;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -98,32 +115,45 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
Widget buildSearchContainer() {
|
||||
return Container(
|
||||
width: 320,
|
||||
height: 48,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: searchTextField.textEditingController,
|
||||
builder: (context, _, __) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: searchTextField.buildTextField()),
|
||||
HSpace(8),
|
||||
FlowyTextButton(
|
||||
LocaleKeys.document_toolbar_insert.tr(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
|
||||
fontSize: 14,
|
||||
fontColor:
|
||||
isButtonEnable ? Colors.white : LinkStyle.textTertiary,
|
||||
fillColor: isButtonEnable
|
||||
? LinkStyle.fillThemeThick
|
||||
: LinkStyle.borderColor,
|
||||
hoverColor: LinkStyle.fillThemeThick,
|
||||
lineHeight: 20 / 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
onPressed: isButtonEnable ? () => onSubmittedLink() : null,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: searchTextField.buildTextField(context: context),
|
||||
),
|
||||
HSpace(8),
|
||||
FlowyTextButton(
|
||||
LocaleKeys.document_toolbar_insert.tr(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
|
||||
fontSize: 14,
|
||||
fontColor: Colors.white,
|
||||
fillColor: LinkStyle.fillThemeThick,
|
||||
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
|
||||
lineHeight: 20 / 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
onPressed: onSubmittedLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showErrorText)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
|
||||
color: LinkStyle.textStatusError,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -131,7 +161,15 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
void onSubmittedLink() => widget.onSubmitted(searchText, false);
|
||||
void onSubmittedLink() {
|
||||
if (!isTextfieldEnable) {
|
||||
setState(() {
|
||||
showErrorText = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
widget.onSubmitted(searchText, false);
|
||||
}
|
||||
|
||||
void onSubmittedPageLink(ViewPB view) async {
|
||||
final workspaceId = context
|
||||
@@ -152,13 +190,16 @@ void showLinkCreateMenu(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
String currentViewId,
|
||||
) {
|
||||
if (!context.mounted) return;
|
||||
final (left, top, right, bottom, alignment) = _getPosition(editorState);
|
||||
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final selectedText = editorState.getTextInSelection(selection).join();
|
||||
|
||||
OverlayEntry? overlay;
|
||||
|
||||
@@ -178,12 +219,18 @@ void showLinkCreateMenu(
|
||||
builder: (context) {
|
||||
return LinkCreateMenu(
|
||||
alignment: alignment,
|
||||
initialText: selectedText,
|
||||
currentViewId: currentViewId,
|
||||
editorState: editorState,
|
||||
onSubmitted: (link, isPage) async {
|
||||
await editorState.formatDelta(selection, {
|
||||
BuiltInAttributeKey.href: link,
|
||||
kIsPageLink: isPage,
|
||||
});
|
||||
await editorState.updateSelectionWithReason(
|
||||
null,
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
);
|
||||
dismissOverlay();
|
||||
},
|
||||
onDismiss: dismissOverlay,
|
||||
|
||||
+146
-89
@@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -10,7 +11,8 @@ import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/editor/util/link_util.dart';
|
||||
import 'link_create_menu.dart';
|
||||
import 'link_search_text_field.dart';
|
||||
import 'link_styles.dart';
|
||||
@@ -22,12 +24,14 @@ class LinkEditMenu extends StatefulWidget {
|
||||
required this.onDismiss,
|
||||
required this.onApply,
|
||||
required this.onRemoveLink,
|
||||
required this.currentViewId,
|
||||
});
|
||||
|
||||
final LinkInfo linkInfo;
|
||||
final ValueChanged<LinkInfo> onApply;
|
||||
final VoidCallback onRemoveLink;
|
||||
final ValueChanged<LinkInfo> onRemoveLink;
|
||||
final VoidCallback onDismiss;
|
||||
final String currentViewId;
|
||||
|
||||
@override
|
||||
State<LinkEditMenu> createState() => _LinkEditMenuState();
|
||||
@@ -36,7 +40,7 @@ class LinkEditMenu extends StatefulWidget {
|
||||
class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
ValueChanged<LinkInfo> get onApply => widget.onApply;
|
||||
|
||||
VoidCallback get onRemoveLink => widget.onRemoveLink;
|
||||
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
|
||||
|
||||
VoidCallback get onDismiss => widget.onDismiss;
|
||||
|
||||
@@ -47,9 +51,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
late LinkSearchTextField searchTextField;
|
||||
bool isShowingSearchResult = false;
|
||||
ViewPB? currentView;
|
||||
|
||||
bool get enableApply =>
|
||||
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
|
||||
bool showErrorText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -58,18 +60,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
if (isPageLink) getPageView();
|
||||
searchTextField = LinkSearchTextField(
|
||||
initialSearchText: isPageLink ? '' : linkInfo.link,
|
||||
onEnter: () {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: onLinkSelected,
|
||||
onRecentViews: () =>
|
||||
onPageSelected(searchTextField.currentRecentView),
|
||||
onSearchViews: () =>
|
||||
onPageSelected(searchTextField.currentSearchedView),
|
||||
onEmpty: () {
|
||||
searchTextField.unfocus();
|
||||
},
|
||||
);
|
||||
},
|
||||
initialViewId: linkInfo.viewId,
|
||||
currentViewId: widget.currentViewId,
|
||||
onEnter: onConfirm,
|
||||
onEscape: () {
|
||||
if (isShowingSearchResult) {
|
||||
hideSearchResult();
|
||||
@@ -95,6 +88,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
Widget build(BuildContext context) {
|
||||
final showingRecent =
|
||||
searchTextField.showingRecent && isShowingSearchResult;
|
||||
final errorHeight = showErrorText ? 20.0 : 0.0;
|
||||
return GestureDetector(
|
||||
onTap: onDismiss,
|
||||
child: Container(
|
||||
@@ -107,9 +101,8 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192,
|
||||
height: 192 + errorHeight,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -123,7 +116,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 80,
|
||||
top: 80 + errorHeight,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
@@ -133,12 +126,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 152,
|
||||
top: 144 + errorHeight,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 108,
|
||||
top: 100 + errorHeight,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
@@ -155,29 +148,53 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
|
||||
Widget buildLinkField() {
|
||||
final showPageView = linkInfo.isPage && !isShowingSearchResult;
|
||||
if (showPageView) return buildPageView();
|
||||
if (!isShowingSearchResult) return buildLinkView();
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 360,
|
||||
height: 32,
|
||||
child: searchTextField.buildTextField(
|
||||
autofocus: true,
|
||||
Widget child;
|
||||
if (showPageView) {
|
||||
child = buildPageView();
|
||||
} else if (!isShowingSearchResult) {
|
||||
child = buildLinkView();
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 360,
|
||||
height: 32,
|
||||
child: searchTextField.buildTextField(
|
||||
autofocus: true,
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
VSpace(6),
|
||||
searchTextField.buildResultContainer(
|
||||
context: context,
|
||||
width: 360,
|
||||
onPageLinkSelected: onPageSelected,
|
||||
onLinkSelected: onLinkSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
child,
|
||||
if (showErrorText)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
|
||||
color: LinkStyle.textStatusError,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
VSpace(6),
|
||||
searchTextField.buildResultContainer(
|
||||
context: context,
|
||||
width: 360,
|
||||
onPageLinkSelected: onPageSelected,
|
||||
onLinkSelected: onLinkSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,15 +214,15 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
preferBelow: false,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
border: Border.all(color: LinkStyle.borderColor(context)),
|
||||
),
|
||||
onPressed: onRemoveLink,
|
||||
onPressed: () => onRemoveLink.call(linkInfo),
|
||||
),
|
||||
Spacer(),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
border: Border.all(color: LinkStyle.borderColor(context)),
|
||||
),
|
||||
child: FlowyTextButton(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
@@ -214,7 +231,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
|
||||
fontSize: 14,
|
||||
lineHeight: 20 / 14,
|
||||
fontColor: LinkStyle.textPrimary,
|
||||
fontColor: Theme.of(context).isLightMode
|
||||
? LinkStyle.textPrimary
|
||||
: Theme.of(context).iconTheme.color,
|
||||
fillColor: Colors.transparent,
|
||||
fontWeight: FontWeight.w400,
|
||||
onPressed: onDismiss,
|
||||
@@ -232,14 +251,26 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
fontSize: 14,
|
||||
lineHeight: 20 / 14,
|
||||
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
|
||||
fontColor:
|
||||
enableApply ? Colors.white : LinkStyle.textTertiary,
|
||||
fillColor: enableApply
|
||||
? LinkStyle.fillThemeThick
|
||||
: LinkStyle.borderColor,
|
||||
fontColor: Colors.white,
|
||||
fillColor: LinkStyle.fillThemeThick,
|
||||
fontWeight: FontWeight.w400,
|
||||
onPressed:
|
||||
enableApply ? () => widget.onApply.call(linkInfo) : null,
|
||||
onPressed: () {
|
||||
if (isShowingSearchResult) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
if (linkInfo.link.isEmpty) {
|
||||
widget.onRemoveLink(linkInfo);
|
||||
return;
|
||||
}
|
||||
if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) {
|
||||
setState(() {
|
||||
showErrorText = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
widget.onApply.call(linkInfo);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -272,6 +303,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
},
|
||||
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
|
||||
LocaleKeys.document_toolbar_linkNameHint.tr(),
|
||||
context,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -288,24 +320,34 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final viewName = view.name;
|
||||
final displayName = viewName.isEmpty
|
||||
? LocaleKeys.document_title_placeholder.tr()
|
||||
: viewName;
|
||||
child = GestureDetector(
|
||||
onTap: showSearchResult,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
searchTextField.buildIcon(view),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: FlowyTooltip(
|
||||
preferBelow: false,
|
||||
message: displayName,
|
||||
child: Container(
|
||||
height: 32,
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
searchTextField.buildIcon(view),
|
||||
HSpace(4),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
figmaLineHeight: 20,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -324,24 +366,28 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
width: 360,
|
||||
height: 32,
|
||||
decoration: buildDecoration(),
|
||||
child: GestureDetector(
|
||||
onTap: showSearchResult,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(FlowySvgs.toolbar_link_earth_m),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
linkInfo.link,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
figmaLineHeight: 20,
|
||||
child: FlowyTooltip(
|
||||
preferBelow: false,
|
||||
message: linkInfo.link,
|
||||
child: GestureDetector(
|
||||
onTap: showSearchResult,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(FlowySvgs.toolbar_link_earth_m),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: FlowyText.regular(
|
||||
linkInfo.link,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -349,12 +395,21 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
void onConfirm() {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: onLinkSelected,
|
||||
onRecentViews: () => onPageSelected(searchTextField.currentRecentView),
|
||||
onSearchViews: () => onPageSelected(searchTextField.currentSearchedView),
|
||||
onEmpty: () {
|
||||
searchTextField.unfocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> getPageView() async {
|
||||
if (!linkInfo.isPage) return;
|
||||
final link = linkInfo.link;
|
||||
final viewId = link.split('/').lastOrNull ?? '';
|
||||
final (view, isInTrash, isDeleted) =
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
await ViewBackendService.getMentionPageStatus(linkInfo.viewId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
currentView = view;
|
||||
@@ -413,7 +468,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
|
||||
BoxDecoration buildDecoration() => BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
border: Border.all(color: LinkStyle.borderColor(context)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,4 +481,6 @@ class LinkInfo {
|
||||
|
||||
Attributes toAttribute() =>
|
||||
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
|
||||
|
||||
String get viewId => isPage ? link.split('/').lastOrNull ?? '' : '';
|
||||
}
|
||||
+133
-38
@@ -3,20 +3,25 @@ import 'dart:math';
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.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/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'link_create_menu.dart';
|
||||
import 'link_edit_menu.dart';
|
||||
import 'link_styles.dart';
|
||||
|
||||
class LinkHoverTrigger extends StatefulWidget {
|
||||
const LinkHoverTrigger({
|
||||
@@ -45,6 +50,7 @@ class LinkHoverTrigger extends StatefulWidget {
|
||||
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
final hoverMenuController = PopoverController();
|
||||
final editMenuController = PopoverController();
|
||||
final toolbarController = getIt<FloatingToolbarController>();
|
||||
bool isHoverMenuShowing = false;
|
||||
bool isHoverMenuHovering = false;
|
||||
bool isHoverTriggerHovering = false;
|
||||
@@ -57,12 +63,13 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
|
||||
Attributes get attribute => widget.attribute;
|
||||
|
||||
HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection);
|
||||
late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
|
||||
toolbarController.addDisplayListener(onToolbarShow);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,6 +77,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
hoverMenuController.close();
|
||||
editMenuController.close();
|
||||
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
|
||||
toolbarController.removeDisplayListener(onToolbarShow);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -132,9 +140,9 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
tryToDismissLinkHoverMenu();
|
||||
},
|
||||
onOpenLink: openLink,
|
||||
onCopyLink: copyLink,
|
||||
onCopyLink: () => copyLink(context),
|
||||
onEditLink: showLinkEditMenu,
|
||||
onRemoveLink: () => removeLink(editorState, selection, true),
|
||||
onRemoveLink: () => removeLink(editorState, selection),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
@@ -144,6 +152,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
final href = attribute.href ?? '',
|
||||
isPage = attribute.isPage,
|
||||
title = editorState.getTextInSelection(selection).join();
|
||||
final currentViewId = context.read<DocumentBloc?>()?.documentId ?? '';
|
||||
return AppFlowyPopover(
|
||||
controller: editMenuController,
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
@@ -159,6 +168,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
minHeight: 282,
|
||||
),
|
||||
popupBuilder: (context) => LinkEditMenu(
|
||||
currentViewId: currentViewId,
|
||||
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
|
||||
onDismiss: () => editMenuController.close(),
|
||||
onApply: (info) async {
|
||||
@@ -173,14 +183,19 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
editMenuController.close();
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
onRemoveLink: () => removeLink(editorState, selection, true),
|
||||
onRemoveLink: (linkinfo) =>
|
||||
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void onToolbarShow() => hoverMenuController.close();
|
||||
|
||||
void showLinkHoverMenu() {
|
||||
if (isHoverMenuShowing) return;
|
||||
if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) {
|
||||
return;
|
||||
}
|
||||
keepEditorFocusNotifier.increase();
|
||||
hoverMenuController.show();
|
||||
}
|
||||
@@ -206,7 +221,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
if (isPage) {
|
||||
final viewId = href.split('/').lastOrNull ?? '';
|
||||
if (viewId.isEmpty) {
|
||||
await afLaunchUrlString(href);
|
||||
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
|
||||
} else {
|
||||
final (view, isInTrash, isDeleted) =
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
@@ -215,24 +230,28 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await afLaunchUrlString(href);
|
||||
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> copyLink() async {
|
||||
Future<void> copyLink(BuildContext context) async {
|
||||
final href = widget.attribute.href ?? '';
|
||||
if (href.isEmpty) return;
|
||||
await getIt<ClipboardService>()
|
||||
.setData(ClipboardServiceData(plainText: href));
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
|
||||
);
|
||||
}
|
||||
hoverMenuController.close();
|
||||
}
|
||||
|
||||
void removeLink(
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
bool isHref,
|
||||
) {
|
||||
if (!isHref) return;
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
@@ -251,9 +270,34 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void onRemoveAndReplaceLink(
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
String text,
|
||||
) {
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final index = selection.normalized.startIndex;
|
||||
final length = selection.length;
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
node,
|
||||
index,
|
||||
length,
|
||||
text,
|
||||
attributes: {
|
||||
BuiltInAttributeKey.href: null,
|
||||
kIsPageLink: null,
|
||||
},
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkHoverMenu extends StatelessWidget {
|
||||
class LinkHoverMenu extends StatefulWidget {
|
||||
const LinkHoverMenu({
|
||||
super.key,
|
||||
required this.attribute,
|
||||
@@ -275,17 +319,31 @@ class LinkHoverMenu extends StatelessWidget {
|
||||
final VoidCallback onEditLink;
|
||||
final VoidCallback onRemoveLink;
|
||||
|
||||
@override
|
||||
State<LinkHoverMenu> createState() => _LinkHoverMenuState();
|
||||
}
|
||||
|
||||
class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
ViewPB? currentView;
|
||||
late bool isPage = widget.attribute.isPage;
|
||||
late String href = widget.attribute.href ?? '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (isPage) getPageView();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final href = attribute.href ?? '';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MouseRegion(
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onEnter: widget.onEnter,
|
||||
onExit: widget.onExit,
|
||||
child: SizedBox(
|
||||
width: max(320, triggerSize.width),
|
||||
width: max(320, widget.triggerSize.width),
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -293,21 +351,15 @@ class LinkHoverMenu extends StatelessWidget {
|
||||
width: 320,
|
||||
height: 48,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.all(8),
|
||||
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText.regular(
|
||||
href,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(child: buildLinkWidget()),
|
||||
Container(
|
||||
height: 20,
|
||||
width: 1,
|
||||
color: LinkStyle.borderColor,
|
||||
color: Color(0xffE8ECF3)
|
||||
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
|
||||
margin: EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
FlowyIconButton(
|
||||
@@ -315,21 +367,21 @@ class LinkHoverMenu extends StatelessWidget {
|
||||
tooltipText: LocaleKeys.editor_copyLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: onCopyLink,
|
||||
onPressed: widget.onCopyLink,
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
|
||||
tooltipText: LocaleKeys.editor_editLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: onEditLink,
|
||||
onPressed: widget.onEditLink,
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
|
||||
tooltipText: LocaleKeys.editor_removeLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: onRemoveLink,
|
||||
onPressed: widget.onRemoveLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -339,13 +391,13 @@ class LinkHoverMenu extends StatelessWidget {
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onEnter: widget.onEnter,
|
||||
onExit: widget.onExit,
|
||||
child: GestureDetector(
|
||||
onTap: onOpenLink,
|
||||
onTap: widget.onOpenLink,
|
||||
child: Container(
|
||||
width: triggerSize.width,
|
||||
height: triggerSize.height,
|
||||
width: widget.triggerSize.width,
|
||||
height: widget.triggerSize.height,
|
||||
color: Colors.black.withAlpha(1),
|
||||
),
|
||||
),
|
||||
@@ -353,6 +405,46 @@ class LinkHoverMenu extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> getPageView() async {
|
||||
final viewId = href.split('/').lastOrNull ?? '';
|
||||
final (view, isInTrash, isDeleted) =
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
currentView = view;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildLinkWidget() {
|
||||
final view = currentView;
|
||||
if (isPage && view == null) {
|
||||
return SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
String text = '';
|
||||
if (isPage && view != null) {
|
||||
text = view.name;
|
||||
if (text.isEmpty) {
|
||||
text = LocaleKeys.document_title_placeholder.tr();
|
||||
}
|
||||
} else {
|
||||
text = href;
|
||||
}
|
||||
return FlowyTooltip(
|
||||
message: text,
|
||||
preferBelow: false,
|
||||
child: FlowyText.regular(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
figmaLineHeight: 20,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HoverTriggerKey {
|
||||
@@ -367,7 +459,11 @@ class HoverTriggerKey {
|
||||
other is HoverTriggerKey &&
|
||||
runtimeType == other.runtimeType &&
|
||||
nodeId == other.nodeId &&
|
||||
selection == other.selection;
|
||||
isSelectionSame(other.selection);
|
||||
|
||||
bool isSelectionSame(Selection other) =>
|
||||
(selection.start == other.start && selection.end == other.end) ||
|
||||
(selection.start == other.end && selection.end == other.start);
|
||||
|
||||
@override
|
||||
int get hashCode => nodeId.hashCode ^ selection.hashCode;
|
||||
@@ -390,8 +486,7 @@ class LinkHoverTriggers {
|
||||
|
||||
void call(HoverTriggerKey key) {
|
||||
final callbacks = _map[key] ?? {};
|
||||
for (final callback in callbacks) {
|
||||
callback.call();
|
||||
}
|
||||
if (callbacks.isEmpty) return;
|
||||
callbacks.first.call();
|
||||
}
|
||||
}
|
||||
+43
-17
@@ -12,6 +12,8 @@ import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/editor/util/link_util.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -25,12 +27,16 @@ class LinkSearchTextField {
|
||||
this.onEscape,
|
||||
this.onEnter,
|
||||
this.onDataRefresh,
|
||||
this.initialViewId = '',
|
||||
required this.currentViewId,
|
||||
String? initialSearchText,
|
||||
}) : textEditingController = TextEditingController(
|
||||
text: initialSearchText ?? '',
|
||||
text: isUri(initialSearchText ?? '') ? initialSearchText : '',
|
||||
);
|
||||
|
||||
final TextEditingController textEditingController;
|
||||
final String initialViewId;
|
||||
final String currentViewId;
|
||||
final ItemScrollController searchController = ItemScrollController();
|
||||
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
|
||||
final List<ViewPB> searchedViews = [];
|
||||
@@ -43,7 +49,7 @@ class LinkSearchTextField {
|
||||
|
||||
String get searchText => textEditingController.text;
|
||||
|
||||
bool get isButtonEnable => searchText.isNotEmpty;
|
||||
bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText);
|
||||
|
||||
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
|
||||
|
||||
@@ -58,7 +64,11 @@ class LinkSearchTextField {
|
||||
recentViews.clear();
|
||||
}
|
||||
|
||||
Widget buildTextField({bool autofocus = false}) {
|
||||
Widget buildTextField({
|
||||
bool autofocus = false,
|
||||
bool showError = false,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
return TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
autofocus: autofocus,
|
||||
@@ -81,6 +91,8 @@ class LinkSearchTextField {
|
||||
},
|
||||
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
|
||||
LocaleKeys.document_toolbar_linkInputHint.tr(),
|
||||
context,
|
||||
showErrorBorder: showError,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -177,29 +189,41 @@ class LinkSearchTextField {
|
||||
bool isSelected,
|
||||
ValueChanged<ViewPB>? onSubmittedPageLink,
|
||||
) {
|
||||
final viewName = view.name;
|
||||
final displayName = viewName.isEmpty
|
||||
? LocaleKeys.document_title_placeholder.tr()
|
||||
: viewName;
|
||||
final isCurrent = initialViewId == view.id;
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: FlowyButton(
|
||||
isSelected: isSelected,
|
||||
leftIcon: buildIcon(view),
|
||||
leftIcon: buildIcon(view, padding: EdgeInsets.zero),
|
||||
text: FlowyText.regular(
|
||||
view.name,
|
||||
displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
|
||||
onTap: () => onSubmittedPageLink?.call(view),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(ViewPB view) {
|
||||
Widget buildIcon(
|
||||
ViewPB view, {
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4),
|
||||
}) {
|
||||
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
|
||||
final iconData = view.icon.toEmojiIconData();
|
||||
return RawEmojiIconWidget(
|
||||
emoji: iconData,
|
||||
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
|
||||
lineHeight: 1,
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: RawEmojiIconWidget(
|
||||
emoji: iconData,
|
||||
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
|
||||
lineHeight: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,6 +312,7 @@ class LinkSearchTextField {
|
||||
final views = sectionViews
|
||||
.unique((e) => e.item.id)
|
||||
.map((e) => e.item)
|
||||
.where((e) => e.id != currentViewId)
|
||||
.take(5)
|
||||
.toList();
|
||||
recentViews.clear();
|
||||
@@ -303,13 +328,14 @@ class LinkSearchTextField {
|
||||
?.items
|
||||
.where(
|
||||
(view) =>
|
||||
view.name.toLowerCase().contains(search.toLowerCase()) ||
|
||||
(view.name.isEmpty && search.isEmpty) ||
|
||||
(view.name.isEmpty &&
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName
|
||||
.tr()
|
||||
.toLowerCase()
|
||||
.contains(search.toLowerCase())),
|
||||
(view.id != currentViewId) &&
|
||||
(view.name.toLowerCase().contains(search.toLowerCase()) ||
|
||||
(view.name.isEmpty && search.isEmpty) ||
|
||||
(view.name.isEmpty &&
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName
|
||||
.tr()
|
||||
.toLowerCase()
|
||||
.contains(search.toLowerCase()))),
|
||||
)
|
||||
.take(10)
|
||||
.toList();
|
||||
|
||||
+17
-5
@@ -1,19 +1,31 @@
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LinkStyle {
|
||||
static const borderColor = Color(0xFFE8ECF3);
|
||||
static const textTertiary = Color(0xFF99A1A8);
|
||||
static const textStatusError = Color(0xffE71D32);
|
||||
static const fillThemeThick = Color(0xFF00B5FF);
|
||||
static const shadowMedium = Color(0x1F22251F);
|
||||
static const textPrimary = Color(0xFF1F2329);
|
||||
|
||||
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
|
||||
const border = OutlineInputBorder(
|
||||
static Color borderColor(BuildContext context) =>
|
||||
Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD);
|
||||
|
||||
static InputDecoration buildLinkTextFieldInputDecoration(
|
||||
String hintText,
|
||||
BuildContext context, {
|
||||
bool showErrorBorder = false,
|
||||
}) {
|
||||
final border = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderSide: BorderSide(color: LinkStyle.borderColor),
|
||||
borderSide: BorderSide(color: borderColor(context)),
|
||||
);
|
||||
final enableBorder = border.copyWith(
|
||||
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
|
||||
borderSide: BorderSide(
|
||||
color: showErrorBorder
|
||||
? LinkStyle.textStatusError
|
||||
: LinkStyle.fillThemeThick,
|
||||
),
|
||||
);
|
||||
const hintStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
|
||||
class ToolbarCubit extends Cubit<ToolbarState> {
|
||||
ToolbarCubit(this.onDismissCallback) : super(ToolbarState._());
|
||||
|
||||
final VoidCallback onDismissCallback;
|
||||
|
||||
void dismiss() {
|
||||
onDismissCallback.call();
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarState {
|
||||
const ToolbarState._();
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart';
|
||||
@@ -68,7 +69,7 @@ class _FormatToolbarItem extends ToolbarItem {
|
||||
final hoverColor = isHighlight
|
||||
? highlightColor
|
||||
: EditorStyleCustomizer.toolbarHoverColor(context);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isDark = !Theme.of(context).isLightMode;
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -78,7 +79,7 @@ class _HighlightColorPickerWidgetState
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final iconColor = Theme.of(context).iconTheme.color;
|
||||
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
|
||||
+16
-10
@@ -1,15 +1,17 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'toolbar_id_enum.dart';
|
||||
|
||||
const kIsPageLink = 'is_page_link';
|
||||
@@ -28,10 +30,10 @@ final customLinkItem = ToolbarItem(
|
||||
);
|
||||
});
|
||||
|
||||
final isDark = !Theme.of(context).isLightMode;
|
||||
final hoverColor = isHref
|
||||
? highlightColor
|
||||
: EditorStyleCustomizer.toolbarHoverColor(context);
|
||||
final toolbarCubit = context.read<ToolbarCubit?>();
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
@@ -41,16 +43,20 @@ final customLinkItem = ToolbarItem(
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.toolbar_link_m,
|
||||
size: Size.square(20.0),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
color: (isDark && isHref)
|
||||
? Color(0xFF282E3A)
|
||||
: AFThemeExtensionV2.of(context).icon_primary,
|
||||
),
|
||||
onPressed: () {
|
||||
toolbarCubit?.dismiss();
|
||||
if (isHref) {
|
||||
getIt<LinkHoverTriggers>().call(
|
||||
HoverTriggerKey(nodes.first.id, selection),
|
||||
);
|
||||
getIt<FloatingToolbarController>().hideToolbar();
|
||||
if (!isHref) {
|
||||
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
|
||||
showLinkCreateMenu(context, editorState, selection, viewId);
|
||||
} else {
|
||||
showLinkCreateMenu(context, editorState, selection);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
getIt<LinkHoverTriggers>()
|
||||
.call(HoverTriggerKey(nodes.first.id, selection));
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_to
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'toolbar_id_enum.dart';
|
||||
@@ -77,7 +78,7 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final iconColor = Theme.of(context).iconTheme.color;
|
||||
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
height: 32,
|
||||
|
||||
+7
-5
@@ -1,9 +1,10 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -288,7 +289,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
|
||||
popoverController: suggestionsPopoverController,
|
||||
popoverDirection: PopoverDirection.leftWithTopAligned,
|
||||
showOffset: Offset(-8, height),
|
||||
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
|
||||
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
|
||||
child: buildCommandItem(
|
||||
MoreOptionCommand.suggestions,
|
||||
rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m),
|
||||
@@ -308,7 +309,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
|
||||
popoverController: textAlignPopoverController,
|
||||
popoverDirection: PopoverDirection.leftWithTopAligned,
|
||||
showOffset: Offset(-8, 0),
|
||||
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
|
||||
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
|
||||
highlightColor: highlightColor,
|
||||
child: buildCommandItem(
|
||||
MoreOptionCommand.textAlign,
|
||||
@@ -379,13 +380,14 @@ enum MoreOptionCommand {
|
||||
(attributes) => attributes[AppFlowyRichTextKeys.href] != null,
|
||||
);
|
||||
});
|
||||
context.read<ToolbarCubit?>()?.dismiss();
|
||||
getIt<FloatingToolbarController>().hideToolbar();
|
||||
if (isHref) {
|
||||
getIt<LinkHoverTriggers>().call(
|
||||
HoverTriggerKey(nodes.first.id, selection),
|
||||
);
|
||||
} else {
|
||||
showLinkCreateMenu(context, editorState, selection);
|
||||
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
|
||||
showLinkCreateMenu(context, editorState, selection, viewId);
|
||||
}
|
||||
} else if (this == strikethrough) {
|
||||
await editorState.toggleAttribute(name);
|
||||
|
||||
+4
-1
@@ -84,12 +84,14 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
refreshSuggestions();
|
||||
editorState.selectionNotifier.addListener(refreshSuggestions);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
editorState.selectionNotifier.removeListener(refreshSuggestions);
|
||||
popoverController.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -290,6 +292,7 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
|
||||
}
|
||||
currentSuggestionItem =
|
||||
suggestions.where((item) => item.type == suggestionType).first;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'emoji_menu.dart';
|
||||
|
||||
const emojiCharacter = ':';
|
||||
const _emojiCharacter = ':';
|
||||
final _letterRegExp = RegExp(r'^[a-zA-Z]$');
|
||||
|
||||
CharacterShortcutEvent emojiCommand(BuildContext context) =>
|
||||
CharacterShortcutEvent(
|
||||
key: 'Opens Emoji Menu',
|
||||
character: emojiCharacter,
|
||||
handler: (editorState) {
|
||||
emojiMenuService ??= EmojiMenu(
|
||||
character: '',
|
||||
regExp: _letterRegExp,
|
||||
handler: (editorState) async {
|
||||
return false;
|
||||
},
|
||||
handlerWithCharacter: (editorState, character) {
|
||||
emojiMenuService = EmojiMenu(
|
||||
context: context,
|
||||
editorState: editorState,
|
||||
);
|
||||
return emojiCommandHandler(editorState, context);
|
||||
return emojiCommandHandler(editorState, context, character);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,6 +30,7 @@ EmojiMenuService? emojiMenuService;
|
||||
Future<bool> emojiCommandHandler(
|
||||
EditorState editorState,
|
||||
BuildContext context,
|
||||
String character,
|
||||
) async {
|
||||
final selection = editorState.selection;
|
||||
|
||||
@@ -35,10 +42,30 @@ Future<bool> emojiCommandHandler(
|
||||
await editorState.deleteSelection(selection);
|
||||
}
|
||||
|
||||
await editorState.insertTextAtPosition(
|
||||
emojiCharacter,
|
||||
position: selection.start,
|
||||
);
|
||||
emojiMenuService?.show();
|
||||
return true;
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null ||
|
||||
delta == null ||
|
||||
delta.isEmpty ||
|
||||
node.type == CodeBlockKeys.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selection.end.offset > 0) {
|
||||
final plain = delta.toPlainText();
|
||||
|
||||
final previousCharacter = plain[selection.end.offset - 1];
|
||||
if (previousCharacter != _emojiCharacter) return false;
|
||||
if (!context.mounted) return false;
|
||||
|
||||
await editorState.insertTextAtPosition(
|
||||
character,
|
||||
position: selection.start,
|
||||
);
|
||||
|
||||
emojiMenuService?.show(character);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra/size.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/flowy_tooltip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import 'emoji_menu.dart';
|
||||
|
||||
@@ -23,6 +24,7 @@ class EmojiHandler extends StatefulWidget {
|
||||
required this.onEmojiSelect,
|
||||
this.startCharAmount = 1,
|
||||
this.cancelBySpaceHandler,
|
||||
this.initialSearchText = '',
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
@@ -31,6 +33,7 @@ class EmojiHandler extends StatefulWidget {
|
||||
final VoidCallback onSelectionUpdate;
|
||||
final SelectEmojiItemHandler onEmojiSelect;
|
||||
final int startCharAmount;
|
||||
final String initialSearchText;
|
||||
final bool Function()? cancelBySpaceHandler;
|
||||
|
||||
@override
|
||||
@@ -38,30 +41,34 @@ class EmojiHandler extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
|
||||
final ItemScrollController controller = ItemScrollController();
|
||||
final focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
|
||||
final scrollController = ScrollController();
|
||||
late EmojiData emojiData;
|
||||
final List<Emoji> searchedEmojis = [];
|
||||
bool loaded = false;
|
||||
int invalidCounter = 0;
|
||||
late int startOffset;
|
||||
String _search = '';
|
||||
late String _search = widget.initialSearchText;
|
||||
double emojiHeight = 36.0;
|
||||
final configuration = EmojiPickerConfiguration(
|
||||
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
|
||||
);
|
||||
|
||||
set search(String search) {
|
||||
_search = search;
|
||||
_doSearch();
|
||||
}
|
||||
|
||||
final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _focusNode.requestFocus(),
|
||||
(_) => focusNode.requestFocus(),
|
||||
);
|
||||
|
||||
startOffset = widget.editorState.selection?.endIndex ?? 0;
|
||||
startOffset = (widget.editorState.selection?.endIndex ?? 0) - 1;
|
||||
|
||||
if (kCachedEmojiData != null) {
|
||||
loadEmojis(kCachedEmojiData!);
|
||||
@@ -77,8 +84,9 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
_selectedIndexNotifier.dispose();
|
||||
focusNode.dispose();
|
||||
selectedIndexNotifier.dispose();
|
||||
scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -86,10 +94,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
Widget build(BuildContext context) {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300),
|
||||
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
color: Theme.of(context).cardColor,
|
||||
@@ -101,52 +110,73 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: noEmojis
|
||||
? SizedBox(
|
||||
width: 400,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ScrollablePositionedList.builder(
|
||||
itemCount: searchedEmojis.length,
|
||||
itemScrollController: controller,
|
||||
padding: EdgeInsets.all(8),
|
||||
shrinkWrap: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemBuilder: (ctx, index) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _selectedIndexNotifier,
|
||||
builder: (context, value, __) {
|
||||
final selectedEmoji = searchedEmojis[index];
|
||||
final displayedEmoji =
|
||||
emojiData.getEmojiById(selectedEmoji.id);
|
||||
final isSelected = value == index;
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(
|
||||
'$displayedEmoji ${selectedEmoji.name}',
|
||||
lineHeight: 1.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
isSelected: isSelected,
|
||||
onTap: () => onSelect(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
child: noEmojis ? buildLoading() : buildEmojis(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index;
|
||||
Widget buildLoading() {
|
||||
return SizedBox(
|
||||
width: 400,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEmojis() {
|
||||
return SizedBox(
|
||||
height:
|
||||
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
|
||||
child: GridView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: searchedEmojis.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: configuration.perLine,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final currentEmoji = searchedEmojis[index];
|
||||
final emojiId = currentEmoji.id;
|
||||
final emoji = emojiData.getEmojiById(
|
||||
emojiId,
|
||||
skinTone: configuration.defaultSkinTone,
|
||||
);
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: selectedIndexNotifier,
|
||||
builder: (context, value, child) {
|
||||
final isSelected = value == index;
|
||||
return SizedBox.square(
|
||||
dimension: emojiHeight,
|
||||
child: FlowyButton(
|
||||
isSelected: isSelected,
|
||||
margin: EdgeInsets.zero,
|
||||
radius: Corners.s8Border,
|
||||
text: ManualTooltip(
|
||||
key: ValueKey('$emojiId-$isSelected'),
|
||||
message: currentEmoji.name,
|
||||
showAutomaticlly: isSelected,
|
||||
preferBelow: false,
|
||||
child: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: configuration.emojiSize,
|
||||
),
|
||||
),
|
||||
onTap: () => onSelect(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void changeSelectedIndex(int index) => selectedIndexNotifier.value = index;
|
||||
|
||||
void loadEmojis(EmojiData data) {
|
||||
emojiData = data;
|
||||
@@ -157,11 +187,14 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
loaded = true;
|
||||
});
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_doSearch();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _doSearch() async {
|
||||
if (!loaded) return;
|
||||
if (_search.startsWith(' ')) {
|
||||
void _doSearch() {
|
||||
if (!loaded || !mounted) return;
|
||||
if (_search.startsWith(' ') || _search.isEmpty) {
|
||||
widget.onDismiss.call();
|
||||
return;
|
||||
}
|
||||
@@ -170,6 +203,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
searchedEmojis.clear();
|
||||
searchedEmojis.addAll(searchEmojiData.emojis.values);
|
||||
changeSelectedIndex(0);
|
||||
_scrollToItem();
|
||||
});
|
||||
if (searchedEmojis.isEmpty) {
|
||||
widget.onDismiss.call();
|
||||
@@ -177,17 +211,19 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
}
|
||||
|
||||
KeyEventResult onKeyEvent(focus, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
const moveKeys = [
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowDown,
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
];
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
onSelect(_selectedIndexNotifier.value);
|
||||
onSelect(selectedIndexNotifier.value);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
// Workaround to bring focus back to editor
|
||||
@@ -214,11 +250,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.character != null &&
|
||||
![
|
||||
...moveKeys,
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
].contains(event.logicalKey)) {
|
||||
!moveKeys.contains(event.logicalKey)) {
|
||||
/// Prevents dismissal of context menu by notifying the parent
|
||||
/// that the selection change occurred from the handler.
|
||||
widget.onSelectionUpdate();
|
||||
@@ -238,42 +270,13 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight]
|
||||
.contains(event.logicalKey)) {
|
||||
widget.onSelectionUpdate();
|
||||
|
||||
event.logicalKey == LogicalKeyboardKey.arrowLeft
|
||||
? widget.editorState.moveCursorForward()
|
||||
: 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.call();
|
||||
}
|
||||
|
||||
/// 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 onSelect(int index) {
|
||||
widget.onEmojiSelect.call(
|
||||
context,
|
||||
(
|
||||
startOffset - widget.startCharAmount,
|
||||
_search.length + widget.startCharAmount
|
||||
),
|
||||
(startOffset - widget.startCharAmount, startOffset + _search.length),
|
||||
emojiData.getEmojiById(searchedEmojis[index].id),
|
||||
);
|
||||
widget.onDismiss.call();
|
||||
@@ -296,49 +299,70 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
.getTextInSelection(
|
||||
selection.copyWith(
|
||||
start: selection.start.copyWith(offset: startOffset),
|
||||
end: selection.start
|
||||
.copyWith(offset: startOffset + _search.length + 1),
|
||||
end: selection.start.copyWith(offset: startOffset + _search.length + 1),
|
||||
),
|
||||
)
|
||||
.join();
|
||||
}
|
||||
|
||||
void _moveSelection(LogicalKeyboardKey key) {
|
||||
bool didChange = false;
|
||||
final index = _selectedIndexNotifier.value;
|
||||
if (key == LogicalKeyboardKey.arrowUp ||
|
||||
(key == LogicalKeyboardKey.tab &&
|
||||
HardwareKeyboard.instance.isShiftPressed)) {
|
||||
final index = selectedIndexNotifier.value,
|
||||
perLine = configuration.perLine,
|
||||
remainder = index % perLine,
|
||||
length = searchedEmojis.length,
|
||||
currentLine = index ~/ perLine,
|
||||
maxLine = (length / perLine).ceil();
|
||||
|
||||
final heightBefore = currentLine * emojiHeight;
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
if (currentLine == 0) {
|
||||
final exceptLine = max(0, maxLine - 1);
|
||||
changeSelectedIndex(min(exceptLine * perLine + remainder, length - 1));
|
||||
} else if (currentLine > 0) {
|
||||
changeSelectedIndex(index - perLine);
|
||||
}
|
||||
} else if (key == LogicalKeyboardKey.arrowDown) {
|
||||
if (currentLine == maxLine - 1) {
|
||||
changeSelectedIndex(remainder);
|
||||
} else if (currentLine < maxLine - 1) {
|
||||
changeSelectedIndex(min(index + perLine, length - 1));
|
||||
}
|
||||
} else if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (index == 0) {
|
||||
changeSelectedIndex(max(0, searchedEmojis.length - 1));
|
||||
didChange = true;
|
||||
changeSelectedIndex(length - 1);
|
||||
} else if (index > 0) {
|
||||
changeSelectedIndex(index - 1);
|
||||
didChange = true;
|
||||
}
|
||||
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab]
|
||||
.contains(key)) {
|
||||
if (index < searchedEmojis.length - 1) {
|
||||
changeSelectedIndex(index + 1);
|
||||
didChange = true;
|
||||
} else if (index == searchedEmojis.length - 1) {
|
||||
} else if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (index == length - 1) {
|
||||
changeSelectedIndex(0);
|
||||
didChange = true;
|
||||
} else if (index < length - 1) {
|
||||
changeSelectedIndex(index + 1);
|
||||
}
|
||||
}
|
||||
final heightAfter =
|
||||
(selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight;
|
||||
|
||||
if (mounted && didChange) {
|
||||
_scrollToItem();
|
||||
if (mounted && (heightAfter != heightBefore)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToItem();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToItem() {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
if (noEmojis) return;
|
||||
controller.scrollTo(
|
||||
index: _selectedIndexNotifier.value,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: 0.5,
|
||||
if (noEmojis || !mounted) return;
|
||||
final currentItem = selectedIndexNotifier.value;
|
||||
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
|
||||
final maxExtent = scrollController.position.maxScrollExtent;
|
||||
final jumpTo = (exceptHeight - maxExtent > 10 * emojiHeight)
|
||||
? exceptHeight
|
||||
: min(exceptHeight, maxExtent);
|
||||
scrollController.animateTo(
|
||||
jumpTo,
|
||||
duration: Duration(milliseconds: 300),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,7 +380,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
search = delta.toPlainText().substring(
|
||||
startOffset,
|
||||
startOffset - 1 + _search.length,
|
||||
startOffset + _search.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'emoji_actions_command.dart';
|
||||
import 'emoji_handler.dart';
|
||||
|
||||
abstract class EmojiMenuService {
|
||||
void show();
|
||||
void show(String character);
|
||||
|
||||
void dismiss();
|
||||
}
|
||||
@@ -16,16 +16,22 @@ class EmojiMenu extends EmojiMenuService {
|
||||
required this.editorState,
|
||||
this.startCharAmount = 1,
|
||||
this.cancelBySpaceHandler,
|
||||
this.menuHeight = 400,
|
||||
this.menuWidth = 300,
|
||||
});
|
||||
|
||||
final BuildContext context;
|
||||
final EditorState editorState;
|
||||
final double menuHeight;
|
||||
final double menuWidth;
|
||||
final bool Function()? cancelBySpaceHandler;
|
||||
|
||||
final int startCharAmount;
|
||||
|
||||
Offset _offset = Offset.zero;
|
||||
Alignment _alignment = Alignment.topLeft;
|
||||
OverlayEntry? _menuEntry;
|
||||
bool selectionChangedByMenu = false;
|
||||
String initialCharacter = '';
|
||||
|
||||
@override
|
||||
void dismiss() {
|
||||
@@ -51,7 +57,8 @@ class EmojiMenu extends EmojiMenuService {
|
||||
void _onSelectionUpdate() => selectionChangedByMenu = true;
|
||||
|
||||
@override
|
||||
void show() {
|
||||
void show(String character) {
|
||||
initialCharacter = character;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
|
||||
}
|
||||
|
||||
@@ -62,41 +69,11 @@ class EmojiMenu extends EmojiMenuService {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuHeight = 400.0, menuWidth = 300.0;
|
||||
const Offset menuOffset = Offset(0, 10);
|
||||
final Offset editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final Size editorSize = editorState.renderBox!.size;
|
||||
final editorHeight = editorSize.height, editorWidth = editorSize.width;
|
||||
// Default to opening the overlay below
|
||||
Alignment alignment = Alignment.topLeft;
|
||||
|
||||
final firstRect = selectionRects.first;
|
||||
Offset offset = firstRect.bottomRight + menuOffset;
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
|
||||
// Show above
|
||||
if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) {
|
||||
offset = firstRect.topRight - menuOffset;
|
||||
alignment = Alignment.bottomLeft;
|
||||
offset = Offset(
|
||||
offset.dx,
|
||||
editorHeight - offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
// Show on the left
|
||||
if (offset.dx > (editorWidth - menuWidth)) {
|
||||
alignment = alignment == Alignment.topLeft
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight;
|
||||
|
||||
offset = Offset(
|
||||
editorWidth - offset.dx,
|
||||
offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
final (left, top, right, bottom) = _getPosition(alignment, offset);
|
||||
final (left, top, right, bottom) = _getPosition();
|
||||
|
||||
_menuEntry = OverlayEntry(
|
||||
builder: (context) => SizedBox(
|
||||
@@ -122,6 +99,7 @@ class EmojiMenu extends EmojiMenuService {
|
||||
onSelectionUpdate: _onSelectionUpdate,
|
||||
startCharAmount: startCharAmount,
|
||||
cancelBySpaceHandler: cancelBySpaceHandler,
|
||||
initialSearchText: initialCharacter,
|
||||
onEmojiSelect: (
|
||||
BuildContext context,
|
||||
(int, int) replacement,
|
||||
@@ -134,10 +112,14 @@ class EmojiMenu extends EmojiMenuService {
|
||||
editorState.document.nodeAtPath(selection.end.path);
|
||||
if (node == null) return;
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
..deleteText(
|
||||
node,
|
||||
replacement.$1,
|
||||
replacement.$2 - replacement.$1,
|
||||
)
|
||||
..insertText(
|
||||
node,
|
||||
replacement.$1,
|
||||
replacement.$2,
|
||||
emoji,
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
@@ -175,30 +157,77 @@ class EmojiMenu extends EmojiMenuService {
|
||||
selectionChangedByMenu = false;
|
||||
}
|
||||
|
||||
(double? left, double? top, double? right, double? bottom) _getPosition(
|
||||
Alignment alignment,
|
||||
Offset offset,
|
||||
) {
|
||||
(double? left, double? top, double? right, double? bottom) _getPosition() {
|
||||
double? left, top, right, bottom;
|
||||
switch (alignment) {
|
||||
switch (_alignment) {
|
||||
case Alignment.topLeft:
|
||||
left = offset.dx;
|
||||
top = offset.dy;
|
||||
left = _offset.dx;
|
||||
top = _offset.dy;
|
||||
break;
|
||||
case Alignment.bottomLeft:
|
||||
left = offset.dx;
|
||||
bottom = offset.dy;
|
||||
left = _offset.dx;
|
||||
bottom = _offset.dy;
|
||||
break;
|
||||
case Alignment.topRight:
|
||||
right = offset.dx;
|
||||
top = offset.dy;
|
||||
right = _offset.dx;
|
||||
top = _offset.dy;
|
||||
break;
|
||||
case Alignment.bottomRight:
|
||||
right = offset.dx;
|
||||
bottom = offset.dy;
|
||||
right = _offset.dx;
|
||||
bottom = _offset.dy;
|
||||
break;
|
||||
}
|
||||
|
||||
return (left, top, right, bottom);
|
||||
}
|
||||
|
||||
void calculateSelectionMenuOffset(Rect rect) {
|
||||
// Workaround: We can customize the padding through the [EditorStyle],
|
||||
// but the coordinates of overlay are not properly converted currently.
|
||||
// Just subtract the padding here as a result.
|
||||
const menuOffset = Offset(0, 10);
|
||||
final editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final editorHeight = editorState.renderBox!.size.height;
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
|
||||
// show below default
|
||||
_alignment = Alignment.topLeft;
|
||||
final bottomRight = rect.bottomRight;
|
||||
final topRight = rect.topRight;
|
||||
var offset = bottomRight + menuOffset;
|
||||
_offset = Offset(
|
||||
offset.dx,
|
||||
offset.dy,
|
||||
);
|
||||
|
||||
// show above
|
||||
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
|
||||
offset = topRight - menuOffset;
|
||||
_alignment = Alignment.bottomLeft;
|
||||
|
||||
_offset = Offset(
|
||||
offset.dx,
|
||||
editorHeight + editorOffset.dy - offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
// show on right
|
||||
if (_offset.dx + menuWidth < editorOffset.dx + editorWidth) {
|
||||
_offset = Offset(
|
||||
_offset.dx,
|
||||
_offset.dy,
|
||||
);
|
||||
} else if (offset.dx - editorOffset.dx > menuWidth) {
|
||||
// show on left
|
||||
_alignment = _alignment == Alignment.topLeft
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight;
|
||||
|
||||
_offset = Offset(
|
||||
editorWidth - _offset.dx + editorOffset.dx,
|
||||
_offset.dy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
|
||||
import 'package:appflowy/util/expand_views.dart';
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
@@ -187,6 +188,9 @@ Future<void> initGetIt(
|
||||
getIt.registerSingleton<PluginSandbox>(PluginSandbox());
|
||||
getIt.registerSingleton<ViewExpanderRegistry>(ViewExpanderRegistry());
|
||||
getIt.registerSingleton<LinkHoverTriggers>(LinkHoverTriggers());
|
||||
getIt.registerSingleton<FloatingToolbarController>(
|
||||
FloatingToolbarController(),
|
||||
);
|
||||
|
||||
await DependencyResolver.resolve(getIt, mode);
|
||||
}
|
||||
|
||||
+111
-60
@@ -1,94 +1,145 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'local_llm_listener.dart';
|
||||
|
||||
part 'local_ai_bloc.freezed.dart';
|
||||
|
||||
class LocalAIToggleBloc extends Bloc<LocalAIToggleEvent, LocalAIToggleState> {
|
||||
LocalAIToggleBloc() : super(const LocalAIToggleState()) {
|
||||
on<LocalAIToggleEvent>(_handleEvent);
|
||||
class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
|
||||
LocalAiPluginBloc() : super(const LoadingLocalAiPluginState()) {
|
||||
on<LocalAiPluginEvent>(_handleEvent);
|
||||
_startListening();
|
||||
_getLocalAiState();
|
||||
}
|
||||
|
||||
final listener = LocalAIStateListener();
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
LocalAIToggleEvent event,
|
||||
Emitter<LocalAIToggleState> emit,
|
||||
LocalAiPluginEvent event,
|
||||
Emitter<LocalAiPluginState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final result = await AIEventGetLocalAIState().send();
|
||||
_handleResult(emit, result);
|
||||
didReceiveAiState: (aiState) {
|
||||
emit(
|
||||
LocalAiPluginState.ready(
|
||||
isEnabled: aiState.enabled,
|
||||
version: aiState.pluginVersion,
|
||||
runningState: aiState.state,
|
||||
lackOfResource:
|
||||
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveLackOfResources: (resources) {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
emit(readyState.copyWith(lackOfResource: resources));
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
toggle: () async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator: const LocalAIToggleStateIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
AIEventToggleLocalAI().send().then(
|
||||
(result) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIToggleEvent.handleResult(result));
|
||||
}
|
||||
},
|
||||
),
|
||||
emit(LocalAiPluginState.loading());
|
||||
await AIEventToggleLocalAI().send().fold(
|
||||
(aiState) {
|
||||
add(LocalAiPluginEvent.didReceiveAiState(aiState));
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
},
|
||||
handleResult: (result) {
|
||||
_handleResult(emit, result);
|
||||
restart: () async {
|
||||
emit(LocalAiPluginState.loading());
|
||||
await AIEventRestartLocalAI().send();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleResult(
|
||||
Emitter<LocalAIToggleState> emit,
|
||||
FlowyResult<LocalAIPB, FlowyError> result,
|
||||
) {
|
||||
result.fold(
|
||||
(localAI) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator:
|
||||
LocalAIToggleStateIndicator.isEnabled(localAI.enabled),
|
||||
),
|
||||
);
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
add(LocalAiPluginEvent.didReceiveAiState(pluginState));
|
||||
},
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator: LocalAIToggleStateIndicator.error(err),
|
||||
),
|
||||
);
|
||||
resourceCallback: (data) {
|
||||
add(LocalAiPluginEvent.didReceiveLackOfResources(data));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _getLocalAiState() {
|
||||
AIEventGetLocalAIState().send().fold(
|
||||
(aiState) {
|
||||
add(LocalAiPluginEvent.didReceiveAiState(aiState));
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIToggleEvent with _$LocalAIToggleEvent {
|
||||
const factory LocalAIToggleEvent.started() = _Started;
|
||||
const factory LocalAIToggleEvent.toggle() = _Toggle;
|
||||
const factory LocalAIToggleEvent.handleResult(
|
||||
FlowyResult<LocalAIPB, FlowyError> result,
|
||||
) = _HandleResult;
|
||||
class LocalAiPluginEvent with _$LocalAiPluginEvent {
|
||||
const factory LocalAiPluginEvent.didReceiveAiState(LocalAIPB aiState) =
|
||||
_DidReceiveAiState;
|
||||
const factory LocalAiPluginEvent.didReceiveLackOfResources(
|
||||
LackOfAIResourcePB resources,
|
||||
) = _DidReceiveLackOfResources;
|
||||
const factory LocalAiPluginEvent.toggle() = _Toggle;
|
||||
const factory LocalAiPluginEvent.restart() = _Restart;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIToggleState with _$LocalAIToggleState {
|
||||
const factory LocalAIToggleState({
|
||||
@Default(LocalAIToggleStateIndicator.loading())
|
||||
LocalAIToggleStateIndicator pageIndicator,
|
||||
}) = _LocalAIToggleState;
|
||||
}
|
||||
class LocalAiPluginState with _$LocalAiPluginState {
|
||||
const LocalAiPluginState._();
|
||||
|
||||
@freezed
|
||||
class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator {
|
||||
// when start downloading the model
|
||||
const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError;
|
||||
const factory LocalAIToggleStateIndicator.isEnabled(bool isEnabled) = _Ready;
|
||||
const factory LocalAIToggleStateIndicator.loading() = _Loading;
|
||||
const factory LocalAiPluginState.ready({
|
||||
required bool isEnabled,
|
||||
required String version,
|
||||
required RunningStatePB runningState,
|
||||
required LackOfAIResourcePB? lackOfResource,
|
||||
}) = ReadyLocalAiPluginState;
|
||||
|
||||
const factory LocalAiPluginState.loading() = LoadingLocalAiPluginState;
|
||||
|
||||
bool get isEnabled {
|
||||
return maybeWhen(
|
||||
ready: (isEnabled, _, __, ___) => isEnabled,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
|
||||
bool get showIndicator {
|
||||
return maybeWhen(
|
||||
ready: (isEnabled, _, runningState, lackOfResource) =>
|
||||
runningState != RunningStatePB.Running || lackOfResource != null,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
|
||||
bool get showSettings {
|
||||
return maybeWhen(
|
||||
ready: (isEnabled, _, runningState, lackOfResource) {
|
||||
final isConnecting = [
|
||||
RunningStatePB.Connecting,
|
||||
RunningStatePB.Connected,
|
||||
].contains(runningState);
|
||||
|
||||
final resourcesReadyOrMissingModel = lackOfResource == null ||
|
||||
lackOfResource.resourceType == LackOfAIResourceTypePB.MissingModel;
|
||||
|
||||
return !isConnecting && resourcesReadyOrMissingModel;
|
||||
},
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
Future<void> _onPaymentSuccessful() async {
|
||||
void _onPaymentSuccessful() {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'local_ai_setting_panel_bloc.freezed.dart';
|
||||
|
||||
class LocalAISettingPanelBloc
|
||||
extends Bloc<LocalAISettingPanelEvent, LocalAISettingPanelState> {
|
||||
LocalAISettingPanelBloc()
|
||||
: listener = LocalAIStateListener(),
|
||||
super(const LocalAISettingPanelState()) {
|
||||
on<LocalAISettingPanelEvent>(_handleEvent);
|
||||
|
||||
listener.start(
|
||||
stateCallback: (newState) {
|
||||
if (!isClosed) {
|
||||
add(LocalAISettingPanelEvent.updateAIState(newState));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
AIEventGetLocalAIState().send().fold(
|
||||
(localAIState) {
|
||||
if (!isClosed) {
|
||||
add(LocalAISettingPanelEvent.updateAIState(localAIState));
|
||||
}
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
}
|
||||
|
||||
final LocalAIStateListener listener;
|
||||
|
||||
/// Handles incoming events and dispatches them to the appropriate handler.
|
||||
Future<void> _handleEvent(
|
||||
LocalAISettingPanelEvent event,
|
||||
Emitter<LocalAISettingPanelState> emit,
|
||||
) async {
|
||||
event.when(
|
||||
updateAIState: (LocalAIPB pluginState) {
|
||||
if (pluginState.pluginDownloaded) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
runningState: pluginState.state,
|
||||
progressIndicator: const LocalAIProgress.checkPluginState(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
progressIndicator: const LocalAIProgress.downloadLocalAIApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAISettingPanelEvent with _$LocalAISettingPanelEvent {
|
||||
const factory LocalAISettingPanelEvent.updateAIState(
|
||||
LocalAIPB aiState,
|
||||
) = _UpdateAIState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAISettingPanelState with _$LocalAISettingPanelState {
|
||||
const factory LocalAISettingPanelState({
|
||||
LocalAIProgress? progressIndicator,
|
||||
@Default(RunningStatePB.Connecting) RunningStatePB runningState,
|
||||
}) = _LocalAIChatSettingState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIProgress with _$LocalAIProgress {
|
||||
const factory LocalAIProgress.checkPluginState() = _CheckPluginStateProgress;
|
||||
const factory LocalAIProgress.downloadLocalAIApp() =
|
||||
_DownloadLocalAIAppProgress;
|
||||
}
|
||||
+3
-5
@@ -80,11 +80,9 @@ class OllamaSettingBloc extends Bloc<OllamaSettingEvent, OllamaSettingState> {
|
||||
}
|
||||
add(OllamaSettingEvent.updateSetting(setting));
|
||||
AIEventUpdateLocalAISetting(setting).send().fold(
|
||||
(_) {
|
||||
Log.info('AI setting updated successfully');
|
||||
},
|
||||
(err) => Log.error("update ai setting failed: $err"),
|
||||
);
|
||||
(_) => Log.info('AI setting updated successfully'),
|
||||
(err) => Log.error("update ai setting failed: $err"),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'plugin_state_bloc.freezed.dart';
|
||||
|
||||
class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
|
||||
PluginStateBloc()
|
||||
: listener = LocalAIStateListener(),
|
||||
super(
|
||||
const PluginStateState(
|
||||
action: PluginStateAction.unknown(),
|
||||
),
|
||||
) {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(PluginStateEvent.updateLocalAIState(pluginState));
|
||||
}
|
||||
},
|
||||
resourceCallback: (data) {
|
||||
if (!isClosed) {
|
||||
add(PluginStateEvent.resourceStateChange(data));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
on<PluginStateEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final LocalAIStateListener listener;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
PluginStateEvent event,
|
||||
Emitter<PluginStateState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final result = await AIEventGetLocalAIState().send();
|
||||
result.fold(
|
||||
(pluginState) {
|
||||
if (!isClosed) {
|
||||
add(PluginStateEvent.updateLocalAIState(pluginState));
|
||||
}
|
||||
},
|
||||
(err) => Log.error(err.toString()),
|
||||
);
|
||||
},
|
||||
updateLocalAIState: (LocalAIPB aiState) {
|
||||
// if the offline ai is not started, ask user to start it
|
||||
if (aiState.hasLackOfResource()) {
|
||||
emit(
|
||||
PluginStateState(
|
||||
action: PluginStateAction.lackOfResource(aiState.lackOfResource),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Chech state of the plugin
|
||||
switch (aiState.state) {
|
||||
case RunningStatePB.ReadyToRun:
|
||||
emit(
|
||||
const PluginStateState(
|
||||
action: PluginStateAction.readToRun(),
|
||||
),
|
||||
);
|
||||
|
||||
case RunningStatePB.Connecting:
|
||||
emit(
|
||||
const PluginStateState(
|
||||
action: PluginStateAction.initializingPlugin(),
|
||||
),
|
||||
);
|
||||
case RunningStatePB.Connected:
|
||||
emit(
|
||||
const PluginStateState(
|
||||
action: PluginStateAction.initializingPlugin(),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case RunningStatePB.Running:
|
||||
emit(
|
||||
PluginStateState(
|
||||
action: PluginStateAction.running(aiState.pluginVersion),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case RunningStatePB.Stopped:
|
||||
emit(
|
||||
state.copyWith(action: const PluginStateAction.restartPlugin()),
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
restartLocalAI: () async {
|
||||
emit(
|
||||
const PluginStateState(action: PluginStateAction.restartPlugin()),
|
||||
);
|
||||
unawaited(AIEventRestartLocalAI().send());
|
||||
},
|
||||
resourceStateChange: (data) {
|
||||
emit(
|
||||
PluginStateState(
|
||||
action: PluginStateAction.lackOfResource(data.resourceDesc),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class PluginStateEvent with _$PluginStateEvent {
|
||||
const factory PluginStateEvent.started() = _Started;
|
||||
const factory PluginStateEvent.updateLocalAIState(LocalAIPB aiState) =
|
||||
_UpdateLocalAIState;
|
||||
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
|
||||
const factory PluginStateEvent.resourceStateChange(LackOfAIResourcePB data) =
|
||||
_ResourceStateChange;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class PluginStateState with _$PluginStateState {
|
||||
const factory PluginStateState({
|
||||
required PluginStateAction action,
|
||||
}) = _PluginStateState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class PluginStateAction with _$PluginStateAction {
|
||||
const factory PluginStateAction.unknown() = _Unknown;
|
||||
const factory PluginStateAction.readToRun() = _ReadyToRun;
|
||||
const factory PluginStateAction.initializingPlugin() = _InitializingPlugin;
|
||||
const factory PluginStateAction.running(String version) = _PluginRunning;
|
||||
const factory PluginStateAction.restartPlugin() = _RestartPlugin;
|
||||
const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource;
|
||||
}
|
||||
+4
-3
@@ -14,7 +14,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
const _confirmText = 'DELETE MY ACCOUNT';
|
||||
const _acceptableConfirmTexts = [
|
||||
'delete my account',
|
||||
'deletemyaccount',
|
||||
@@ -135,7 +134,8 @@ class _AccountDeletionDialog extends StatelessWidget {
|
||||
),
|
||||
const VSpace(12.0),
|
||||
FlowyTextField(
|
||||
hintText: _confirmText,
|
||||
hintText:
|
||||
LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(),
|
||||
controller: controller,
|
||||
),
|
||||
const VSpace(16),
|
||||
@@ -176,7 +176,8 @@ class _AccountDeletionDialog extends StatelessWidget {
|
||||
bool _isConfirmTextValid(String text) {
|
||||
// don't convert the text to lower case or upper case,
|
||||
// just check if the text is in the list
|
||||
return _acceptableConfirmTexts.contains(text);
|
||||
return _acceptableConfirmTexts.contains(text) ||
|
||||
text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr();
|
||||
}
|
||||
|
||||
Future<void> deleteMyAccount(
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.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';
|
||||
|
||||
class InitLocalAIIndicator extends StatelessWidget {
|
||||
const InitLocalAIIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEDF7ED),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: BlocBuilder<LocalAISettingPanelBloc, LocalAISettingPanelState>(
|
||||
builder: (context, state) {
|
||||
switch (state.runningState) {
|
||||
case RunningStatePB.Connecting:
|
||||
case RunningStatePB.Connected:
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIInitializing
|
||||
.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case RunningStatePB.Running:
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
const FlowySvg(
|
||||
FlowySvgs.download_success_s,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAILoaded.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
case RunningStatePB.Stopped:
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIStopped.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFFC62828),
|
||||
),
|
||||
],
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+122
-100
@@ -1,128 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:expandable/expandable.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';
|
||||
|
||||
class LocalAISetting extends StatelessWidget {
|
||||
import 'ollama_setting.dart';
|
||||
import 'plugin_status_indicator.dart';
|
||||
|
||||
class LocalAISetting extends StatefulWidget {
|
||||
const LocalAISetting({super.key});
|
||||
|
||||
@override
|
||||
State<LocalAISetting> createState() => _LocalAISettingState();
|
||||
}
|
||||
|
||||
class _LocalAISettingState extends State<LocalAISetting> {
|
||||
final expandableController = ExpandableController(initialExpanded: false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
expandableController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: ExpandableNotifier(
|
||||
child: BlocListener<LocalAIToggleBloc, LocalAIToggleState>(
|
||||
listener: (context, state) {
|
||||
final controller =
|
||||
ExpandableController.of(context, required: true)!;
|
||||
|
||||
state.pageIndicator.when(
|
||||
error: (_) => controller.expanded = true,
|
||||
isEnabled: (enabled) => controller.expanded = enabled,
|
||||
loading: () => controller.expanded = true,
|
||||
);
|
||||
},
|
||||
child: ExpandablePanel(
|
||||
theme: const ExpandableThemeData(
|
||||
headerAlignment: ExpandablePanelHeaderAlignment.center,
|
||||
tapBodyToCollapse: false,
|
||||
hasIcon: false,
|
||||
tapBodyToExpand: false,
|
||||
tapHeaderToExpand: false,
|
||||
),
|
||||
header: const LocalAISettingHeader(),
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: Column(
|
||||
children: [
|
||||
const VSpace(12),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: const Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: LocalAISettingPanel(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
create: (context) => LocalAiPluginBloc(),
|
||||
child: BlocConsumer<LocalAiPluginBloc, LocalAiPluginState>(
|
||||
listener: (context, state) {
|
||||
expandableController.value = state.isEnabled;
|
||||
},
|
||||
builder: (context, state) {
|
||||
return ExpandablePanel(
|
||||
controller: expandableController,
|
||||
theme: ExpandableThemeData(
|
||||
tapBodyToCollapse: false,
|
||||
hasIcon: false,
|
||||
tapBodyToExpand: false,
|
||||
tapHeaderToExpand: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
header: LocalAiSettingHeader(
|
||||
isEnabled: state.isEnabled,
|
||||
isToggleable: state is ReadyLocalAiPluginState,
|
||||
),
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: LocalAISettingPanel(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAISettingHeader extends StatelessWidget {
|
||||
const LocalAISettingHeader({super.key});
|
||||
class LocalAiSettingHeader extends StatelessWidget {
|
||||
const LocalAiSettingHeader({
|
||||
super.key,
|
||||
required this.isEnabled,
|
||||
required this.isToggleable,
|
||||
});
|
||||
|
||||
final bool isEnabled;
|
||||
final bool isToggleable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>(
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
|
||||
),
|
||||
const VSpace(4),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(),
|
||||
maxLines: 3,
|
||||
fontSize: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !isToggleable,
|
||||
child: Opacity(
|
||||
opacity: isToggleable ? 1 : 0.5,
|
||||
child: Toggle(
|
||||
value: isEnabled,
|
||||
onChanged: (_) => _onToggleChanged(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onToggleChanged(BuildContext context) {
|
||||
if (isEnabled) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.settings_aiPage_keys_disableLocalAITitle.tr(),
|
||||
description:
|
||||
LocaleKeys.settings_aiPage_keys_disableLocalAIDescription.tr(),
|
||||
confirmLabel: LocaleKeys.button_confirm.tr(),
|
||||
onConfirm: () {
|
||||
context
|
||||
.read<LocalAiPluginBloc>()
|
||||
.add(const LocalAiPluginEvent.toggle());
|
||||
},
|
||||
);
|
||||
} else {
|
||||
context.read<LocalAiPluginBloc>().add(const LocalAiPluginEvent.toggle());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAISettingPanel extends StatelessWidget {
|
||||
const LocalAISettingPanel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
|
||||
builder: (context, state) {
|
||||
return state.pageIndicator.when(
|
||||
error: (error) => SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
isEnabled: (isEnabled) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FlowyText.medium(
|
||||
LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(),
|
||||
),
|
||||
const Spacer(),
|
||||
Toggle(
|
||||
value: isEnabled,
|
||||
onChanged: (_) {
|
||||
if (isEnabled) {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: LocaleKeys
|
||||
.settings_aiPage_keys_disableLocalAITitle
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_aiPage_keys_disableLocalAIDescription
|
||||
.tr(),
|
||||
confirmLabel: LocaleKeys.button_confirm.tr(),
|
||||
onConfirm: () => context
|
||||
.read<LocalAIToggleBloc>()
|
||||
.add(const LocalAIToggleEvent.toggle()),
|
||||
);
|
||||
} else {
|
||||
context
|
||||
.read<LocalAIToggleBloc>()
|
||||
.add(const LocalAIToggleEvent.toggle());
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(4),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIToggleSubTitle.tr(),
|
||||
maxLines: 3,
|
||||
fontSize: 12,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (state is! ReadyLocalAiPluginState) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const LocalAIStatusIndicator(),
|
||||
if (state.showSettings) ...[
|
||||
const VSpace(10),
|
||||
OllamaSettingPage(),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/ollma_setting.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'plugin_state.dart';
|
||||
|
||||
class LocalAISettingPanel extends StatelessWidget {
|
||||
const LocalAISettingPanel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => LocalAISettingPanelBloc(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: BlocBuilder<LocalAISettingPanelBloc, LocalAISettingPanelState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OllamaSettingPage(),
|
||||
VSpace(6),
|
||||
PluginStateIndicator(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-8
@@ -20,38 +20,44 @@ class AIModelSelection extends StatelessWidget {
|
||||
buildWhen: (previous, current) =>
|
||||
previous.availableModels != current.availableModels,
|
||||
builder: (context, state) {
|
||||
if (state.availableModels == null) {
|
||||
final models = state.availableModels?.models;
|
||||
if (models == null) {
|
||||
return const SizedBox(
|
||||
// Using same height as SettingsDropdown to avoid layout shift
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
|
||||
final localModels = models.where((model) => model.isLocal).toList();
|
||||
final cloudModels = models.where((model) => !model.isLocal).toList();
|
||||
final selectedModel = state.availableModels!.selectedModel;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
Expanded(
|
||||
child: FlowyText.medium(
|
||||
LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
|
||||
fontSize: 14,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: SettingsDropdown<AIModelPB>(
|
||||
key: const Key('_AIModelSelection'),
|
||||
onChanged: (model) => context
|
||||
.read<SettingsAIBloc>()
|
||||
.add(SettingsAIEvent.selectModel(model)),
|
||||
selectedOption: state.availableModels!.selectedModel,
|
||||
options: state.availableModels!.models
|
||||
selectedOption: selectedModel,
|
||||
selectOptionCompare: (left, right) =>
|
||||
left?.name == right?.name,
|
||||
options: [...localModels, ...cloudModels]
|
||||
.map(
|
||||
(model) => buildDropdownMenuEntry<AIModelPB>(
|
||||
context,
|
||||
value: model,
|
||||
label: model.i18n,
|
||||
label:
|
||||
model.isLocal ? "${model.i18n} 🔐" : model.i18n,
|
||||
subLabel: model.desc,
|
||||
maximumHeight: height,
|
||||
),
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.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/flowy_tooltip.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class OllamaSettingPage extends StatelessWidget {
|
||||
const OllamaSettingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
OllamaSettingBloc()..add(const OllamaSettingEvent.started()),
|
||||
child: BlocBuilder<OllamaSettingBloc, OllamaSettingState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.inputItems != current.inputItems ||
|
||||
previous.isEdited != current.isEdited,
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
for (final item in state.inputItems)
|
||||
_SettingItemWidget(item: item),
|
||||
_SaveButton(isEdited: state.isEdited),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingItemWidget extends StatelessWidget {
|
||||
const _SettingItemWidget({required this.item});
|
||||
|
||||
final SettingItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
key: ValueKey(item.content + item.settingType.title),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText(
|
||||
item.settingType.title,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
const VSpace(4),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FlowyTextField(
|
||||
hintText: item.hintText,
|
||||
text: item.content,
|
||||
onChanged: (content) {
|
||||
context.read<OllamaSettingBloc>().add(
|
||||
OllamaSettingEvent.onEdit(content, item.settingType),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
const _SaveButton({required this.isEdited});
|
||||
|
||||
final bool isEdited;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: FlowyTooltip(
|
||||
message: isEdited ? null : 'No changes',
|
||||
child: SizedBox(
|
||||
child: FlowyButton(
|
||||
text: FlowyText(
|
||||
'Apply',
|
||||
figmaLineHeight: 20,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
disable: !isEdited,
|
||||
expandText: false,
|
||||
margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primary.withAlpha(200),
|
||||
onTap: () {
|
||||
if (isEdited) {
|
||||
context
|
||||
.read<OllamaSettingBloc>()
|
||||
.add(const OllamaSettingEvent.submit());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/ollama_setting_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.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/flowy_tooltip.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class OllamaSettingPage extends StatelessWidget {
|
||||
const OllamaSettingPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
OllamaSettingBloc()..add(const OllamaSettingEvent.started()),
|
||||
child: BlocBuilder<OllamaSettingBloc, OllamaSettingState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.inputItems != current.inputItems ||
|
||||
previous.isEdited != current.isEdited,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListView.separated(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: state.inputItems.length,
|
||||
separatorBuilder: (_, __) => const VSpace(10),
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.inputItems[index];
|
||||
return _SettingItemWidget(item: item);
|
||||
},
|
||||
),
|
||||
const VSpace(6),
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: _InstallOllamaInstruction(),
|
||||
),
|
||||
_SaveButton(isEdited: state.isEdited),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingItemWidget extends StatelessWidget {
|
||||
const _SettingItemWidget({required this.item});
|
||||
final SettingItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
key: ValueKey(item.content + item.settingType.title),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText(item.settingType.title),
|
||||
const VSpace(8),
|
||||
FlowyTextField(
|
||||
hintText: item.hintText,
|
||||
text: item.content,
|
||||
onChanged: (content) {
|
||||
context.read<OllamaSettingBloc>().add(
|
||||
OllamaSettingEvent.onEdit(content, item.settingType),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
const _SaveButton({required this.isEdited});
|
||||
|
||||
final bool isEdited;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tooltipMessage = isEdited ? 'Click to apply changes' : 'No changes';
|
||||
return SizedBox(
|
||||
height: 50,
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: FlowyTooltip(
|
||||
message: tooltipMessage,
|
||||
child: Opacity(
|
||||
opacity: isEdited ? 1 : 0.5,
|
||||
child: FlowyTextButton(
|
||||
'Apply',
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
onPressed: isEdited
|
||||
? () {
|
||||
context
|
||||
.read<OllamaSettingBloc>()
|
||||
.add(const OllamaSettingEvent.submit());
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallOllamaInstruction extends StatelessWidget {
|
||||
const _InstallOllamaInstruction();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RichText(
|
||||
maxLines: 3,
|
||||
textAlign: TextAlign.left,
|
||||
text: TextSpan(
|
||||
children: <TextSpan>[
|
||||
TextSpan(
|
||||
text:
|
||||
"${LocaleKeys.settings_aiPage_keys_localAISetupInstruction1.tr()} ",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(height: 1.5),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
" ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction2.tr()} ",
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: FontSizes.s14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
height: 1.5,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => afLaunchUrlString(
|
||||
"https://appflowy.com/guide/appflowy-local-ai-ollama",
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
" ${LocaleKeys.settings_aiPage_keys_localAISetupInstruction3.tr()} ",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart';
|
||||
import 'package:easy_localization/easy_localization.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:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class PluginStateIndicator extends StatelessWidget {
|
||||
const PluginStateIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
PluginStateBloc()..add(const PluginStateEvent.started()),
|
||||
child: BlocBuilder<PluginStateBloc, PluginStateState>(
|
||||
builder: (context, state) {
|
||||
return state.action.when(
|
||||
unknown: () => const SizedBox.shrink(),
|
||||
readToRun: () => const _PrepareRunning(),
|
||||
initializingPlugin: () => const InitLocalAIIndicator(),
|
||||
running: (version) => _LocalAIRunning(
|
||||
key: ValueKey(version),
|
||||
version: version,
|
||||
),
|
||||
restartPlugin: () => const _RestartPluginButton(),
|
||||
lackOfResource: (desc) => _LackOfResource(desc: desc),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrepareRunning extends StatelessWidget {
|
||||
const _PrepareRunning();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RestartPluginButton extends StatelessWidget {
|
||||
const _RestartPluginButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.download_warn_s,
|
||||
color: Color(0xFFC62828),
|
||||
),
|
||||
const HSpace(6),
|
||||
FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 30,
|
||||
child: FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text:
|
||||
FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()),
|
||||
onTap: () {
|
||||
context.read<PluginStateBloc>().add(
|
||||
const PluginStateEvent.restartLocalAI(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalAIRunning extends StatelessWidget {
|
||||
const _LocalAIRunning({required this.version, super.key});
|
||||
|
||||
final String version;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEDF7ED),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
const FlowySvg(
|
||||
FlowySvgs.download_success_s,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
const HSpace(6),
|
||||
if (version.isNotEmpty)
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
"($version) ",
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIRunning.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LackOfResource extends StatelessWidget {
|
||||
const _LackOfResource({required this.desc});
|
||||
|
||||
final String desc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.toast_warning_filled_s,
|
||||
size: const Size.square(20.0),
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(6),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
desc,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.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/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class LocalAIStatusIndicator extends StatelessWidget {
|
||||
const LocalAIStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeWhen(
|
||||
ready: (_, version, runningState, lackOfResource) {
|
||||
if (lackOfResource != null) {
|
||||
return _LackOfResource(resource: lackOfResource);
|
||||
}
|
||||
|
||||
return switch (runningState) {
|
||||
RunningStatePB.ReadyToRun => const _ReadyToRun(),
|
||||
RunningStatePB.Connecting ||
|
||||
RunningStatePB.Connected =>
|
||||
_Initializing(),
|
||||
RunningStatePB.Running => _LocalAIRunning(version: version),
|
||||
RunningStatePB.Stopped => const _RestartPluginButton(),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadyToRun extends StatelessWidget {
|
||||
const _ReadyToRun();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox.square(
|
||||
dimension: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
),
|
||||
const HSpace(8.0),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Initializing extends StatelessWidget {
|
||||
const _Initializing();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox.square(
|
||||
dimension: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
),
|
||||
HSpace(8),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RestartPluginButton extends StatelessWidget {
|
||||
const _RestartPluginButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle =
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0x80FFE7EE)
|
||||
: const Color(0x80591734),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.toast_error_filled_s,
|
||||
size: Size.square(20.0),
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
maxLines: 3,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text:
|
||||
LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ',
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_restartLocalAI.tr(),
|
||||
style: textStyle?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
context
|
||||
.read<LocalAiPluginBloc>()
|
||||
.add(const LocalAiPluginEvent.restart());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalAIRunning extends StatelessWidget {
|
||||
const _LocalAIRunning({
|
||||
required this.version,
|
||||
});
|
||||
|
||||
final String version;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr();
|
||||
final text = version.isEmpty ? runningText : "$runningText ($version)";
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEDF7ED),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.download_success_s,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
const HSpace(6),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
text,
|
||||
color: const Color(0xFF1E4620),
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LackOfResource extends StatelessWidget {
|
||||
const _LackOfResource({required this.resource});
|
||||
|
||||
final LackOfAIResourcePB resource;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0x80FFE7EE)
|
||||
: const Color(0x80591734),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.toast_error_filled_s,
|
||||
size: const Size.square(20.0),
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8),
|
||||
Expanded(
|
||||
child: switch (resource.resourceType) {
|
||||
LackOfAIResourceTypePB.PluginExecutableNotReady =>
|
||||
_buildNoLAI(context),
|
||||
LackOfAIResourceTypePB.OllamaServerNotReady =>
|
||||
_buildNoOllama(context),
|
||||
LackOfAIResourceTypePB.MissingModel =>
|
||||
_buildNoModel(context, resource.missingModelNames),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle? _textStyle(BuildContext context) {
|
||||
return Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5);
|
||||
}
|
||||
|
||||
Widget _buildNoLAI(BuildContext context) {
|
||||
final textStyle = _textStyle(context);
|
||||
return RichText(
|
||||
maxLines: 3,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_laiNotReady.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(text: ' ', style: _textStyle(context)),
|
||||
..._downloadInstructions(textStyle),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoOllama(BuildContext context) {
|
||||
final textStyle = _textStyle(context);
|
||||
return RichText(
|
||||
maxLines: 3,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_ollamaNotReady.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(text: ' ', style: textStyle),
|
||||
..._downloadInstructions(textStyle),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoModel(BuildContext context, List<String> modelNames) {
|
||||
final textStyle = _textStyle(context);
|
||||
|
||||
return RichText(
|
||||
maxLines: 3,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_modelsMissing.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ',
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ',
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_instructions.tr(),
|
||||
style: textStyle?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
afLaunchUrlString(
|
||||
"https://appflowy.com/guide/appflowy-local-ai-ollama",
|
||||
);
|
||||
},
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ',
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_downloadModel.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<TextSpan> _downloadInstructions(TextStyle? textStyle) {
|
||||
return [
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_pleaseFollowThese.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ',
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_instructions.tr(),
|
||||
style: textStyle?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
afLaunchUrlString(
|
||||
"https://appflowy.com/guide/appflowy-local-ai-ollama",
|
||||
);
|
||||
},
|
||||
),
|
||||
TextSpan(text: ' ', style: textStyle),
|
||||
TextSpan(
|
||||
text: LocaleKeys.settings_aiPage_keys_installOllamaLai.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
+3
@@ -16,9 +16,11 @@ class SettingsDropdown<T> extends StatefulWidget {
|
||||
this.onChanged,
|
||||
this.actions,
|
||||
this.expandWidth = true,
|
||||
this.selectOptionCompare,
|
||||
});
|
||||
|
||||
final T selectedOption;
|
||||
final CompareFunction<T>? selectOptionCompare;
|
||||
final List<DropdownMenuEntry<T>> options;
|
||||
final void Function(T)? onChanged;
|
||||
final List<Widget>? actions;
|
||||
@@ -52,6 +54,7 @@ class _SettingsDropdownState<T> extends State<SettingsDropdown<T>> {
|
||||
expandedInsets: widget.expandWidth ? EdgeInsets.zero : null,
|
||||
initialSelection: widget.selectedOption,
|
||||
dropdownMenuEntries: widget.options,
|
||||
selectOptionCompare: widget.selectOptionCompare,
|
||||
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontFamily: fontFamilyUsed,
|
||||
fontWeight: FontWeight.w400,
|
||||
|
||||
+26
-13
@@ -86,7 +86,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
|
||||
),
|
||||
buildChild: (controller) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.questionBubble_help.tr(),
|
||||
message: LocaleKeys.questionBubble_getSupport.tr(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
@@ -121,22 +121,22 @@ class _BubbleActionListState extends State<BubbleActionList> {
|
||||
if (action is BubbleActionWrapper) {
|
||||
switch (action.inner) {
|
||||
case BubbleAction.whatsNews:
|
||||
afLaunchUrlString("https://www.appflowy.io/what-is-new");
|
||||
afLaunchUrlString('https://www.appflowy.io/what-is-new');
|
||||
break;
|
||||
case BubbleAction.help:
|
||||
afLaunchUrlString("https://discord.gg/9Q2xaN37tV");
|
||||
case BubbleAction.getSupport:
|
||||
afLaunchUrlString('https://discord.gg/9Q2xaN37tV');
|
||||
break;
|
||||
case BubbleAction.debug:
|
||||
_DebugToast().show();
|
||||
break;
|
||||
case BubbleAction.shortcuts:
|
||||
afLaunchUrlString(
|
||||
"https://docs.appflowy.io/docs/appflowy/product/shortcuts",
|
||||
'https://docs.appflowy.io/docs/appflowy/product/shortcuts',
|
||||
);
|
||||
break;
|
||||
case BubbleAction.markdown:
|
||||
afLaunchUrlString(
|
||||
"https://docs.appflowy.io/docs/appflowy/product/markdown",
|
||||
'https://docs.appflowy.io/docs/appflowy/product/markdown',
|
||||
);
|
||||
break;
|
||||
case BubbleAction.github:
|
||||
@@ -144,6 +144,11 @@ class _BubbleActionListState extends State<BubbleActionList> {
|
||||
'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose',
|
||||
);
|
||||
break;
|
||||
case BubbleAction.helpAndDocumentation:
|
||||
afLaunchUrlString(
|
||||
'https://appflowy.com/guide',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +160,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
|
||||
|
||||
class _DebugToast {
|
||||
void show() async {
|
||||
String debugInfo = "";
|
||||
String debugInfo = '';
|
||||
debugInfo += await _getDeviceInfo();
|
||||
debugInfo += await _getDocumentPath();
|
||||
await Clipboard.setData(ClipboardData(text: debugInfo));
|
||||
@@ -168,20 +173,21 @@ class _DebugToast {
|
||||
final deviceInfo = await deviceInfoPlugin.deviceInfo;
|
||||
|
||||
return deviceInfo.data.entries
|
||||
.fold('', (prev, el) => "$prev${el.key}: ${el.value}\n");
|
||||
.fold('', (prev, el) => '$prev${el.key}: ${el.value}\n');
|
||||
}
|
||||
|
||||
Future<String> _getDocumentPath() async {
|
||||
return appFlowyApplicationDataDirectory().then((directory) {
|
||||
final path = directory.path.toString();
|
||||
return "Document: $path\n";
|
||||
return 'Document: $path\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum BubbleAction {
|
||||
whatsNews,
|
||||
help,
|
||||
helpAndDocumentation,
|
||||
getSupport,
|
||||
debug,
|
||||
shortcuts,
|
||||
markdown,
|
||||
@@ -204,8 +210,10 @@ extension QuestionBubbleExtension on BubbleAction {
|
||||
switch (this) {
|
||||
case BubbleAction.whatsNews:
|
||||
return LocaleKeys.questionBubble_whatsNew.tr();
|
||||
case BubbleAction.help:
|
||||
return LocaleKeys.questionBubble_help.tr();
|
||||
case BubbleAction.helpAndDocumentation:
|
||||
return LocaleKeys.questionBubble_helpAndDocumentation.tr();
|
||||
case BubbleAction.getSupport:
|
||||
return LocaleKeys.questionBubble_getSupport.tr();
|
||||
case BubbleAction.debug:
|
||||
return LocaleKeys.questionBubble_debug_name.tr();
|
||||
case BubbleAction.shortcuts:
|
||||
@@ -221,7 +229,12 @@ extension QuestionBubbleExtension on BubbleAction {
|
||||
switch (this) {
|
||||
case BubbleAction.whatsNews:
|
||||
return const FlowySvg(FlowySvgs.star_s);
|
||||
case BubbleAction.help:
|
||||
case BubbleAction.helpAndDocumentation:
|
||||
return const FlowySvg(
|
||||
FlowySvgs.help_and_documentation_s,
|
||||
size: Size.square(16.0),
|
||||
);
|
||||
case BubbleAction.getSupport:
|
||||
return const FlowySvg(FlowySvgs.message_support_s);
|
||||
case BubbleAction.debug:
|
||||
return const FlowySvg(FlowySvgs.debug_s);
|
||||
|
||||
+10
-15
@@ -25,18 +25,13 @@ class SocialMediaSection extends CustomActionCell {
|
||||
action: SocialMediaWrapper(social),
|
||||
itemHeight: ActionListSizes.itemHeight,
|
||||
onSelected: (action) {
|
||||
switch (action.inner) {
|
||||
case SocialMedia.reddit:
|
||||
afLaunchUrlString(
|
||||
'https://www.reddit.com/r/AppFlowy/',
|
||||
);
|
||||
case SocialMedia.twitter:
|
||||
afLaunchUrlString(
|
||||
'https://x.com/appflowy',
|
||||
);
|
||||
case SocialMedia.forum:
|
||||
afLaunchUrlString('https://forum.appflowy.io/');
|
||||
}
|
||||
final url = switch (action.inner) {
|
||||
SocialMedia.reddit => 'https://www.reddit.com/r/AppFlowy/',
|
||||
SocialMedia.twitter => 'https://x.com/appflowy',
|
||||
SocialMedia.forum => 'https://forum.appflowy.com/',
|
||||
};
|
||||
|
||||
afLaunchUrlString(url);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -85,11 +80,11 @@ extension QuestionBubbleExtension on SocialMedia {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case SocialMedia.forum:
|
||||
return "Community Forum";
|
||||
return 'Community Forum';
|
||||
case SocialMedia.twitter:
|
||||
return "Twitter – @appflowy";
|
||||
return 'Twitter – @appflowy';
|
||||
case SocialMedia.reddit:
|
||||
return "Reddit – r/appflowy";
|
||||
return 'Reddit – r/appflowy';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,34 +144,34 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
|
||||
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
|
||||
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
|
||||
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
|
||||
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
|
||||
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
|
||||
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
|
||||
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
|
||||
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
|
||||
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
|
||||
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
|
||||
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
|
||||
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
|
||||
device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041
|
||||
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
|
||||
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
|
||||
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
|
||||
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
|
||||
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
||||
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
|
||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
|
||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
|
||||
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
|
||||
|
||||
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
|
||||
|
||||
|
||||
@@ -50,6 +50,72 @@ class FlowyTooltip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ManualTooltip extends StatefulWidget {
|
||||
const ManualTooltip({
|
||||
super.key,
|
||||
this.message,
|
||||
this.richMessage,
|
||||
this.preferBelow,
|
||||
this.margin,
|
||||
this.verticalOffset,
|
||||
this.padding,
|
||||
this.showAutomaticlly = false,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final String? message;
|
||||
final InlineSpan? richMessage;
|
||||
final bool? preferBelow;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Widget? child;
|
||||
final double? verticalOffset;
|
||||
final EdgeInsets? padding;
|
||||
final bool showAutomaticlly;
|
||||
|
||||
@override
|
||||
State<ManualTooltip> createState() => _ManualTooltipState();
|
||||
}
|
||||
|
||||
class _ManualTooltipState extends State<ManualTooltip> {
|
||||
final key = GlobalKey<TooltipState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.showAutomaticlly) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) key.currentState?.ensureTooltipVisible();
|
||||
});
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
key: key,
|
||||
margin: widget.margin,
|
||||
verticalOffset: widget.verticalOffset ?? 16.0,
|
||||
triggerMode: widget.showAutomaticlly ? TooltipTriggerMode.manual : null,
|
||||
padding: widget.padding ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.tooltipBackgroundColor(),
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
waitDuration: _tooltipWaitDuration,
|
||||
message: widget.message,
|
||||
textStyle: widget.message != null ? context.tooltipTextStyle() : null,
|
||||
richMessage: widget.richMessage,
|
||||
preferBelow: widget.preferBelow,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension FlowyToolTipExtension on BuildContext {
|
||||
double tooltipFontSize() => 14.0;
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "5ad9d77"
|
||||
resolved-ref: "5ad9d771a8496dea95a9c5b1ec77f76df3983037"
|
||||
ref: "552f95f"
|
||||
resolved-ref: "552f95fd15627e10a138c6db2a6d0a8089bc9a25"
|
||||
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
||||
source: git
|
||||
version: "5.1.0"
|
||||
|
||||
@@ -184,7 +184,7 @@ dependency_overrides:
|
||||
appflowy_editor:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||
ref: "5ad9d77"
|
||||
ref: "552f95f"
|
||||
|
||||
appflowy_editor_plugins:
|
||||
git:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 8.8V10C18 13.7712 18 15.6569 16.8284 16.8284C15.6569 18 13.7712 18 10 18C6.22876 18 4.34314 18 3.17158 16.8284C2 15.6569 2 13.7712 2 10C2 6.22876 2 4.34314 3.17158 3.17158C4.34314 2 6.22876 2 10 2H11.2M6 11H13M6 14H10M13 4.5C13 5.16304 13.2634 5.79893 13.7322 6.26777C14.2011 6.73661 14.837 7 15.5 7C16.163 7 16.7989 6.73661 17.2678 6.26777C17.7366 5.79893 18 5.16304 18 4.5C18 3.83696 17.7366 3.20107 17.2678 2.73223C16.7989 2.26339 16.163 2 15.5 2C14.837 2 14.2011 2.26339 13.7322 2.73223C13.2634 3.20107 13 3.83696 13 4.5Z" stroke="#21232A" stroke-width="1.25" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -256,6 +256,12 @@
|
||||
"bulletWithImageDescription": "@:chat.changeFormat.bullet مع الصورة",
|
||||
"tableWithImageDescription": "@:chat.changeFormat.table مع الصورة"
|
||||
},
|
||||
"switchModel": {
|
||||
"label": "تبديل النموذج",
|
||||
"localModel": "النموذج المحلي",
|
||||
"cloudModel": "نموذج السحابة",
|
||||
"autoModel": "آلي"
|
||||
},
|
||||
"selectBanner": {
|
||||
"saveButton": "أضف إلى...",
|
||||
"selectMessages": "حدد الرسائل",
|
||||
@@ -858,6 +864,8 @@
|
||||
"localAILoading": "جاري تحميل نموذج الدردشة المحلية للذكاء الاصطناعي...",
|
||||
"localAIStopped": "تم إيقاف الذكاء الاصطناعي المحلي",
|
||||
"localAIRunning": "الذكاء الاصطناعي المحلي قيد التشغيل",
|
||||
"localAINotReadyRetryLater": "جاري تهيئة الذكاء الاصطناعي المحلي، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.",
|
||||
"localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك",
|
||||
"localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي",
|
||||
"failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي",
|
||||
@@ -875,9 +883,13 @@
|
||||
"activeOfflineAI": "نشط",
|
||||
"downloadOfflineAI": "التنزيل",
|
||||
"openModelDirectory": "افتح المجلد",
|
||||
"localAISetupInstruction1": "اتبع هؤلاء",
|
||||
"localAISetupInstruction2": "التعليمات",
|
||||
"localAISetupInstruction3": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل",
|
||||
"laiNotReady": "لم يتم تثبيت تطبيق الذكاء الاصطناعي المحلي بشكل صحيح.",
|
||||
"ollamaNotReady": "خادم Ollama غير جاهز.",
|
||||
"pleaseFollowThese": "اتبع هؤلاء",
|
||||
"instructions": "التعليمات",
|
||||
"installOllamaLai": "لإعداد Ollama وAppFlowy Local AI. تخطَّ هذه الخطوة إذا كنت قد قمت بإعدادها بالفعل",
|
||||
"modelsMissing": "لم يتم العثور على النماذج المطلوبة.",
|
||||
"downloadModel": "لتنزيلها.",
|
||||
"startLocalAI": "قد يستغرق الأمر بضع ثوانٍ لبدء تشغيل الذكاء الاصطناعي المحلي"
|
||||
}
|
||||
},
|
||||
@@ -2133,6 +2145,7 @@
|
||||
"toolbar": {
|
||||
"resetToDefaultFont": "إعادة تعيين إلى الافتراضي",
|
||||
"textSize": "حجم النص",
|
||||
"textColor": "لون النص",
|
||||
"h1": "العنوان 1",
|
||||
"h2": "العنوان 2",
|
||||
"h3": "العنوان 3",
|
||||
@@ -2143,9 +2156,15 @@
|
||||
"textAlign": "محاذاة النص",
|
||||
"moreOptions": "المزيد من الخيارات",
|
||||
"font": "الخط",
|
||||
"inlineCode": "الكود المضمن",
|
||||
"suggestions": "اقتراحات",
|
||||
"turnInto": "تحول إلى",
|
||||
"equation": "معادلة"
|
||||
"equation": "معادلة",
|
||||
"insert": "إدراج",
|
||||
"linkInputHint": "لصق الرابط أو البحث عن الصفحات",
|
||||
"pageOrURL": "الصفحة أو عنوان URL",
|
||||
"linkName": "اسم الرابط",
|
||||
"linkNameHint": "اسم رابط الإدخال"
|
||||
},
|
||||
"errorBlock": {
|
||||
"theBlockIsNotSupported": "الإصدار الحالي لا يدعم هذا الحقل.",
|
||||
@@ -2255,11 +2274,11 @@
|
||||
"layoutDateField": "تقويم التخطيط بواسطة",
|
||||
"changeLayoutDateField": "تغيير حقل التخطيط",
|
||||
"noDateTitle": "بدون تاريخ",
|
||||
"noDateHint": "ستظهر الأحداث غير المجدولة هنا",
|
||||
"unscheduledEventsTitle": "الأحداث غير المجدولة",
|
||||
"clickToAdd": "انقر للإضافة إلى التقويم",
|
||||
"name": "تخطيط التقويم",
|
||||
"clickToOpen": "انقر لفتح السجل",
|
||||
"noDateHint": "ستظهر الأحداث غير المجدولة هنا"
|
||||
"clickToOpen": "انقر لفتح السجل"
|
||||
},
|
||||
"referencedCalendarPrefix": "نظرا ل",
|
||||
"quickJumpYear": "انتقل إلى",
|
||||
@@ -2610,12 +2629,12 @@
|
||||
"dialogTitle": "حذف الحساب",
|
||||
"dialogContent1": "هل أنت متأكد أنك تريد حذف حسابك نهائياً؟",
|
||||
"dialogContent2": "لا يمكن التراجع عن هذا الإجراء، وسوف يؤدي إلى إزالة الوصول من جميع مساحات العمل، ومسح حسابك بالكامل، بما في ذلك مساحات العمل الخاصة، وإزالتك من جميع مساحات العمل المشتركة.",
|
||||
"confirmHint1": "من فضلك اكتب \"حذف حسابي\" للتأكيد.",
|
||||
"confirmHint1": "من فضلك اكتب \"@:newSettings.myAccount.deleteAccount.confirmHint3\" للتأكيد.",
|
||||
"confirmHint2": "أفهم أن هذا الإجراء لا رجعة فيه وسيؤدي إلى حذف حسابي وجميع البيانات المرتبطة به بشكل دائم.",
|
||||
"confirmHint3": "حذف حسابي",
|
||||
"checkToConfirmError": "يجب عليك تحديد المربع لتأكيد الحذف",
|
||||
"failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم",
|
||||
"confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"حذف حسابي\"",
|
||||
"confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
|
||||
"deleteAccountSuccess": "تم حذف الحساب بنجاح"
|
||||
}
|
||||
},
|
||||
@@ -3187,7 +3206,8 @@
|
||||
"editing": "تحرير",
|
||||
"analyzing": "تحليل",
|
||||
"continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ",
|
||||
"continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!"
|
||||
"continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!",
|
||||
"more": "أكثر"
|
||||
},
|
||||
"autoUpdate": {
|
||||
"criticalUpdateTitle": "التحديث ضروري للمتابعة",
|
||||
@@ -3217,4 +3237,4 @@
|
||||
"rewrite": "إعادة كتابة",
|
||||
"insertBelow": "أدخل أدناه"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2517,11 +2517,11 @@
|
||||
"dialogTitle": "Benutzerkonto löschen",
|
||||
"dialogContent1": "Bist du sicher, dass du dein Benutzerkonto unwiderruflich löschen möchtest?",
|
||||
"dialogContent2": "Diese Aktion kann nicht rückgängig gemacht werden und führt dazu, dass der Zugriff auf alle Teambereiche aufgehoben wird, dein gesamtes Benutzerkonto, einschließlich privater Arbeitsbereiche, gelöscht wird und du aus allen freigegebenen Arbeitsbereichen entfernt wirst.",
|
||||
"confirmHint1": "Geben Sie zur Bestätigung bitte „MEIN KONTO LÖSCHEN“ ein.",
|
||||
"confirmHint1": "Geben Sie zur Bestätigung bitte „@:newSettings.myAccount.deleteAccount.confirmHint3“ ein.",
|
||||
"confirmHint3": "MEIN KONTO LÖSCHEN",
|
||||
"checkToConfirmError": "Sie müssen das Kontrollkästchen aktivieren, um das Löschen zu bestätigen",
|
||||
"failedToGetCurrentUser": "Aktuelle Benutzer-E-Mail konnte nicht abgerufen werden.",
|
||||
"confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „MEIN KONTO LÖSCHEN“ überein.",
|
||||
"confirmTextValidationFailed": "Ihr Bestätigungstext stimmt nicht mit „@:newSettings.myAccount.deleteAccount.confirmHint3“ überein.",
|
||||
"deleteAccountSuccess": "Konto erfolgreich gelöscht"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
},
|
||||
"url": {
|
||||
"launch": "Άνοιγμα συνδέσμου στο πρόγραμμα περιήγησης",
|
||||
"copy": "Copy link to clipboard",
|
||||
"copy": "Copied link to clipboard",
|
||||
"textFieldHint": "Enter a URL",
|
||||
"copiedNotification": "Copied to clipboard!"
|
||||
},
|
||||
@@ -1403,4 +1403,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,8 @@
|
||||
"questionBubble": {
|
||||
"shortcuts": "Shortcuts",
|
||||
"whatsNew": "What's new?",
|
||||
"help": "Help & Support",
|
||||
"helpAndDocumentation": "Help & documentation",
|
||||
"getSupport": "Get support",
|
||||
"markdown": "Markdown",
|
||||
"debug": {
|
||||
"name": "Debug Info",
|
||||
@@ -507,7 +508,8 @@
|
||||
"settings": "Settings",
|
||||
"members": "Members",
|
||||
"trash": "Trash",
|
||||
"helpAndSupport": "Help & Support"
|
||||
"helpAndDocumentation": "Help & documentation",
|
||||
"getSupport": "Get Support"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites",
|
||||
@@ -854,16 +856,16 @@
|
||||
"downloadAIModelButton": "Download",
|
||||
"downloadingModel": "Downloading",
|
||||
"localAILoaded": "Local AI Model successfully added and ready to use",
|
||||
"localAIStart": "Local AI is starting. If it’s slow, try toggling it off and on",
|
||||
"localAIStart": "Local AI is starting. If it's slow, try toggling it off and on",
|
||||
"localAILoading": "Local AI Chat Model is loading...",
|
||||
"localAIStopped": "Local AI stopped",
|
||||
"localAIRunning": "Local AI is running",
|
||||
"localAIInitializing": "Local AI is loading and may take a few seconds, depending on your device",
|
||||
"localAINotReadyRetryLater": "Local AI is initializing, please retry later",
|
||||
"localAIDisabled": "You are using local AI, but it is disabled. Please go to settings to enable it or try different model",
|
||||
"localAIInitializing": "Local AI is loading. This may take a few seconds depending on your device",
|
||||
"localAINotReadyTextFieldPrompt": "You can not edit while Local AI is loading",
|
||||
"failToLoadLocalAI": "Failed to start local AI",
|
||||
"restartLocalAI": "Restart Local AI",
|
||||
"failToLoadLocalAI": "Failed to start local AI.",
|
||||
"restartLocalAI": "Restart",
|
||||
"disableLocalAITitle": "Disable local AI",
|
||||
"disableLocalAIDescription": "Do you want to disable local AI?",
|
||||
"localAIToggleTitle": "AppFlowy Local AI (LAI)",
|
||||
@@ -877,10 +879,13 @@
|
||||
"activeOfflineAI": "Active",
|
||||
"downloadOfflineAI": "Download",
|
||||
"openModelDirectory": "Open folder",
|
||||
"localAISetupInstruction1": "Follow these",
|
||||
"localAISetupInstruction2": "instructions",
|
||||
"localAISetupInstruction3": "to set up Ollama and AppFlowy Local AI. Skip if you've already set it up",
|
||||
"startLocalAI": "It may take a few seconds to start the local AI"
|
||||
"laiNotReady": "The Local AI app was not installed correctly.",
|
||||
"ollamaNotReady": "The Ollama server is not ready.",
|
||||
"pleaseFollowThese": "Please follow these",
|
||||
"instructions": "instructions",
|
||||
"installOllamaLai": "to set up Ollama and AppFlowy Local AI.",
|
||||
"modelsMissing": "Cannot find the required models.",
|
||||
"downloadModel": "to download them."
|
||||
}
|
||||
},
|
||||
"planPage": {
|
||||
@@ -1642,7 +1647,7 @@
|
||||
},
|
||||
"url": {
|
||||
"launch": "Open link in browser",
|
||||
"copy": "Copy link to clipboard",
|
||||
"copy": "Copied link to clipboard",
|
||||
"textFieldHint": "Enter a URL",
|
||||
"copiedNotification": "Copied to clipboard!"
|
||||
},
|
||||
@@ -2596,12 +2601,12 @@
|
||||
"dialogTitle": "Delete account",
|
||||
"dialogContent1": "Are you sure you want to permanently delete your account?",
|
||||
"dialogContent2": "This action cannot be undone, and will remove access from all workspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.",
|
||||
"confirmHint1": "Please type \"DELETE MY ACCOUNT\" to confirm.",
|
||||
"confirmHint1": "Please type \"@:newSettings.myAccount.deleteAccount.confirmHint3\" to confirm.",
|
||||
"confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.",
|
||||
"confirmHint3": "DELETE MY ACCOUNT",
|
||||
"checkToConfirmError": "You must check the box to confirm deletion",
|
||||
"failedToGetCurrentUser": "Failed to get current user email",
|
||||
"confirmTextValidationFailed": "Your confirmation text does not match \"DELETE MY ACCOUNT\"",
|
||||
"confirmTextValidationFailed": "Your confirmation text does not match \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
|
||||
"deleteAccountSuccess": "Account deleted successfully"
|
||||
}
|
||||
},
|
||||
@@ -3204,4 +3209,4 @@
|
||||
"rewrite": "Rewrite",
|
||||
"insertBelow": "Insert below"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2519,12 +2519,12 @@
|
||||
"dialogTitle": "Supprimer le compte",
|
||||
"dialogContent1": "Êtes-vous sûr de vouloir supprimer définitivement votre compte ?",
|
||||
"dialogContent2": "Cette action ne peut pas être annulée et supprimera l'accès à tous les espaces d'équipe, effaçant l'intégralité de votre compte, y compris les espaces de travail privés, et vous supprimant de tous les espaces de travail partagés.",
|
||||
"confirmHint1": "Veuillez taper « SUPPRIMER MON COMPTE » pour confirmer.",
|
||||
"confirmHint1": "Veuillez taper « @:newSettings.myAccount.deleteAccount.confirmHint3 » pour confirmer.",
|
||||
"confirmHint2": "Je comprends que cette action est irréversible et supprimera définitivement mon compte et toutes les données associées.",
|
||||
"confirmHint3": "SUPPRIMER MON COMPTE",
|
||||
"checkToConfirmError": "Vous devez cocher la case pour confirmer la suppression",
|
||||
"failedToGetCurrentUser": "Impossible d'obtenir l'e-mail de l'utilisateur actuel",
|
||||
"confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « SUPPRIMER MON COMPTE »",
|
||||
"confirmTextValidationFailed": "Votre texte de confirmation ne correspond pas à « @:newSettings.myAccount.deleteAccount.confirmHint3 »",
|
||||
"deleteAccountSuccess": "Compte supprimé avec succès"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -868,9 +868,9 @@
|
||||
"activeOfflineAI": "활성화됨",
|
||||
"downloadOfflineAI": "다운로드",
|
||||
"openModelDirectory": "폴더 열기",
|
||||
"localAISetupInstruction1": "이 지침을 따르세요",
|
||||
"localAISetupInstruction2": "지침",
|
||||
"localAISetupInstruction3": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요",
|
||||
"pleaseFollowThese": "지침",
|
||||
"instructions": "이 지침을 따르세요",
|
||||
"installOllamaLai": "Ollama 및 AppFlowy 로컬 AI를 설정합니다. 이미 설정한 경우 건너뛰세요",
|
||||
"startLocalAI": "로컬 AI를 시작하는 데 몇 초가 소요될 수 있습니다"
|
||||
}
|
||||
},
|
||||
@@ -2579,12 +2579,12 @@
|
||||
"dialogTitle": "계정 삭제",
|
||||
"dialogContent1": "계정을 영구적으로 삭제하시겠습니까?",
|
||||
"dialogContent2": "이 작업은 되돌릴 수 없으며, 모든 작업 공간에서 액세스를 제거하고, 개인 작업 공간을 포함한 전체 계정을 삭제하며, 모든 공유 작업 공간에서 제거됩니다.",
|
||||
"confirmHint1": "\"내 계정 삭제\"를 입력하여 확인하세요.",
|
||||
"confirmHint1": "\"@:newSettings.myAccount.deleteAccount.confirmHint3\"를 입력하여 확인하세요.",
|
||||
"confirmHint2": "이 작업은 되돌릴 수 없으며, 계정과 모든 관련 데이터를 영구적으로 삭제합니다.",
|
||||
"confirmHint3": "내 계정 삭제",
|
||||
"checkToConfirmError": "삭제를 확인하려면 확인란을 선택해야 합니다",
|
||||
"failedToGetCurrentUser": "현재 사용자 이메일을 가져오지 못했습니다",
|
||||
"confirmTextValidationFailed": "확인 텍스트가 \"내 계정 삭제\"와 일치하지 않습니다",
|
||||
"confirmTextValidationFailed": "확인 텍스트가 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"와 일치하지 않습니다",
|
||||
"deleteAccountSuccess": "계정이 성공적으로 삭제되었습니다"
|
||||
}
|
||||
},
|
||||
@@ -3185,4 +3185,4 @@
|
||||
"rewrite": "다시 작성",
|
||||
"insertBelow": "아래에 삽입"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2518,12 +2518,12 @@
|
||||
"dialogTitle": "Hesabı sil",
|
||||
"dialogContent1": "Hesabınızı kalıcı olarak silmek istediğinizden emin misiniz?",
|
||||
"dialogContent2": "Bu işlem GERİ ALINAMAZ ve tüm çalışma alanlarından erişiminizi kaldıracak, özel çalışma alanları dahil tüm hesabınızı silecek ve sizi tüm paylaşılan çalışma alanlarından çıkaracaktır.",
|
||||
"confirmHint1": "Onaylamak için lütfen \"HESABIMI SİL\" yazın.",
|
||||
"confirmHint1": "Onaylamak için lütfen \"@:newSettings.myAccount.deleteAccount.confirmHint3\" yazın.",
|
||||
"confirmHint2": "Bu işlemin GERİ ALINAMAZ olduğunu ve hesabımı ve ilişkili tüm verileri kalıcı olarak sileceğini anlıyorum.",
|
||||
"confirmHint3": "HESABIMI SİL",
|
||||
"checkToConfirmError": "Silme işlemini onaylamak için kutuyu işaretlemelisiniz",
|
||||
"failedToGetCurrentUser": "Mevcut kullanıcı e-postası alınamadı",
|
||||
"confirmTextValidationFailed": "Onay metniniz \"HESABIMI SİL\" ile eşleşmiyor",
|
||||
"confirmTextValidationFailed": "Onay metniniz \"@:newSettings.myAccount.deleteAccount.confirmHint3\" ile eşleşmiyor",
|
||||
"deleteAccountSuccess": "Hesap başarıyla silindi"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2270,12 +2270,12 @@
|
||||
"dialogTitle": "Xóa tài khoản",
|
||||
"dialogContent1": "Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản của mình không?",
|
||||
"dialogContent2": "Không thể hoàn tác hành động này và sẽ xóa quyền truy cập khỏi mọi không gian làm việc nhóm, xóa toàn bộ tài khoản của bạn, bao gồm cả không gian làm việc riêng tư và xóa bạn khỏi mọi không gian làm việc được chia sẻ.",
|
||||
"confirmHint1": "Vui lòng nhập \"XÓA TÀI KHOẢN CỦA TÔI\" để xác nhận.",
|
||||
"confirmHint1": "Vui lòng nhập \"@:newSettings.myAccount.deleteAccount.confirmHint3\" để xác nhận.",
|
||||
"confirmHint2": "Tôi hiểu rằng hành động này là không thể đảo ngược và sẽ xóa vĩnh viễn tài khoản của tôi cùng mọi dữ liệu liên quan.",
|
||||
"confirmHint3": "XÓA TÀI KHOẢN CỦA TÔI",
|
||||
"checkToConfirmError": "Bạn phải đánh dấu vào ô để xác nhận việc xóa",
|
||||
"failedToGetCurrentUser": "Không lấy được email người dùng hiện tại",
|
||||
"confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"XÓA TÀI KHOẢN CỦA TÔI\"",
|
||||
"confirmTextValidationFailed": "Văn bản xác nhận của bạn không khớp với \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
|
||||
"deleteAccountSuccess": "Tài khoản đã được xóa thành công"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1920,7 +1920,14 @@
|
||||
"deleteMyAccount": "删除我的账户",
|
||||
"dialogTitle": "删除帐户",
|
||||
"dialogContent1": "你确定要永久删除您的帐户吗?",
|
||||
"dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。"
|
||||
"dialogContent2": "此操作无法撤消,并且将删除所有团队空间的访问权限,删除你的整个帐户(包括私人工作区),并将你从所有共享工作区中删除。",
|
||||
"confirmHint1": "请输入 \"@:newSettings.myAccount.deleteAccount.confirmHint3\" 以确认。",
|
||||
"confirmHint2": "我理解此操作是不可逆的,并且将永久删除我的帐户和所有关联数据。",
|
||||
"confirmHint3": "删除我的账户",
|
||||
"checkToConfirmError": "你必须勾选以确认删除。",
|
||||
"failedToGetCurrentUser": "获取当前用户邮箱失败",
|
||||
"confirmTextValidationFailed": "你的确认文本不匹配 \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
|
||||
"deleteAccountSuccess": "账户删除成功"
|
||||
}
|
||||
},
|
||||
"workplace": {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::entities::{
|
||||
AIModelPB, AvailableModelsPB, ChatInfoPB, ChatMessageListPB, ChatMessagePB, ChatSettingsPB,
|
||||
FilePB, PredefinedFormatPB, RepeatedRelatedQuestionPB, StreamMessageParams,
|
||||
};
|
||||
use crate::local_ai::controller::LocalAIController;
|
||||
use crate::local_ai::controller::{LocalAIController, LocalAISetting};
|
||||
use crate::middleware::chat_service_mw::AICloudServiceMiddleware;
|
||||
use crate::persistence::{insert_chat, read_chat_metadata, ChatTable};
|
||||
use std::collections::HashMap;
|
||||
@@ -280,6 +280,24 @@ impl AIManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> {
|
||||
let previous_model = self.local_ai.get_local_ai_setting().chat_model_name;
|
||||
self.local_ai.update_local_ai_setting(setting).await?;
|
||||
let current_model = self.local_ai.get_local_ai_setting().chat_model_name;
|
||||
|
||||
if previous_model != current_model {
|
||||
info!(
|
||||
"[AI Plugin] update global active model, previous: {}, current: {}",
|
||||
previous_model, current_model
|
||||
);
|
||||
let source_key = ai_available_models_key(GLOBAL_ACTIVE_MODEL_KEY);
|
||||
let model = AIModel::local(current_model, "".to_string());
|
||||
self.update_selected_model(source_key, model).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_workspace_select_model(&self) -> FlowyResult<String> {
|
||||
let workspace_id = self.user_service.workspace_id()?;
|
||||
let model = self
|
||||
@@ -358,6 +376,10 @@ impl AIManager {
|
||||
}
|
||||
|
||||
pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> {
|
||||
info!(
|
||||
"[Model Selection] update {} selected model: {:?}",
|
||||
source, model
|
||||
);
|
||||
let source_key = ai_available_models_key(&source);
|
||||
self
|
||||
.store_preferences
|
||||
@@ -418,9 +440,15 @@ impl AIManager {
|
||||
|
||||
trace!("[Model Selection]: Available models: {:?}", models);
|
||||
|
||||
let mut current_active_local_ai_model = None;
|
||||
|
||||
// If user enable local ai, then add local ai model to the list.
|
||||
if let Some(local_model) = self.local_ai.get_plugin_chat_model() {
|
||||
models.push(AIModel::local(local_model, "".to_string()));
|
||||
let model = AIModel::local(local_model, "".to_string());
|
||||
current_active_local_ai_model = Some(model.clone());
|
||||
|
||||
trace!("[Model Selection] current local ai model: {}", model.name);
|
||||
models.push(model);
|
||||
}
|
||||
|
||||
if models.is_empty() {
|
||||
@@ -454,8 +482,16 @@ impl AIManager {
|
||||
user_selected_model = local_ai_model.clone();
|
||||
}
|
||||
},
|
||||
Some(model) => {
|
||||
trace!("[Model Selection] user select model: {:?}", model);
|
||||
Some(mut model) => {
|
||||
trace!("[Model Selection] user previous select model: {:?}", model);
|
||||
if model.is_local {
|
||||
if let Some(local_ai_model) = ¤t_active_local_ai_model {
|
||||
if local_ai_model.name != model.name {
|
||||
model = local_ai_model.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user_selected_model = model;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ pub struct LocalAIPB {
|
||||
pub enabled: bool,
|
||||
|
||||
#[pb(index = 2, one_of)]
|
||||
pub lack_of_resource: Option<String>,
|
||||
pub lack_of_resource: Option<LackOfAIResourcePB>,
|
||||
|
||||
#[pb(index = 3)]
|
||||
pub state: RunningStatePB,
|
||||
@@ -721,5 +721,35 @@ impl From<LocalAISettingPB> for LocalAISetting {
|
||||
#[derive(Default, ProtoBuf, Clone, Debug)]
|
||||
pub struct LackOfAIResourcePB {
|
||||
#[pb(index = 1)]
|
||||
pub resource_desc: String,
|
||||
pub resource_type: LackOfAIResourceTypePB,
|
||||
|
||||
#[pb(index = 2)]
|
||||
pub missing_model_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, ProtoBuf_Enum)]
|
||||
pub enum LackOfAIResourceTypePB {
|
||||
#[default]
|
||||
PluginExecutableNotReady = 0,
|
||||
OllamaServerNotReady = 1,
|
||||
MissingModel = 2,
|
||||
}
|
||||
|
||||
impl From<PendingResource> for LackOfAIResourcePB {
|
||||
fn from(value: PendingResource) -> Self {
|
||||
match value {
|
||||
PendingResource::PluginExecutableNotReady => Self {
|
||||
resource_type: LackOfAIResourceTypePB::PluginExecutableNotReady,
|
||||
missing_model_names: vec![],
|
||||
},
|
||||
PendingResource::OllamaServerNotReady => Self {
|
||||
resource_type: LackOfAIResourceTypePB::OllamaServerNotReady,
|
||||
missing_model_names: vec![],
|
||||
},
|
||||
PendingResource::MissingModel(model_name) => Self {
|
||||
resource_type: LackOfAIResourceTypePB::MissingModel,
|
||||
missing_model_names: vec![model_name],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,9 +369,6 @@ pub(crate) async fn update_local_ai_setting_handler(
|
||||
) -> Result<(), FlowyError> {
|
||||
let data = data.try_into_inner()?;
|
||||
let ai_manager = upgrade_ai_manager(ai_manager)?;
|
||||
ai_manager
|
||||
.local_ai
|
||||
.update_local_ai_setting(data.into())
|
||||
.await?;
|
||||
ai_manager.update_local_ai_setting(data.into()).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::ai_manager::AIUserService;
|
||||
use crate::entities::{LackOfAIResourcePB, LocalAIPB, RunningStatePB};
|
||||
use crate::entities::{LocalAIPB, RunningStatePB};
|
||||
use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController};
|
||||
use crate::notification::{
|
||||
chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY,
|
||||
@@ -292,8 +292,10 @@ impl LocalAIController {
|
||||
setting,
|
||||
std::thread::current().id()
|
||||
);
|
||||
self.resource.set_llm_setting(setting).await?;
|
||||
self.reload().await?;
|
||||
|
||||
if self.resource.set_llm_setting(setting).await.is_ok() {
|
||||
self.reload().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -539,9 +541,7 @@ async fn initialize_ai_plugin(
|
||||
APPFLOWY_AI_NOTIFICATION_KEY,
|
||||
ChatNotification::LocalAIResourceUpdated,
|
||||
)
|
||||
.payload(LackOfAIResourcePB {
|
||||
resource_desc: lack_of_resource,
|
||||
})
|
||||
.payload(lack_of_resource)
|
||||
.send();
|
||||
|
||||
return Ok(());
|
||||
|
||||
@@ -47,22 +47,13 @@ pub enum WatchDiskEvent {
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PendingResource {
|
||||
PluginExecutableNotReady,
|
||||
OllamaServerNotReady,
|
||||
MissingModel(String),
|
||||
}
|
||||
|
||||
impl PendingResource {
|
||||
pub fn desc(self) -> String {
|
||||
match self {
|
||||
PendingResource::PluginExecutableNotReady => "The Local AI app was not installed correctly. Please follow the instructions to install the Local AI application".to_string(),
|
||||
PendingResource::OllamaServerNotReady => "Ollama is not ready. Please follow the instructions to install Ollama".to_string(),
|
||||
PendingResource::MissingModel(model) => format!("Cannot find the model: {}. Please use the ollama pull command to install the model", model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LocalAIResourceController {
|
||||
user_service: Arc<dyn AIUserService>,
|
||||
resource_service: Arc<dyn LLMResourceService>,
|
||||
@@ -128,10 +119,10 @@ impl LocalAIResourceController {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self.calculate_pending_resources().await {
|
||||
Ok(res) => res.is_empty(),
|
||||
Err(_) => false,
|
||||
}
|
||||
self
|
||||
.calculate_pending_resources()
|
||||
.await
|
||||
.is_ok_and(|r| r.is_none())
|
||||
}
|
||||
|
||||
pub async fn get_plugin_download_link(&self) -> FlowyResult<String> {
|
||||
@@ -147,36 +138,37 @@ impl LocalAIResourceController {
|
||||
#[instrument(level = "info", skip_all, err)]
|
||||
pub async fn set_llm_setting(&self, setting: LocalAISetting) -> FlowyResult<()> {
|
||||
self.resource_service.store_setting(setting)?;
|
||||
let mut resources = self.calculate_pending_resources().await?;
|
||||
if let Some(resource) = resources.pop() {
|
||||
if let Some(resource) = self.calculate_pending_resources().await? {
|
||||
let resource = LackOfAIResourcePB::from(resource);
|
||||
chat_notification_builder(
|
||||
APPFLOWY_AI_NOTIFICATION_KEY,
|
||||
ChatNotification::LocalAIResourceUpdated,
|
||||
)
|
||||
.payload(LackOfAIResourcePB {
|
||||
resource_desc: resource.desc(),
|
||||
})
|
||||
.payload(resource.clone())
|
||||
.send();
|
||||
return Err(FlowyError::local_ai().with_context(format!("{:?}", resource)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_lack_of_resource(&self) -> Option<String> {
|
||||
let mut resources = self.calculate_pending_resources().await.ok()?;
|
||||
resources.pop().map(|r| r.desc())
|
||||
pub async fn get_lack_of_resource(&self) -> Option<LackOfAIResourcePB> {
|
||||
self
|
||||
.calculate_pending_resources()
|
||||
.await
|
||||
.ok()?
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
pub async fn calculate_pending_resources(&self) -> FlowyResult<Vec<PendingResource>> {
|
||||
let mut resources = vec![];
|
||||
pub async fn calculate_pending_resources(&self) -> FlowyResult<Option<PendingResource>> {
|
||||
let app_path = ollama_plugin_path();
|
||||
if !is_plugin_ready() {
|
||||
trace!("[LLM Resource] offline app not found: {:?}", app_path);
|
||||
resources.push(PendingResource::PluginExecutableNotReady);
|
||||
return Ok(resources);
|
||||
return Ok(Some(PendingResource::PluginExecutableNotReady));
|
||||
}
|
||||
|
||||
let setting = self.get_llm_setting();
|
||||
let client = Client::builder().timeout(Duration::from_secs(5)).build()?;
|
||||
|
||||
match client.get(&setting.ollama_server_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!(
|
||||
@@ -189,8 +181,7 @@ impl LocalAIResourceController {
|
||||
"[LLM Resource] Ollama server is not responding at {}",
|
||||
setting.ollama_server_url
|
||||
);
|
||||
resources.push(PendingResource::OllamaServerNotReady);
|
||||
return Ok(resources);
|
||||
return Ok(Some(PendingResource::OllamaServerNotReady));
|
||||
},
|
||||
}
|
||||
|
||||
@@ -201,12 +192,8 @@ impl LocalAIResourceController {
|
||||
|
||||
match client.get(&tags_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let tags: TagsResponse = resp.json().await.map_err(|e| {
|
||||
log::error!(
|
||||
"[LLM Resource] Failed to parse /api/tags JSON response: {:?}",
|
||||
e
|
||||
);
|
||||
e
|
||||
let tags: TagsResponse = resp.json().await.inspect_err(|e| {
|
||||
log::error!("[LLM Resource] Failed to parse /api/tags JSON response: {e:?}")
|
||||
})?;
|
||||
// Check each required model is present in the response.
|
||||
for required in &required_models {
|
||||
@@ -215,9 +202,7 @@ impl LocalAIResourceController {
|
||||
"[LLM Resource] required model '{}' not found in API response",
|
||||
required
|
||||
);
|
||||
resources.push(PendingResource::MissingModel(required.clone()));
|
||||
// Optionally, you could continue checking all models rather than returning early.
|
||||
return Ok(resources);
|
||||
return Ok(Some(PendingResource::MissingModel(required.clone())));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -226,12 +211,11 @@ impl LocalAIResourceController {
|
||||
"[LLM Resource] Failed to fetch models from {} (GET /api/tags)",
|
||||
setting.ollama_server_url
|
||||
);
|
||||
resources.push(PendingResource::OllamaServerNotReady);
|
||||
return Ok(resources);
|
||||
return Ok(Some(PendingResource::OllamaServerNotReady));
|
||||
},
|
||||
}
|
||||
|
||||
Ok(resources)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip_all)]
|
||||
|
||||
Reference in new issue
Block a user