Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89647ae683 |
No files matched your search
@@ -1,15 +1,4 @@
|
||||
# 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
|
||||
|
||||
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
APPFLOWY_VERSION = "0.8.9"
|
||||
APPFLOWY_VERSION = "0.8.8"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -4,7 +4,6 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- "packages/**/*.dart"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ void main() {
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
|
||||
await tester.tapContinousAnotherWay();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
|
||||
-41
@@ -15,7 +15,6 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a database and add a linked database view
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
|
||||
|
||||
@@ -30,11 +29,6 @@ void main() {
|
||||
await tester.tapHidePropertyButton();
|
||||
tester.noFieldWithName('New field 1');
|
||||
|
||||
// create another field, New field 1 to be hidden still
|
||||
await tester.tapNewPropertyButton();
|
||||
await tester.dismissFieldEditor();
|
||||
tester.noFieldWithName('New field 1');
|
||||
|
||||
// go back to inline database view, expect field to be shown
|
||||
await tester.tapTabBarLinkedViewByViewName('Untitled');
|
||||
tester.findFieldWithName('New field 1');
|
||||
@@ -66,40 +60,5 @@ void main() {
|
||||
await tester.tapDatabaseSortButton();
|
||||
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
|
||||
});
|
||||
|
||||
testWidgets('field cell width', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a database and add a linked database view
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
|
||||
|
||||
// create a field
|
||||
await tester.scrollToRight(find.byType(GridPage));
|
||||
await tester.tapNewPropertyButton();
|
||||
await tester.renameField('New field 1');
|
||||
await tester.dismissFieldEditor();
|
||||
|
||||
// check the width of the field
|
||||
expect(tester.getFieldWidth('New field 1'), 150);
|
||||
|
||||
// change the width of the field
|
||||
await tester.changeFieldWidth('New field 1', 200);
|
||||
expect(tester.getFieldWidth('New field 1'), 205);
|
||||
|
||||
// create another field, New field 1 to be same width
|
||||
await tester.tapNewPropertyButton();
|
||||
await tester.dismissFieldEditor();
|
||||
expect(tester.getFieldWidth('New field 1'), 205);
|
||||
|
||||
// go back to inline database view, expect New field 1 to be 150px
|
||||
await tester.tapTabBarLinkedViewByViewName('Untitled');
|
||||
expect(tester.getFieldWidth('New field 1'), 150);
|
||||
|
||||
// go back to linked database view, expect New field 1 to be 205px
|
||||
await tester.tapTabBarLinkedViewByViewName('Grid');
|
||||
expect(tester.getFieldWidth('New field 1'), 205);
|
||||
});
|
||||
});
|
||||
}
|
||||
-37
@@ -1,6 +1,5 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -48,41 +47,5 @@ void main() {
|
||||
|
||||
expect(editorState.selection!.start.offset, 0);
|
||||
});
|
||||
|
||||
testWidgets('select and delete text', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
/// create a new document
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
/// input text
|
||||
final editor = tester.editor;
|
||||
final editorState = editor.getCurrentEditorState();
|
||||
|
||||
const inputText = 'Test for text selection and deletion';
|
||||
final texts = inputText.split(' ');
|
||||
await editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(inputText);
|
||||
|
||||
/// selecte and delete
|
||||
int index = 0;
|
||||
while (texts.isNotEmpty) {
|
||||
final text = texts.removeAt(0);
|
||||
await tester.editor.updateSelection(
|
||||
Selection(
|
||||
start: Position(path: [0], offset: index),
|
||||
end: Position(path: [0], offset: index + text.length),
|
||||
),
|
||||
);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
|
||||
index++;
|
||||
}
|
||||
|
||||
/// excpete the text value is correct
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
final nodeText = node.delta?.toPlainText() ?? '';
|
||||
expect(nodeText, ' ' * (index - 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
-40
@@ -184,45 +184,6 @@ 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', () {
|
||||
@@ -267,7 +228,6 @@ void main() {
|
||||
expect(toolbar, findsNothing);
|
||||
|
||||
/// show toolbar again
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await selectText(tester, text);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
|
||||
+11
-4
@@ -43,12 +43,12 @@ void main() {
|
||||
});
|
||||
|
||||
group('insert emoji by colon', () {
|
||||
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async {
|
||||
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(':${search ?? 'a'}');
|
||||
await tester.ime.insertText(':');
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
@@ -106,10 +106,11 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('insert with searching', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester, search: 's');
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// search for `smiling eyes`, IME is not working, use keyboard input
|
||||
final searchText = [
|
||||
LogicalKeyboardKey.keyS,
|
||||
LogicalKeyboardKey.keyM,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyL,
|
||||
@@ -137,10 +138,16 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('start searching with sapce', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester, search: ' ');
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -942,31 +942,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> changeFieldWidth(String fieldName, double width) async {
|
||||
final field = find.byWidgetPredicate(
|
||||
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
|
||||
);
|
||||
await hoverOnWidget(
|
||||
field,
|
||||
onHover: () async {
|
||||
final dragHandle = find.descendant(
|
||||
of: field,
|
||||
matching: find.byType(DragToExpandLine),
|
||||
);
|
||||
await drag(dragHandle, Offset(width - getSize(field).width, 0));
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double getFieldWidth(String fieldName) {
|
||||
final field = find.byWidgetPredicate(
|
||||
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
|
||||
);
|
||||
|
||||
return getSize(field).width;
|
||||
}
|
||||
|
||||
Future<void> findDateEditor(dynamic matcher) async {
|
||||
final finder = find.byType(DateCellEditor);
|
||||
expect(finder, matcher);
|
||||
|
||||
@@ -307,11 +307,9 @@ class EditorOperations {
|
||||
Future<void> openTurnIntoMenu(Path path) async {
|
||||
await hoverAndClickOptionMenuButton(path);
|
||||
await tester.tapButton(
|
||||
find
|
||||
.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
|
||||
)
|
||||
.first,
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
|
||||
}
|
||||
|
||||
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
|
||||
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
|
||||
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
|
||||
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
|
||||
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
|
||||
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
|
||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
|
||||
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
|
||||
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
|
||||
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
|
||||
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
|
||||
+10
-17
@@ -23,7 +23,6 @@ class DesktopPromptInput extends StatefulWidget {
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
this.hideDecoration = false,
|
||||
this.hideFormats = false,
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
@@ -35,7 +34,6 @@ 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
|
||||
@@ -141,11 +139,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: getTextFieldConstraints(
|
||||
state.showPredefinedFormats && !widget.hideFormats,
|
||||
state.showPredefinedFormats,
|
||||
),
|
||||
child: inputTextField(),
|
||||
),
|
||||
if (state.showPredefinedFormats && !widget.hideFormats)
|
||||
if (state.showPredefinedFormats)
|
||||
Positioned.fill(
|
||||
bottom: null,
|
||||
child: TextFieldTapRegion(
|
||||
@@ -170,9 +168,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
top: null,
|
||||
child: TextFieldTapRegion(
|
||||
child: _PromptBottomActions(
|
||||
showPredefinedFormatBar:
|
||||
showPredefinedFormats:
|
||||
state.showPredefinedFormats,
|
||||
showPredefinedFormatButton: !widget.hideFormats,
|
||||
onTogglePredefinedFormatSection: () =>
|
||||
context.read<AIPromptInputBloc>().add(
|
||||
AIPromptInputEvent
|
||||
@@ -574,8 +571,7 @@ class PromptInputTextField extends StatelessWidget {
|
||||
class _PromptBottomActions extends StatelessWidget {
|
||||
const _PromptBottomActions({
|
||||
required this.sendButtonState,
|
||||
required this.showPredefinedFormatBar,
|
||||
required this.showPredefinedFormatButton,
|
||||
required this.showPredefinedFormats,
|
||||
required this.onTogglePredefinedFormatSection,
|
||||
required this.onStartMention,
|
||||
required this.onSendPressed,
|
||||
@@ -585,8 +581,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
final bool showPredefinedFormatBar;
|
||||
final bool showPredefinedFormatButton;
|
||||
final bool showPredefinedFormats;
|
||||
final void Function() onTogglePredefinedFormatSection;
|
||||
final void Function() onStartMention;
|
||||
final SendButtonState sendButtonState;
|
||||
@@ -605,12 +600,10 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
children: [
|
||||
if (showPredefinedFormatButton) ...[
|
||||
_predefinedFormatButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
],
|
||||
_predefinedFormatButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
SelectModelMenu(
|
||||
aiModelStateNotifier:
|
||||
context.read<AIPromptInputBloc>().aiModelStateNotifier,
|
||||
@@ -648,7 +641,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
|
||||
Widget _predefinedFormatButton() {
|
||||
return PromptInputDesktopToggleFormatButton(
|
||||
showFormatBar: showPredefinedFormatBar,
|
||||
showFormatBar: showPredefinedFormats,
|
||||
onTap: onTogglePredefinedFormatSection,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ 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,8 +16,6 @@ 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>{
|
||||
@@ -88,7 +86,6 @@ class AFDropdownMenu<T> extends StatefulWidget {
|
||||
this.requestFocusOnTap,
|
||||
this.expandedInsets,
|
||||
this.searchCallback,
|
||||
this.selectOptionCompare,
|
||||
required this.dropdownMenuEntries,
|
||||
});
|
||||
|
||||
@@ -270,11 +267,6 @@ 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>();
|
||||
}
|
||||
@@ -309,16 +301,7 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
|
||||
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
|
||||
|
||||
final int index = filteredEntries.indexWhere(
|
||||
(DropdownMenuEntry<T> entry) {
|
||||
if (widget.selectOptionCompare != null) {
|
||||
return widget.selectOptionCompare!(
|
||||
entry.value,
|
||||
widget.initialSelection,
|
||||
);
|
||||
} else {
|
||||
return entry.value == widget.initialSelection;
|
||||
}
|
||||
},
|
||||
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
|
||||
);
|
||||
if (index != -1) {
|
||||
_textEditingController.value = TextEditingValue(
|
||||
|
||||
+1
-15
@@ -16,7 +16,6 @@ enum _MobileSettingsPopupMenuItem {
|
||||
members,
|
||||
trash,
|
||||
help,
|
||||
helpAndDocumentation,
|
||||
}
|
||||
|
||||
class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
@@ -63,16 +62,10 @@ 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_getSupport.tr(),
|
||||
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
|
||||
),
|
||||
],
|
||||
onSelected: (_MobileSettingsPopupMenuItem value) {
|
||||
@@ -89,9 +82,6 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
case _MobileSettingsPopupMenuItem.help:
|
||||
_openHelpPage(context);
|
||||
break;
|
||||
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
|
||||
_openHelpAndDocumentationPage(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: const Padding(
|
||||
@@ -133,10 +123,6 @@ 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 {
|
||||
|
||||
+2
-2
@@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
|
||||
),
|
||||
MobileSettingItem(
|
||||
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
|
||||
),
|
||||
if (kDebugMode)
|
||||
MobileSettingItem(
|
||||
|
||||
+1
-2
@@ -282,12 +282,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
void _inviteMember(BuildContext context) {
|
||||
final email = emailController.text;
|
||||
if (!isEmail(email)) {
|
||||
showToastNotification(
|
||||
return showToastNotification(
|
||||
context,
|
||||
type: ToastificationType.error,
|
||||
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context
|
||||
.read<WorkspaceMemberBloc>()
|
||||
|
||||
+6
-11
@@ -411,28 +411,23 @@ class FieldController {
|
||||
/// Listen for field setting changes in the backend.
|
||||
void _listenOnFieldSettingsChanged() {
|
||||
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
|
||||
final newFields = [...fieldInfos];
|
||||
final List<FieldInfo> newFields = fieldInfos;
|
||||
var updatedField = newFields.firstOrNull;
|
||||
|
||||
if (newFields.isEmpty) {
|
||||
if (updatedField == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final index = newFields
|
||||
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
|
||||
|
||||
if (index != -1) {
|
||||
newFields[index] =
|
||||
newFields[index].copyWith(fieldSettings: updatedFieldSettings);
|
||||
_fieldNotifier.fieldInfos = newFields;
|
||||
_fieldSettings
|
||||
..removeWhere(
|
||||
(field) => field.fieldId == updatedFieldSettings.fieldId,
|
||||
)
|
||||
..add(updatedFieldSettings);
|
||||
return newFields[index];
|
||||
updatedField = newFields[index];
|
||||
}
|
||||
|
||||
return null;
|
||||
_fieldNotifier.fieldInfos = newFields;
|
||||
return updatedField;
|
||||
}
|
||||
|
||||
_fieldSettingsListener.start(
|
||||
|
||||
+3
-6
@@ -108,7 +108,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: DragToExpandLine(),
|
||||
child: _DragToExpandLine(),
|
||||
);
|
||||
|
||||
return _GridHeaderCellContainer(
|
||||
@@ -158,11 +158,8 @@ class _GridHeaderCellContainer extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class DragToExpandLine extends StatelessWidget {
|
||||
const DragToExpandLine({
|
||||
super.key,
|
||||
});
|
||||
class _DragToExpandLine extends StatelessWidget {
|
||||
const _DragToExpandLine();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
+12
-8
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -14,7 +16,6 @@ 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';
|
||||
|
||||
@@ -200,16 +201,19 @@ class _ChecklistItems extends StatelessWidget {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
proxyDecorator: (child, index, _) => Material(
|
||||
color: Colors.transparent,
|
||||
child: MouseRegion(
|
||||
cursor: UniversalPlatform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: IgnorePointer(
|
||||
child: BlocProvider.value(
|
||||
child: Stack(
|
||||
children: [
|
||||
BlocProvider.value(
|
||||
value: context.read<ChecklistCellBloc>(),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: Platform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
buildDefaultDragHandles: false,
|
||||
|
||||
+10
-8
@@ -14,7 +14,6 @@ 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';
|
||||
@@ -126,16 +125,19 @@ class ChecklistItemList extends StatelessWidget {
|
||||
shrinkWrap: true,
|
||||
proxyDecorator: (child, index, _) => Material(
|
||||
color: Colors.transparent,
|
||||
child: MouseRegion(
|
||||
cursor: UniversalPlatform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: IgnorePointer(
|
||||
child: BlocProvider.value(
|
||||
child: Stack(
|
||||
children: [
|
||||
BlocProvider.value(
|
||||
value: context.read<ChecklistCellBloc>(),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: Platform.isWindows
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.grabbing,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
buildDefaultDragHandles: false,
|
||||
|
||||
@@ -272,9 +272,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
}
|
||||
|
||||
if (options.inMemoryUpdate) {
|
||||
if (enableDocumentInternalLog) {
|
||||
Log.trace('skip transaction for in-memory update');
|
||||
}
|
||||
Log.trace('skip transaction for in-memory update');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -432,50 +432,44 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
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,
|
||||
),
|
||||
),
|
||||
placeHolderBuilder: (_) => customPlaceholderItem,
|
||||
editorState: editorState,
|
||||
editorScrollController: editorScrollController,
|
||||
textDirection: textDirection,
|
||||
tooltipBuilder: (context, id, message, child) =>
|
||||
widget.styleCustomizer.buildToolbarItemTooltip(
|
||||
context,
|
||||
id,
|
||||
message,
|
||||
child,
|
||||
),
|
||||
child: editor,
|
||||
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: (context, child, onDismiss, isMetricsChanged) =>
|
||||
DesktopFloatingToolbar(
|
||||
editorState: editorState,
|
||||
onDismiss: onDismiss,
|
||||
enableAnimation: !isMetricsChanged,
|
||||
child: child,
|
||||
),
|
||||
placeHolderBuilder: (_) => customPlaceholderItem,
|
||||
editorState: editorState,
|
||||
editorScrollController: editorScrollController,
|
||||
textDirection: textDirection,
|
||||
tooltipBuilder: (context, id, message, child) =>
|
||||
widget.styleCustomizer.buildToolbarItemTooltip(
|
||||
context,
|
||||
id,
|
||||
message,
|
||||
child,
|
||||
),
|
||||
child: editor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
-6
@@ -490,12 +490,6 @@ 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);
|
||||
|
||||
+13
-51
@@ -16,11 +16,6 @@ import 'ai_writer_block_operations.dart';
|
||||
import 'ai_writer_entities.dart';
|
||||
import 'ai_writer_node_extension.dart';
|
||||
|
||||
/// Enable the debug log for the AiWriterCubit.
|
||||
///
|
||||
/// This is useful for debugging the AI writer cubit.
|
||||
const _aiWriterCubitDebugLog = false;
|
||||
|
||||
class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
AiWriterCubit({
|
||||
required this.documentId,
|
||||
@@ -101,10 +96,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
final command = node.aiWriterCommand;
|
||||
final (run, prompt) = await _addSelectionTextToRecords(command);
|
||||
|
||||
_aiWriterCubitLog(
|
||||
'command: $command, run: $run, prompt: $prompt',
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
await exit();
|
||||
return;
|
||||
@@ -221,26 +212,20 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept
|
||||
//
|
||||
// If the user clicks accept, we need to replace the selection with the AI's response
|
||||
if (action case SuggestionAction.accept) {
|
||||
// trim the markdown text to avoid extra new lines
|
||||
final trimmedMarkdownText = _textRobot.markdownText.trim();
|
||||
|
||||
_aiWriterCubitLog(
|
||||
'trigger accept action, markdown text: $trimmedMarkdownText',
|
||||
await _textRobot.persist();
|
||||
await formatSelection(
|
||||
editorState,
|
||||
selection,
|
||||
ApplySuggestionFormatType.clear,
|
||||
);
|
||||
|
||||
await _textRobot.deleteAINodes();
|
||||
|
||||
await _textRobot.replace(
|
||||
selection: selection,
|
||||
markdownText: trimmedMarkdownText,
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
final transaction = editorState.transaction..deleteNodes(nodes);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
withUpdateSelection: false,
|
||||
);
|
||||
|
||||
await exit(withDiscard: false, withUnformat: false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,35 +277,30 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
AiWriterCommand command,
|
||||
) async {
|
||||
final node = aiWriterNode;
|
||||
|
||||
// check the node is registered
|
||||
if (node == null) {
|
||||
Log.warn('[AI writer] Node is null');
|
||||
return (false, '');
|
||||
}
|
||||
|
||||
// check the selection is valid
|
||||
final selection = node.aiWriterSelection?.normalized;
|
||||
if (selection == null) {
|
||||
Log.warn('[AI writer]Selection is null');
|
||||
return (false, '');
|
||||
}
|
||||
|
||||
// if the command is continue writing, we don't need to get the selection text
|
||||
if (command == AiWriterCommand.continueWriting) {
|
||||
return (true, '');
|
||||
}
|
||||
|
||||
// if the selection is collapsed, we don't need to get the selection text
|
||||
if (selection.isCollapsed) {
|
||||
return (true, '');
|
||||
}
|
||||
|
||||
final selectionText = await editorState.getMarkdownInSelection(selection);
|
||||
Log.warn('[AI writer] Selection is null');
|
||||
|
||||
if (command == AiWriterCommand.userQuestion) {
|
||||
records.add(
|
||||
AiWriterRecord.user(content: selectionText, format: null),
|
||||
);
|
||||
|
||||
return (true, '');
|
||||
} else {
|
||||
return (true, selectionText);
|
||||
@@ -564,10 +544,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
attributes: ApplySuggestionFormatType.replace.attributes,
|
||||
);
|
||||
onAppendToDocument?.call();
|
||||
|
||||
_aiWriterCubitLog(
|
||||
'received message: $text',
|
||||
);
|
||||
},
|
||||
processAssistMessage: (text) async {
|
||||
if (state case final GeneratingAiWriterState generatingState) {
|
||||
@@ -579,10 +555,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_aiWriterCubitLog(
|
||||
'received assist message: $text',
|
||||
);
|
||||
},
|
||||
onEnd: () async {
|
||||
if (state case final GeneratingAiWriterState generatingState) {
|
||||
@@ -599,10 +571,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
records.add(
|
||||
AiWriterRecord.ai(content: _textRobot.markdownText),
|
||||
);
|
||||
|
||||
_aiWriterCubitLog(
|
||||
'returned response: ${_textRobot.markdownText}',
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) async {
|
||||
@@ -694,12 +662,6 @@ class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _aiWriterCubitLog(String message) {
|
||||
if (_aiWriterCubitDebugLog) {
|
||||
Log.debug('[AiWriterCubit] $message');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mixin RegisteredAiWriter {
|
||||
|
||||
+1
-20
@@ -57,30 +57,11 @@ extension AiWriterNodeExtension on EditorState {
|
||||
slicedNodes.add(copiedNode);
|
||||
}
|
||||
|
||||
for (final (i, node) in slicedNodes.indexed) {
|
||||
final childNodesShouldBeDeleted = <Node>[];
|
||||
for (final child in node.children) {
|
||||
if (!child.path.inSelection(selection)) {
|
||||
childNodesShouldBeDeleted.add(child);
|
||||
}
|
||||
}
|
||||
for (final child in childNodesShouldBeDeleted) {
|
||||
slicedNodes[i] = node.copyWith(
|
||||
children: node.children.where((e) => e.id != child.id).toList(),
|
||||
type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// use \n\n as line break to improve the ai response
|
||||
// using \n will cause the ai response treat the text as a single line
|
||||
final markdown = await customDocumentToMarkdown(
|
||||
Document.blank()..insert([0], slicedNodes),
|
||||
lineBreak: '\n\n',
|
||||
);
|
||||
|
||||
// trim the last \n if it exists
|
||||
return markdown.trimRight();
|
||||
return markdown;
|
||||
}
|
||||
|
||||
List<String> getPlainTextInSelection(Selection? selection) {
|
||||
|
||||
+4
-19
@@ -208,26 +208,11 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
|
||||
throttler.call(() {
|
||||
if (aiWriterCubit.aiWriterNode != null) {
|
||||
final path = aiWriterCubit.aiWriterNode!.path;
|
||||
|
||||
if (path.isEmpty) {
|
||||
return;
|
||||
if (path.isNotEmpty) {
|
||||
widget.editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: path)),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+1
-220
@@ -110,13 +110,10 @@ class MarkdownTextRobot {
|
||||
}
|
||||
|
||||
/// Persist the text into the document
|
||||
Future<void> persist({
|
||||
String? markdownText,
|
||||
}) async {
|
||||
Future<void> persist({String? markdownText}) async {
|
||||
if (markdownText != null) {
|
||||
_markdownText = markdownText;
|
||||
}
|
||||
|
||||
await _lock.synchronized(() async {
|
||||
await _refresh(inMemoryUpdate: false);
|
||||
});
|
||||
@@ -127,34 +124,6 @@ class MarkdownTextRobot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the selected content with the AI's response
|
||||
Future<void> replace({
|
||||
required Selection selection,
|
||||
required String markdownText,
|
||||
}) async {
|
||||
if (selection.isSingle) {
|
||||
await _replaceInSameLine(
|
||||
selection: selection,
|
||||
markdownText: markdownText,
|
||||
);
|
||||
} else {
|
||||
await _replaceInMultiLines(
|
||||
selection: selection,
|
||||
markdownText: markdownText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the temporary inserted AI nodes
|
||||
Future<void> deleteAINodes() async {
|
||||
final nodes = getInsertedNodes();
|
||||
final transaction = editorState.transaction..deleteNodes(nodes);
|
||||
await editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(recordUndo: false),
|
||||
);
|
||||
}
|
||||
|
||||
/// Discard the inserted content
|
||||
Future<void> discard() async {
|
||||
final start = _insertPosition;
|
||||
@@ -313,194 +282,6 @@ class MarkdownTextRobot {
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
/// If the selected content is in the same line,
|
||||
/// keep the selected node and replace the delta.
|
||||
Future<void> _replaceInSameLine({
|
||||
required Selection selection,
|
||||
required String markdownText,
|
||||
}) async {
|
||||
if (markdownText.isEmpty) {
|
||||
assert(false, 'Expected non-empty markdown text');
|
||||
Log.error('Expected non-empty markdown text');
|
||||
return;
|
||||
}
|
||||
|
||||
selection = selection.normalized;
|
||||
|
||||
// If the selection is not a single node, do nothing.
|
||||
if (!selection.isSingle) {
|
||||
assert(false, 'Expected single node selection');
|
||||
Log.error('Expected single node selection');
|
||||
return;
|
||||
}
|
||||
|
||||
final startIndex = selection.startIndex;
|
||||
final endIndex = selection.endIndex;
|
||||
final length = endIndex - startIndex;
|
||||
|
||||
// Get the selected node.
|
||||
final node = editorState.getNodeAtPath(selection.start.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
assert(false, 'Expected non-null node and delta');
|
||||
Log.error('Expected non-null node and delta');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the markdown text to delta.
|
||||
// Question: Why we need to convert the markdown to document first?
|
||||
// Answer: Because the markdown text may contain the list item,
|
||||
// if we convert the markdown to delta directly, the list item will be
|
||||
// treated as a normal text node, and the delta will be incorrect.
|
||||
// For example, the markdown text is:
|
||||
// ```
|
||||
// 1. item1
|
||||
// ```
|
||||
// if we convert the markdown to delta directly, the delta will be:
|
||||
// ```
|
||||
// [
|
||||
// {
|
||||
// "insert": "1. item1"
|
||||
// }
|
||||
// ]
|
||||
// ```
|
||||
// if we convert the markdown to document first, the document will be:
|
||||
// ```
|
||||
// [
|
||||
// {
|
||||
// "type": "numbered_list",
|
||||
// "children": [
|
||||
// {
|
||||
// "insert": "item1"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
final document = customMarkdownToDocument(markdownText);
|
||||
final nodes = document.root.children;
|
||||
final decoder = DeltaMarkdownDecoder();
|
||||
final markdownDelta =
|
||||
nodes.firstOrNull?.delta ?? decoder.convert(markdownText);
|
||||
|
||||
if (markdownDelta.isEmpty) {
|
||||
assert(false, 'Expected non-empty markdown delta');
|
||||
Log.error('Expected non-empty markdown delta');
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the delta of the selected node.
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
// it means the user selected the entire sentence, we just replace the node
|
||||
if (startIndex == 0 && length == node.delta?.length) {
|
||||
transaction
|
||||
..insertNodes(node.path.next, nodes)
|
||||
..deleteNode(node);
|
||||
} else {
|
||||
// it means the user selected a part of the sentence, we need to delete the
|
||||
// selected part and insert the new delta.
|
||||
transaction
|
||||
..deleteText(node, startIndex, length)
|
||||
..insertTextDelta(node, startIndex, markdownDelta);
|
||||
|
||||
// Add the remaining nodes to the document.
|
||||
final remainingNodes = nodes.skip(1);
|
||||
if (remainingNodes.isNotEmpty) {
|
||||
transaction.insertNodes(
|
||||
node.path.next,
|
||||
remainingNodes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
/// If the selected content is in multiple lines
|
||||
Future<void> _replaceInMultiLines({
|
||||
required Selection selection,
|
||||
required String markdownText,
|
||||
}) async {
|
||||
selection = selection.normalized;
|
||||
|
||||
// If the selection is a single node, do nothing.
|
||||
if (selection.isSingle) {
|
||||
assert(false, 'Expected multi-line selection');
|
||||
Log.error('Expected multi-line selection');
|
||||
return;
|
||||
}
|
||||
|
||||
final markdownNodes = customMarkdownToDocument(
|
||||
markdownText,
|
||||
tableWidth: 250.0,
|
||||
).root.children;
|
||||
|
||||
// Get the selected nodes.
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
|
||||
// Note: Don't change its order, otherwise the delta will be incorrect.
|
||||
// step 1. merge the first selected node and the first node from the ai response
|
||||
// step 2. merge the last selected node and the last node from the ai response
|
||||
// step 3. insert the middle nodes from the ai response
|
||||
// step 4. delete the middle nodes
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
// step 1
|
||||
final firstNode = nodes.firstOrNull;
|
||||
final delta = firstNode?.delta;
|
||||
final firstMarkdownNode = markdownNodes.firstOrNull;
|
||||
final firstMarkdownDelta = firstMarkdownNode?.delta;
|
||||
if (firstNode != null &&
|
||||
delta != null &&
|
||||
firstMarkdownNode != null &&
|
||||
firstMarkdownDelta != null) {
|
||||
final startIndex = selection.startIndex;
|
||||
final length = delta.length - startIndex;
|
||||
|
||||
transaction
|
||||
..deleteText(firstNode, startIndex, length)
|
||||
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
|
||||
}
|
||||
|
||||
// step 2
|
||||
final lastNode = nodes.lastOrNull;
|
||||
final lastDelta = lastNode?.delta;
|
||||
final lastMarkdownNode = markdownNodes.lastOrNull;
|
||||
final lastMarkdownDelta = lastMarkdownNode?.delta;
|
||||
if (lastNode != null &&
|
||||
lastDelta != null &&
|
||||
lastMarkdownNode != null &&
|
||||
lastMarkdownDelta != null) {
|
||||
final endIndex = selection.endIndex;
|
||||
|
||||
transaction.deleteText(lastNode, 0, endIndex);
|
||||
|
||||
// if the last node is same as the first node, it means we have replaced the
|
||||
// selected text in the first node.
|
||||
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
|
||||
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
|
||||
}
|
||||
}
|
||||
|
||||
// step 3
|
||||
final insertedPath = selection.start.path.nextNPath(1);
|
||||
if (markdownNodes.length > 2) {
|
||||
transaction.insertNodes(
|
||||
insertedPath,
|
||||
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// step 4
|
||||
final length = nodes.length - 2;
|
||||
if (length > 0) {
|
||||
final middleNodes = nodes.skip(1).take(length).toList();
|
||||
transaction.deleteNodes(middleNodes);
|
||||
}
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
class AINodeExternalValues extends NodeExternalValues {
|
||||
|
||||
+12
-50
@@ -1,9 +1,10 @@
|
||||
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({
|
||||
@@ -27,7 +28,6 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
_Position? position;
|
||||
final toolbarController = getIt<FloatingToolbarController>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,32 +39,24 @@ 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 Positioned(
|
||||
left: position!.left,
|
||||
top: position!.top,
|
||||
right: position!.right,
|
||||
child: widget.enableAnimation
|
||||
? ToolbarAnimationWidget(child: widget.child)
|
||||
: widget.child,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void dismiss() {
|
||||
widget.onDismiss.call();
|
||||
}
|
||||
|
||||
_Position calculateSelectionMenuOffset(
|
||||
Rect rect,
|
||||
) {
|
||||
@@ -100,33 +92,3 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-68
@@ -19,15 +19,11 @@ 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
|
||||
@@ -36,8 +32,6 @@ class LinkCreateMenu extends StatefulWidget {
|
||||
|
||||
class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
late LinkSearchTextField searchTextField = LinkSearchTextField(
|
||||
currentViewId: widget.currentViewId,
|
||||
initialSearchText: widget.initialText,
|
||||
onEnter: () {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: () => onSubmittedLink(),
|
||||
@@ -54,28 +48,17 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
},
|
||||
);
|
||||
|
||||
bool get isTextfieldEnable => searchTextField.isTextfieldEnable;
|
||||
bool get isButtonEnable => searchText.isNotEmpty;
|
||||
|
||||
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
|
||||
@@ -115,45 +98,32 @@ 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 Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -161,15 +131,7 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
void onSubmittedLink() {
|
||||
if (!isTextfieldEnable) {
|
||||
setState(() {
|
||||
showErrorText = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
widget.onSubmitted(searchText, false);
|
||||
}
|
||||
void onSubmittedLink() => widget.onSubmitted(searchText, false);
|
||||
|
||||
void onSubmittedPageLink(ViewPB view) async {
|
||||
final workspaceId = context
|
||||
@@ -190,16 +152,13 @@ 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;
|
||||
|
||||
@@ -219,18 +178,12 @@ 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,
|
||||
|
||||
+89
-146
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -11,8 +10,7 @@ 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';
|
||||
@@ -24,14 +22,12 @@ class LinkEditMenu extends StatefulWidget {
|
||||
required this.onDismiss,
|
||||
required this.onApply,
|
||||
required this.onRemoveLink,
|
||||
required this.currentViewId,
|
||||
});
|
||||
|
||||
final LinkInfo linkInfo;
|
||||
final ValueChanged<LinkInfo> onApply;
|
||||
final ValueChanged<LinkInfo> onRemoveLink;
|
||||
final VoidCallback onRemoveLink;
|
||||
final VoidCallback onDismiss;
|
||||
final String currentViewId;
|
||||
|
||||
@override
|
||||
State<LinkEditMenu> createState() => _LinkEditMenuState();
|
||||
@@ -40,7 +36,7 @@ class LinkEditMenu extends StatefulWidget {
|
||||
class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
ValueChanged<LinkInfo> get onApply => widget.onApply;
|
||||
|
||||
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
|
||||
VoidCallback get onRemoveLink => widget.onRemoveLink;
|
||||
|
||||
VoidCallback get onDismiss => widget.onDismiss;
|
||||
|
||||
@@ -51,7 +47,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
late LinkSearchTextField searchTextField;
|
||||
bool isShowingSearchResult = false;
|
||||
ViewPB? currentView;
|
||||
bool showErrorText = false;
|
||||
|
||||
bool get enableApply =>
|
||||
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,9 +58,18 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
if (isPageLink) getPageView();
|
||||
searchTextField = LinkSearchTextField(
|
||||
initialSearchText: isPageLink ? '' : linkInfo.link,
|
||||
initialViewId: linkInfo.viewId,
|
||||
currentViewId: widget.currentViewId,
|
||||
onEnter: onConfirm,
|
||||
onEnter: () {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: onLinkSelected,
|
||||
onRecentViews: () =>
|
||||
onPageSelected(searchTextField.currentRecentView),
|
||||
onSearchViews: () =>
|
||||
onPageSelected(searchTextField.currentSearchedView),
|
||||
onEmpty: () {
|
||||
searchTextField.unfocus();
|
||||
},
|
||||
);
|
||||
},
|
||||
onEscape: () {
|
||||
if (isShowingSearchResult) {
|
||||
hideSearchResult();
|
||||
@@ -88,7 +95,6 @@ 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(
|
||||
@@ -101,8 +107,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192 + errorHeight,
|
||||
height: 192,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -116,7 +123,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 80 + errorHeight,
|
||||
top: 80,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
@@ -126,12 +133,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 144 + errorHeight,
|
||||
top: 152,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 100 + errorHeight,
|
||||
top: 108,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
@@ -148,53 +155,29 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
|
||||
Widget buildLinkField() {
|
||||
final showPageView = linkInfo.isPage && !isShowingSearchResult;
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
VSpace(6),
|
||||
searchTextField.buildResultContainer(
|
||||
context: context,
|
||||
width: 360,
|
||||
onPageLinkSelected: onPageSelected,
|
||||
onLinkSelected: onLinkSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -214,15 +197,15 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
preferBelow: false,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: LinkStyle.borderColor(context)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
),
|
||||
onPressed: () => onRemoveLink.call(linkInfo),
|
||||
onPressed: onRemoveLink,
|
||||
),
|
||||
Spacer(),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: LinkStyle.borderColor(context)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
),
|
||||
child: FlowyTextButton(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
@@ -231,9 +214,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
|
||||
fontSize: 14,
|
||||
lineHeight: 20 / 14,
|
||||
fontColor: Theme.of(context).isLightMode
|
||||
? LinkStyle.textPrimary
|
||||
: Theme.of(context).iconTheme.color,
|
||||
fontColor: LinkStyle.textPrimary,
|
||||
fillColor: Colors.transparent,
|
||||
fontWeight: FontWeight.w400,
|
||||
onPressed: onDismiss,
|
||||
@@ -251,26 +232,14 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
fontSize: 14,
|
||||
lineHeight: 20 / 14,
|
||||
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
|
||||
fontColor: Colors.white,
|
||||
fillColor: LinkStyle.fillThemeThick,
|
||||
fontColor:
|
||||
enableApply ? Colors.white : LinkStyle.textTertiary,
|
||||
fillColor: enableApply
|
||||
? LinkStyle.fillThemeThick
|
||||
: LinkStyle.borderColor,
|
||||
fontWeight: FontWeight.w400,
|
||||
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);
|
||||
},
|
||||
onPressed:
|
||||
enableApply ? () => widget.onApply.call(linkInfo) : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -303,7 +272,6 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
},
|
||||
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
|
||||
LocaleKeys.document_toolbar_linkNameHint.tr(),
|
||||
context,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -320,34 +288,24 @@ 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: 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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -366,28 +324,24 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
width: 360,
|
||||
height: 32,
|
||||
decoration: buildDecoration(),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -395,21 +349,12 @@ 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(linkInfo.viewId);
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
currentView = view;
|
||||
@@ -468,7 +413,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
|
||||
BoxDecoration buildDecoration() => BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: LinkStyle.borderColor(context)),
|
||||
border: Border.all(color: LinkStyle.borderColor),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -481,6 +426,4 @@ class LinkInfo {
|
||||
|
||||
Attributes toAttribute() =>
|
||||
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
|
||||
|
||||
String get viewId => isPage ? link.split('/').lastOrNull ?? '' : '';
|
||||
}
|
||||
+38
-133
@@ -3,25 +3,20 @@ 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({
|
||||
@@ -50,7 +45,6 @@ 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;
|
||||
@@ -63,13 +57,12 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
|
||||
Attributes get attribute => widget.attribute;
|
||||
|
||||
late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection);
|
||||
HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
|
||||
toolbarController.addDisplayListener(onToolbarShow);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -77,7 +70,6 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
hoverMenuController.close();
|
||||
editMenuController.close();
|
||||
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
|
||||
toolbarController.removeDisplayListener(onToolbarShow);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -140,9 +132,9 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
tryToDismissLinkHoverMenu();
|
||||
},
|
||||
onOpenLink: openLink,
|
||||
onCopyLink: () => copyLink(context),
|
||||
onCopyLink: copyLink,
|
||||
onEditLink: showLinkEditMenu,
|
||||
onRemoveLink: () => removeLink(editorState, selection),
|
||||
onRemoveLink: () => removeLink(editorState, selection, true),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
@@ -152,7 +144,6 @@ 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,
|
||||
@@ -168,7 +159,6 @@ 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 {
|
||||
@@ -183,19 +173,14 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
editMenuController.close();
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
onRemoveLink: (linkinfo) =>
|
||||
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
|
||||
onRemoveLink: () => removeLink(editorState, selection, true),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void onToolbarShow() => hoverMenuController.close();
|
||||
|
||||
void showLinkHoverMenu() {
|
||||
if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) {
|
||||
return;
|
||||
}
|
||||
if (isHoverMenuShowing) return;
|
||||
keepEditorFocusNotifier.increase();
|
||||
hoverMenuController.show();
|
||||
}
|
||||
@@ -221,7 +206,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
if (isPage) {
|
||||
final viewId = href.split('/').lastOrNull ?? '';
|
||||
if (viewId.isEmpty) {
|
||||
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
|
||||
await afLaunchUrlString(href);
|
||||
} else {
|
||||
final (view, isInTrash, isDeleted) =
|
||||
await ViewBackendService.getMentionPageStatus(viewId);
|
||||
@@ -230,28 +215,24 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
|
||||
await afLaunchUrlString(href);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> copyLink(BuildContext context) async {
|
||||
Future<void> copyLink() 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;
|
||||
@@ -270,34 +251,9 @@ 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 StatefulWidget {
|
||||
class LinkHoverMenu extends StatelessWidget {
|
||||
const LinkHoverMenu({
|
||||
super.key,
|
||||
required this.attribute,
|
||||
@@ -319,31 +275,17 @@ class LinkHoverMenu extends StatefulWidget {
|
||||
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: widget.onEnter,
|
||||
onExit: widget.onExit,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: SizedBox(
|
||||
width: max(320, widget.triggerSize.width),
|
||||
width: max(320, triggerSize.width),
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -351,15 +293,21 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
width: 320,
|
||||
height: 48,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: buildLinkWidget()),
|
||||
Expanded(
|
||||
child: FlowyText.regular(
|
||||
href,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 20,
|
||||
width: 1,
|
||||
color: Color(0xffE8ECF3)
|
||||
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
|
||||
color: LinkStyle.borderColor,
|
||||
margin: EdgeInsets.symmetric(horizontal: 6),
|
||||
),
|
||||
FlowyIconButton(
|
||||
@@ -367,21 +315,21 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
tooltipText: LocaleKeys.editor_copyLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: widget.onCopyLink,
|
||||
onPressed: onCopyLink,
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
|
||||
tooltipText: LocaleKeys.editor_editLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: widget.onEditLink,
|
||||
onPressed: onEditLink,
|
||||
),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
|
||||
tooltipText: LocaleKeys.editor_removeLink.tr(),
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: widget.onRemoveLink,
|
||||
onPressed: onRemoveLink,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -391,13 +339,13 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: widget.onEnter,
|
||||
onExit: widget.onExit,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onOpenLink,
|
||||
onTap: onOpenLink,
|
||||
child: Container(
|
||||
width: widget.triggerSize.width,
|
||||
height: widget.triggerSize.height,
|
||||
width: triggerSize.width,
|
||||
height: triggerSize.height,
|
||||
color: Colors.black.withAlpha(1),
|
||||
),
|
||||
),
|
||||
@@ -405,46 +353,6 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -459,11 +367,7 @@ class HoverTriggerKey {
|
||||
other is HoverTriggerKey &&
|
||||
runtimeType == other.runtimeType &&
|
||||
nodeId == other.nodeId &&
|
||||
isSelectionSame(other.selection);
|
||||
|
||||
bool isSelectionSame(Selection other) =>
|
||||
(selection.start == other.start && selection.end == other.end) ||
|
||||
(selection.start == other.end && selection.end == other.start);
|
||||
selection == other.selection;
|
||||
|
||||
@override
|
||||
int get hashCode => nodeId.hashCode ^ selection.hashCode;
|
||||
@@ -486,7 +390,8 @@ class LinkHoverTriggers {
|
||||
|
||||
void call(HoverTriggerKey key) {
|
||||
final callbacks = _map[key] ?? {};
|
||||
if (callbacks.isEmpty) return;
|
||||
callbacks.first.call();
|
||||
for (final callback in callbacks) {
|
||||
callback.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-43
@@ -12,8 +12,6 @@ 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';
|
||||
@@ -27,16 +25,12 @@ class LinkSearchTextField {
|
||||
this.onEscape,
|
||||
this.onEnter,
|
||||
this.onDataRefresh,
|
||||
this.initialViewId = '',
|
||||
required this.currentViewId,
|
||||
String? initialSearchText,
|
||||
}) : textEditingController = TextEditingController(
|
||||
text: isUri(initialSearchText ?? '') ? initialSearchText : '',
|
||||
text: initialSearchText ?? '',
|
||||
);
|
||||
|
||||
final TextEditingController textEditingController;
|
||||
final String initialViewId;
|
||||
final String currentViewId;
|
||||
final ItemScrollController searchController = ItemScrollController();
|
||||
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
|
||||
final List<ViewPB> searchedViews = [];
|
||||
@@ -49,7 +43,7 @@ class LinkSearchTextField {
|
||||
|
||||
String get searchText => textEditingController.text;
|
||||
|
||||
bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText);
|
||||
bool get isButtonEnable => searchText.isNotEmpty;
|
||||
|
||||
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
|
||||
|
||||
@@ -64,11 +58,7 @@ class LinkSearchTextField {
|
||||
recentViews.clear();
|
||||
}
|
||||
|
||||
Widget buildTextField({
|
||||
bool autofocus = false,
|
||||
bool showError = false,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
Widget buildTextField({bool autofocus = false}) {
|
||||
return TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
autofocus: autofocus,
|
||||
@@ -91,8 +81,6 @@ class LinkSearchTextField {
|
||||
},
|
||||
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
|
||||
LocaleKeys.document_toolbar_linkInputHint.tr(),
|
||||
context,
|
||||
showErrorBorder: showError,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -189,41 +177,29 @@ 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, padding: EdgeInsets.zero),
|
||||
leftIcon: buildIcon(view),
|
||||
text: FlowyText.regular(
|
||||
displayName,
|
||||
view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
|
||||
onTap: () => onSubmittedPageLink?.call(view),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(
|
||||
ViewPB view, {
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4),
|
||||
}) {
|
||||
Widget buildIcon(ViewPB view) {
|
||||
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
|
||||
final iconData = view.icon.toEmojiIconData();
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: RawEmojiIconWidget(
|
||||
emoji: iconData,
|
||||
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
|
||||
lineHeight: 1,
|
||||
),
|
||||
return RawEmojiIconWidget(
|
||||
emoji: iconData,
|
||||
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
|
||||
lineHeight: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,7 +288,6 @@ class LinkSearchTextField {
|
||||
final views = sectionViews
|
||||
.unique((e) => e.item.id)
|
||||
.map((e) => e.item)
|
||||
.where((e) => e.id != currentViewId)
|
||||
.take(5)
|
||||
.toList();
|
||||
recentViews.clear();
|
||||
@@ -328,14 +303,13 @@ class LinkSearchTextField {
|
||||
?.items
|
||||
.where(
|
||||
(view) =>
|
||||
(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()))),
|
||||
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();
|
||||
|
||||
+5
-17
@@ -1,31 +1,19 @@
|
||||
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 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(
|
||||
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
|
||||
const border = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderSide: BorderSide(color: borderColor(context)),
|
||||
borderSide: BorderSide(color: LinkStyle.borderColor),
|
||||
);
|
||||
final enableBorder = border.copyWith(
|
||||
borderSide: BorderSide(
|
||||
color: showErrorBorder
|
||||
? LinkStyle.textStatusError
|
||||
: LinkStyle.fillThemeThick,
|
||||
),
|
||||
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
|
||||
);
|
||||
const hintStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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._();
|
||||
}
|
||||
+1
-2
@@ -104,11 +104,10 @@ Future<void> downloadMediaFile(
|
||||
await afLaunchUrlString(file.url);
|
||||
} else {
|
||||
if (userProfile == null) {
|
||||
showToastNotification(
|
||||
return showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_media_downloadFailedToken.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final uri = Uri.parse(file.url);
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -69,7 +68,7 @@ class _FormatToolbarItem extends ToolbarItem {
|
||||
final hoverColor = isHighlight
|
||||
? highlightColor
|
||||
: EditorStyleCustomizer.toolbarHoverColor(context);
|
||||
final isDark = !Theme.of(context).isLightMode;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ 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';
|
||||
|
||||
@@ -79,7 +78,7 @@ class _HighlightColorPickerWidgetState
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
|
||||
final iconColor = Theme.of(context).iconTheme.color;
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
|
||||
+10
-16
@@ -1,17 +1,15 @@
|
||||
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';
|
||||
@@ -30,10 +28,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,
|
||||
@@ -43,20 +41,16 @@ final customLinkItem = ToolbarItem(
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.toolbar_link_m,
|
||||
size: Size.square(20.0),
|
||||
color: (isDark && isHref)
|
||||
? Color(0xFF282E3A)
|
||||
: AFThemeExtensionV2.of(context).icon_primary,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
getIt<FloatingToolbarController>().hideToolbar();
|
||||
if (!isHref) {
|
||||
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
|
||||
showLinkCreateMenu(context, editorState, selection, viewId);
|
||||
toolbarCubit?.dismiss();
|
||||
if (isHref) {
|
||||
getIt<LinkHoverTriggers>().call(
|
||||
HoverTriggerKey(nodes.first.id, selection),
|
||||
);
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
getIt<LinkHoverTriggers>()
|
||||
.call(HoverTriggerKey(nodes.first.id, selection));
|
||||
});
|
||||
showLinkCreateMenu(context, editorState, selection);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
+1
-2
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -78,7 +77,7 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
|
||||
final iconColor = Theme.of(context).iconTheme.color;
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
height: 32,
|
||||
|
||||
+5
-7
@@ -1,10 +1,9 @@
|
||||
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';
|
||||
@@ -289,7 +288,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
|
||||
popoverController: suggestionsPopoverController,
|
||||
popoverDirection: PopoverDirection.leftWithTopAligned,
|
||||
showOffset: Offset(-8, height),
|
||||
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
|
||||
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
|
||||
child: buildCommandItem(
|
||||
MoreOptionCommand.suggestions,
|
||||
rightIcon: FlowySvg(FlowySvgs.toolbar_arrow_right_m),
|
||||
@@ -309,7 +308,7 @@ class _MoreOptionActionListState extends State<MoreOptionActionList> {
|
||||
popoverController: textAlignPopoverController,
|
||||
popoverDirection: PopoverDirection.leftWithTopAligned,
|
||||
showOffset: Offset(-8, 0),
|
||||
onSelect: () => getIt<FloatingToolbarController>().hideToolbar(),
|
||||
onSelect: () => context.read<ToolbarCubit?>()?.dismiss(),
|
||||
highlightColor: highlightColor,
|
||||
child: buildCommandItem(
|
||||
MoreOptionCommand.textAlign,
|
||||
@@ -380,14 +379,13 @@ enum MoreOptionCommand {
|
||||
(attributes) => attributes[AppFlowyRichTextKeys.href] != null,
|
||||
);
|
||||
});
|
||||
getIt<FloatingToolbarController>().hideToolbar();
|
||||
context.read<ToolbarCubit?>()?.dismiss();
|
||||
if (isHref) {
|
||||
getIt<LinkHoverTriggers>().call(
|
||||
HoverTriggerKey(nodes.first.id, selection),
|
||||
);
|
||||
} else {
|
||||
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
|
||||
showLinkCreateMenu(context, editorState, selection, viewId);
|
||||
showLinkCreateMenu(context, editorState, selection);
|
||||
}
|
||||
} else if (this == strikethrough) {
|
||||
await editorState.toggleAttribute(name);
|
||||
|
||||
+1
-4
@@ -84,14 +84,12 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
refreshSuggestions();
|
||||
editorState.selectionNotifier.addListener(refreshSuggestions);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
editorState.selectionNotifier.removeListener(refreshSuggestions);
|
||||
popoverController.close();
|
||||
super.dispose();
|
||||
popoverController.close();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -292,7 +290,6 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
|
||||
}
|
||||
currentSuggestionItem =
|
||||
suggestions.where((item) => item.type == suggestionType).first;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -568,6 +568,7 @@ class EditorStyleCustomizer {
|
||||
if (style == null) {
|
||||
return null;
|
||||
}
|
||||
final fontSize = style.fontSize ?? 14.0;
|
||||
final isLight = Theme.of(context).isLightMode;
|
||||
final textColor = isLight ? Color(0xFF007296) : Color(0xFF49CFF4);
|
||||
final underlineColor = isLight ? Color(0x33005A7A) : Color(0x3349CFF4);
|
||||
@@ -577,10 +578,17 @@ class EditorStyleCustomizer {
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
AiWriterBlockKeys.suggestionReplacement => style.copyWith(
|
||||
color: textColor,
|
||||
color: Colors.transparent,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: underlineColor,
|
||||
decorationThickness: 1.0,
|
||||
// hack: https://jtmuller5.medium.com/the-ultimate-guide-to-underlining-text-in-flutter-57936f5c79bb
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: textColor,
|
||||
offset: Offset(0, -fontSize * 0.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => style,
|
||||
};
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
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 = ':';
|
||||
final _letterRegExp = RegExp(r'^[a-zA-Z]$');
|
||||
const emojiCharacter = ':';
|
||||
|
||||
CharacterShortcutEvent emojiCommand(BuildContext context) =>
|
||||
CharacterShortcutEvent(
|
||||
key: 'Opens Emoji Menu',
|
||||
character: '',
|
||||
regExp: _letterRegExp,
|
||||
handler: (editorState) async {
|
||||
return false;
|
||||
},
|
||||
handlerWithCharacter: (editorState, character) {
|
||||
emojiMenuService = EmojiMenu(
|
||||
character: emojiCharacter,
|
||||
handler: (editorState) {
|
||||
emojiMenuService ??= EmojiMenu(
|
||||
context: context,
|
||||
editorState: editorState,
|
||||
);
|
||||
return emojiCommandHandler(editorState, context, character);
|
||||
return emojiCommandHandler(editorState, context);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,7 +24,6 @@ EmojiMenuService? emojiMenuService;
|
||||
Future<bool> emojiCommandHandler(
|
||||
EditorState editorState,
|
||||
BuildContext context,
|
||||
String character,
|
||||
) async {
|
||||
final selection = editorState.selection;
|
||||
|
||||
@@ -38,32 +31,14 @@ Future<bool> emojiCommandHandler(
|
||||
return false;
|
||||
}
|
||||
|
||||
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.isCollapsed) {
|
||||
await editorState.deleteSelection(selection);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!selection.isCollapsed) return false;
|
||||
|
||||
await editorState.insertTextAtPosition(
|
||||
character,
|
||||
position: selection.start,
|
||||
);
|
||||
|
||||
emojiMenuService?.show(character);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
await editorState.insertTextAtPosition(
|
||||
emojiCharacter,
|
||||
position: selection.start,
|
||||
);
|
||||
emojiMenuService?.show();
|
||||
return true;
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
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';
|
||||
|
||||
@@ -24,7 +23,6 @@ class EmojiHandler extends StatefulWidget {
|
||||
required this.onEmojiSelect,
|
||||
this.startCharAmount = 1,
|
||||
this.cancelBySpaceHandler,
|
||||
this.initialSearchText = '',
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
@@ -33,7 +31,6 @@ class EmojiHandler extends StatefulWidget {
|
||||
final VoidCallback onSelectionUpdate;
|
||||
final SelectEmojiItemHandler onEmojiSelect;
|
||||
final int startCharAmount;
|
||||
final String initialSearchText;
|
||||
final bool Function()? cancelBySpaceHandler;
|
||||
|
||||
@override
|
||||
@@ -41,34 +38,30 @@ class EmojiHandler extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
final focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
|
||||
final scrollController = ScrollController();
|
||||
final _focusNode = FocusNode(debugLabel: 'emoji_menu_handler');
|
||||
final ItemScrollController controller = ItemScrollController();
|
||||
late EmojiData emojiData;
|
||||
final List<Emoji> searchedEmojis = [];
|
||||
bool loaded = false;
|
||||
int invalidCounter = 0;
|
||||
late int startOffset;
|
||||
late String _search = widget.initialSearchText;
|
||||
double emojiHeight = 36.0;
|
||||
final configuration = EmojiPickerConfiguration(
|
||||
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
|
||||
);
|
||||
String _search = '';
|
||||
|
||||
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) - 1;
|
||||
startOffset = widget.editorState.selection?.endIndex ?? 0;
|
||||
|
||||
if (kCachedEmojiData != null) {
|
||||
loadEmojis(kCachedEmojiData!);
|
||||
@@ -84,9 +77,8 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
selectedIndexNotifier.dispose();
|
||||
scrollController.dispose();
|
||||
_focusNode.dispose();
|
||||
_selectedIndexNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -94,11 +86,10 @@ 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: 392, maxWidth: 360),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
constraints: const BoxConstraints(maxHeight: 400, maxWidth: 300),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
color: Theme.of(context).cardColor,
|
||||
@@ -110,73 +101,52 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: noEmojis ? buildLoading() : buildEmojis(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
child: noEmojis
|
||||
? SizedBox(
|
||||
width: 400,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
onTap: () => onSelect(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
: 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void changeSelectedIndex(int index) => selectedIndexNotifier.value = index;
|
||||
void changeSelectedIndex(int index) => _selectedIndexNotifier.value = index;
|
||||
|
||||
void loadEmojis(EmojiData data) {
|
||||
emojiData = data;
|
||||
@@ -187,14 +157,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
loaded = true;
|
||||
});
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_doSearch();
|
||||
});
|
||||
}
|
||||
|
||||
void _doSearch() {
|
||||
if (!loaded || !mounted) return;
|
||||
if (_search.startsWith(' ') || _search.isEmpty) {
|
||||
Future<void> _doSearch() async {
|
||||
if (!loaded) return;
|
||||
if (_search.startsWith(' ')) {
|
||||
widget.onDismiss.call();
|
||||
return;
|
||||
}
|
||||
@@ -203,7 +170,6 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
searchedEmojis.clear();
|
||||
searchedEmojis.addAll(searchEmojiData.emojis.values);
|
||||
changeSelectedIndex(0);
|
||||
_scrollToItem();
|
||||
});
|
||||
if (searchedEmojis.isEmpty) {
|
||||
widget.onDismiss.call();
|
||||
@@ -211,19 +177,17 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
}
|
||||
|
||||
KeyEventResult onKeyEvent(focus, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
if (event is! KeyDownEvent) {
|
||||
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
|
||||
@@ -250,7 +214,11 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.character != null &&
|
||||
!moveKeys.contains(event.logicalKey)) {
|
||||
![
|
||||
...moveKeys,
|
||||
LogicalKeyboardKey.arrowLeft,
|
||||
LogicalKeyboardKey.arrowRight,
|
||||
].contains(event.logicalKey)) {
|
||||
/// Prevents dismissal of context menu by notifying the parent
|
||||
/// that the selection change occurred from the handler.
|
||||
widget.onSelectionUpdate();
|
||||
@@ -270,13 +238,42 @@ 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, startOffset + _search.length),
|
||||
(
|
||||
startOffset - widget.startCharAmount,
|
||||
_search.length + widget.startCharAmount
|
||||
),
|
||||
emojiData.getEmojiById(searchedEmojis[index].id),
|
||||
);
|
||||
widget.onDismiss.call();
|
||||
@@ -299,70 +296,49 @@ 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) {
|
||||
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) {
|
||||
bool didChange = false;
|
||||
final index = _selectedIndexNotifier.value;
|
||||
if (key == LogicalKeyboardKey.arrowUp ||
|
||||
(key == LogicalKeyboardKey.tab &&
|
||||
HardwareKeyboard.instance.isShiftPressed)) {
|
||||
if (index == 0) {
|
||||
changeSelectedIndex(length - 1);
|
||||
changeSelectedIndex(max(0, searchedEmojis.length - 1));
|
||||
didChange = true;
|
||||
} else if (index > 0) {
|
||||
changeSelectedIndex(index - 1);
|
||||
didChange = true;
|
||||
}
|
||||
} else if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (index == length - 1) {
|
||||
changeSelectedIndex(0);
|
||||
} else if (index < length - 1) {
|
||||
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab]
|
||||
.contains(key)) {
|
||||
if (index < searchedEmojis.length - 1) {
|
||||
changeSelectedIndex(index + 1);
|
||||
didChange = true;
|
||||
} else if (index == searchedEmojis.length - 1) {
|
||||
changeSelectedIndex(0);
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
final heightAfter =
|
||||
(selectedIndexNotifier.value ~/ configuration.perLine) * emojiHeight;
|
||||
|
||||
if (mounted && (heightAfter != heightBefore)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToItem();
|
||||
});
|
||||
if (mounted && didChange) {
|
||||
_scrollToItem();
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToItem() {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
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,
|
||||
if (noEmojis) return;
|
||||
controller.scrollTo(
|
||||
index: _selectedIndexNotifier.value,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +356,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
search = delta.toPlainText().substring(
|
||||
startOffset,
|
||||
startOffset + _search.length - 1,
|
||||
startOffset - 1 + _search.length,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'emoji_actions_command.dart';
|
||||
import 'emoji_handler.dart';
|
||||
|
||||
abstract class EmojiMenuService {
|
||||
void show(String character);
|
||||
void show();
|
||||
|
||||
void dismiss();
|
||||
}
|
||||
@@ -16,22 +16,16 @@ 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() {
|
||||
@@ -57,8 +51,7 @@ class EmojiMenu extends EmojiMenuService {
|
||||
void _onSelectionUpdate() => selectionChangedByMenu = true;
|
||||
|
||||
@override
|
||||
void show(String character) {
|
||||
initialCharacter = character;
|
||||
void show() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
|
||||
}
|
||||
|
||||
@@ -69,11 +62,41 @@ 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;
|
||||
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final firstRect = selectionRects.first;
|
||||
Offset offset = firstRect.bottomRight + menuOffset;
|
||||
|
||||
final (left, top, right, bottom) = _getPosition();
|
||||
// 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);
|
||||
|
||||
_menuEntry = OverlayEntry(
|
||||
builder: (context) => SizedBox(
|
||||
@@ -99,7 +122,6 @@ class EmojiMenu extends EmojiMenuService {
|
||||
onSelectionUpdate: _onSelectionUpdate,
|
||||
startCharAmount: startCharAmount,
|
||||
cancelBySpaceHandler: cancelBySpaceHandler,
|
||||
initialSearchText: initialCharacter,
|
||||
onEmojiSelect: (
|
||||
BuildContext context,
|
||||
(int, int) replacement,
|
||||
@@ -112,14 +134,10 @@ class EmojiMenu extends EmojiMenuService {
|
||||
editorState.document.nodeAtPath(selection.end.path);
|
||||
if (node == null) return;
|
||||
final transaction = editorState.transaction
|
||||
..deleteText(
|
||||
node,
|
||||
replacement.$1,
|
||||
replacement.$2 - replacement.$1,
|
||||
)
|
||||
..insertText(
|
||||
..replaceText(
|
||||
node,
|
||||
replacement.$1,
|
||||
replacement.$2,
|
||||
emoji,
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
@@ -157,77 +175,30 @@ class EmojiMenu extends EmojiMenuService {
|
||||
selectionChangedByMenu = false;
|
||||
}
|
||||
|
||||
(double? left, double? top, double? right, double? bottom) _getPosition() {
|
||||
(double? left, double? top, double? right, double? bottom) _getPosition(
|
||||
Alignment alignment,
|
||||
Offset offset,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ Future<String> customDocumentToMarkdown(
|
||||
Document document, {
|
||||
String path = '',
|
||||
AsyncValueSetter<Archive>? onArchive,
|
||||
String lineBreak = '',
|
||||
}) async {
|
||||
final List<Future<ArchiveFile>> fileFutures = [];
|
||||
|
||||
@@ -42,7 +41,6 @@ Future<String> customDocumentToMarkdown(
|
||||
try {
|
||||
markdown = documentToMarkdown(
|
||||
document,
|
||||
lineBreak: lineBreak,
|
||||
customParsers: [
|
||||
const MathEquationNodeParser(),
|
||||
const CalloutNodeParser(),
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -188,9 +187,6 @@ Future<void> initGetIt(
|
||||
getIt.registerSingleton<PluginSandbox>(PluginSandbox());
|
||||
getIt.registerSingleton<ViewExpanderRegistry>(ViewExpanderRegistry());
|
||||
getIt.registerSingleton<LinkHoverTriggers>(LinkHoverTriggers());
|
||||
getIt.registerSingleton<FloatingToolbarController>(
|
||||
FloatingToolbarController(),
|
||||
);
|
||||
|
||||
await DependencyResolver.resolve(getIt, mode);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -231,19 +230,14 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(state.textScaleFactor),
|
||||
),
|
||||
child: AppFlowyTheme(
|
||||
data: Theme.of(context).brightness == Brightness.light
|
||||
? AppFlowyThemeData.light()
|
||||
: AppFlowyThemeData.dark(),
|
||||
child: overlayManagerBuilder(
|
||||
context,
|
||||
!UniversalPlatform.isMobile && FeatureFlag.search.isOn
|
||||
? CommandPalette(
|
||||
notifier: _commandPaletteNotifier,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
child: overlayManagerBuilder(
|
||||
context,
|
||||
!UniversalPlatform.isMobile && FeatureFlag.search.isOn
|
||||
? CommandPalette(
|
||||
notifier: _commandPaletteNotifier,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
),
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
||||
@@ -8,15 +8,10 @@ import 'package:appflowy/startup/startup.dart';
|
||||
class WindowSizeManager {
|
||||
static const double minWindowHeight = 640.0;
|
||||
static const double minWindowWidth = 640.0;
|
||||
|
||||
// Preventing failed assertion due to Texture Descriptor Validation
|
||||
static const double maxWindowHeight = 8192.0;
|
||||
static const double maxWindowWidth = 8192.0;
|
||||
|
||||
// Default windows size
|
||||
static const double defaultWindowHeight = 960.0;
|
||||
static const double defaultWindowWidth = 1280.0;
|
||||
|
||||
static const double maxScaleFactor = 2.0;
|
||||
static const double minScaleFactor = 0.5;
|
||||
|
||||
@@ -41,8 +36,8 @@ class WindowSizeManager {
|
||||
Future<Size> getSize() async {
|
||||
final defaultWindowSize = jsonEncode(
|
||||
{
|
||||
WindowSizeManager.height: defaultWindowHeight,
|
||||
WindowSizeManager.width: defaultWindowWidth,
|
||||
WindowSizeManager.height: minWindowHeight,
|
||||
WindowSizeManager.width: minWindowWidth,
|
||||
},
|
||||
);
|
||||
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
|
||||
|
||||
@@ -83,13 +83,6 @@ class AppFlowyCloudDeepLink {
|
||||
void unsubscribeDeepLinkLoadingState(VoidCallback listener) =>
|
||||
_stateNotifier?.removeListener(listener);
|
||||
|
||||
Future<void> passGotrueTokenResponse(
|
||||
GotrueTokenResponsePB gotrueTokenResponse,
|
||||
) async {
|
||||
final uri = _buildDeepLinkUri(gotrueTokenResponse);
|
||||
await _handleUri(uri);
|
||||
}
|
||||
|
||||
Future<void> _handleUri(
|
||||
Uri? uri,
|
||||
) async {
|
||||
@@ -180,57 +173,6 @@ class AppFlowyCloudDeepLink {
|
||||
bool _isPaymentSuccessUri(Uri uri) {
|
||||
return uri.host == 'payment-success';
|
||||
}
|
||||
|
||||
Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) {
|
||||
final params = <String, String>{};
|
||||
|
||||
if (gotrueTokenResponse.hasAccessToken() &&
|
||||
gotrueTokenResponse.accessToken.isNotEmpty) {
|
||||
params['access_token'] = gotrueTokenResponse.accessToken;
|
||||
}
|
||||
|
||||
if (gotrueTokenResponse.hasExpiresAt()) {
|
||||
params['expires_at'] = gotrueTokenResponse.expiresAt.toString();
|
||||
}
|
||||
|
||||
if (gotrueTokenResponse.hasExpiresIn()) {
|
||||
params['expires_in'] = gotrueTokenResponse.expiresIn.toString();
|
||||
}
|
||||
|
||||
if (gotrueTokenResponse.hasProviderRefreshToken() &&
|
||||
gotrueTokenResponse.providerRefreshToken.isNotEmpty) {
|
||||
params['provider_refresh_token'] =
|
||||
gotrueTokenResponse.providerRefreshToken;
|
||||
}
|
||||
|
||||
if (gotrueTokenResponse.hasProviderAccessToken() &&
|
||||
gotrueTokenResponse.providerAccessToken.isNotEmpty) {
|
||||
params['provider_token'] = gotrueTokenResponse.providerAccessToken;
|
||||
}
|
||||
|
||||
if (gotrueTokenResponse.hasRefreshToken() &&
|
||||
gotrueTokenResponse.refreshToken.isNotEmpty) {
|
||||
params['refresh_token'] = gotrueTokenResponse.refreshToken;
|
||||
}
|
||||
|
||||
if (gotrueTokenResponse.hasTokenType() &&
|
||||
gotrueTokenResponse.tokenType.isNotEmpty) {
|
||||
params['token_type'] = gotrueTokenResponse.tokenType;
|
||||
}
|
||||
|
||||
if (params.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fragment = params.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}',
|
||||
)
|
||||
.join('&');
|
||||
|
||||
return Uri.parse('appflowy-flutter://login-callback#$fragment');
|
||||
}
|
||||
}
|
||||
|
||||
class InitAppFlowyCloudTask extends LaunchTask {
|
||||
|
||||
@@ -32,17 +32,12 @@ class AppFlowyCloudAuthService implements AuthService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
|
||||
signInWithEmailPassword({
|
||||
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
return _backendAuthService.signInWithEmailPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
params: params,
|
||||
);
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -111,17 +106,6 @@ class AppFlowyCloudAuthService implements AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
|
||||
required String email,
|
||||
required String passcode,
|
||||
}) async {
|
||||
return _backendAuthService.signInWithPasscode(
|
||||
email: email,
|
||||
passcode: passcode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<UserProfilePB, FlowyError>> getUser() async {
|
||||
return UserBackendService.getCurrentUserProfile();
|
||||
|
||||
+1
-10
@@ -33,8 +33,7 @@ class AppFlowyCloudMockAuthService implements AuthService {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
|
||||
signInWithEmailPassword({
|
||||
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
Map<String, String> params = const {},
|
||||
@@ -107,12 +106,4 @@ class AppFlowyCloudMockAuthService implements AuthService {
|
||||
Future<FlowyResult<UserProfilePB, FlowyError>> getUser() async {
|
||||
return UserBackendService.getCurrentUserProfile();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
|
||||
required String email,
|
||||
required String passcode,
|
||||
}) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
class AuthServiceMapKeys {
|
||||
@@ -23,8 +23,7 @@ abstract class AuthService {
|
||||
///
|
||||
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
|
||||
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
|
||||
signInWithEmailPassword({
|
||||
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
Map<String, String> params,
|
||||
@@ -76,17 +75,6 @@ abstract class AuthService {
|
||||
Map<String, String> params,
|
||||
});
|
||||
|
||||
/// Authenticates a user with a passcode sent to their email.
|
||||
///
|
||||
/// - `email`: The email address of the user.
|
||||
/// - `passcode`: The passcode of the user.
|
||||
///
|
||||
/// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError].
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
|
||||
required String email,
|
||||
required String passcode,
|
||||
});
|
||||
|
||||
/// Signs out the currently authenticated user.
|
||||
Future<void> signOut();
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ class BackendAuthService implements AuthService {
|
||||
final AuthenticatorPB authType;
|
||||
|
||||
@override
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
|
||||
signInWithEmailPassword({
|
||||
Future<FlowyResult<UserProfilePB, FlowyError>> signInWithEmailPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
Map<String, String> params = const {},
|
||||
@@ -30,7 +29,8 @@ class BackendAuthService implements AuthService {
|
||||
..password = password
|
||||
..authType = authType
|
||||
..deviceId = await getDeviceId();
|
||||
return UserEventSignInWithEmailPassword(request).send();
|
||||
final response = UserEventSignInWithEmailPassword(request).send();
|
||||
return response.then((value) => value);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,12 +107,4 @@ class BackendAuthService implements AuthService {
|
||||
// No need to pass the redirect URL.
|
||||
return UserBackendService.signInWithMagicLink(email, '');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<GotrueTokenResponsePB, FlowyError>> signInWithPasscode({
|
||||
required String email,
|
||||
required String passcode,
|
||||
}) async {
|
||||
return UserBackendService.signInWithPasscode(email, passcode);
|
||||
}
|
||||
}
|
||||
@@ -30,26 +30,12 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
on<SignInEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
signInWithEmailAndPassword: (email, password) async =>
|
||||
_onSignInWithEmailAndPassword(
|
||||
emit,
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
signInWithOAuth: (platform) async => _onSignInWithOAuth(
|
||||
emit,
|
||||
platform: platform,
|
||||
),
|
||||
signInAsGuest: () async => _onSignInAsGuest(emit),
|
||||
signInWithMagicLink: (email) async => _onSignInWithMagicLink(
|
||||
emit,
|
||||
email: email,
|
||||
),
|
||||
signInWithPasscode: (email, passcode) async => _onSignInWithPasscode(
|
||||
emit,
|
||||
email: email,
|
||||
passcode: passcode,
|
||||
),
|
||||
signedInWithUserEmailAndPassword: () async => _onSignIn(emit),
|
||||
signedInWithOAuth: (platform) async =>
|
||||
_onSignInWithOAuth(emit, platform),
|
||||
signedInAsGuest: () async => _onSignInAsGuest(emit),
|
||||
signedWithMagicLink: (email) async =>
|
||||
_onSignInWithMagicLink(emit, email),
|
||||
deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result),
|
||||
cancel: () {
|
||||
emit(
|
||||
@@ -133,34 +119,26 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSignInWithEmailAndPassword(
|
||||
Emitter<SignInState> emit, {
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
Future<void> _onSignIn(Emitter<SignInState> emit) async {
|
||||
final result = await authService.signInWithEmailPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
email: state.email ?? '',
|
||||
password: state.password ?? '',
|
||||
);
|
||||
emit(
|
||||
result.fold(
|
||||
(gotrueTokenResponse) {
|
||||
getIt<AppFlowyCloudDeepLink>().passGotrueTokenResponse(
|
||||
gotrueTokenResponse,
|
||||
);
|
||||
return state.copyWith(
|
||||
isSubmitting: false,
|
||||
);
|
||||
},
|
||||
(userProfile) => state.copyWith(
|
||||
isSubmitting: false,
|
||||
successOrFail: FlowyResult.success(userProfile),
|
||||
),
|
||||
(error) => _stateFromCode(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSignInWithOAuth(
|
||||
Emitter<SignInState> emit, {
|
||||
required String platform,
|
||||
}) async {
|
||||
Emitter<SignInState> emit,
|
||||
String platform,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: true,
|
||||
@@ -183,9 +161,9 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
}
|
||||
|
||||
Future<void> _onSignInWithMagicLink(
|
||||
Emitter<SignInState> emit, {
|
||||
required String email,
|
||||
}) async {
|
||||
Emitter<SignInState> emit,
|
||||
String email,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: true,
|
||||
@@ -205,40 +183,6 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSignInWithPasscode(
|
||||
Emitter<SignInState> emit, {
|
||||
required String email,
|
||||
required String passcode,
|
||||
}) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: true,
|
||||
emailError: null,
|
||||
passwordError: null,
|
||||
successOrFail: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await authService.signInWithPasscode(
|
||||
email: email,
|
||||
passcode: passcode,
|
||||
);
|
||||
|
||||
emit(
|
||||
result.fold(
|
||||
(gotrueTokenResponse) {
|
||||
getIt<AppFlowyCloudDeepLink>().passGotrueTokenResponse(
|
||||
gotrueTokenResponse,
|
||||
);
|
||||
return state.copyWith(
|
||||
isSubmitting: false,
|
||||
);
|
||||
},
|
||||
(error) => _stateFromCode(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSignInAsGuest(
|
||||
Emitter<SignInState> emit,
|
||||
) async {
|
||||
@@ -280,17 +224,10 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
emailError: null,
|
||||
);
|
||||
case ErrorCode.UserUnauthorized:
|
||||
final errorMsg = error.msg;
|
||||
String msg = LocaleKeys.signIn_generalError.tr();
|
||||
if (errorMsg.contains('rate limit')) {
|
||||
msg = LocaleKeys.signIn_limitRateError.tr();
|
||||
} else if (errorMsg.contains('invalid')) {
|
||||
msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr();
|
||||
}
|
||||
return state.copyWith(
|
||||
isSubmitting: false,
|
||||
successOrFail: FlowyResult.failure(
|
||||
FlowyError(msg: msg),
|
||||
FlowyError(msg: LocaleKeys.signIn_limitRateError.tr()),
|
||||
),
|
||||
);
|
||||
default:
|
||||
@@ -306,35 +243,19 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
|
||||
@freezed
|
||||
class SignInEvent with _$SignInEvent {
|
||||
// Sign in methods
|
||||
const factory SignInEvent.signInWithEmailAndPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
}) = SignInWithEmailAndPassword;
|
||||
const factory SignInEvent.signInWithOAuth({
|
||||
required String platform,
|
||||
}) = SignInWithOAuth;
|
||||
const factory SignInEvent.signInAsGuest() = SignInAsGuest;
|
||||
const factory SignInEvent.signInWithMagicLink({
|
||||
required String email,
|
||||
}) = SignInWithMagicLink;
|
||||
const factory SignInEvent.signInWithPasscode({
|
||||
required String email,
|
||||
required String passcode,
|
||||
}) = SignInWithPasscode;
|
||||
|
||||
// Event handlers
|
||||
const factory SignInEvent.emailChanged({
|
||||
required String email,
|
||||
}) = EmailChanged;
|
||||
const factory SignInEvent.passwordChanged({
|
||||
required String password,
|
||||
}) = PasswordChanged;
|
||||
const factory SignInEvent.signedInWithUserEmailAndPassword() =
|
||||
SignedInWithUserEmailAndPassword;
|
||||
const factory SignInEvent.signedInWithOAuth(String platform) =
|
||||
SignedInWithOAuth;
|
||||
const factory SignInEvent.signedInAsGuest() = SignedInAsGuest;
|
||||
const factory SignInEvent.signedWithMagicLink(String email) =
|
||||
SignedWithMagicLink;
|
||||
const factory SignInEvent.emailChanged(String email) = EmailChanged;
|
||||
const factory SignInEvent.passwordChanged(String password) = PasswordChanged;
|
||||
const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) =
|
||||
DeepLinkStateChange;
|
||||
|
||||
const factory SignInEvent.cancel() = Cancel;
|
||||
const factory SignInEvent.switchLoginType(LoginType type) = SwitchLoginType;
|
||||
const factory SignInEvent.cancel() = _Cancel;
|
||||
const factory SignInEvent.switchLoginType(LoginType type) = _SwitchLoginType;
|
||||
}
|
||||
|
||||
// we support sign in directly without sign up, but we want to allow the users to sign up if they want to
|
||||
|
||||
@@ -86,15 +86,6 @@ class UserBackendService implements IUserBackendService {
|
||||
return UserEventMagicLinkSignIn(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<GotrueTokenResponsePB, FlowyError>>
|
||||
signInWithPasscode(
|
||||
String email,
|
||||
String passcode,
|
||||
) async {
|
||||
final payload = PasscodeSignInPB(email: email, passcode: passcode);
|
||||
return UserEventPasscodeSignIn(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<void, FlowyError>> signOut() {
|
||||
return UserEventSignOut().send();
|
||||
}
|
||||
|
||||
+24
-48
@@ -1,13 +1,11 @@
|
||||
import 'package:appflowy/core/frameless_window.dart';
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/settings/show_settings.dart';
|
||||
import 'package:appflowy/shared/window_title_bar.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/user/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -21,8 +19,6 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
const indicatorMinHeight = 4.0;
|
||||
return BlocBuilder<SignInBloc, SignInState>(
|
||||
builder: (context, state) {
|
||||
@@ -33,23 +29,25 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
const VSpace(20),
|
||||
|
||||
// logo and title
|
||||
FlowyLogoTitle(
|
||||
title: LocaleKeys.welcomeText.tr(),
|
||||
logoSize: Size.square(36),
|
||||
logoSize: const Size(60, 60),
|
||||
),
|
||||
VSpace(theme.spacing.xxl),
|
||||
const VSpace(20),
|
||||
|
||||
// continue with email and password
|
||||
const ContinueWithEmailAndPassword(),
|
||||
VSpace(theme.spacing.xxl),
|
||||
// magic link sign in
|
||||
const SignInWithMagicLinkButtons(),
|
||||
const VSpace(20),
|
||||
|
||||
// third-party sign in.
|
||||
if (isAuthEnabled) ...[
|
||||
const _OrDivider(),
|
||||
VSpace(theme.spacing.xxl),
|
||||
const VSpace(20),
|
||||
const ThirdPartySignInButtons(),
|
||||
VSpace(theme.spacing.xxl),
|
||||
const VSpace(20),
|
||||
],
|
||||
|
||||
// sign in agreement
|
||||
@@ -71,7 +69,7 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DesktopSignInSettingsButton(),
|
||||
HSpace(20),
|
||||
HSpace(42),
|
||||
SignInAnonymousButtonV2(),
|
||||
],
|
||||
),
|
||||
@@ -101,24 +99,18 @@ class DesktopSignInSettingsButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return AFGhostIconTextButton(
|
||||
text: LocaleKeys.signIn_settings.tr(),
|
||||
textColor: (context, isHovering, disabled) {
|
||||
return theme.textColorScheme.secondary;
|
||||
},
|
||||
size: AFButtonSize.s,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
vertical: theme.spacing.xs,
|
||||
return FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
LocaleKeys.signIn_settings.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
fontSize: 12.0,
|
||||
// fontWeight: FontWeight.w500,
|
||||
color: Colors.grey,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
onTap: () => showSimpleSettingsDialog(context),
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.settings_s,
|
||||
size: Size.square(20),
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
onTap: () {
|
||||
showSimpleSettingsDialog(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -129,30 +121,14 @@ class _OrDivider extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Divider(
|
||||
thickness: 1,
|
||||
color: theme.borderColorScheme.greyTertiary,
|
||||
),
|
||||
),
|
||||
const Flexible(child: Divider(thickness: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
LocaleKeys.signIn_or.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Divider(
|
||||
thickness: 1,
|
||||
color: theme.borderColorScheme.greyTertiary,
|
||||
),
|
||||
child: FlowyText.regular(LocaleKeys.signIn_or.tr()),
|
||||
),
|
||||
const Flexible(child: Divider(thickness: 1)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+12
-20
@@ -7,7 +7,6 @@ import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -38,7 +37,7 @@ class MobileSignInScreen extends StatelessWidget {
|
||||
const VSpace(spacing * 2),
|
||||
isLocalAuthEnabled
|
||||
? const SignInAnonymousButtonV3()
|
||||
: const ContinueWithEmailAndPassword(),
|
||||
: const SignInWithMagicLinkButtons(),
|
||||
const VSpace(spacing),
|
||||
if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme),
|
||||
const VSpace(spacing * 1.5),
|
||||
@@ -104,28 +103,21 @@ class MobileSignInScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildSettingsButton(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AFGhostIconTextButton(
|
||||
text: LocaleKeys.signIn_settings.tr(),
|
||||
textColor: (context, isHovering, disabled) {
|
||||
return theme.textColorScheme.secondary;
|
||||
},
|
||||
size: AFButtonSize.s,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
vertical: theme.spacing.xs,
|
||||
FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
LocaleKeys.signIn_settings.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
fontSize: 12.0,
|
||||
// fontWeight: FontWeight.w500,
|
||||
color: Colors.grey,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
onTap: () => context.push(MobileLaunchSettingsPage.routeName),
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.settings_s,
|
||||
size: Size.square(20),
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
onTap: () {
|
||||
context.push(MobileLaunchSettingsPage.routeName);
|
||||
},
|
||||
),
|
||||
const HSpace(24),
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class SignInAnonymousButtonV3 extends StatelessWidget {
|
||||
? () {
|
||||
context
|
||||
.read<SignInBloc>()
|
||||
.add(const SignInEvent.signInAsGuest());
|
||||
.add(const SignInEvent.signedInAsGuest());
|
||||
}
|
||||
: () {
|
||||
final bloc = context.read<AnonUserBloc>();
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnonymousSignInButton extends StatelessWidget {
|
||||
const AnonymousSignInButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFGhostButton.normal(
|
||||
onTap: () {},
|
||||
builder: (context, isHovering, disabled) {
|
||||
return const Placeholder();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class ContinueWithEmail extends StatelessWidget {
|
||||
const ContinueWithEmail({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFFilledTextButton.primary(
|
||||
text: LocaleKeys.signIn_continueWithEmail.tr(),
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class ContinueWithEmailAndPassword extends StatefulWidget {
|
||||
const ContinueWithEmailAndPassword({super.key});
|
||||
|
||||
@override
|
||||
State<ContinueWithEmailAndPassword> createState() =>
|
||||
_ContinueWithEmailAndPasswordState();
|
||||
}
|
||||
|
||||
class _ContinueWithEmailAndPasswordState
|
||||
extends State<ContinueWithEmailAndPassword> {
|
||||
final controller = TextEditingController();
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
focusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: UniversalPlatform.isMobile ? 38.0 : 40.0,
|
||||
child: AFTextField(
|
||||
controller: controller,
|
||||
hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(),
|
||||
radius: 10,
|
||||
onSubmitted: (value) => _pushContinueWithMagicLinkOrPasscodePage(
|
||||
context,
|
||||
value,
|
||||
),
|
||||
),
|
||||
),
|
||||
VSpace(theme.spacing.l),
|
||||
ContinueWithEmail(
|
||||
onTap: () => _pushContinueWithMagicLinkOrPasscodePage(
|
||||
context,
|
||||
controller.text,
|
||||
),
|
||||
),
|
||||
// Hide password sign in until we implement the reset password / forgot password
|
||||
// VSpace(theme.spacing.l),
|
||||
// ContinueWithPassword(
|
||||
// onTap: () => _pushContinueWithPasswordPage(
|
||||
// context,
|
||||
// controller.text,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _pushContinueWithMagicLinkOrPasscodePage(
|
||||
BuildContext context,
|
||||
String email,
|
||||
) {
|
||||
if (!isEmail(email)) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.signIn_invalidEmail.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final signInBloc = context.read<SignInBloc>();
|
||||
|
||||
signInBloc.add(SignInEvent.signInWithMagicLink(email: email));
|
||||
|
||||
// push the a continue with magic link or passcode screen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: signInBloc,
|
||||
child: ContinueWithMagicLinkOrPasscodePage(
|
||||
email: email,
|
||||
backToLogin: () => Navigator.pop(context),
|
||||
onEnterPasscode: (passcode) => signInBloc.add(
|
||||
SignInEvent.signInWithPasscode(
|
||||
email: email,
|
||||
passcode: passcode,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// void _pushContinueWithPasswordPage(
|
||||
// BuildContext context,
|
||||
// String email,
|
||||
// ) {
|
||||
// final signInBloc = context.read<SignInBloc>();
|
||||
// Navigator.push(
|
||||
// context,
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => ContinueWithPasswordPage(
|
||||
// email: email,
|
||||
// backToLogin: () => Navigator.pop(context),
|
||||
// onEnterPassword: (password) => signInBloc.add(
|
||||
// SignInEvent.signInWithEmailAndPassword(
|
||||
// email: email,
|
||||
// password: password,
|
||||
// ),
|
||||
// ),
|
||||
// onForgotPassword: () {
|
||||
// // todo: implement forgot password
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ContinueWithMagicLinkOrPasscodePage extends StatefulWidget {
|
||||
const ContinueWithMagicLinkOrPasscodePage({
|
||||
super.key,
|
||||
required this.backToLogin,
|
||||
required this.email,
|
||||
required this.onEnterPasscode,
|
||||
});
|
||||
|
||||
final String email;
|
||||
final VoidCallback backToLogin;
|
||||
final ValueChanged<String> onEnterPasscode;
|
||||
|
||||
@override
|
||||
State<ContinueWithMagicLinkOrPasscodePage> createState() =>
|
||||
_ContinueWithMagicLinkOrPasscodePageState();
|
||||
}
|
||||
|
||||
class _ContinueWithMagicLinkOrPasscodePageState
|
||||
extends State<ContinueWithMagicLinkOrPasscodePage> {
|
||||
final passcodeController = TextEditingController();
|
||||
|
||||
bool isEnteringPasscode = false;
|
||||
|
||||
ToastificationItem? toastificationItem;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
passcodeController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<SignInBloc, SignInState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSubmitting) {
|
||||
_showLoadingDialog();
|
||||
} else {
|
||||
_dismissLoadingDialog();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo, title and description
|
||||
..._buildLogoTitleAndDescription(),
|
||||
|
||||
// Enter code manually
|
||||
..._buildEnterCodeManually(),
|
||||
|
||||
// Back to login
|
||||
..._buildBackToLogin(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildEnterCodeManually() {
|
||||
// todo: ask designer to provide the spacing
|
||||
final spacing = VSpace(20);
|
||||
|
||||
if (!isEnteringPasscode) {
|
||||
return [
|
||||
AFFilledTextButton.primary(
|
||||
text: LocaleKeys.signIn_enterCodeManually.tr(),
|
||||
onTap: () => setState(() => isEnteringPasscode = true),
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
spacing,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
// Enter code manually
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: AFTextField(
|
||||
controller: passcodeController,
|
||||
hintText: LocaleKeys.signIn_enterCode.tr(),
|
||||
keyboardType: TextInputType.number,
|
||||
radius: 10,
|
||||
autoFocus: true,
|
||||
onSubmitted: widget.onEnterPasscode,
|
||||
),
|
||||
),
|
||||
// todo: ask designer to provide the spacing
|
||||
VSpace(12),
|
||||
|
||||
// continue to login
|
||||
AFFilledTextButton.primary(
|
||||
text: 'Continue to sign in',
|
||||
onTap: () => widget.onEnterPasscode(passcodeController.text),
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
|
||||
spacing,
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildBackToLogin() {
|
||||
return [
|
||||
AFGhostTextButton(
|
||||
text: LocaleKeys.signIn_backToLogin.tr(),
|
||||
size: AFButtonSize.s,
|
||||
onTap: widget.backToLogin,
|
||||
textColor: (context, isHovering, disabled) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.themeThickHover;
|
||||
}
|
||||
return theme.textColorScheme.theme;
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildLogoTitleAndDescription() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final spacing = VSpace(theme.spacing.xxl);
|
||||
return [
|
||||
// logo
|
||||
const AFLogo(),
|
||||
spacing,
|
||||
|
||||
// title
|
||||
Text(
|
||||
LocaleKeys.signIn_checkYourEmail.tr(),
|
||||
style: theme.textStyle.heading.h3(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
spacing,
|
||||
|
||||
// description
|
||||
Text(
|
||||
LocaleKeys.signIn_temporaryVerificationSent.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
widget.email,
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
spacing,
|
||||
];
|
||||
}
|
||||
|
||||
void _showLoadingDialog() {
|
||||
_dismissLoadingDialog();
|
||||
|
||||
toastificationItem = showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.signIn_signingIn.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
void _dismissLoadingDialog() {
|
||||
final toastificationItem = this.toastificationItem;
|
||||
if (toastificationItem != null) {
|
||||
toastification.dismiss(toastificationItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ContinueWithPassword extends StatelessWidget {
|
||||
const ContinueWithPassword({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOutlinedTextButton.normal(
|
||||
text: 'Continue with password',
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ContinueWithPasswordPage extends StatefulWidget {
|
||||
const ContinueWithPasswordPage({
|
||||
super.key,
|
||||
required this.backToLogin,
|
||||
required this.email,
|
||||
required this.onEnterPassword,
|
||||
required this.onForgotPassword,
|
||||
});
|
||||
|
||||
final String email;
|
||||
final VoidCallback backToLogin;
|
||||
final ValueChanged<String> onEnterPassword;
|
||||
final VoidCallback onForgotPassword;
|
||||
|
||||
@override
|
||||
State<ContinueWithPasswordPage> createState() =>
|
||||
_ContinueWithPasswordPageState();
|
||||
}
|
||||
|
||||
class _ContinueWithPasswordPageState extends State<ContinueWithPasswordPage> {
|
||||
final passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo and title
|
||||
..._buildLogoAndTitle(),
|
||||
|
||||
// Password input and buttons
|
||||
..._buildPasswordSection(),
|
||||
|
||||
// Back to login
|
||||
..._buildBackToLogin(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildLogoAndTitle() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final spacing = VSpace(theme.spacing.xxl);
|
||||
return [
|
||||
// logo
|
||||
const AFLogo(),
|
||||
spacing,
|
||||
|
||||
// title
|
||||
Text(
|
||||
'Enter password',
|
||||
style: theme.textStyle.heading.h3(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
spacing,
|
||||
|
||||
// email display
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Login as ',
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: widget.email,
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
spacing,
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildPasswordSection() {
|
||||
return [
|
||||
// Password input
|
||||
AFTextField(
|
||||
controller: passwordController,
|
||||
hintText: 'Enter password',
|
||||
autoFocus: true,
|
||||
onSubmitted: widget.onEnterPassword,
|
||||
),
|
||||
// todo: ask designer to provide the spacing
|
||||
VSpace(12),
|
||||
|
||||
// todo: forgot password is not implemented yet
|
||||
// Forgot password button
|
||||
// AFGhostTextButton(
|
||||
// text: 'Forget password?',
|
||||
// size: AFButtonSize.s,
|
||||
// onTap: widget.onForgotPassword,
|
||||
// textColor: (context, isHovering, disabled) {
|
||||
// return theme.textColorScheme.theme;
|
||||
// },
|
||||
// ),
|
||||
VSpace(12),
|
||||
|
||||
// Continue button
|
||||
AFFilledTextButton.primary(
|
||||
text: 'Continue',
|
||||
onTap: () => widget.onEnterPassword(passwordController.text),
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
VSpace(20),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildBackToLogin() {
|
||||
return [
|
||||
AFGhostTextButton(
|
||||
text: 'Back to Login',
|
||||
size: AFButtonSize.s,
|
||||
onTap: widget.backToLogin,
|
||||
textColor: (context, isHovering, disabled) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return theme.textColorScheme.theme;
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AFLogo extends StatelessWidget {
|
||||
const AFLogo({
|
||||
super.key,
|
||||
this.size = const Size.square(36),
|
||||
});
|
||||
|
||||
final Size size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
blendMode: null,
|
||||
size: size,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -64,17 +64,14 @@ class _SignInWithMagicLinkButtonsState
|
||||
|
||||
void _sendMagicLink(BuildContext context, String email) {
|
||||
if (!isEmail(email)) {
|
||||
showToastNotification(
|
||||
return showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.signIn_invalidEmail.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context
|
||||
.read<SignInBloc>()
|
||||
.add(SignInEvent.signInWithMagicLink(email: email));
|
||||
context.read<SignInBloc>().add(SignInEvent.signedWithMagicLink(email));
|
||||
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
|
||||
+16
-16
@@ -1,7 +1,6 @@
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -13,40 +12,41 @@ class SignInAgreement extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final textStyle = theme.textStyle.caption.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
final underlinedTextStyle = theme.textStyle.caption.underline(
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
return RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: isLocalAuthEnabled
|
||||
? '${LocaleKeys.web_signInLocalAgreement.tr()} \n'
|
||||
: '${LocaleKeys.web_signInAgreement.tr()} \n',
|
||||
style: textStyle,
|
||||
? '${LocaleKeys.web_signInLocalAgreement.tr()} '
|
||||
: '${LocaleKeys.web_signInAgreement.tr()} ',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.web_termOfUse.tr()} ',
|
||||
style: underlinedTextStyle,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => afLaunchUrlString('https://appflowy.com/terms'),
|
||||
..onTap = () => afLaunchUrlString('https://appflowy.io/terms'),
|
||||
),
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.web_and.tr()} ',
|
||||
style: textStyle,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys.web_privacyPolicy.tr(),
|
||||
style: underlinedTextStyle,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => afLaunchUrlString('https://appflowy.com/privacy'),
|
||||
..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+89
-20
@@ -1,14 +1,91 @@
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/anon_user_bloc.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
/// Used in DesktopSignInScreen and MobileSignInScreen
|
||||
class SignInAnonymousButton extends StatelessWidget {
|
||||
const SignInAnonymousButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = UniversalPlatform.isMobile;
|
||||
|
||||
return BlocBuilder<SignInBloc, SignInState>(
|
||||
builder: (context, signInState) {
|
||||
return BlocProvider(
|
||||
create: (context) => AnonUserBloc()
|
||||
..add(
|
||||
const AnonUserEvent.initial(),
|
||||
),
|
||||
child: BlocListener<AnonUserBloc, AnonUserState>(
|
||||
listener: (context, state) async {
|
||||
if (state.openedAnonUser != null) {
|
||||
await runAppFlowy();
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AnonUserBloc, AnonUserState>(
|
||||
builder: (context, state) {
|
||||
final text = state.anonUsers.isEmpty
|
||||
? LocaleKeys.signIn_loginStartWithAnonymous.tr()
|
||||
: LocaleKeys.signIn_continueAnonymousUser.tr();
|
||||
final onTap = state.anonUsers.isEmpty
|
||||
? () {
|
||||
context
|
||||
.read<SignInBloc>()
|
||||
.add(const SignInEvent.signedInAsGuest());
|
||||
}
|
||||
: () {
|
||||
final bloc = context.read<AnonUserBloc>();
|
||||
final user = bloc.state.anonUsers.first;
|
||||
bloc.add(AnonUserEvent.openAnonUser(user));
|
||||
};
|
||||
// SignInAnonymousButton in mobile
|
||||
if (isMobile) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 56),
|
||||
),
|
||||
onPressed: onTap,
|
||||
child: FlowyText(
|
||||
LocaleKeys.signIn_loginStartWithAnonymous.tr(),
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
// SignInAnonymousButton in desktop
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: FlowyButton(
|
||||
isSelected: true,
|
||||
disable: signInState.isSubmitting,
|
||||
text: FlowyText.medium(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
radius: Corners.s6Border,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SignInAnonymousButtonV2 extends StatelessWidget {
|
||||
const SignInAnonymousButtonV2({
|
||||
@@ -32,35 +109,27 @@ class SignInAnonymousButtonV2 extends StatelessWidget {
|
||||
},
|
||||
child: BlocBuilder<AnonUserBloc, AnonUserState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final text = LocaleKeys.signIn_anonymous.tr();
|
||||
final onTap = state.anonUsers.isEmpty
|
||||
? () {
|
||||
context
|
||||
.read<SignInBloc>()
|
||||
.add(const SignInEvent.signInAsGuest());
|
||||
.add(const SignInEvent.signedInAsGuest());
|
||||
}
|
||||
: () {
|
||||
final bloc = context.read<AnonUserBloc>();
|
||||
final user = bloc.state.anonUsers.first;
|
||||
bloc.add(AnonUserEvent.openAnonUser(user));
|
||||
};
|
||||
return AFGhostIconTextButton(
|
||||
text: LocaleKeys.signIn_anonymousMode.tr(),
|
||||
textColor: (context, isHovering, disabled) {
|
||||
return theme.textColorScheme.secondary;
|
||||
},
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
vertical: theme.spacing.xs,
|
||||
),
|
||||
size: AFButtonSize.s,
|
||||
return FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
onTap: onTap,
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.anonymous_mode_m,
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
},
|
||||
text: FlowyText(
|
||||
text,
|
||||
color: Colors.grey,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+44
-13
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileLogoutButton extends StatelessWidget {
|
||||
@@ -18,19 +18,50 @@ class MobileLogoutButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOutlinedIconTextButton.normal(
|
||||
text: text,
|
||||
final style = Theme.of(context);
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
size: AFButtonSize.l,
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
if (icon == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return FlowySvg(
|
||||
icon!,
|
||||
size: Size.square(18),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(4),
|
||||
),
|
||||
border: Border.all(
|
||||
color: textColor ?? style.colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
SizedBox(
|
||||
// The icon could be in different height as original aspect ratio, we use a fixed sizebox to wrap it to make sure they all occupy the same space.
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
child: FlowySvg(
|
||||
icon!,
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const HSpace(8),
|
||||
],
|
||||
FlowyText(
|
||||
text,
|
||||
fontSize: 14.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: textColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+95
-29
@@ -1,7 +1,10 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
|
||||
import 'package:appflowy/user/presentation/widgets/widgets.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ThirdPartySignInButtonType {
|
||||
@@ -99,55 +102,118 @@ class MobileThirdPartySignInButton extends StatelessWidget {
|
||||
super.key,
|
||||
this.height = 38,
|
||||
this.fontSize = 14.0,
|
||||
required this.onTap,
|
||||
required this.onPressed,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onPressed;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
final ThirdPartySignInButtonType type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOutlinedIconTextButton.normal(
|
||||
text: type.labelText,
|
||||
onTap: onTap,
|
||||
size: AFButtonSize.l,
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
type.icon,
|
||||
size: Size.square(16),
|
||||
blendMode: type.blendMode,
|
||||
);
|
||||
},
|
||||
final style = Theme.of(context);
|
||||
|
||||
return AnimatedGestureDetector(
|
||||
scaleFactor: 1.0,
|
||||
onTapUp: onPressed,
|
||||
child: Container(
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: type.backgroundColor(context),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(4),
|
||||
),
|
||||
border: Border.all(
|
||||
color: style.colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (type != ThirdPartySignInButtonType.anonymous)
|
||||
FlowySvg(
|
||||
type.icon,
|
||||
size: Size.square(fontSize),
|
||||
blendMode: type.blendMode,
|
||||
color: type.textColor(context),
|
||||
),
|
||||
const HSpace(8.0),
|
||||
FlowyText(
|
||||
type.labelText,
|
||||
fontSize: fontSize,
|
||||
color: type.textColor(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopThirdPartySignInButton extends StatelessWidget {
|
||||
const DesktopThirdPartySignInButton({
|
||||
class DesktopSignInButton extends StatelessWidget {
|
||||
const DesktopSignInButton({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.onTap,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final ThirdPartySignInButtonType type;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOutlinedIconTextButton.normal(
|
||||
text: type.labelText,
|
||||
onTap: onTap,
|
||||
size: AFButtonSize.l,
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
type.icon,
|
||||
size: Size.square(18),
|
||||
blendMode: type.blendMode,
|
||||
);
|
||||
},
|
||||
final style = Theme.of(context);
|
||||
// In desktop, the width of button is limited by [AuthFormContainer]
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
width: AuthFormContainer.width,
|
||||
child: OutlinedButton.icon(
|
||||
// In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left.
|
||||
icon: Container(
|
||||
width: AuthFormContainer.width / 4,
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
// Some icons are not square, so we just use a fixed width here.
|
||||
width: 24,
|
||||
child: FlowySvg(
|
||||
type.icon,
|
||||
blendMode: type.blendMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
label: Container(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FlowyText(
|
||||
type.labelText,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: ButtonStyle(
|
||||
overlayColor: WidgetStateProperty.resolveWith<Color?>(
|
||||
(states) {
|
||||
if (states.contains(WidgetState.hovered)) {
|
||||
return style.colorScheme.onSecondaryContainer;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
shape: WidgetStateProperty.all(
|
||||
const RoundedRectangleBorder(
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
),
|
||||
side: WidgetStateProperty.all(
|
||||
BorderSide(
|
||||
color: style.dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+43
-43
@@ -1,7 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -39,7 +40,7 @@ class ThirdPartySignInButtons extends StatelessWidget {
|
||||
|
||||
void _signIn(BuildContext context, String provider) {
|
||||
context.read<SignInBloc>().add(
|
||||
SignInEvent.signInWithOAuth(platform: provider),
|
||||
SignInEvent.signedInWithOAuth(provider),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,22 +58,23 @@ class _DesktopThirdPartySignIn extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> {
|
||||
static const padding = 12.0;
|
||||
|
||||
bool isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
DesktopThirdPartySignInButton(
|
||||
DesktopSignInButton(
|
||||
key: signInWithGoogleButtonKey,
|
||||
type: ThirdPartySignInButtonType.google,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google),
|
||||
),
|
||||
VSpace(theme.spacing.l),
|
||||
DesktopThirdPartySignInButton(
|
||||
const VSpace(padding),
|
||||
DesktopSignInButton(
|
||||
type: ThirdPartySignInButtonType.apple,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
|
||||
),
|
||||
...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(),
|
||||
],
|
||||
@@ -80,38 +82,38 @@ class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> {
|
||||
}
|
||||
|
||||
List<Widget> _buildExpandedButtons() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return [
|
||||
VSpace(theme.spacing.l),
|
||||
DesktopThirdPartySignInButton(
|
||||
const VSpace(padding * 1.5),
|
||||
DesktopSignInButton(
|
||||
type: ThirdPartySignInButtonType.github,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github),
|
||||
),
|
||||
VSpace(theme.spacing.l),
|
||||
DesktopThirdPartySignInButton(
|
||||
const VSpace(padding),
|
||||
DesktopSignInButton(
|
||||
type: ThirdPartySignInButtonType.discord,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildCollapsedButtons() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return [
|
||||
VSpace(theme.spacing.l),
|
||||
AFGhostTextButton(
|
||||
text: 'More options',
|
||||
textColor: (context, isHovering, disabled) {
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.themeThickHover;
|
||||
}
|
||||
return theme.textColorScheme.theme;
|
||||
},
|
||||
onTap: () {
|
||||
setState(() {
|
||||
isExpanded = !isExpanded;
|
||||
});
|
||||
},
|
||||
const VSpace(padding),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
isExpanded = !isExpanded;
|
||||
});
|
||||
},
|
||||
child: FlowyText(
|
||||
LocaleKeys.signIn_continueAnotherWay.tr(),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -151,14 +153,14 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> {
|
||||
if (Platform.isIOS) ...[
|
||||
MobileThirdPartySignInButton(
|
||||
type: ThirdPartySignInButtonType.apple,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple),
|
||||
),
|
||||
const VSpace(padding),
|
||||
],
|
||||
MobileThirdPartySignInButton(
|
||||
key: signInWithGoogleButtonKey,
|
||||
type: ThirdPartySignInButtonType.google,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.google),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google),
|
||||
),
|
||||
...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(),
|
||||
],
|
||||
@@ -170,33 +172,31 @@ class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> {
|
||||
const VSpace(padding),
|
||||
MobileThirdPartySignInButton(
|
||||
type: ThirdPartySignInButtonType.github,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.github),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github),
|
||||
),
|
||||
const VSpace(padding),
|
||||
MobileThirdPartySignInButton(
|
||||
type: ThirdPartySignInButtonType.discord,
|
||||
onTap: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
|
||||
onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildCollapsedButtons() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return [
|
||||
const VSpace(padding * 2),
|
||||
AFGhostTextButton(
|
||||
text: 'More options',
|
||||
textColor: (context, isHovering, disabled) {
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.themeThickHover;
|
||||
}
|
||||
return theme.textColorScheme.theme;
|
||||
},
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
isExpanded = !isExpanded;
|
||||
});
|
||||
},
|
||||
child: FlowyText(
|
||||
LocaleKeys.signIn_continueAnotherWay.tr(),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
export 'continue_with/continue_with_email_and_password.dart';
|
||||
export 'sign_in_agreement.dart';
|
||||
export 'magic_link_sign_in_buttons.dart';
|
||||
export 'sign_in_anonymous_button.dart';
|
||||
export 'sign_in_or_logout_button.dart';
|
||||
export 'third_party_sign_in_button/third_party_sign_in_button.dart';
|
||||
export 'third_party_sign_in_button.dart';
|
||||
// export 'switch_sign_in_sign_up_button.dart';
|
||||
export 'third_party_sign_in_button/third_party_sign_in_buttons.dart';
|
||||
export 'third_party_sign_in_buttons.dart';
|
||||
export 'sign_in_agreement.dart';
|
||||
@@ -8,7 +8,7 @@ class AuthFormContainer extends StatelessWidget {
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
static const double width = 320;
|
||||
static const double width = 340;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/logo/logo.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class FlowyLogoTitle extends StatelessWidget {
|
||||
const FlowyLogoTitle({
|
||||
@@ -15,20 +16,25 @@ class FlowyLogoTitle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AFLogo(size: logoSize),
|
||||
const VSpace(20),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textStyle.heading.h3(
|
||||
color: theme.textColorScheme.primary,
|
||||
SizedBox.fromSize(
|
||||
size: logoSize,
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
const VSpace(20),
|
||||
FlowyText.regular(
|
||||
title,
|
||||
fontSize: FontSizes.s24,
|
||||
fontFamily:
|
||||
GoogleFonts.poppins(fontWeight: FontWeight.w500).fontFamily,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+61
-112
@@ -1,145 +1,94 @@
|
||||
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 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();
|
||||
class LocalAIToggleBloc extends Bloc<LocalAIToggleEvent, LocalAIToggleState> {
|
||||
LocalAIToggleBloc() : super(const LocalAIToggleState()) {
|
||||
on<LocalAIToggleEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
LocalAiPluginEvent event,
|
||||
Emitter<LocalAiPluginState> emit,
|
||||
LocalAIToggleEvent event,
|
||||
Emitter<LocalAIToggleState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
didReceiveAiState: (aiState) {
|
||||
started: () async {
|
||||
final result = await AIEventGetLocalAIState().send();
|
||||
_handleResult(emit, result);
|
||||
},
|
||||
toggle: () async {
|
||||
emit(
|
||||
LocalAiPluginState.ready(
|
||||
isEnabled: aiState.enabled,
|
||||
version: aiState.pluginVersion,
|
||||
runningState: aiState.state,
|
||||
lackOfResource:
|
||||
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
|
||||
state.copyWith(
|
||||
pageIndicator: const LocalAIToggleStateIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
AIEventToggleLocalAI().send().then(
|
||||
(result) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIToggleEvent.handleResult(result));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveLackOfResources: (resources) {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
emit(readyState.copyWith(lackOfResource: resources));
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
toggle: () async {
|
||||
emit(LocalAiPluginState.loading());
|
||||
await AIEventToggleLocalAI().send().fold(
|
||||
(aiState) {
|
||||
add(LocalAiPluginEvent.didReceiveAiState(aiState));
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
},
|
||||
restart: () async {
|
||||
emit(LocalAiPluginState.loading());
|
||||
await AIEventRestartLocalAI().send();
|
||||
handleResult: (result) {
|
||||
_handleResult(emit, result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
add(LocalAiPluginEvent.didReceiveAiState(pluginState));
|
||||
void _handleResult(
|
||||
Emitter<LocalAIToggleState> emit,
|
||||
FlowyResult<LocalAIPB, FlowyError> result,
|
||||
) {
|
||||
result.fold(
|
||||
(localAI) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator:
|
||||
LocalAIToggleStateIndicator.isEnabled(localAI.enabled),
|
||||
),
|
||||
);
|
||||
},
|
||||
resourceCallback: (data) {
|
||||
add(LocalAiPluginEvent.didReceiveLackOfResources(data));
|
||||
(err) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
pageIndicator: LocalAIToggleStateIndicator.error(err),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _getLocalAiState() {
|
||||
AIEventGetLocalAIState().send().fold(
|
||||
(aiState) {
|
||||
add(LocalAiPluginEvent.didReceiveAiState(aiState));
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
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;
|
||||
class LocalAIToggleEvent with _$LocalAIToggleEvent {
|
||||
const factory LocalAIToggleEvent.started() = _Started;
|
||||
const factory LocalAIToggleEvent.toggle() = _Toggle;
|
||||
const factory LocalAIToggleEvent.handleResult(
|
||||
FlowyResult<LocalAIPB, FlowyError> result,
|
||||
) = _HandleResult;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAiPluginState with _$LocalAiPluginState {
|
||||
const LocalAiPluginState._();
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
class LocalAIToggleState with _$LocalAIToggleState {
|
||||
const factory LocalAIToggleState({
|
||||
@Default(LocalAIToggleStateIndicator.loading())
|
||||
LocalAIToggleStateIndicator pageIndicator,
|
||||
}) = _LocalAIToggleState;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
+1
-1
@@ -26,7 +26,7 @@ class LocalAIOnBoardingBloc
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
void _onPaymentSuccessful() {
|
||||
Future<void> _onPaymentSuccessful() async {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
}
|
||||
+5
-3
@@ -80,9 +80,11 @@ 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
@@ -0,0 +1,150 @@
|
||||
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;
|
||||
}
|
||||
-7
@@ -4,7 +4,6 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileAppearance extends BaseAppearance {
|
||||
@@ -30,7 +29,6 @@ class MobileAppearance extends BaseAppearance {
|
||||
);
|
||||
|
||||
final codeFontStyle = getFontStyle(fontFamily: codeFontFamily);
|
||||
final isLight = brightness == Brightness.light;
|
||||
|
||||
final theme = brightness == Brightness.light
|
||||
? appTheme.lightTheme
|
||||
@@ -285,11 +283,6 @@ class MobileAppearance extends BaseAppearance {
|
||||
toolbarHoverColor: theme.toolbarHoverColor,
|
||||
),
|
||||
ToolbarColorExtension.fromBrightness(brightness),
|
||||
isLight
|
||||
? lightAFThemeV2
|
||||
: darkAFThemeV2.copyWith(
|
||||
icon_primary: theme.icon,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
-3
@@ -188,9 +188,6 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
|
||||
UserEventGetWorkspaceUsage(payload).send().then((result) {
|
||||
result.onSuccess(
|
||||
(usage) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
|
||||
},
|
||||
);
|
||||
|
||||
+3
-4
@@ -14,6 +14,7 @@ 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',
|
||||
@@ -134,8 +135,7 @@ class _AccountDeletionDialog extends StatelessWidget {
|
||||
),
|
||||
const VSpace(12.0),
|
||||
FlowyTextField(
|
||||
hintText:
|
||||
LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr(),
|
||||
hintText: _confirmText,
|
||||
controller: controller,
|
||||
),
|
||||
const VSpace(16),
|
||||
@@ -176,8 +176,7 @@ 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) ||
|
||||
text == LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint3.tr();
|
||||
return _acceptableConfirmTexts.contains(text);
|
||||
}
|
||||
|
||||
Future<void> deleteMyAccount(
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/prelude.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart';
|
||||
import 'package:appflowy/util/navigator_context_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
@@ -111,7 +111,7 @@ class _SignInDialogContent extends StatelessWidget {
|
||||
const _DialogHeader(),
|
||||
const _DialogTitle(),
|
||||
const VSpace(16),
|
||||
const ContinueWithEmailAndPassword(),
|
||||
const SignInWithMagicLinkButtons(),
|
||||
if (isAuthEnabled) ...[
|
||||
const VSpace(20),
|
||||
const _OrDivider(),
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+100
-120
@@ -1,148 +1,128 @@
|
||||
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';
|
||||
|
||||
import 'ollama_setting.dart';
|
||||
import 'plugin_status_indicator.dart';
|
||||
|
||||
class LocalAISetting extends StatefulWidget {
|
||||
class LocalAISetting extends StatelessWidget {
|
||||
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) => 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,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
required this.isEnabled,
|
||||
required this.isToggleable,
|
||||
});
|
||||
|
||||
final bool isEnabled;
|
||||
final bool isToggleable;
|
||||
class LocalAISettingHeader extends StatelessWidget {
|
||||
const LocalAISettingHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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>(
|
||||
return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>(
|
||||
builder: (context, state) {
|
||||
if (state is! ReadyLocalAiPluginState) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const LocalAIStatusIndicator(),
|
||||
const VSpace(10),
|
||||
OllamaSettingPage(),
|
||||
],
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-14
@@ -20,44 +20,38 @@ class AIModelSelection extends StatelessWidget {
|
||||
buildWhen: (previous, current) =>
|
||||
previous.availableModels != current.availableModels,
|
||||
builder: (context, state) {
|
||||
final models = state.availableModels?.models;
|
||||
if (models == null) {
|
||||
if (state.availableModels == 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: [
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: SettingsDropdown<AIModelPB>(
|
||||
key: const Key('_AIModelSelection'),
|
||||
onChanged: (model) => context
|
||||
.read<SettingsAIBloc>()
|
||||
.add(SettingsAIEvent.selectModel(model)),
|
||||
selectedOption: selectedModel,
|
||||
selectOptionCompare: (left, right) =>
|
||||
left?.name == right?.name,
|
||||
options: [...localModels, ...cloudModels]
|
||||
selectedOption: state.availableModels!.selectedModel,
|
||||
options: state.availableModels!.models
|
||||
.map(
|
||||
(model) => buildDropdownMenuEntry<AIModelPB>(
|
||||
context,
|
||||
value: model,
|
||||
label:
|
||||
model.isLocal ? "${model.i18n} 🔐" : model.i18n,
|
||||
label: model.i18n,
|
||||
subLabel: model.desc,
|
||||
maximumHeight: height,
|
||||
),
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
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
@@ -0,0 +1,168 @@
|
||||
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
@@ -0,0 +1,168 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
-363
@@ -1,363 +0,0 @@
|
||||
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: modelNames.join(', '),
|
||||
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,11 +16,9 @@ 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;
|
||||
@@ -54,7 +52,6 @@ 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,
|
||||
|
||||
+2
-2
@@ -71,7 +71,7 @@ class AppFlowyCloudViewSetting extends StatelessWidget {
|
||||
const VSpace(8),
|
||||
const AppFlowyCloudEnableSync(),
|
||||
const VSpace(6),
|
||||
// const AppFlowyCloudSyncLogEnabled(),
|
||||
const AppFlowyCloudSyncLogEnabled(),
|
||||
const VSpace(12),
|
||||
RestartButton(
|
||||
onClick: () {
|
||||
@@ -130,7 +130,7 @@ class CustomAppFlowyCloudView extends StatelessWidget {
|
||||
final List<Widget> children = [];
|
||||
children.addAll([
|
||||
const AppFlowyCloudEnableSync(),
|
||||
// const AppFlowyCloudSyncLogEnabled(),
|
||||
const AppFlowyCloudSyncLogEnabled(),
|
||||
const VSpace(40),
|
||||
]);
|
||||
|
||||
|
||||
@@ -363,7 +363,7 @@ class OkCancelButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
ToastificationItem showToastNotification(
|
||||
void showToastNotification(
|
||||
BuildContext context, {
|
||||
String? message,
|
||||
TextSpan? richMessage,
|
||||
@@ -376,7 +376,7 @@ ToastificationItem showToastNotification(
|
||||
(message == null) != (richMessage == null),
|
||||
"Exactly one of message or richMessage must be non-null.",
|
||||
);
|
||||
return toastification.showCustom(
|
||||
toastification.showCustom(
|
||||
alignment: Alignment.bottomCenter,
|
||||
autoCloseDuration: const Duration(milliseconds: 3000),
|
||||
callbacks: callbacks ?? const ToastificationCallbacks(),
|
||||
|
||||
+13
-26
@@ -86,7 +86,7 @@ class _BubbleActionListState extends State<BubbleActionList> {
|
||||
),
|
||||
buildChild: (controller) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.questionBubble_getSupport.tr(),
|
||||
message: LocaleKeys.questionBubble_help.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.getSupport:
|
||||
afLaunchUrlString('https://discord.gg/9Q2xaN37tV');
|
||||
case BubbleAction.help:
|
||||
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,11 +144,6 @@ class _BubbleActionListState extends State<BubbleActionList> {
|
||||
'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose',
|
||||
);
|
||||
break;
|
||||
case BubbleAction.helpAndDocumentation:
|
||||
afLaunchUrlString(
|
||||
'https://appflowy.com/guide',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +155,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));
|
||||
@@ -173,21 +168,20 @@ 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,
|
||||
helpAndDocumentation,
|
||||
getSupport,
|
||||
help,
|
||||
debug,
|
||||
shortcuts,
|
||||
markdown,
|
||||
@@ -210,10 +204,8 @@ extension QuestionBubbleExtension on BubbleAction {
|
||||
switch (this) {
|
||||
case BubbleAction.whatsNews:
|
||||
return LocaleKeys.questionBubble_whatsNew.tr();
|
||||
case BubbleAction.helpAndDocumentation:
|
||||
return LocaleKeys.questionBubble_helpAndDocumentation.tr();
|
||||
case BubbleAction.getSupport:
|
||||
return LocaleKeys.questionBubble_getSupport.tr();
|
||||
case BubbleAction.help:
|
||||
return LocaleKeys.questionBubble_help.tr();
|
||||
case BubbleAction.debug:
|
||||
return LocaleKeys.questionBubble_debug_name.tr();
|
||||
case BubbleAction.shortcuts:
|
||||
@@ -229,12 +221,7 @@ extension QuestionBubbleExtension on BubbleAction {
|
||||
switch (this) {
|
||||
case BubbleAction.whatsNews:
|
||||
return const FlowySvg(FlowySvgs.star_s);
|
||||
case BubbleAction.helpAndDocumentation:
|
||||
return const FlowySvg(
|
||||
FlowySvgs.help_and_documentation_s,
|
||||
size: Size.square(16.0),
|
||||
);
|
||||
case BubbleAction.getSupport:
|
||||
case BubbleAction.help:
|
||||
return const FlowySvg(FlowySvgs.message_support_s);
|
||||
case BubbleAction.debug:
|
||||
return const FlowySvg(FlowySvgs.debug_s);
|
||||
|
||||
+15
-10
@@ -25,13 +25,18 @@ class SocialMediaSection extends CustomActionCell {
|
||||
action: SocialMediaWrapper(social),
|
||||
itemHeight: ActionListSizes.itemHeight,
|
||||
onSelected: (action) {
|
||||
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);
|
||||
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/');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -80,11 +85,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: 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
|
||||
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
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
|
||||
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
||||
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
|
||||
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
|
||||
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
|
||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
|
||||
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
|
||||
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
build/
|
||||
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user