Compare commits
37
Commits
anon_user_mode
...
0.8.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf928e6e4 | ||
|
|
8f63667282 | ||
|
|
954e844a21 | ||
|
|
13acc3af86 | ||
|
|
079112c9d2 | ||
|
|
0be8dcc000 | ||
|
|
d7d040b0f9 | ||
|
|
f652229718 | ||
|
|
f727dde74b | ||
|
|
92179fe61c | ||
|
|
ecfcf3be4d | ||
|
|
be132d867a | ||
|
|
e6951012f0 | ||
|
|
3214ec075b | ||
|
|
be1d2b4b92 | ||
|
|
a5eb2cdd9a | ||
|
|
9b077969e7 | ||
|
|
7160790596 | ||
|
|
5c3e81e6dc | ||
|
|
846172a709 | ||
|
|
c6511cfb55 | ||
|
|
69b5452af5 | ||
|
|
9291236733 | ||
|
|
3b3ae7fde9 | ||
|
|
d9748d5ef1 | ||
|
|
d01909830d | ||
|
|
03ecc3d6c9 | ||
|
|
1630e11c4d | ||
|
|
99fb6ab743 | ||
|
|
35bc095760 | ||
|
|
a44ad63230 | ||
|
|
ddbaf0d530 | ||
|
|
437deaf986 | ||
|
|
54df6b2863 | ||
|
|
69b98cb323 | ||
|
|
4d172761ce | ||
|
|
8c4324ee9d |
No files matched your search
@@ -338,7 +338,7 @@ jobs:
|
||||
- {
|
||||
arch: x86_64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter_profile: production-linux-x86_64,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
# Release Notes
|
||||
## Version 0.8.9 - 16/04/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
- Supported pasting a link as a mention, providing a more condensed visualization of linked content
|
||||
- Supported converting between link formats (e.g. transforming a mention into a bookmark)
|
||||
- Improved the link editing experience with enhanced UX
|
||||
- Added OTP (One-Time Password) support for sign-in authentication
|
||||
- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet
|
||||
#### Bug Fixes
|
||||
- Fixed an issue where properties were not displaying in the row detail page
|
||||
- Fixed a bug where Undo didn't work in the row detail page
|
||||
- Fixed an issue where blocks didn't grow when the grid got bigger
|
||||
- Fixed several bugs related to AI writers
|
||||
### Mobile
|
||||
#### New Features
|
||||
- Added sign-in with OTP (One-Time Password)
|
||||
#### Bug Fixes
|
||||
- Fixed an issue where the slash menu sometimes failed to display
|
||||
- Updated the mention page block to handle page selection with more context.
|
||||
|
||||
## Version 0.8.8 - 01/04/2025
|
||||
### New Features
|
||||
- Added support for selecting AI models in AI writer
|
||||
|
||||
Whitespace-only changes.
Binary file not shown.
+9
-9
@@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -44,12 +44,12 @@ void main() {
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||
|
||||
// Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna)
|
||||
expect(find.byType(SearchResultTile), findsNWidgets(2));
|
||||
expect(find.byType(SearchResultCell), findsNWidgets(2));
|
||||
|
||||
// The score should be higher for "ViewOna" thus it should be shown first
|
||||
final secondDocumentWidget = tester
|
||||
.widget(find.byType(SearchResultTile).first) as SearchResultTile;
|
||||
expect(secondDocumentWidget.result.data, secondDocument);
|
||||
.widget(find.byType(SearchResultCell).first) as SearchResultCell;
|
||||
expect(secondDocumentWidget.item.displayName, secondDocument);
|
||||
|
||||
// Change search to "ViewOne"
|
||||
await tester.enterText(searchFieldFinder, firstDocument);
|
||||
@@ -57,9 +57,9 @@ void main() {
|
||||
|
||||
// The score should be higher for "ViewOne" thus it should be shown first
|
||||
final firstDocumentWidget = tester.widget(
|
||||
find.byType(SearchResultTile).first,
|
||||
) as SearchResultTile;
|
||||
expect(firstDocumentWidget.result.data, firstDocument);
|
||||
find.byType(SearchResultCell).first,
|
||||
) as SearchResultCell;
|
||||
expect(firstDocumentWidget.item.displayName, firstDocument);
|
||||
});
|
||||
|
||||
testWidgets('Displaying icons in search results', (tester) async {
|
||||
@@ -89,11 +89,11 @@ void main() {
|
||||
);
|
||||
await tester.enterText(searchFieldFinder, 'Page-$randomValue');
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||
expect(find.byType(SearchResultTile), findsNWidgets(2));
|
||||
expect(find.byType(SearchResultCell), findsNWidgets(2));
|
||||
|
||||
/// check results
|
||||
final svgs = find.descendant(
|
||||
of: find.byType(SearchResultTile),
|
||||
of: find.byType(SearchResultCell),
|
||||
matching: find.byType(FlowySvg),
|
||||
);
|
||||
expect(svgs, findsNWidgets(2));
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
@@ -27,11 +27,12 @@ void main() {
|
||||
expect(find.byType(RecentViewsList), findsOneWidget);
|
||||
|
||||
// Expect three recent history items
|
||||
expect(find.byType(RecentViewTile), findsNWidgets(3));
|
||||
expect(find.byType(SearchRecentViewCell), findsNWidgets(3));
|
||||
|
||||
// Expect the first item to be the last viewed document
|
||||
final firstDocumentWidget =
|
||||
tester.widget(find.byType(RecentViewTile).first) as RecentViewTile;
|
||||
tester.widget(find.byType(SearchRecentViewCell).first)
|
||||
as SearchRecentViewCell;
|
||||
expect(firstDocumentWidget.view.name, secondDocument);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -73,5 +78,37 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('insert grid in column', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
/// create page and show slash menu
|
||||
await tester.createNewPageWithNameUnderParent(name: 'test page');
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
/// create a column
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_twoColumns.tr(),
|
||||
);
|
||||
final actionList = find.byType(BlockActionList);
|
||||
expect(actionList, findsNWidgets(2));
|
||||
final position = tester.getCenter(actionList.last);
|
||||
|
||||
/// tap the second child of column
|
||||
await tester.tapAt(position.copyWith(dx: position.dx + 50));
|
||||
|
||||
/// create a grid
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_grid.tr(),
|
||||
);
|
||||
|
||||
final grid = find.byType(GridPageContent);
|
||||
expect(grid, findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
+3
-13
@@ -347,8 +347,9 @@ void main() {
|
||||
await tester.tapButton(menu);
|
||||
|
||||
final convertToLinkButton = find.text(
|
||||
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl.tr(),
|
||||
);
|
||||
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
|
||||
.tr(),
|
||||
);
|
||||
expect(convertToLinkButton, findsOneWidget);
|
||||
await tester.tapButton(convertToLinkButton);
|
||||
},
|
||||
@@ -384,7 +385,6 @@ void main() {
|
||||
expect(node.attributes[LinkPreviewBlockKeys.url], url);
|
||||
});
|
||||
|
||||
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyZ,
|
||||
isControlPressed:
|
||||
@@ -483,16 +483,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('paste image url without extension', (tester) async {
|
||||
const plainText =
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
|
||||
await tester.pasteContent(plainText: plainText, (editorState) {
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
expect(node.type, ImageBlockKeys.type);
|
||||
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
const testMarkdownText = '''
|
||||
# I'm h1
|
||||
## I'm h2
|
||||
|
||||
+2
@@ -13,6 +13,8 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
|
||||
await tester.tapAnonymousSignInButton();
|
||||
final finder = find.text(gettingStarted, findRichText: true);
|
||||
await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2));
|
||||
|
||||
// create a new document
|
||||
const pageName = 'Test Document';
|
||||
|
||||
+5
-4
@@ -5,6 +5,7 @@ import 'package:appflowy/generated/locale_keys.g.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/link/link_hover_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
|
||||
@@ -397,7 +398,7 @@ void main() {
|
||||
|
||||
Future<void> hoverAndConvert(
|
||||
WidgetTester tester,
|
||||
PasteMenuType command,
|
||||
LinkEmbedConvertCommand command,
|
||||
) async {
|
||||
final embed = find.byType(LinkEmbedBlockComponent);
|
||||
expect(embed, findsOneWidget);
|
||||
@@ -425,7 +426,7 @@ void main() {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.mention);
|
||||
await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkMention(node, link);
|
||||
});
|
||||
@@ -434,7 +435,7 @@ void main() {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.url);
|
||||
await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkUrl(node, link);
|
||||
});
|
||||
@@ -444,7 +445,7 @@ void main() {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.bookmark);
|
||||
await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
|
||||
@@ -67,12 +67,10 @@ extension CommonOperations on WidgetTester {
|
||||
} else {
|
||||
// cloud version
|
||||
final anonymousButton = find.byType(SignInAnonymousButtonV2);
|
||||
await tapButton(anonymousButton);
|
||||
await tapButton(anonymousButton, warnIfMissed: true);
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> tapContinousAnotherWay() async {
|
||||
|
||||
@@ -45,16 +45,18 @@ Future<bool> afLaunchUri(
|
||||
}
|
||||
|
||||
// try to launch the uri directly
|
||||
bool result;
|
||||
try {
|
||||
result = await launcher.launchUrl(
|
||||
uri,
|
||||
mode: mode,
|
||||
webOnlyWindowName: webOnlyWindowName,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
Log.error('Failed to open uri: $e');
|
||||
return false;
|
||||
bool result = await launcher.canLaunchUrl(uri);
|
||||
if (result) {
|
||||
try {
|
||||
result = await launcher.launchUrl(
|
||||
uri,
|
||||
mode: mode,
|
||||
webOnlyWindowName: webOnlyWindowName,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
Log.error('Failed to open uri: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// if the uri is not a valid url, try to launch it with http scheme
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.delete:
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
context.pop();
|
||||
Navigator.of(context).pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.addToFavorites:
|
||||
_addFavorite(context);
|
||||
|
||||
+3
-1
@@ -182,7 +182,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
),
|
||||
_divider(),
|
||||
..._buildPublishActions(context),
|
||||
_divider(),
|
||||
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.button_delete.tr(),
|
||||
textColor: Theme.of(context).colorScheme.error,
|
||||
@@ -236,6 +236,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
MobileViewBottomSheetBodyAction.unpublish,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
@@ -246,6 +247,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
MobileViewBottomSheetBodyAction.publish,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -71,12 +71,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
|
||||
}
|
||||
|
||||
Widget _buildSettingsWidget(UserProfilePB userProfile) {
|
||||
// show the third-party sign in buttons if user logged in with local session and auth is enabled.
|
||||
|
||||
final isLocalAuthEnabled =
|
||||
userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled;
|
||||
'';
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => UserWorkspaceBloc(userProfile: userProfile)
|
||||
..add(const UserWorkspaceEvent.initial()),
|
||||
@@ -105,7 +99,7 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
|
||||
const AboutSettingGroup(),
|
||||
UserSessionSettingGroup(
|
||||
userProfile: userProfile,
|
||||
showThirdPartyLogin: isLocalAuthEnabled,
|
||||
showThirdPartyLogin: false,
|
||||
),
|
||||
const VSpace(20),
|
||||
],
|
||||
|
||||
+48
-31
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -71,6 +70,7 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
|
||||
_positionNotifier = ValueNotifier(position);
|
||||
final showAtTop = position.top != null;
|
||||
_selectionMenuEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return SizedBox(
|
||||
@@ -94,6 +94,7 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
child: MobileSelectionMenuWidget(
|
||||
selectionMenuStyle: style,
|
||||
singleColumn: singleColumn,
|
||||
showAtTop: showAtTop,
|
||||
items: selectionMenuItems
|
||||
..forEach((element) {
|
||||
if (element is MobileSelectionMenuItem) {
|
||||
@@ -166,7 +167,8 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
if (selectionRects.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
calculateSelectionMenuOffset(selectionRects.first, screenSize);
|
||||
final (left, top, right, bottom) = getPosition();
|
||||
return _Position(left, top, right, bottom);
|
||||
}
|
||||
@@ -205,50 +207,65 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
return (left, top, right, bottom);
|
||||
}
|
||||
|
||||
void calculateSelectionMenuOffset(Rect rect) {
|
||||
void calculateSelectionMenuOffset(Rect rect, Size screenSize) {
|
||||
// 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 menuHeight = 192.0, menuWidth = 240.0 + 10;
|
||||
const menuOffset = Offset(0, 10);
|
||||
const menuHeight = 192.0, menuWidth = 240.0;
|
||||
final editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final editorHeight = editorState.renderBox!.size.height;
|
||||
final screenHeight = screenSize.height;
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
final rectHeight = rect.height;
|
||||
|
||||
// show below default
|
||||
_alignment = Alignment.topLeft;
|
||||
final bottomRight = rect.bottomRight;
|
||||
final topRight = rect.topRight;
|
||||
var offset = bottomRight + menuOffset;
|
||||
final limitX = editorWidth - menuWidth + editorOffset.dx;
|
||||
_alignment = Alignment.bottomRight;
|
||||
final bottomRight = rect.topLeft;
|
||||
final offset = bottomRight;
|
||||
final limitX = editorWidth + editorOffset.dx - menuWidth,
|
||||
limitY = screenHeight -
|
||||
editorHeight +
|
||||
editorOffset.dy -
|
||||
menuHeight -
|
||||
rectHeight;
|
||||
_offset = Offset(
|
||||
min(offset.dx, limitX),
|
||||
offset.dy,
|
||||
editorWidth - offset.dx - menuWidth,
|
||||
screenHeight - offset.dy - menuHeight - rectHeight,
|
||||
);
|
||||
|
||||
// show above
|
||||
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
|
||||
offset = topRight - menuOffset;
|
||||
_alignment = Alignment.bottomLeft;
|
||||
|
||||
_offset = Offset(
|
||||
offset.dx,
|
||||
editorOffset.dy + editorHeight - offset.dy,
|
||||
);
|
||||
/// show above
|
||||
if (offset.dy > menuHeight) {
|
||||
_offset = Offset(
|
||||
_offset.dx,
|
||||
offset.dy - menuHeight,
|
||||
);
|
||||
_alignment = Alignment.topRight;
|
||||
} else {
|
||||
_offset = Offset(
|
||||
_offset.dx,
|
||||
limitY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// show on left
|
||||
if (_offset.dx - editorOffset.dx > editorWidth / 2) {
|
||||
_alignment = _alignment == Alignment.topLeft
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight;
|
||||
|
||||
final x = editorWidth - _offset.dx + editorOffset.dx;
|
||||
_offset = Offset(
|
||||
min(x, limitX),
|
||||
_offset.dy,
|
||||
);
|
||||
if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) {
|
||||
/// show left
|
||||
if (offset.dx > menuWidth) {
|
||||
_alignment = _alignment == Alignment.bottomRight
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.topLeft;
|
||||
_offset = Offset(
|
||||
offset.dx - menuWidth,
|
||||
_offset.dy,
|
||||
);
|
||||
} else {
|
||||
_offset = Offset(
|
||||
limitX,
|
||||
_offset.dy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-20
@@ -22,6 +22,7 @@ class MobileSelectionMenuWidget extends StatefulWidget {
|
||||
required this.deleteSlashByDefault,
|
||||
required this.singleColumn,
|
||||
required this.startOffset,
|
||||
required this.showAtTop,
|
||||
this.nameBuilder,
|
||||
});
|
||||
|
||||
@@ -38,6 +39,7 @@ class MobileSelectionMenuWidget extends StatefulWidget {
|
||||
|
||||
final bool deleteSlashByDefault;
|
||||
final bool singleColumn;
|
||||
final bool showAtTop;
|
||||
final int startOffset;
|
||||
|
||||
final SelectionMenuItemNameBuilder? nameBuilder;
|
||||
@@ -172,27 +174,37 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: _showingItems.isEmpty
|
||||
? _buildNoResultsWidget(context)
|
||||
: _buildResultsWidget(
|
||||
context,
|
||||
_showingItems,
|
||||
widget.itemCountFilter,
|
||||
return SizedBox(
|
||||
height: 192,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.showAtTop) Spacer(),
|
||||
Focus(
|
||||
focusNode: _focusNode,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: _showingItems.isEmpty
|
||||
? _buildNoResultsWidget(context)
|
||||
: _buildResultsWidget(
|
||||
context,
|
||||
_showingItems,
|
||||
widget.itemCountFilter,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!widget.showAtTop) Spacer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
@@ -7,10 +5,10 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../widgets/widgets.dart';
|
||||
|
||||
import 'personal_info.dart';
|
||||
|
||||
class PersonalInfoSettingGroup extends StatelessWidget {
|
||||
@@ -32,7 +30,7 @@ class PersonalInfoSettingGroup extends StatelessWidget {
|
||||
selector: (state) => state.userProfile.name,
|
||||
builder: (context, userName) {
|
||||
return MobileSettingGroup(
|
||||
groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(),
|
||||
groupTitle: LocaleKeys.settings_accountPage_title.tr(),
|
||||
settingItemList: [
|
||||
MobileSettingItem(
|
||||
name: userName,
|
||||
|
||||
+9
-2
@@ -63,8 +63,15 @@ class UserSessionSettingGroup extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
return const ThirdPartySignInButtons(
|
||||
expanded: true,
|
||||
return Column(
|
||||
children: [
|
||||
const ContinueWithEmailAndPassword(),
|
||||
const VSpace(12.0),
|
||||
const ThirdPartySignInButtons(
|
||||
expanded: true,
|
||||
),
|
||||
const VSpace(16.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -27,7 +27,7 @@ class ChatAIAvatar extends StatelessWidget {
|
||||
child: const CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.flowy_logo_s,
|
||||
FlowySvgs.app_logo_s,
|
||||
size: Size.square(16),
|
||||
blendMode: null,
|
||||
),
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChatWelcomePage extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
FlowySvgs.app_logo_xl,
|
||||
size: Size.square(32),
|
||||
blendMode: null,
|
||||
),
|
||||
|
||||
+14
-17
@@ -73,27 +73,24 @@ class RelatedRowDetailPageBloc
|
||||
});
|
||||
}
|
||||
|
||||
/// initialize bloc through the `database_id` and `row_id`. The process is as
|
||||
/// follows:
|
||||
/// 1. use the `database_id` to get the database meta, which contains the
|
||||
/// `inline_view_id`
|
||||
/// 2. use the `inline_view_id` to instantiate a `DatabaseController`.
|
||||
/// 3. use the `row_id` with the DatabaseController` to create `RowController`
|
||||
void _init(String databaseId, String initialRowId) async {
|
||||
final databaseMeta =
|
||||
await DatabaseEventGetDatabaseMeta(DatabaseIdPB(value: databaseId))
|
||||
.send()
|
||||
.fold<DatabaseMetaPB?>((s) => s, (f) => null);
|
||||
if (databaseMeta == null) {
|
||||
final viewId = await DatabaseEventGetDefaultDatabaseViewId(
|
||||
DatabaseIdPB(value: databaseId),
|
||||
).send().fold(
|
||||
(pb) => pb.value,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
if (viewId == null) {
|
||||
return;
|
||||
}
|
||||
final inlineView =
|
||||
await ViewBackendService.getView(databaseMeta.inlineViewId)
|
||||
.fold((viewPB) => viewPB, (f) => null);
|
||||
if (inlineView == null) {
|
||||
|
||||
final databaseView = await ViewBackendService.getView(viewId)
|
||||
.fold((viewPB) => viewPB, (f) => null);
|
||||
if (databaseView == null) {
|
||||
return;
|
||||
}
|
||||
final databaseController = DatabaseController(view: inlineView);
|
||||
final databaseController = DatabaseController(view: databaseView);
|
||||
await databaseController.open().fold(
|
||||
(s) => databaseController.setIsLoading(false),
|
||||
(f) => null,
|
||||
@@ -104,7 +101,7 @@ class RelatedRowDetailPageBloc
|
||||
}
|
||||
final rowController = RowController(
|
||||
rowMeta: rowInfo.rowMeta,
|
||||
viewId: inlineView.id,
|
||||
viewId: databaseView.id,
|
||||
rowCache: databaseController.rowCache,
|
||||
);
|
||||
|
||||
|
||||
@@ -386,15 +386,15 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
scrollManager: scrollManager,
|
||||
),
|
||||
),
|
||||
cardBuilder: (context, column, columnItem) =>
|
||||
cardBuilder: (cardContext, column, columnItem) =>
|
||||
MultiBlocProvider(
|
||||
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
|
||||
providers: [
|
||||
BlocProvider<BoardBloc>.value(
|
||||
value: context.read<BoardBloc>(),
|
||||
value: cardContext.read<BoardBloc>(),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: context.read<BoardActionsCubit>(),
|
||||
value: cardContext.read<BoardActionsCubit>(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => ViewLockStatusBloc(view: widget.view)
|
||||
@@ -402,7 +402,7 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
|
||||
builder: (context, state) {
|
||||
builder: (lockStatusContext, state) {
|
||||
return IgnorePointer(
|
||||
ignoring: state.isLocked,
|
||||
child: _BoardCard(
|
||||
@@ -412,6 +412,13 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
notifier: widget.focusScope,
|
||||
cellBuilder: cellBuilder,
|
||||
compactMode: compactMode,
|
||||
onOpenCard: (rowMeta) => _openCard(
|
||||
context: context,
|
||||
databaseController: lockStatusContext
|
||||
.read<BoardBloc>()
|
||||
.databaseController,
|
||||
rowMeta: rowMeta,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -581,6 +588,7 @@ class _BoardCard extends StatefulWidget {
|
||||
required this.cellBuilder,
|
||||
required this.notifier,
|
||||
required this.compactMode,
|
||||
required this.onOpenCard,
|
||||
});
|
||||
|
||||
final AppFlowyGroupData afGroupData;
|
||||
@@ -589,6 +597,7 @@ class _BoardCard extends StatefulWidget {
|
||||
final CardCellBuilder cellBuilder;
|
||||
final BoardFocusScope notifier;
|
||||
final bool compactMode;
|
||||
final void Function(RowMetaPB) onOpenCard;
|
||||
|
||||
@override
|
||||
State<_BoardCard> createState() => _BoardCardState();
|
||||
@@ -698,10 +707,8 @@ class _BoardCardState extends State<_BoardCard> {
|
||||
groupingFieldId: widget.groupItem.fieldInfo.id,
|
||||
isEditing: _isEditing,
|
||||
cellBuilder: widget.cellBuilder,
|
||||
onTap: (context) => _openCard(
|
||||
context: context,
|
||||
databaseController: databaseController,
|
||||
rowMeta: context.read<CardBloc>().rowController.rowMeta,
|
||||
onTap: (context) => widget.onOpenCard(
|
||||
context.read<CardBloc>().rowController.rowMeta,
|
||||
),
|
||||
onShiftTap: (_) {
|
||||
Focus.of(context).requestFocus();
|
||||
|
||||
@@ -362,9 +362,13 @@ const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder';
|
||||
const kDatabasePluginWidgetBuilderNode = 'node';
|
||||
|
||||
class DatabasePluginWidgetBuilderSize {
|
||||
const DatabasePluginWidgetBuilderSize({required this.horizontalPadding});
|
||||
const DatabasePluginWidgetBuilderSize({
|
||||
required this.horizontalPadding,
|
||||
this.verticalPadding = 16.0,
|
||||
});
|
||||
|
||||
final double horizontalPadding;
|
||||
final double verticalPadding;
|
||||
}
|
||||
|
||||
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
@@ -120,29 +121,34 @@ class _RowEditor extends StatelessWidget {
|
||||
return context;
|
||||
},
|
||||
dispose: (_, editorContext) => editorContext.dispose(),
|
||||
child: EditorDropHandler(
|
||||
child: AiWriterScrollWrapper(
|
||||
viewId: view.id,
|
||||
editorState: editorState,
|
||||
isLocalMode: context.read<DocumentBloc>().isLocalMode,
|
||||
dropManagerState: context.read<EditorDropManagerState>(),
|
||||
child: EditorTransactionService(
|
||||
child: EditorDropHandler(
|
||||
viewId: view.id,
|
||||
editorState: editorState,
|
||||
child: Provider(
|
||||
create: (context) =>
|
||||
DatabasePluginWidgetBuilderSize(horizontalPadding: 0),
|
||||
child: AppFlowyEditorPage(
|
||||
shrinkWrap: true,
|
||||
autoFocus: false,
|
||||
editorState: editorState,
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: const EdgeInsets.only(left: 16, right: 54),
|
||||
isLocalMode: context.read<DocumentBloc>().isLocalMode,
|
||||
dropManagerState: context.read<EditorDropManagerState>(),
|
||||
child: EditorTransactionService(
|
||||
viewId: view.id,
|
||||
editorState: editorState,
|
||||
child: Provider(
|
||||
create: (context) => DatabasePluginWidgetBuilderSize(
|
||||
horizontalPadding: 0,
|
||||
),
|
||||
child: AppFlowyEditorPage(
|
||||
shrinkWrap: true,
|
||||
autoFocus: false,
|
||||
editorState: editorState,
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: const EdgeInsets.only(left: 16, right: 54),
|
||||
),
|
||||
showParagraphPlaceholder: (editorState, _) =>
|
||||
editorState.document.isEmpty,
|
||||
placeholderText: (_) =>
|
||||
LocaleKeys.cardDetails_notesPlaceholder.tr(),
|
||||
),
|
||||
showParagraphPlaceholder: (editorState, _) =>
|
||||
editorState.document.isEmpty,
|
||||
placeholderText: (_) =>
|
||||
LocaleKeys.cardDetails_notesPlaceholder.tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+32
-36
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart';
|
||||
@@ -7,8 +8,10 @@ import 'package:appflowy/plugins/database/widgets/row/row_property.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/shared/flowy_error_page.dart';
|
||||
@@ -18,8 +21,10 @@ import 'package:appflowy/workspace/application/action_navigation/navigation_acti
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../workspace/application/view/view_bloc.dart';
|
||||
|
||||
@@ -48,18 +53,6 @@ class DatabaseDocumentPage extends StatefulWidget {
|
||||
class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
EditorState? editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
EditorNotification.addListener(_onEditorNotification);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
EditorNotification.removeListener(_onEditorNotification);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
@@ -104,7 +97,11 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listener: _onNotificationAction,
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
child: _buildEditorPage(context, state),
|
||||
child: AiWriterScrollWrapper(
|
||||
viewId: widget.view.id,
|
||||
editorState: editorState,
|
||||
child: _buildEditorPage(context, state),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -121,21 +118,34 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: EditorStyleCustomizer.documentPadding,
|
||||
editorState: state.editorState!,
|
||||
),
|
||||
header: _buildDatabaseDataContent(context, state.editorState!),
|
||||
initialSelection: widget.initialSelection,
|
||||
useViewInfoBloc: false,
|
||||
placeholderText: (node) =>
|
||||
node.type == ParagraphBlockKeys.type && !node.isInTable
|
||||
? LocaleKeys.editor_slashPlaceHolder.tr()
|
||||
: '',
|
||||
),
|
||||
);
|
||||
|
||||
return EditorTransactionService(
|
||||
viewId: widget.view.id,
|
||||
editorState: state.editorState!,
|
||||
child: Column(
|
||||
children: [
|
||||
if (state.isDeleted) _buildBanner(context),
|
||||
Expanded(child: appflowyEditorPage),
|
||||
],
|
||||
return Provider(
|
||||
create: (_) {
|
||||
final context = SharedEditorContext();
|
||||
context.isInDatabaseRowPage = true;
|
||||
return context;
|
||||
},
|
||||
dispose: (_, editorContext) => editorContext.dispose(),
|
||||
child: EditorTransactionService(
|
||||
viewId: widget.view.id,
|
||||
editorState: state.editorState!,
|
||||
child: Column(
|
||||
children: [
|
||||
if (state.isDeleted) _buildBanner(context),
|
||||
Expanded(child: appflowyEditorPage),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -208,20 +218,6 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onEditorNotification(EditorNotificationType type) {
|
||||
final editorState = this.editorState;
|
||||
if (editorState == null) {
|
||||
return;
|
||||
}
|
||||
if (type == EditorNotificationType.undo) {
|
||||
undoCommand.execute(editorState);
|
||||
} else if (type == EditorNotificationType.redo) {
|
||||
redoCommand.execute(editorState);
|
||||
} else if (type == EditorNotificationType.exitEditing) {
|
||||
editorState.selection = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onNotificationAction(
|
||||
BuildContext context,
|
||||
ActionNavigationState state,
|
||||
|
||||
@@ -983,7 +983,6 @@ CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
|
||||
return const EdgeInsets.symmetric(vertical: 10);
|
||||
},
|
||||
),
|
||||
cache: LinkPreviewDataCache(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys;
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -352,7 +352,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
final isLocked =
|
||||
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
|
||||
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final editor = Directionality(
|
||||
textDirection: textDirection,
|
||||
child: AppFlowyEditor(
|
||||
@@ -431,6 +430,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
),
|
||||
);
|
||||
}
|
||||
final appTheme = AppFlowyTheme.of(context);
|
||||
return Center(
|
||||
child: BlocProvider.value(
|
||||
value: context.read<DocumentBloc>(),
|
||||
@@ -443,15 +443,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(appTheme.borderRadius.l),
|
||||
color: appTheme.surfaceColorScheme.primary,
|
||||
boxShadow: [appTheme.shadow.small],
|
||||
),
|
||||
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
|
||||
BlocProvider.value(
|
||||
|
||||
+6
-5
@@ -5,8 +5,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
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 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -119,7 +119,7 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
|
||||
final child = FlowyIconButton(
|
||||
width: 48,
|
||||
height: 32,
|
||||
@@ -131,13 +131,13 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_ai_writer_m,
|
||||
size: Size.square(20),
|
||||
color: themeV2.icon_primary,
|
||||
color: iconScheme.primary,
|
||||
),
|
||||
HSpace(4),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
size: Size(12, 20),
|
||||
color: themeV2.icon_tertiary,
|
||||
color: iconScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -180,6 +180,7 @@ class ImproveWritingButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
height: 32,
|
||||
@@ -187,7 +188,7 @@ class ImproveWritingButton extends StatelessWidget {
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.toolbar_ai_improve_writing_m,
|
||||
size: Size.square(20.0),
|
||||
color: AFThemeExtensionV2.of(context).icon_primary,
|
||||
color: theme.iconColorTheme.primary,
|
||||
),
|
||||
onPressed: () {
|
||||
if (_isAIEnabled(editorState)) {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ 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;
|
||||
const _aiWriterCubitDebugLog = true;
|
||||
|
||||
class AiWriterCubit extends Cubit<AiWriterState> {
|
||||
AiWriterCubit({
|
||||
|
||||
+10
-2
@@ -34,7 +34,15 @@ extension AiWriterNodeExtension on EditorState {
|
||||
|
||||
// if the selected nodes are not entirely selected, slice the nodes
|
||||
final slicedNodes = <Node>[];
|
||||
final nodes = getNodesInSelection(selection);
|
||||
final List<Node> flattenNodes = getNodesInSelection(selection);
|
||||
final List<Node> nodes = [];
|
||||
|
||||
for (final node in flattenNodes) {
|
||||
if (nodes.any((element) => element.isParentOf(node))) {
|
||||
continue;
|
||||
}
|
||||
nodes.add(node);
|
||||
}
|
||||
|
||||
for (final node in nodes) {
|
||||
final delta = node.delta;
|
||||
@@ -76,7 +84,7 @@ extension AiWriterNodeExtension on EditorState {
|
||||
// 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',
|
||||
lineBreak: '\n',
|
||||
);
|
||||
|
||||
// trim the last \n if it exists
|
||||
|
||||
+6
-1
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class BuiltInPageWidget extends StatefulWidget {
|
||||
const BuiltInPageWidget({
|
||||
@@ -77,6 +79,9 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
}
|
||||
|
||||
Widget _buildPage(BuildContext context, ViewPB view) {
|
||||
final verticalPadding =
|
||||
context.read<DatabasePluginWidgetBuilderSize?>()?.verticalPadding ??
|
||||
0.0;
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
onFocusChange: (value) {
|
||||
@@ -85,7 +90,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: EdgeInsets.symmetric(vertical: verticalPadding),
|
||||
child: widget.builder(view),
|
||||
),
|
||||
);
|
||||
|
||||
+52
-4
@@ -398,6 +398,15 @@ class MarkdownTextRobot {
|
||||
|
||||
// it means the user selected the entire sentence, we just replace the node
|
||||
if (startIndex == 0 && length == node.delta?.length) {
|
||||
if (nodes.isNotEmpty && node.children.isNotEmpty) {
|
||||
// merge the children of the selected node and the first node of the ai response
|
||||
nodes[0] = nodes[0].copyWith(
|
||||
children: [
|
||||
...node.children.map((e) => e.deepCopy()),
|
||||
...nodes[0].children,
|
||||
],
|
||||
);
|
||||
}
|
||||
transaction
|
||||
..insertNodes(node.path.next, nodes)
|
||||
..deleteNode(node);
|
||||
@@ -441,7 +450,14 @@ class MarkdownTextRobot {
|
||||
).root.children;
|
||||
|
||||
// Get the selected nodes.
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
final flattenNodes = editorState.getNodesInSelection(selection);
|
||||
final nodes = <Node>[];
|
||||
for (final node in flattenNodes) {
|
||||
if (nodes.any((element) => element.isParentOf(node))) {
|
||||
continue;
|
||||
}
|
||||
nodes.add(node);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -465,9 +481,23 @@ class MarkdownTextRobot {
|
||||
transaction
|
||||
..deleteText(firstNode, startIndex, length)
|
||||
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
|
||||
|
||||
// if the first markdown node has children, we need to insert the children
|
||||
// and delete the children of the first node that are in the selection.
|
||||
if (firstMarkdownNode.children.isNotEmpty) {
|
||||
transaction.insertNodes(
|
||||
firstNode.path.child(0),
|
||||
firstMarkdownNode.children.map((e) => e.deepCopy()),
|
||||
);
|
||||
}
|
||||
|
||||
final nodesToDelete =
|
||||
firstNode.children.where((e) => e.path.inSelection(selection));
|
||||
transaction.deleteNodes(nodesToDelete);
|
||||
}
|
||||
|
||||
// step 2
|
||||
bool handledLastNode = false;
|
||||
final lastNode = nodes.lastOrNull;
|
||||
final lastDelta = lastNode?.delta;
|
||||
final lastMarkdownNode = markdownNodes.lastOrNull;
|
||||
@@ -475,7 +505,10 @@ class MarkdownTextRobot {
|
||||
if (lastNode != null &&
|
||||
lastDelta != null &&
|
||||
lastMarkdownNode != null &&
|
||||
lastMarkdownDelta != null) {
|
||||
lastMarkdownDelta != null &&
|
||||
firstNode?.id != lastNode.id) {
|
||||
handledLastNode = true;
|
||||
|
||||
final endIndex = selection.endIndex;
|
||||
|
||||
transaction.deleteText(lastNode, 0, endIndex);
|
||||
@@ -484,15 +517,30 @@ class MarkdownTextRobot {
|
||||
// selected text in the first node.
|
||||
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
|
||||
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
|
||||
|
||||
if (lastMarkdownNode.children.isNotEmpty) {
|
||||
transaction
|
||||
..insertNodes(
|
||||
lastNode.path.child(0),
|
||||
lastMarkdownNode.children.map((e) => e.deepCopy()),
|
||||
)
|
||||
..deleteNodes(
|
||||
lastNode.children.where((e) => e.path.inSelection(selection)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step 3
|
||||
final insertedPath = selection.start.path.nextNPath(1);
|
||||
if (markdownNodes.length > 2) {
|
||||
final insertLength = handledLastNode ? 2 : 1;
|
||||
if (markdownNodes.length > insertLength) {
|
||||
transaction.insertNodes(
|
||||
insertedPath,
|
||||
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
|
||||
markdownNodes
|
||||
.skip(1)
|
||||
.take(markdownNodes.length - insertLength)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -115,7 +116,11 @@ class SimpleColumnBlockComponentState extends State<SimpleColumnBlockComponent>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: node.children.map(
|
||||
(e) {
|
||||
Widget child = IntrinsicHeight(
|
||||
Widget child = Provider(
|
||||
create: (_) => DatabasePluginWidgetBuilderSize(
|
||||
verticalPadding: 0,
|
||||
horizontalPadding: 0,
|
||||
),
|
||||
child: editorState.renderer.build(context, e),
|
||||
);
|
||||
if (SimpleColumnsBlockConstants.enableDebugBorder) {
|
||||
|
||||
+5
-4
@@ -9,10 +9,12 @@ class SimpleColumnBlockWidthResizer extends StatefulWidget {
|
||||
super.key,
|
||||
required this.columnNode,
|
||||
required this.editorState,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final Node columnNode;
|
||||
final EditorState editorState;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<SimpleColumnBlockWidthResizer> createState() =>
|
||||
@@ -53,15 +55,14 @@ class _SimpleColumnBlockWidthResizerState
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: isHovering,
|
||||
builder: (context, isHovering, child) {
|
||||
if (isDraggingAppFlowyEditorBlock.value) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
final hide = isDraggingAppFlowyEditorBlock.value || !isHovering;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: widget.height ?? 20,
|
||||
margin: EdgeInsets.symmetric(horizontal: 2),
|
||||
color: isHovering
|
||||
color: !hide
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
|
||||
+28
-10
@@ -92,17 +92,18 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
final ScrollController scrollController = ScrollController();
|
||||
|
||||
final ValueNotifier<double?> heightValueNotifier = ValueNotifier(null);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_updateColumnsBlock();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
|
||||
heightValueNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -110,15 +111,13 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildChildren(),
|
||||
);
|
||||
|
||||
child = Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IntrinsicHeight(
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
child = Padding(
|
||||
@@ -141,7 +140,10 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
// the columns block does not support the block actions and selection
|
||||
// because the columns block is a layout wrapper, it does not have a content
|
||||
return child;
|
||||
return NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (v) => updateHeightValueNotifier(v),
|
||||
child: SizeChangedLayoutNotifier(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildChildren() {
|
||||
@@ -164,9 +166,15 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
if (i != length - 1) {
|
||||
children.add(
|
||||
SimpleColumnBlockWidthResizer(
|
||||
columnNode: childNode,
|
||||
editorState: editorState,
|
||||
ValueListenableBuilder(
|
||||
valueListenable: heightValueNotifier,
|
||||
builder: (context, height, child) {
|
||||
return SimpleColumnBlockWidthResizer(
|
||||
columnNode: childNode,
|
||||
editorState: editorState,
|
||||
height: height,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -194,6 +202,16 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
}
|
||||
}
|
||||
|
||||
bool updateHeightValueNotifier(SizeChangedLayoutNotification notification) {
|
||||
if (!mounted) return true;
|
||||
final height = _renderBox?.size.height;
|
||||
if (heightValueNotifier.value == height) return true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
heightValueNotifier.value = height;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path);
|
||||
|
||||
|
||||
+2
-2
@@ -186,7 +186,7 @@ Future<bool> _pasteAsLinkPreview(
|
||||
node.delta?.toPlainText().isNotEmpty == true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMobile) return false;
|
||||
final bool isImageUrl;
|
||||
try {
|
||||
isImageUrl = await _isImageUrl(text);
|
||||
@@ -195,7 +195,7 @@ Future<bool> _pasteAsLinkPreview(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMobile && !isImageUrl) return false;
|
||||
if (!isImageUrl) return false;
|
||||
|
||||
// insert the text with link format
|
||||
final textTransaction = editorState.transaction
|
||||
|
||||
+7
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
@@ -27,9 +28,15 @@ extension TextDeltaExtension on Delta {
|
||||
if (op.text == MentionBlockKeys.mentionChar) {
|
||||
final mention = attributes?[MentionBlockKeys.mention];
|
||||
final mentionPageId = mention?[MentionBlockKeys.pageId];
|
||||
final mentionType = mention?[MentionBlockKeys.type];
|
||||
if (mentionPageId != null) {
|
||||
text += await getMentionPageName(mentionPageId);
|
||||
continue;
|
||||
} else if (mentionType == MentionType.externalLink.name) {
|
||||
final url = mention?[MentionBlockKeys.url] ?? '';
|
||||
final info = await LinkInfoCache.get(url);
|
||||
text += info?.title ?? url;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-14
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.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';
|
||||
@@ -307,17 +308,13 @@ void showLinkCreateMenu(
|
||||
ShapeDecoration buildToolbarLinkDecoration(
|
||||
BuildContext context, {
|
||||
double radius = 12.0,
|
||||
}) =>
|
||||
ShapeDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
shadows: [
|
||||
const BoxShadow(
|
||||
color: LinkStyle.shadowMedium,
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
}) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return ShapeDecoration(
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
shadows: [theme.shadow.small],
|
||||
);
|
||||
}
|
||||
+97
-67
@@ -13,6 +13,7 @@ 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 'package:flutter/services.dart';
|
||||
import 'link_create_menu.dart';
|
||||
import 'link_search_text_field.dart';
|
||||
import 'link_styles.dart';
|
||||
@@ -38,15 +39,14 @@ class LinkEditMenu extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
ValueChanged<LinkInfo> get onApply => widget.onApply;
|
||||
|
||||
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
|
||||
|
||||
VoidCallback get onDismiss => widget.onDismiss;
|
||||
|
||||
late TextEditingController linkNameController =
|
||||
TextEditingController(text: linkInfo.name);
|
||||
final textFocusNode = FocusNode();
|
||||
late FocusNode textFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent);
|
||||
late FocusNode menuFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent);
|
||||
late LinkInfo linkInfo = widget.linkInfo;
|
||||
late LinkSearchTextField searchTextField;
|
||||
bool isShowingSearchResult = false;
|
||||
@@ -74,12 +74,14 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
)..searchRecentViews();
|
||||
makeSureHasFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
linkNameController.dispose();
|
||||
textFocusNode.dispose();
|
||||
menuFocusNode.dispose();
|
||||
searchTextField.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -91,56 +93,59 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
final errorHeight = showErrorText ? 20.0 : 0.0;
|
||||
return GestureDetector(
|
||||
onTap: onDismiss,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 250 + (showingRecent ? 32 : 0),
|
||||
color: Colors.white.withAlpha(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192 + errorHeight,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
child: Focus(
|
||||
focusNode: menuFocusNode,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 250 + (showingRecent ? 32 : 0),
|
||||
color: Colors.white.withAlpha(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192 + errorHeight,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_pageOrURL.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_pageOrURL.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 80 + errorHeight,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
Positioned(
|
||||
top: 80 + errorHeight,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 144 + errorHeight,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 100 + errorHeight,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
Positioned(
|
||||
top: 36,
|
||||
left: 20,
|
||||
child: buildLinkField(),
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
top: 144 + errorHeight,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 100 + errorHeight,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
Positioned(
|
||||
top: 36,
|
||||
left: 20,
|
||||
child: buildLinkField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -254,23 +259,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
fontColor: Colors.white,
|
||||
fillColor: LinkStyle.fillThemeThick,
|
||||
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: onApply,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -287,6 +276,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
child: TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
focusNode: textFocusNode,
|
||||
autofocus: true,
|
||||
textAlign: TextAlign.left,
|
||||
controller: linkNameController,
|
||||
style: TextStyle(
|
||||
@@ -395,6 +385,45 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult onFocusKeyEvent(FocusNode node, KeyEvent key) {
|
||||
if (key is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (key.logicalKey == LogicalKeyboardKey.enter) {
|
||||
onApply();
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Future<void> makeSureHasFocus() async {
|
||||
final focusNode = textFocusNode;
|
||||
if (!mounted || focusNode.hasFocus) return;
|
||||
focusNode.requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
makeSureHasFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void onApply() {
|
||||
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);
|
||||
}
|
||||
|
||||
void onConfirm() {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: onLinkSelected,
|
||||
@@ -404,6 +433,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
searchTextField.unfocus();
|
||||
},
|
||||
);
|
||||
menuFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
Future<void> getPageView() async {
|
||||
|
||||
+74
-50
@@ -1,6 +1,9 @@
|
||||
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/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
@@ -37,11 +40,12 @@ class LinkEmbedBlockComponent extends BlockComponentStatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<LinkEmbedBlockComponent> createState() =>
|
||||
DefaultSelectableMixinState<LinkEmbedBlockComponent> createState() =>
|
||||
LinkEmbedBlockComponentState();
|
||||
}
|
||||
|
||||
class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
class LinkEmbedBlockComponentState
|
||||
extends DefaultSelectableMixinState<LinkEmbedBlockComponent>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
@@ -49,11 +53,11 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url] ?? '';
|
||||
|
||||
EmbedLoadingStatus status = EmbedLoadingStatus.loading;
|
||||
LinkLoadingStatus status = LinkLoadingStatus.loading;
|
||||
final parser = LinkParser();
|
||||
LinkInfo linkInfo = LinkInfo();
|
||||
late LinkInfo linkInfo = LinkInfo(url: url);
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
bool isMenuShowing = false, isHovering = false;
|
||||
@@ -62,13 +66,14 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
void initState() {
|
||||
super.initState();
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (v.isEmpty() && linkInfo.isEmpty()) {
|
||||
status = EmbedLoadingStatus.error;
|
||||
} else {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = EmbedLoadingStatus.idle;
|
||||
status = LinkLoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = LinkLoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -98,7 +103,13 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
},
|
||||
child: buildChild(context),
|
||||
);
|
||||
result = Padding(padding: padding, child: result);
|
||||
final parent = node.parent;
|
||||
EdgeInsets newPadding = padding;
|
||||
if (parent?.type == CalloutBlockKeys.type) {
|
||||
newPadding = padding.copyWith(right: padding.right + 10);
|
||||
}
|
||||
|
||||
result = Padding(padding: newPadding, child: result);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
result = BlockComponentActionWrapper(
|
||||
@@ -115,7 +126,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
fillSceme = theme.fillColorScheme,
|
||||
borderScheme = theme.borderColorScheme;
|
||||
Widget child;
|
||||
final isIdle = status == EmbedLoadingStatus.idle;
|
||||
final isIdle = status == LinkLoadingStatus.idle;
|
||||
if (isIdle) {
|
||||
child = buildContent(context);
|
||||
} else {
|
||||
@@ -123,6 +134,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
}
|
||||
return Container(
|
||||
height: 450,
|
||||
key: widgetKey,
|
||||
decoration: BoxDecoration(
|
||||
color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover,
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
@@ -150,7 +162,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
node: node,
|
||||
onReload: () {
|
||||
setState(() {
|
||||
status = EmbedLoadingStatus.loading;
|
||||
status = LinkLoadingStatus.loading;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) parser.start(url);
|
||||
@@ -185,43 +197,51 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Center(
|
||||
child: linkInfo.buildIconWidget(size: Size.square(32)),
|
||||
),
|
||||
),
|
||||
HSpace(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
linkInfo.siteName ?? '',
|
||||
color: textScheme.primary,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
afLaunchUrlString(url, addingHttpSchemeWhenFailed: true),
|
||||
child: Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Center(
|
||||
child: linkInfo.buildIconWidget(size: Size.square(32)),
|
||||
),
|
||||
VSpace(4),
|
||||
FlowyText.regular(
|
||||
url,
|
||||
color: textScheme.secondary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
HSpace(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
linkInfo.siteName ?? '',
|
||||
color: textScheme.primary,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
VSpace(4),
|
||||
FlowyText.regular(
|
||||
url,
|
||||
color: textScheme.secondary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -230,7 +250,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
|
||||
Widget buildErrorLoadingWidget(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme;
|
||||
final isLoading = status == EmbedLoadingStatus.loading;
|
||||
final isLoading = status == LinkLoadingStatus.loading;
|
||||
return isLoading
|
||||
? Center(
|
||||
child: SizedBox.square(
|
||||
@@ -264,7 +284,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys
|
||||
.document_plugins_linkPreview_linkPreviewMenu_refuseConnect
|
||||
.document_plugins_linkPreview_linkPreviewMenu_unableToDisplay
|
||||
.tr(),
|
||||
style: TextStyle(
|
||||
color: textSceme.primary,
|
||||
@@ -281,6 +301,10 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum EmbedLoadingStatus { loading, idle, error }
|
||||
@override
|
||||
Node get currentNode => node;
|
||||
|
||||
@override
|
||||
EdgeInsets get boxPadding => padding;
|
||||
}
|
||||
+34
-12
@@ -3,7 +3,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.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/link/link_replace_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -70,7 +69,7 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: fillScheme.primaryAlpha80,
|
||||
),
|
||||
child: Row(
|
||||
@@ -90,18 +89,19 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
||||
FlowySvgs.toolbar_link_m,
|
||||
color: iconScheme.tertiary,
|
||||
),
|
||||
radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)),
|
||||
tooltipText: LocaleKeys.editor_copyLink.tr(),
|
||||
preferBelow: false,
|
||||
onPressed: () => copyLink(context),
|
||||
),
|
||||
buildTurnIntoBotton(),
|
||||
buildconvertBotton(),
|
||||
buildMoreOptionBotton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTurnIntoBotton() {
|
||||
Widget buildconvertBotton() {
|
||||
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
|
||||
return AppFlowyPopover(
|
||||
offset: Offset(0, 6),
|
||||
@@ -117,22 +117,22 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
||||
turnintoMenuNum--;
|
||||
checkToHideMenu();
|
||||
},
|
||||
popupBuilder: (context) => buildTurnIntoMenu(),
|
||||
popupBuilder: (context) => buildConvertMenu(),
|
||||
child: FlowyIconButton(
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.turninto_m,
|
||||
color: iconScheme.tertiary,
|
||||
),
|
||||
tooltipText: LocaleKeys.document_toolbar_turnInto.tr(),
|
||||
radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)),
|
||||
tooltipText: LocaleKeys.editor_convertTo.tr(),
|
||||
preferBelow: false,
|
||||
onPressed: showTurnIntoMenu,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTurnIntoMenu() {
|
||||
final types =
|
||||
PasteMenuType.values.where((e) => e != PasteMenuType.embed).toList();
|
||||
Widget buildConvertMenu() {
|
||||
final types = LinkEmbedConvertCommand.values;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SeparatedColumn(
|
||||
@@ -149,16 +149,16 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
onTap: () {
|
||||
if (command == PasteMenuType.bookmark) {
|
||||
if (command == LinkEmbedConvertCommand.toBookmark) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
LinkPreviewBlockKeys.url: url,
|
||||
LinkEmbedKeys.previewType: '',
|
||||
});
|
||||
editorState.apply(transaction);
|
||||
} else if (command == PasteMenuType.mention) {
|
||||
} else if (command == LinkEmbedConvertCommand.toMention) {
|
||||
convertUrlPreviewNodeToMention(editorState, node);
|
||||
} else if (command == PasteMenuType.url) {
|
||||
} else if (command == LinkEmbedConvertCommand.toURL) {
|
||||
convertUrlPreviewNodeToLink(editorState, node);
|
||||
}
|
||||
},
|
||||
@@ -192,6 +192,7 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
||||
FlowySvgs.toolbar_more_m,
|
||||
color: iconScheme.tertiary,
|
||||
),
|
||||
radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)),
|
||||
tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(),
|
||||
preferBelow: false,
|
||||
onPressed: showMoreOptionMenu,
|
||||
@@ -330,3 +331,24 @@ enum LinkEmbedMenuCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkEmbedConvertCommand {
|
||||
toMention,
|
||||
toURL,
|
||||
toBookmark;
|
||||
|
||||
String get title {
|
||||
switch (this) {
|
||||
case toMention:
|
||||
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion
|
||||
.tr();
|
||||
case toURL:
|
||||
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
|
||||
.tr();
|
||||
case toBookmark:
|
||||
return LocaleKeys
|
||||
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
|
||||
.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-27
@@ -5,39 +5,39 @@ import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_svg.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:favicon/favicon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:flutter_link_previewer/flutter_link_previewer.dart' hide Size;
|
||||
import 'link_parsers/default_parser.dart';
|
||||
import 'link_parsers/youtube_parser.dart';
|
||||
|
||||
class LinkParser {
|
||||
static final LinkInfoCache _cache = LinkInfoCache();
|
||||
final Set<ValueChanged<LinkInfo>> _listeners = <ValueChanged<LinkInfo>>{};
|
||||
static final Map<String, LinkInfoParser> _hostToParsers = {
|
||||
'www.youtube.com': YoutubeParser(),
|
||||
'youtube.com': YoutubeParser(),
|
||||
'youtu.be': YoutubeParser(),
|
||||
};
|
||||
|
||||
Future<void> start(String url) async {
|
||||
final data = await _cache.get(url);
|
||||
Future<void> start(String url, {LinkInfoParser? parser}) async {
|
||||
final uri = Uri.tryParse(LinkInfoParser.formatUrl(url)) ?? Uri.parse(url);
|
||||
final data = await LinkInfoCache.get(uri);
|
||||
if (data != null) {
|
||||
refreshLinkInfo(data);
|
||||
}
|
||||
await _getLinkInfo(url);
|
||||
|
||||
final host = uri.host;
|
||||
final currentParser = parser ?? _hostToParsers[host] ?? DefaultParser();
|
||||
await _getLinkInfo(uri, currentParser);
|
||||
}
|
||||
|
||||
Future<LinkInfo?> _getLinkInfo(String url) async {
|
||||
Future<LinkInfo?> _getLinkInfo(Uri uri, LinkInfoParser parser) async {
|
||||
try {
|
||||
final previewData = await getPreviewData(url);
|
||||
final favicon = await FaviconFinder.getBest(url);
|
||||
final linkInfo = LinkInfo(
|
||||
siteName: previewData.title,
|
||||
description: previewData.description,
|
||||
imageUrl: previewData.image?.url,
|
||||
faviconUrl: favicon?.url,
|
||||
);
|
||||
if (!linkInfo.isEmpty()) await _cache.set(url, linkInfo);
|
||||
final linkInfo = await parser.parse(uri) ?? LinkInfo(url: '$uri');
|
||||
if (!linkInfo.isEmpty()) await LinkInfoCache.set(uri, linkInfo);
|
||||
refreshLinkInfo(linkInfo);
|
||||
return linkInfo;
|
||||
} catch (e, s) {
|
||||
Log.error('get link info error: ', e, s);
|
||||
refreshLinkInfo(LinkInfo());
|
||||
refreshLinkInfo(LinkInfo(url: '$uri'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -60,35 +60,45 @@ class LinkParser {
|
||||
class LinkInfo {
|
||||
factory LinkInfo.fromJson(Map<String, dynamic> json) => LinkInfo(
|
||||
siteName: json['siteName'],
|
||||
url: json['url'] ?? '',
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
imageUrl: json['imageUrl'],
|
||||
faviconUrl: json['faviconUrl'],
|
||||
);
|
||||
|
||||
LinkInfo({
|
||||
required this.url,
|
||||
this.siteName,
|
||||
this.title,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.faviconUrl,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String? siteName;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final String? faviconUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'siteName': siteName,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'imageUrl': imageUrl,
|
||||
'faviconUrl': faviconUrl,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LinkInfo{url: $url, siteName: $siteName, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl}';
|
||||
}
|
||||
|
||||
bool isEmpty() {
|
||||
return siteName == null ||
|
||||
description == null ||
|
||||
imageUrl == null ||
|
||||
faviconUrl == null;
|
||||
return title == null;
|
||||
}
|
||||
|
||||
Widget buildIconWidget({Size size = const Size.square(20.0)}) {
|
||||
@@ -116,20 +126,26 @@ class LinkInfo {
|
||||
}
|
||||
|
||||
class LinkInfoCache {
|
||||
final _linkInfoPrefix = 'link_info';
|
||||
static const _linkInfoPrefix = 'link_info';
|
||||
|
||||
Future<LinkInfo?> get(String url) async {
|
||||
static Future<LinkInfo?> get(Uri uri) async {
|
||||
final option = await getIt<KeyValueStorage>().getWithFormat<LinkInfo?>(
|
||||
_linkInfoPrefix + url,
|
||||
'$_linkInfoPrefix$uri',
|
||||
(value) => LinkInfo.fromJson(jsonDecode(value)),
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
Future<void> set(String url, LinkInfo data) async {
|
||||
static Future<void> set(Uri uri, LinkInfo data) async {
|
||||
await getIt<KeyValueStorage>().set(
|
||||
_linkInfoPrefix + url,
|
||||
'$_linkInfoPrefix$uri',
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkLoadingStatus {
|
||||
loading,
|
||||
idle,
|
||||
error,
|
||||
}
|
||||
+67
-63
@@ -3,8 +3,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -14,6 +16,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
|
||||
import 'custom_link_parser.dart';
|
||||
|
||||
class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
const CustomLinkPreviewWidget({
|
||||
super.key,
|
||||
@@ -23,7 +27,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.isHovering = false,
|
||||
this.status = LinkPreviewStatus.loading,
|
||||
this.status = LinkLoadingStatus.loading,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
@@ -32,7 +36,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final String url;
|
||||
final bool isHovering;
|
||||
final LinkPreviewStatus status;
|
||||
final LinkLoadingStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -46,6 +50,8 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
.text
|
||||
.fontSize ??
|
||||
16.0;
|
||||
final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type &&
|
||||
!Theme.of(context).isLightMode;
|
||||
final (fontSize, width) = UniversalPlatform.isDesktopOrWeb
|
||||
? (documentFontSize, 160.0)
|
||||
: (documentFontSize - 2, 120.0);
|
||||
@@ -53,7 +59,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isHovering
|
||||
color: isHovering || isInDarkCallout
|
||||
? borderScheme.greyTertiaryHover
|
||||
: borderScheme.greyTertiary,
|
||||
),
|
||||
@@ -67,43 +73,44 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
buildImage(context),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 60, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
buildLoadingOrErrorWidget(),
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText.medium(
|
||||
title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
color: textScheme.primary,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 58, 12),
|
||||
child: status != LinkLoadingStatus.idle
|
||||
? buildLoadingOrErrorWidget()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText.medium(
|
||||
title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
color: textScheme.primary,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: FlowyText(
|
||||
description!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
color: textScheme.primary,
|
||||
),
|
||||
),
|
||||
FlowyText(
|
||||
url.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: textScheme.secondary,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: FlowyText(
|
||||
description!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
color: textScheme.primary,
|
||||
),
|
||||
),
|
||||
FlowyText(
|
||||
url.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: textScheme.secondary,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -112,9 +119,12 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (UniversalPlatform.isDesktopOrWeb) {
|
||||
return InkWell(
|
||||
onTap: () => afLaunchUrlString(url),
|
||||
child: child,
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => afLaunchUrlString(url),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +166,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
iconScheme = theme.iconColorTheme;
|
||||
final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0;
|
||||
Widget child;
|
||||
if (imageUrl != null) {
|
||||
if (imageUrl?.isNotEmpty ?? false) {
|
||||
child = FlowyNetworkImage(
|
||||
url: imageUrl!,
|
||||
width: width,
|
||||
@@ -184,26 +194,22 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget buildLoadingOrErrorWidget() {
|
||||
if (status == LinkPreviewStatus.loading) {
|
||||
return Expanded(
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
if (status == LinkLoadingStatus.loading) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
} else if (status == LinkPreviewStatus.error) {
|
||||
return Expanded(
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
} else if (status == LinkLoadingStatus.error) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -211,5 +217,3 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkPreviewStatus { loading, error, idle }
|
||||
+94
-104
@@ -1,19 +1,19 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'custom_link_preview.dart';
|
||||
import 'default_selectable_mixin.dart';
|
||||
import 'link_preview_menu.dart';
|
||||
|
||||
class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
|
||||
CustomLinkPreviewBlockComponentBuilder({
|
||||
super.configuration,
|
||||
this.cache,
|
||||
});
|
||||
|
||||
final LinkPreviewDataCacheInterface? cache;
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
@@ -35,7 +35,6 @@ class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
|
||||
configuration: configuration,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
|
||||
cache: cache,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,18 +50,15 @@ class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget {
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
this.cache,
|
||||
});
|
||||
|
||||
final LinkPreviewDataCacheInterface? cache;
|
||||
|
||||
@override
|
||||
State<CustomLinkPreviewBlockComponent> createState() =>
|
||||
DefaultSelectableMixinState<CustomLinkPreviewBlockComponent> createState() =>
|
||||
CustomLinkPreviewBlockComponentState();
|
||||
}
|
||||
|
||||
class CustomLinkPreviewBlockComponentState
|
||||
extends State<CustomLinkPreviewBlockComponent>
|
||||
extends DefaultSelectableMixinState<CustomLinkPreviewBlockComponent>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
@@ -72,8 +68,9 @@ class CustomLinkPreviewBlockComponentState
|
||||
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
|
||||
late final LinkPreviewParser parser;
|
||||
late Future<void> future;
|
||||
final parser = LinkParser();
|
||||
LinkLoadingStatus status = LinkLoadingStatus.loading;
|
||||
late LinkInfo linkInfo = LinkInfo(url: url);
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
bool isMenuShowing = false, isHovering = false;
|
||||
@@ -81,21 +78,26 @@ class CustomLinkPreviewBlockComponentState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
parser = LinkPreviewParser(url: url, cache: widget.cache);
|
||||
future = parser.start();
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = LinkLoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = LinkLoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
parser.start(url);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomLinkPreviewBlockComponent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final url = widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
final oldUrl = oldWidget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
if (url != oldUrl) {
|
||||
parser = LinkPreviewParser(url: url, cache: widget.cache);
|
||||
setState(() {
|
||||
future = parser.start();
|
||||
});
|
||||
}
|
||||
void dispose() {
|
||||
parser.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -114,91 +116,79 @@ class CustomLinkPreviewBlockComponentState
|
||||
},
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
opaque: false,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: showActionsNotifier,
|
||||
builder: (context, showActions, child) {
|
||||
return FutureBuilder(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
Widget child;
|
||||
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
child = CustomLinkPreviewWidget(
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
);
|
||||
} else {
|
||||
final title = parser.getContent(LinkPreviewRegex.title);
|
||||
final description =
|
||||
parser.getContent(LinkPreviewRegex.description);
|
||||
final image = parser.getContent(LinkPreviewRegex.image);
|
||||
|
||||
if (title == null && description == null && image == null) {
|
||||
child = CustomLinkPreviewWidget(
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
status: LinkPreviewStatus.error,
|
||||
);
|
||||
} else {
|
||||
child = CustomLinkPreviewWidget(
|
||||
node: node,
|
||||
url: url,
|
||||
title: title,
|
||||
description: description,
|
||||
imageUrl: image,
|
||||
isHovering: showActions,
|
||||
status: LinkPreviewStatus.idle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
child = Padding(padding: padding, child: child);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = Stack(
|
||||
children: [
|
||||
child,
|
||||
if (showActions)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: CustomLinkPreviewMenu(
|
||||
onMenuShowed: () {
|
||||
isMenuShowing = true;
|
||||
},
|
||||
onMenuHided: () {
|
||||
isMenuShowing = false;
|
||||
if (!isHovering && mounted) {
|
||||
showActionsNotifier.value = false;
|
||||
}
|
||||
},
|
||||
onReload: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
future = parser.start();
|
||||
});
|
||||
}
|
||||
},
|
||||
node: node,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return child;
|
||||
},
|
||||
);
|
||||
return buildPreview(showActions);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPreview(bool showActions) {
|
||||
Widget child = CustomLinkPreviewWidget(
|
||||
key: widgetKey,
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
title: linkInfo.siteName,
|
||||
description: linkInfo.description,
|
||||
imageUrl: linkInfo.imageUrl,
|
||||
status: status,
|
||||
);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = Stack(
|
||||
children: [
|
||||
child,
|
||||
if (showActions)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: CustomLinkPreviewMenu(
|
||||
onMenuShowed: () {
|
||||
isMenuShowing = true;
|
||||
},
|
||||
onMenuHided: () {
|
||||
isMenuShowing = false;
|
||||
if (!isHovering && mounted) {
|
||||
showActionsNotifier.value = false;
|
||||
}
|
||||
},
|
||||
onReload: () {
|
||||
setState(() {
|
||||
status = LinkLoadingStatus.loading;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) parser.start(url);
|
||||
});
|
||||
},
|
||||
node: node,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final parent = node.parent;
|
||||
EdgeInsets newPadding = padding;
|
||||
if (parent?.type == CalloutBlockKeys.type) {
|
||||
newPadding = padding.copyWith(right: padding.right + 10);
|
||||
}
|
||||
child = Padding(padding: newPadding, child: child);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Node get currentNode => node;
|
||||
|
||||
@override
|
||||
EdgeInsets get boxPadding => padding;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class DefaultSelectableMixinState<T extends StatefulWidget>
|
||||
extends State<T> with SelectableMixin {
|
||||
final widgetKey = GlobalKey();
|
||||
RenderBox? get _renderBox =>
|
||||
widgetKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
Node get currentNode;
|
||||
|
||||
EdgeInsets get boxPadding => EdgeInsets.zero;
|
||||
|
||||
@override
|
||||
Position start() => Position(path: currentNode.path);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: currentNode.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.cover;
|
||||
|
||||
@override
|
||||
Rect getBlockRect({
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final box = _renderBox;
|
||||
if (box is RenderBox) {
|
||||
return boxPadding.topLeft & box.size;
|
||||
}
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(
|
||||
Position position, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final rects = getRectsInSelection(Selection.collapsed(position));
|
||||
return rects.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(
|
||||
Selection selection, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
if (_renderBox == null) {
|
||||
return [];
|
||||
}
|
||||
final parentBox = context.findRenderObject();
|
||||
final box = widgetKey.currentContext?.findRenderObject();
|
||||
if (parentBox is RenderBox && box is RenderBox) {
|
||||
return [
|
||||
box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size,
|
||||
];
|
||||
}
|
||||
return [Offset.zero & _renderBox!.size];
|
||||
}
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: currentNode.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
|
||||
_renderBox!.localToGlobal(offset);
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
abstract class LinkInfoParser {
|
||||
Future<LinkInfo?> parse(
|
||||
Uri link, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
|
||||
static String formatUrl(String url) {
|
||||
Uri? uri = Uri.tryParse(url);
|
||||
if (uri == null) return url;
|
||||
if (!uri.hasScheme) uri = Uri.tryParse('http://$url');
|
||||
if (uri == null) return url;
|
||||
final isHome = (uri.hasEmptyPath || uri.path == '/') && !uri.hasQuery;
|
||||
final homeUrl = '${uri.scheme}://${uri.host}/';
|
||||
if (isHome) return homeUrl;
|
||||
return '$uri';
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultParser implements LinkInfoParser {
|
||||
@override
|
||||
Future<LinkInfo?> parse(
|
||||
Uri link, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery;
|
||||
final http.Response response =
|
||||
await http.get(link, headers: headers).timeout(timeout);
|
||||
final code = response.statusCode;
|
||||
if (code != 200 && isHome) {
|
||||
throw Exception('Http request error: $code');
|
||||
}
|
||||
// else if (!isHome && code == 403) {
|
||||
// uri = Uri.parse('${uri.scheme}://${uri.host}/');
|
||||
// response = await http.get(uri).timeout(timeout);
|
||||
// }
|
||||
|
||||
final document = html_parser.parse(response.body);
|
||||
|
||||
final siteName = document
|
||||
.querySelector('meta[property="og:site_name"]')
|
||||
?.attributes['content'];
|
||||
|
||||
String? title = document
|
||||
.querySelector('meta[property="og:title"]')
|
||||
?.attributes['content'];
|
||||
title ??= document.querySelector('title')?.text;
|
||||
|
||||
String? description = document
|
||||
.querySelector('meta[property="og:description"]')
|
||||
?.attributes['content'];
|
||||
description ??= document
|
||||
.querySelector('meta[name="description"]')
|
||||
?.attributes['content'];
|
||||
|
||||
String? imageUrl = document
|
||||
.querySelector('meta[property="og:image"]')
|
||||
?.attributes['content'];
|
||||
if (imageUrl != null && !imageUrl.startsWith('http')) {
|
||||
imageUrl = link.resolve(imageUrl).toString();
|
||||
}
|
||||
|
||||
final favicon =
|
||||
'https://www.faviconextractor.com/favicon/${link.host}?larger=true';
|
||||
|
||||
return LinkInfo(
|
||||
url: '$link',
|
||||
siteName: siteName,
|
||||
title: title,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
faviconUrl: favicon,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.error('Parse link $link error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'default_parser.dart';
|
||||
|
||||
class YoutubeParser implements LinkInfoParser {
|
||||
@override
|
||||
Future<LinkInfo?> parse(
|
||||
Uri link, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery;
|
||||
if (isHome) {
|
||||
return DefaultParser().parse(
|
||||
link,
|
||||
timeout: timeout,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
|
||||
final requestLink =
|
||||
'https://www.youtube.com/oembed?url=$link&format=json';
|
||||
final http.Response response = await http
|
||||
.get(Uri.parse(requestLink), headers: headers)
|
||||
.timeout(timeout);
|
||||
final code = response.statusCode;
|
||||
if (code != 200) {
|
||||
throw Exception('Http request error: $code');
|
||||
}
|
||||
|
||||
final youtubeInfo = YoutubeInfo.fromJson(jsonDecode(response.body));
|
||||
|
||||
final favicon =
|
||||
'https://www.google.com/s2/favicons?sz=64&domain=${link.host}';
|
||||
return LinkInfo(
|
||||
url: '$link',
|
||||
title: youtubeInfo.title,
|
||||
siteName: youtubeInfo.authorName,
|
||||
imageUrl: youtubeInfo.thumbnailUrl,
|
||||
faviconUrl: favicon,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.error('Parse link $link error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class YoutubeInfo {
|
||||
YoutubeInfo({
|
||||
this.title,
|
||||
this.authorName,
|
||||
this.version,
|
||||
this.providerName,
|
||||
this.providerUrl,
|
||||
this.thumbnailUrl,
|
||||
});
|
||||
|
||||
YoutubeInfo.fromJson(Map<String, dynamic> json) {
|
||||
title = json['title'];
|
||||
authorName = json['author_name'];
|
||||
version = json['version'];
|
||||
providerName = json['provider_name'];
|
||||
providerUrl = json['provider_url'];
|
||||
thumbnailUrl = json['thumbnail_url'];
|
||||
}
|
||||
String? title;
|
||||
String? authorName;
|
||||
String? version;
|
||||
String? providerName;
|
||||
String? providerUrl;
|
||||
String? thumbnailUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'author_name': authorName,
|
||||
'version': version,
|
||||
'provider_name': providerName,
|
||||
'provider_url': providerUrl,
|
||||
'thumbnail_url': thumbnailUrl,
|
||||
};
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
|
||||
class LinkPreviewDataCache implements LinkPreviewDataCacheInterface {
|
||||
@override
|
||||
Future<LinkPreviewData?> get(String url) async {
|
||||
final option =
|
||||
await getIt<KeyValueStorage>().getWithFormat<LinkPreviewData?>(
|
||||
url,
|
||||
(value) => LinkPreviewData.fromJson(jsonDecode(value)),
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> set(String url, LinkPreviewData data) async {
|
||||
await getIt<KeyValueStorage>().set(
|
||||
url,
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-17
@@ -3,8 +3,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -28,11 +28,10 @@ class PasteAsMenuService {
|
||||
|
||||
void dismiss() {
|
||||
if (_menuEntry != null) {
|
||||
editorState.service.keyboardService?.enable();
|
||||
editorState.service.scrollService?.enable();
|
||||
keepEditorFocusNotifier.decrease();
|
||||
// editorState.service.scrollService?.enable();
|
||||
// editorState.service.keyboardService?.enable();
|
||||
}
|
||||
|
||||
_menuEntry?.remove();
|
||||
_menuEntry = null;
|
||||
}
|
||||
@@ -58,6 +57,7 @@ class PasteAsMenuService {
|
||||
children: [
|
||||
ltrb.buildPositioned(
|
||||
child: PasteAsMenu(
|
||||
editorState: editorState,
|
||||
onSelect: (t) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) return;
|
||||
@@ -91,8 +91,9 @@ class PasteAsMenuService {
|
||||
|
||||
Overlay.of(context).insert(_menuEntry!);
|
||||
|
||||
editorState.service.keyboardService?.disable(showCursor: true);
|
||||
editorState.service.scrollService?.disable();
|
||||
keepEditorFocusNotifier.increase();
|
||||
// editorState.service.keyboardService?.disable(showCursor: true);
|
||||
// editorState.service.scrollService?.disable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +102,11 @@ class PasteAsMenu extends StatefulWidget {
|
||||
super.key,
|
||||
required this.onSelect,
|
||||
required this.onDismiss,
|
||||
required this.editorState,
|
||||
});
|
||||
final ValueChanged<PasteMenuType?> onSelect;
|
||||
final VoidCallback onDismiss;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<PasteAsMenu> createState() => _PasteAsMenuState();
|
||||
@@ -113,24 +116,28 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
final focusNode = FocusNode(debugLabel: 'paste_as_menu');
|
||||
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
|
||||
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => focusNode.requestFocus(),
|
||||
);
|
||||
editorState.selectionNotifier.addListener(dismiss);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
selectedIndexNotifier.dispose();
|
||||
editorState.selectionNotifier.removeListener(dismiss);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: onKeyEvent,
|
||||
@@ -140,14 +147,8 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
padding: EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: Offset(0, 4),
|
||||
blurRadius: 16,
|
||||
color: themeV2.shadow_medium,
|
||||
),
|
||||
],
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
boxShadow: [theme.shadow.medium],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -156,7 +157,7 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
height: 32,
|
||||
padding: EdgeInsets.all(8),
|
||||
child: FlowyText.semibold(
|
||||
color: themeV2.text_tertiary,
|
||||
color: theme.textColorScheme.primary,
|
||||
LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs
|
||||
.tr(),
|
||||
),
|
||||
@@ -201,8 +202,11 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
length = PasteMenuType.values.length;
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
onSelect(PasteMenuType.values[index]);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismiss();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
dismiss();
|
||||
} else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft]
|
||||
.contains(event.logicalKey)) {
|
||||
if (index == 0) {
|
||||
@@ -211,6 +215,7 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
index--;
|
||||
}
|
||||
changeIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight]
|
||||
.contains(event.logicalKey)) {
|
||||
if (index == length - 1) {
|
||||
@@ -219,8 +224,9 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
index++;
|
||||
}
|
||||
changeIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void onSelect(PasteMenuType type) => widget.onSelect.call(type);
|
||||
|
||||
+26
-10
@@ -42,8 +42,8 @@ class MentionLinkBlock extends StatefulWidget {
|
||||
class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
||||
final parser = LinkParser();
|
||||
_LoadingStatus status = _LoadingStatus.loading;
|
||||
late LinkInfo linkInfo = LinkInfo(url: url);
|
||||
final previewController = PopoverController();
|
||||
LinkInfo linkInfo = LinkInfo();
|
||||
bool isHovering = false;
|
||||
int previewFocusNum = 0;
|
||||
bool isPreviewHovering = false;
|
||||
@@ -67,13 +67,14 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
||||
super.initState();
|
||||
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (v.isEmpty() && linkInfo.isEmpty()) {
|
||||
status = _LoadingStatus.error;
|
||||
} else {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = _LoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = _LoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -148,6 +149,7 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
||||
|
||||
Widget buildIconWithTitle(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final siteName = linkInfo.siteName, linkTitle = linkInfo.title ?? url;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
@@ -169,12 +171,25 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
||||
buildIcon(),
|
||||
HSpace(4),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
linkInfo.siteName ?? url,
|
||||
color: theme.textColorScheme.primary,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
child: RichText(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
if (siteName != null) ...[
|
||||
TextSpan(
|
||||
text: siteName,
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
WidgetSpan(child: HSpace(2)),
|
||||
],
|
||||
TextSpan(
|
||||
text: linkTitle,
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
HSpace(2),
|
||||
@@ -323,9 +338,10 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
||||
maxHeight: 48 + size.height,
|
||||
);
|
||||
}
|
||||
final hasImage = linkInfo.imageUrl?.isNotEmpty ?? false;
|
||||
return BoxConstraints(
|
||||
maxWidth: max(300, size.width),
|
||||
maxHeight: 300,
|
||||
maxHeight: hasImage ? 300 : 180,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-22
@@ -56,7 +56,9 @@ class _MentionLinkPreviewState extends State<MentionLinkPreview> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
textColorScheme = theme.textColorScheme;
|
||||
|
||||
final imageUrl = linkInfo.imageUrl ?? '',
|
||||
description = linkInfo.description ?? '';
|
||||
final imageHeight = 120.0;
|
||||
final card = MouseRegion(
|
||||
onEnter: widget.onEnter,
|
||||
onExit: widget.onExit,
|
||||
@@ -67,20 +69,21 @@ class _MentionLinkPreviewState extends State<MentionLinkPreview> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
child: FlowyNetworkImage(
|
||||
url: linkInfo.imageUrl ?? '',
|
||||
width: 280,
|
||||
height: 120,
|
||||
if (imageUrl.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
child: FlowyNetworkImage(
|
||||
url: linkInfo.imageUrl ?? '',
|
||||
width: 280,
|
||||
height: imageHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
VSpace(12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FlowyText.semibold(
|
||||
linkInfo.siteName ?? '',
|
||||
linkInfo.title ?? linkInfo.siteName ?? '',
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
color: textColorScheme.primary,
|
||||
@@ -88,18 +91,20 @@ class _MentionLinkPreviewState extends State<MentionLinkPreview> {
|
||||
),
|
||||
),
|
||||
VSpace(4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FlowyText(
|
||||
linkInfo.description ?? '',
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: textColorScheme.secondary,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
if (description.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FlowyText(
|
||||
description,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: textColorScheme.secondary,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
VSpace(36),
|
||||
VSpace(36),
|
||||
],
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 28,
|
||||
@@ -109,7 +114,7 @@ class _MentionLinkPreviewState extends State<MentionLinkPreview> {
|
||||
HSpace(6),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
linkInfo.description ?? '',
|
||||
linkInfo.siteName ?? linkInfo.url,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: textColorScheme.primary,
|
||||
|
||||
-1
@@ -40,7 +40,6 @@ export 'inline_math_equation/inline_math_equation.dart';
|
||||
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
|
||||
export 'keyboard_interceptor/keyboard_interceptor.dart';
|
||||
export 'link_preview/custom_link_preview.dart';
|
||||
export 'link_preview/link_preview_cache.dart';
|
||||
export 'link_preview/link_preview_menu.dart';
|
||||
export 'math_equation/math_equation_block_component.dart';
|
||||
export 'math_equation/mobile_math_equation_toolbar_item.dart';
|
||||
|
||||
+3
-2
@@ -6,8 +6,8 @@ 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';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -70,6 +70,7 @@ class _FormatToolbarItem extends ToolbarItem {
|
||||
? highlightColor
|
||||
: EditorStyleCustomizer.toolbarHoverColor(context);
|
||||
final isDark = !Theme.of(context).isLightMode;
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
@@ -81,7 +82,7 @@ class _FormatToolbarItem extends ToolbarItem {
|
||||
size: Size.square(20.0),
|
||||
color: (isDark && isHighlight)
|
||||
? Color(0xFF282E3A)
|
||||
: AFThemeExtensionV2.of(context).icon_primary,
|
||||
: theme.iconColorTheme.primary,
|
||||
),
|
||||
onPressed: () => editorState.toggleAttribute(
|
||||
name,
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -82,7 +82,8 @@ class _HighlightColorPickerWidgetState
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
iconColor = theme.iconColorTheme.primary;
|
||||
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ 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:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -34,7 +34,7 @@ final customLinkItem = ToolbarItem(
|
||||
final hoverColor = isHref
|
||||
? highlightColor
|
||||
: EditorStyleCustomizer.toolbarHoverColor(context);
|
||||
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
height: 32,
|
||||
@@ -45,7 +45,7 @@ final customLinkItem = ToolbarItem(
|
||||
size: Size.square(20.0),
|
||||
color: (isDark && isHref)
|
||||
? Color(0xFF282E3A)
|
||||
: AFThemeExtensionV2.of(context).icon_primary,
|
||||
: theme.iconColorTheme.primary,
|
||||
),
|
||||
onPressed: () {
|
||||
getIt<FloatingToolbarController>().hideToolbar();
|
||||
|
||||
+5
-4
@@ -3,8 +3,8 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
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';
|
||||
|
||||
@@ -96,7 +96,8 @@ class _TextAlignActionListState extends State<TextAlignActionList> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
iconColor = theme.iconColorTheme.primary;
|
||||
final child = FlowyIconButton(
|
||||
width: 48,
|
||||
height: 32,
|
||||
@@ -108,13 +109,13 @@ class _TextAlignActionListState extends State<TextAlignActionList> {
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_alignment_m,
|
||||
size: Size.square(20),
|
||||
color: themeV2.icon_primary,
|
||||
color: iconColor,
|
||||
),
|
||||
HSpace(4),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
size: Size(12, 20),
|
||||
color: themeV2.icon_tertiary,
|
||||
color: iconColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+6
-2
@@ -4,8 +4,8 @@ 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:appflowy_ui/appflowy_ui.dart';
|
||||
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';
|
||||
@@ -57,6 +57,9 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (editorState.selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final selectionRectList = editorState.selectionRects();
|
||||
final top =
|
||||
selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height;
|
||||
@@ -78,7 +81,8 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
iconColor = theme.iconColorTheme.primary;
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
height: 32,
|
||||
|
||||
+5
-4
@@ -4,8 +4,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
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';
|
||||
|
||||
@@ -78,7 +78,8 @@ class _TextHeadingActionListState extends State<TextHeadingActionList> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
iconColor = theme.iconColorTheme.primary;
|
||||
final child = FlowyIconButton(
|
||||
width: 48,
|
||||
height: 32,
|
||||
@@ -90,13 +91,13 @@ class _TextHeadingActionListState extends State<TextHeadingActionList> {
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_text_format_m,
|
||||
size: Size.square(20),
|
||||
color: themeV2.icon_primary,
|
||||
color: iconColor,
|
||||
),
|
||||
HSpace(4),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
size: Size(12, 20),
|
||||
color: themeV2.icon_tertiary,
|
||||
color: iconColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+4
-4
@@ -8,9 +8,9 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide QuoteBlockComponentBuilder, quoteNode, QuoteBlockKeys;
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -119,8 +119,8 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
iconColor = theme.iconColorTheme.primary;
|
||||
final child = FlowyHover(
|
||||
isSelected: () => isSelected,
|
||||
style: HoverStyle(
|
||||
@@ -163,7 +163,7 @@ class _SuggestionsActionListState extends State<SuggestionsActionList> {
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
size: Size(12, 20),
|
||||
color: themeV2.icon_tertiary,
|
||||
color: iconColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -100,7 +100,6 @@ class _DesktopSyncErrorPage extends StatelessWidget {
|
||||
onTapUp: () {
|
||||
getIt<ClipboardService>().setPlainText(error.toString());
|
||||
showToastNotification(
|
||||
|
||||
message: LocaleKeys.message_copy_success.tr(),
|
||||
bottomPadding: 0,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,10 @@ class WindowSizeManager {
|
||||
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;
|
||||
|
||||
@@ -36,8 +40,8 @@ class WindowSizeManager {
|
||||
Future<Size> getSize() async {
|
||||
final defaultWindowSize = jsonEncode(
|
||||
{
|
||||
WindowSizeManager.height: minWindowHeight,
|
||||
WindowSizeManager.width: minWindowWidth,
|
||||
WindowSizeManager.height: defaultWindowHeight,
|
||||
WindowSizeManager.width: defaultWindowWidth,
|
||||
},
|
||||
);
|
||||
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
|
||||
|
||||
@@ -136,7 +136,6 @@ class AppFlowyCloudDeepLink {
|
||||
final context = AppGlobals.rootNavKey.currentState?.context;
|
||||
if (context != null) {
|
||||
showToastNotification(
|
||||
|
||||
message: err.msg,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,13 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
Emitter<SignInState> emit, {
|
||||
required String email,
|
||||
}) async {
|
||||
if (state.isSubmitting) {
|
||||
Log.error('Sign in with magic link is already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('Sign in with magic link: $email');
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: true,
|
||||
@@ -199,7 +206,9 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
|
||||
emit(
|
||||
result.fold(
|
||||
(userProfile) => state.copyWith(isSubmitting: true),
|
||||
(userProfile) => state.copyWith(
|
||||
isSubmitting: false,
|
||||
),
|
||||
(error) => _stateFromCode(error),
|
||||
),
|
||||
);
|
||||
@@ -210,6 +219,13 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
required String email,
|
||||
required String passcode,
|
||||
}) async {
|
||||
if (state.isSubmitting) {
|
||||
Log.error('Sign in with passcode is already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('Sign in with passcode: $email, $passcode');
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSubmitting: true,
|
||||
@@ -282,8 +298,9 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||
case ErrorCode.UserUnauthorized:
|
||||
final errorMsg = error.msg;
|
||||
String msg = LocaleKeys.signIn_generalError.tr();
|
||||
if (errorMsg.contains('rate limit')) {
|
||||
msg = LocaleKeys.signIn_limitRateError.tr();
|
||||
if (errorMsg.contains('rate limit') ||
|
||||
errorMsg.contains('For security purposes')) {
|
||||
msg = LocaleKeys.signIn_tooFrequentVerificationCodeRequest.tr();
|
||||
} else if (errorMsg.contains('invalid')) {
|
||||
msg = LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr();
|
||||
}
|
||||
|
||||
-2
@@ -17,14 +17,12 @@ void handleOpenWorkspaceError(BuildContext context, FlowyError error) {
|
||||
case ErrorCode.InvalidEncryptSecret:
|
||||
case ErrorCode.NetworkError:
|
||||
showToastNotification(
|
||||
|
||||
message: error.msg,
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
showToastNotification(
|
||||
|
||||
message: error.msg,
|
||||
type: ToastificationType.error,
|
||||
callbacks: ToastificationCallbacks(
|
||||
|
||||
+7
-12
@@ -5,6 +5,7 @@ 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/anonymous_sign_in_button.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';
|
||||
@@ -23,9 +24,9 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
const indicatorMinHeight = 4.0;
|
||||
return BlocBuilder<SignInBloc, SignInState>(
|
||||
builder: (context, state) {
|
||||
final bottomPadding = UniversalPlatform.isDesktop ? 20.0 : 24.0;
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(),
|
||||
body: Center(
|
||||
@@ -41,7 +42,10 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
VSpace(theme.spacing.xxl),
|
||||
|
||||
// continue with email and password
|
||||
const ContinueWithEmailAndPassword(),
|
||||
isLocalAuthEnabled
|
||||
? const SignInAnonymousButtonV3()
|
||||
: const ContinueWithEmailAndPassword(),
|
||||
|
||||
VSpace(theme.spacing.xxl),
|
||||
|
||||
// third-party sign in.
|
||||
@@ -55,15 +59,6 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
// sign in agreement
|
||||
const SignInAgreement(),
|
||||
|
||||
// loading status
|
||||
const VSpace(indicatorMinHeight),
|
||||
state.isSubmitting
|
||||
? const LinearProgressIndicator(
|
||||
minHeight: indicatorMinHeight,
|
||||
)
|
||||
: const VSpace(indicatorMinHeight),
|
||||
const VSpace(20),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// anonymous sign in and settings
|
||||
@@ -75,7 +70,7 @@ class DesktopSignInScreen extends StatelessWidget {
|
||||
SignInAnonymousButtonV2(),
|
||||
],
|
||||
),
|
||||
const VSpace(16),
|
||||
VSpace(bottomPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+18
-37
@@ -7,6 +7,7 @@ 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/user/presentation/widgets/flowy_logo_title.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -21,34 +22,29 @@ class MobileSignInScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double spacing = 16;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return BlocBuilder<SignInBloc, SignInState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 4),
|
||||
_buildLogo(),
|
||||
const VSpace(spacing),
|
||||
_buildAppNameText(colorScheme),
|
||||
const VSpace(spacing * 2),
|
||||
const Spacer(),
|
||||
FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()),
|
||||
VSpace(theme.spacing.xxl),
|
||||
isLocalAuthEnabled
|
||||
? const SignInAnonymousButtonV3()
|
||||
: const ContinueWithEmailAndPassword(),
|
||||
const VSpace(spacing),
|
||||
if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme),
|
||||
const VSpace(spacing * 1.5),
|
||||
VSpace(theme.spacing.xxl),
|
||||
if (isAuthEnabled) ...[
|
||||
_buildThirdPartySignInButtons(context),
|
||||
VSpace(theme.spacing.xxl),
|
||||
],
|
||||
const SignInAgreement(),
|
||||
const VSpace(spacing),
|
||||
if (!isAuthEnabled) const Spacer(flex: 2),
|
||||
const Spacer(flex: 2),
|
||||
const Spacer(),
|
||||
Expanded(child: _buildSettingsButton(context)),
|
||||
if (Platform.isAndroid) const Spacer(),
|
||||
_buildSettingsButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -57,25 +53,8 @@ class MobileSignInScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogo() {
|
||||
return const FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
size: Size.square(56),
|
||||
blendMode: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppNameText(ColorScheme colorScheme) {
|
||||
return FlowyText(
|
||||
LocaleKeys.appName.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
fontSize: 28,
|
||||
color: const Color(0xFF00BCF0),
|
||||
fontWeight: FontWeight.w700,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThirdPartySignInButtons(ColorScheme colorScheme) {
|
||||
Widget _buildThirdPartySignInButtons(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -84,10 +63,12 @@ class MobileSignInScreen extends StatelessWidget {
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FlowyText(
|
||||
child: Text(
|
||||
LocaleKeys.signIn_or.tr(),
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSecondary,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
|
||||
+16
-14
@@ -2,14 +2,13 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy/user/presentation/router.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../helpers/helpers.dart';
|
||||
|
||||
class SignInScreen extends StatelessWidget {
|
||||
const SignInScreen({super.key});
|
||||
|
||||
@@ -22,13 +21,9 @@ class SignInScreen extends StatelessWidget {
|
||||
child: BlocConsumer<SignInBloc, SignInState>(
|
||||
listener: _showSignInError,
|
||||
builder: (context, state) {
|
||||
final isLoading = context.read<SignInBloc>().state.isSubmitting;
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return isLoading
|
||||
? const MobileLoadingScreen()
|
||||
: const MobileSignInScreen();
|
||||
}
|
||||
return const DesktopSignInScreen();
|
||||
return UniversalPlatform.isDesktop
|
||||
? const DesktopSignInScreen()
|
||||
: const MobileSignInScreen();
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -37,10 +32,17 @@ class SignInScreen extends StatelessWidget {
|
||||
void _showSignInError(BuildContext context, SignInState state) {
|
||||
final successOrFail = state.successOrFail;
|
||||
if (successOrFail != null) {
|
||||
handleUserProfileResult(
|
||||
successOrFail,
|
||||
context,
|
||||
getIt<AuthRouter>(),
|
||||
successOrFail.fold(
|
||||
(userProfile) {
|
||||
if (userProfile.encryptionType == EncryptionTypePB.Symmetric) {
|
||||
getIt<AuthRouter>().pushEncryptionScreen(context, userProfile);
|
||||
} else {
|
||||
getIt<AuthRouter>().goHomeScreen(context, userProfile);
|
||||
}
|
||||
},
|
||||
(error) {
|
||||
Log.error('Sign in error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-13
@@ -2,8 +2,8 @@ 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_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -29,7 +29,7 @@ class SignInAnonymousButtonV3 extends StatelessWidget {
|
||||
},
|
||||
child: BlocBuilder<AnonUserBloc, AnonUserState>(
|
||||
builder: (context, state) {
|
||||
final text = LocaleKeys.signUp_getStartedText.tr();
|
||||
final text = LocaleKeys.signIn_continueWithLocalModel.tr();
|
||||
final onTap = state.anonUsers.isEmpty
|
||||
? () {
|
||||
context
|
||||
@@ -41,17 +41,11 @@ class SignInAnonymousButtonV3 extends StatelessWidget {
|
||||
final user = bloc.state.anonUsers.first;
|
||||
bloc.add(AnonUserEvent.openAnonUser(user));
|
||||
};
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 32),
|
||||
maximumSize: const Size(double.infinity, 38),
|
||||
),
|
||||
onPressed: onTap,
|
||||
child: FlowyText(
|
||||
text,
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
return AFFilledTextButton.primary(
|
||||
text: text,
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+92
-48
@@ -2,14 +2,12 @@ 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});
|
||||
@@ -23,6 +21,9 @@ class _ContinueWithEmailAndPasswordState
|
||||
extends State<ContinueWithEmailAndPassword> {
|
||||
final controller = TextEditingController();
|
||||
final focusNode = FocusNode();
|
||||
final emailKey = GlobalKey<AFTextFieldState>();
|
||||
|
||||
bool _hasPushedContinueWithMagicLinkOrPasscodePage = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -36,36 +37,72 @@ class _ContinueWithEmailAndPasswordState
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: UniversalPlatform.isMobile ? 38.0 : 40.0,
|
||||
child: AFTextField(
|
||||
return BlocListener<SignInBloc, SignInState>(
|
||||
listener: (context, state) {
|
||||
final successOrFail = state.successOrFail;
|
||||
// only push the continue with magic link or passcode page if the magic link is sent successfully
|
||||
if (successOrFail != null) {
|
||||
successOrFail.fold(
|
||||
(_) => emailKey.currentState?.clearError(),
|
||||
(error) => emailKey.currentState?.syncError(
|
||||
errorText: error.msg,
|
||||
),
|
||||
);
|
||||
} else if (successOrFail == null && !state.isSubmitting) {
|
||||
emailKey.currentState?.clearError();
|
||||
|
||||
// _pushContinueWithMagicLinkOrPasscodePage(
|
||||
// context,
|
||||
// controller.text,
|
||||
// );
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
AFTextField(
|
||||
key: emailKey,
|
||||
controller: controller,
|
||||
hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(),
|
||||
radius: 10,
|
||||
onSubmitted: (value) => _pushContinueWithMagicLinkOrPasscodePage(
|
||||
onSubmitted: (value) => _signInWithEmail(
|
||||
context,
|
||||
value,
|
||||
),
|
||||
),
|
||||
),
|
||||
VSpace(theme.spacing.l),
|
||||
ContinueWithEmail(
|
||||
onTap: () => _pushContinueWithMagicLinkOrPasscodePage(
|
||||
context,
|
||||
controller.text,
|
||||
VSpace(theme.spacing.l),
|
||||
ContinueWithEmail(
|
||||
onTap: () => _signInWithEmail(
|
||||
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,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
// VSpace(theme.spacing.l),
|
||||
// ContinueWithPassword(
|
||||
// onTap: () => _pushContinueWithPasswordPage(
|
||||
// context,
|
||||
// controller.text,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _signInWithEmail(BuildContext context, String email) {
|
||||
if (!isEmail(email)) {
|
||||
emailKey.currentState?.syncError(
|
||||
errorText: LocaleKeys.signIn_invalidEmail.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context
|
||||
.read<SignInBloc>()
|
||||
.add(SignInEvent.signInWithMagicLink(email: email));
|
||||
|
||||
_pushContinueWithMagicLinkOrPasscodePage(
|
||||
context,
|
||||
email,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,18 +110,12 @@ class _ContinueWithEmailAndPasswordState
|
||||
BuildContext context,
|
||||
String email,
|
||||
) {
|
||||
if (!isEmail(email)) {
|
||||
showToastNotification(
|
||||
message: LocaleKeys.signIn_invalidEmail.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
if (_hasPushedContinueWithMagicLinkOrPasscodePage) {
|
||||
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,
|
||||
@@ -93,17 +124,27 @@ class _ContinueWithEmailAndPasswordState
|
||||
value: signInBloc,
|
||||
child: ContinueWithMagicLinkOrPasscodePage(
|
||||
email: email,
|
||||
backToLogin: () => Navigator.pop(context),
|
||||
onEnterPasscode: (passcode) => signInBloc.add(
|
||||
SignInEvent.signInWithPasscode(
|
||||
email: email,
|
||||
passcode: passcode,
|
||||
),
|
||||
),
|
||||
backToLogin: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
emailKey.currentState?.clearError();
|
||||
|
||||
_hasPushedContinueWithMagicLinkOrPasscodePage = false;
|
||||
},
|
||||
onEnterPasscode: (passcode) {
|
||||
signInBloc.add(
|
||||
SignInEvent.signInWithPasscode(
|
||||
email: email,
|
||||
passcode: passcode,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_hasPushedContinueWithMagicLinkOrPasscodePage = true;
|
||||
}
|
||||
|
||||
// void _pushContinueWithPasswordPage(
|
||||
@@ -114,18 +155,21 @@ class _ContinueWithEmailAndPasswordState
|
||||
// Navigator.push(
|
||||
// context,
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => ContinueWithPasswordPage(
|
||||
// email: email,
|
||||
// backToLogin: () => Navigator.pop(context),
|
||||
// onEnterPassword: (password) => signInBloc.add(
|
||||
// SignInEvent.signInWithEmailAndPassword(
|
||||
// email: email,
|
||||
// password: password,
|
||||
// builder: (context) => BlocProvider.value(
|
||||
// value: signInBloc,
|
||||
// child: ContinueWithPasswordPage(
|
||||
// email: email,
|
||||
// backToLogin: () => Navigator.pop(context),
|
||||
// onEnterPassword: (password) => signInBloc.add(
|
||||
// SignInEvent.signInWithEmailAndPassword(
|
||||
// email: email,
|
||||
// password: password,
|
||||
// ),
|
||||
// ),
|
||||
// onForgotPassword: () {
|
||||
// // todo: implement forgot password
|
||||
// },
|
||||
// ),
|
||||
// onForgotPassword: () {
|
||||
// // todo: implement forgot password
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
|
||||
+94
-54
@@ -33,6 +33,8 @@ class _ContinueWithMagicLinkOrPasscodePageState
|
||||
|
||||
ToastificationItem? toastificationItem;
|
||||
|
||||
final inputPasscodeKey = GlobalKey<AFTextFieldState>();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
passcodeController.dispose();
|
||||
@@ -44,10 +46,13 @@ class _ContinueWithMagicLinkOrPasscodePageState
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<SignInBloc, SignInState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSubmitting) {
|
||||
_showLoadingDialog();
|
||||
} else {
|
||||
_dismissLoadingDialog();
|
||||
final successOrFail = state.successOrFail;
|
||||
if (successOrFail != null && successOrFail.isFailure) {
|
||||
successOrFail.onFailure((error) {
|
||||
inputPasscodeKey.currentState?.syncError(
|
||||
errorText: LocaleKeys.signIn_tokenHasExpiredOrInvalid.tr(),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -91,24 +96,39 @@ class _ContinueWithMagicLinkOrPasscodePageState
|
||||
|
||||
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,
|
||||
),
|
||||
AFTextField(
|
||||
key: inputPasscodeKey,
|
||||
controller: passcodeController,
|
||||
hintText: LocaleKeys.signIn_enterCode.tr(),
|
||||
keyboardType: TextInputType.number,
|
||||
radius: 10,
|
||||
autoFocus: true,
|
||||
onSubmitted: (passcode) {
|
||||
if (passcode.isEmpty) {
|
||||
inputPasscodeKey.currentState?.syncError(
|
||||
errorText: LocaleKeys.signIn_invalidVerificationCode.tr(),
|
||||
);
|
||||
} else {
|
||||
widget.onEnterPasscode(passcode);
|
||||
}
|
||||
},
|
||||
),
|
||||
// todo: ask designer to provide the spacing
|
||||
VSpace(12),
|
||||
|
||||
// continue to login
|
||||
AFFilledTextButton.primary(
|
||||
text: 'Continue to sign in',
|
||||
onTap: () => widget.onEnterPasscode(passcodeController.text),
|
||||
text: LocaleKeys.signIn_continueToSignIn.tr(),
|
||||
onTap: () {
|
||||
final passcode = passcodeController.text;
|
||||
if (passcode.isEmpty) {
|
||||
inputPasscodeKey.currentState?.syncError(
|
||||
errorText: LocaleKeys.signIn_invalidVerificationCode.tr(),
|
||||
);
|
||||
} else {
|
||||
widget.onEnterPasscode(passcode);
|
||||
}
|
||||
},
|
||||
size: AFButtonSize.l,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
@@ -123,6 +143,7 @@ class _ContinueWithMagicLinkOrPasscodePageState
|
||||
text: LocaleKeys.signIn_backToLogin.tr(),
|
||||
size: AFButtonSize.s,
|
||||
onTap: widget.backToLogin,
|
||||
padding: EdgeInsets.zero,
|
||||
textColor: (context, isHovering, disabled) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (isHovering) {
|
||||
@@ -137,51 +158,70 @@ class _ContinueWithMagicLinkOrPasscodePageState
|
||||
List<Widget> _buildLogoTitleAndDescription() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final spacing = VSpace(theme.spacing.xxl);
|
||||
return [
|
||||
// logo
|
||||
const AFLogo(),
|
||||
spacing,
|
||||
if (!isEnteringPasscode) {
|
||||
return [
|
||||
// logo
|
||||
const AFLogo(),
|
||||
spacing,
|
||||
|
||||
// title
|
||||
Text(
|
||||
LocaleKeys.signIn_checkYourEmail.tr(),
|
||||
style: theme.textStyle.heading.h3(
|
||||
color: theme.textColorScheme.primary,
|
||||
// title
|
||||
Text(
|
||||
LocaleKeys.signIn_checkYourEmail.tr(),
|
||||
style: theme.textStyle.heading.h3(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
spacing,
|
||||
spacing,
|
||||
|
||||
// description
|
||||
Text(
|
||||
LocaleKeys.signIn_temporaryVerificationSent.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
// description
|
||||
Text(
|
||||
LocaleKeys.signIn_temporaryVerificationLinkSent.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
widget.email,
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.primary,
|
||||
Text(
|
||||
widget.email,
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
spacing,
|
||||
];
|
||||
}
|
||||
spacing,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
// logo
|
||||
const AFLogo(),
|
||||
spacing,
|
||||
|
||||
void _showLoadingDialog() {
|
||||
_dismissLoadingDialog();
|
||||
// title
|
||||
Text(
|
||||
LocaleKeys.signIn_enterCode.tr(),
|
||||
style: theme.textStyle.heading.h3(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
spacing,
|
||||
|
||||
toastificationItem = showToastNotification(
|
||||
message: LocaleKeys.signIn_signingIn.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
void _dismissLoadingDialog() {
|
||||
final toastificationItem = this.toastificationItem;
|
||||
if (toastificationItem != null) {
|
||||
toastification.dismiss(toastificationItem);
|
||||
// description
|
||||
Text(
|
||||
LocaleKeys.signIn_temporaryVerificationCodeSent.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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-21
@@ -1,8 +1,10 @@
|
||||
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_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';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ContinueWithPasswordPage extends StatefulWidget {
|
||||
const ContinueWithPasswordPage({
|
||||
@@ -25,6 +27,7 @@ class ContinueWithPasswordPage extends StatefulWidget {
|
||||
|
||||
class _ContinueWithPasswordPageState extends State<ContinueWithPasswordPage> {
|
||||
final passwordController = TextEditingController();
|
||||
final inputPasswordKey = GlobalKey<AFTextFieldState>();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -38,18 +41,29 @@ class _ContinueWithPasswordPageState extends State<ContinueWithPasswordPage> {
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo and title
|
||||
..._buildLogoAndTitle(),
|
||||
child: BlocListener<SignInBloc, SignInState>(
|
||||
listener: (context, state) {
|
||||
if (state.passwordError != null) {
|
||||
inputPasswordKey.currentState?.syncError(
|
||||
errorText: 'Incorrect password. Please try again.',
|
||||
);
|
||||
} else {
|
||||
inputPasswordKey.currentState?.clearError();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo and title
|
||||
..._buildLogoAndTitle(),
|
||||
|
||||
// Password input and buttons
|
||||
..._buildPasswordSection(),
|
||||
// Password input and buttons
|
||||
..._buildPasswordSection(),
|
||||
|
||||
// Back to login
|
||||
..._buildBackToLogin(),
|
||||
],
|
||||
// Back to login
|
||||
..._buildBackToLogin(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -100,25 +114,33 @@ class _ContinueWithPasswordPageState extends State<ContinueWithPasswordPage> {
|
||||
return [
|
||||
// Password input
|
||||
AFTextField(
|
||||
key: inputPasswordKey,
|
||||
controller: passwordController,
|
||||
hintText: 'Enter password',
|
||||
autoFocus: true,
|
||||
onSubmitted: widget.onEnterPassword,
|
||||
),
|
||||
// todo: ask designer to provide the spacing
|
||||
VSpace(12),
|
||||
VSpace(8),
|
||||
|
||||
// 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),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AFGhostTextButton(
|
||||
text: 'Forget password?',
|
||||
size: AFButtonSize.s,
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: widget.onForgotPassword,
|
||||
textColor: (context, isHovering, disabled) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.themeThickHover;
|
||||
}
|
||||
return theme.textColorScheme.theme;
|
||||
},
|
||||
),
|
||||
),
|
||||
VSpace(20),
|
||||
|
||||
// Continue button
|
||||
AFFilledTextButton.primary(
|
||||
@@ -137,8 +159,12 @@ class _ContinueWithPasswordPageState extends State<ContinueWithPasswordPage> {
|
||||
text: 'Back to Login',
|
||||
size: AFButtonSize.s,
|
||||
onTap: widget.backToLogin,
|
||||
padding: EdgeInsets.zero,
|
||||
textColor: (context, isHovering, disabled) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.themeThickHover;
|
||||
}
|
||||
return theme.textColorScheme.theme;
|
||||
},
|
||||
),
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ class AFLogo extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
FlowySvgs.app_logo_xl,
|
||||
blendMode: null,
|
||||
size: size,
|
||||
);
|
||||
|
||||
+1
-4
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -25,9 +24,7 @@ class SignInAgreement extends StatelessWidget {
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: isLocalAuthEnabled
|
||||
? '${LocaleKeys.web_signInLocalAgreement.tr()} \n'
|
||||
: '${LocaleKeys.web_signInAgreement.tr()} \n',
|
||||
text: LocaleKeys.web_signInAgreement.tr(),
|
||||
style: textStyle,
|
||||
),
|
||||
TextSpan(
|
||||
|
||||
+17
-8
@@ -6,7 +6,6 @@ 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_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -78,13 +77,16 @@ class ChangeCloudModeButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
'Cloud',
|
||||
decoration: TextDecoration.underline,
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return AFGhostIconTextButton(
|
||||
text: LocaleKeys.signIn_switchToAppFlowyCloud.tr(),
|
||||
textColor: (context, isHovering, disabled) {
|
||||
return theme.textColorScheme.secondary;
|
||||
},
|
||||
size: AFButtonSize.s,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
vertical: theme.spacing.xs,
|
||||
),
|
||||
onTap: () async {
|
||||
await useAppFlowyBetaCloudWithURL(
|
||||
@@ -93,6 +95,13 @@ class ChangeCloudModeButton extends StatelessWidget {
|
||||
);
|
||||
await runAppFlowy();
|
||||
},
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.cloud_mode_m,
|
||||
size: Size.square(20),
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -101,6 +101,7 @@ class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> {
|
||||
VSpace(theme.spacing.l),
|
||||
AFGhostTextButton(
|
||||
text: 'More options',
|
||||
padding: EdgeInsets.zero,
|
||||
textColor: (context, isHovering, disabled) {
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.themeThickHover;
|
||||
|
||||
@@ -115,7 +115,7 @@ class Body extends StatelessWidget {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
child: UniversalPlatform.isMobile
|
||||
? const FlowySvg(FlowySvgs.flowy_logo_xl, blendMode: null)
|
||||
? const FlowySvg(FlowySvgs.app_logo_xl, blendMode: null)
|
||||
: const _DesktopSplashBody(),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ class _MobileWorkspaceStartScreenState
|
||||
children: [
|
||||
const Spacer(),
|
||||
const FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
FlowySvgs.app_logo_xl,
|
||||
size: Size.square(64),
|
||||
blendMode: null,
|
||||
),
|
||||
|
||||
@@ -25,7 +25,6 @@ Future<void> shareLogFiles(BuildContext? context) async {
|
||||
if (archiveLogFiles.isEmpty) {
|
||||
if (context != null && context.mounted) {
|
||||
showToastNotification(
|
||||
|
||||
message: LocaleKeys.noLogFiles.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
@@ -42,7 +41,6 @@ Future<void> shareLogFiles(BuildContext? context) async {
|
||||
if (zip == null) {
|
||||
if (context != null && context.mounted) {
|
||||
showToastNotification(
|
||||
|
||||
message: LocaleKeys.noLogFiles.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
@@ -72,7 +70,6 @@ Future<void> shareLogFiles(BuildContext? context) async {
|
||||
} catch (e) {
|
||||
if (context != null && context.mounted) {
|
||||
showToastNotification(
|
||||
|
||||
message: e.toString(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
|
||||
+277
-125
@@ -2,10 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:appflowy/plugins/trash/application/trash_listener.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_listener.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -13,184 +11,338 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'command_palette_bloc.freezed.dart';
|
||||
|
||||
const _searchChannel = 'CommandPalette';
|
||||
class Debouncer {
|
||||
Debouncer({required this.delay});
|
||||
|
||||
final Duration delay;
|
||||
Timer? _timer;
|
||||
|
||||
void run(void Function() action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(delay, action);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class CommandPaletteBloc
|
||||
extends Bloc<CommandPaletteEvent, CommandPaletteState> {
|
||||
CommandPaletteBloc() : super(CommandPaletteState.initial()) {
|
||||
_searchListener.start(
|
||||
onResultsChanged: _onResultsChanged,
|
||||
);
|
||||
on<_SearchChanged>(_onSearchChanged);
|
||||
on<_PerformSearch>(_onPerformSearch);
|
||||
on<_NewSearchStream>(_onNewSearchStream);
|
||||
on<_ResultsChanged>(_onResultsChanged);
|
||||
on<_TrashChanged>(_onTrashChanged);
|
||||
on<_WorkspaceChanged>(_onWorkspaceChanged);
|
||||
on<_ClearSearch>(_onClearSearch);
|
||||
|
||||
_initTrash();
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
Timer? _debounceOnChanged;
|
||||
final TrashService _trashService = TrashService();
|
||||
final SearchListener _searchListener = SearchListener(
|
||||
channel: _searchChannel,
|
||||
final Debouncer _searchDebouncer = Debouncer(
|
||||
delay: const Duration(milliseconds: 300),
|
||||
);
|
||||
final TrashService _trashService = TrashService();
|
||||
final TrashListener _trashListener = TrashListener();
|
||||
String? _oldQuery;
|
||||
String? _activeQuery;
|
||||
String? _workspaceId;
|
||||
int _messagesReceived = 0;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_trashListener.close();
|
||||
_searchListener.stop();
|
||||
_debounceOnChanged?.cancel();
|
||||
_searchDebouncer.dispose();
|
||||
state.searchResponseStream?.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<CommandPaletteEvent>((event, emit) async {
|
||||
event.when(
|
||||
searchChanged: _debounceOnSearchChanged,
|
||||
trashChanged: (trash) async {
|
||||
if (trash != null) {
|
||||
return emit(state.copyWith(trash: trash));
|
||||
}
|
||||
|
||||
final trashOrFailure = await _trashService.readTrash();
|
||||
final trashRes = trashOrFailure.fold(
|
||||
(trash) => trash,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
if (trashRes != null) {
|
||||
emit(state.copyWith(trash: trashRes.items));
|
||||
}
|
||||
},
|
||||
performSearch: (search) async {
|
||||
if (search.isNotEmpty && search != state.query) {
|
||||
_oldQuery = state.query;
|
||||
emit(state.copyWith(query: search, isLoading: true));
|
||||
await SearchBackendService.performSearch(
|
||||
search,
|
||||
workspaceId: _workspaceId,
|
||||
channel: _searchChannel,
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWith(query: null, isLoading: false, results: []));
|
||||
}
|
||||
},
|
||||
resultsChanged: (results) {
|
||||
if (state.query != _oldQuery) {
|
||||
emit(state.copyWith(results: [], isLoading: true));
|
||||
_oldQuery = state.query;
|
||||
_messagesReceived = 0;
|
||||
}
|
||||
|
||||
if (state.query != results.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
_messagesReceived++;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
results: _filterDuplicates(results.items),
|
||||
isLoading: _messagesReceived != results.sends.toInt(),
|
||||
),
|
||||
);
|
||||
},
|
||||
workspaceChanged: (workspaceId) {
|
||||
_workspaceId = workspaceId;
|
||||
emit(state.copyWith(results: [], query: '', isLoading: false));
|
||||
},
|
||||
clearSearch: () {
|
||||
emit(state.copyWith(results: [], query: '', isLoading: false));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initTrash() async {
|
||||
_trashListener.start(
|
||||
trashUpdated: (trashOrFailed) {
|
||||
final trash = trashOrFailed.toNullable();
|
||||
add(CommandPaletteEvent.trashChanged(trash: trash));
|
||||
},
|
||||
trashUpdated: (trashOrFailed) => add(
|
||||
CommandPaletteEvent.trashChanged(
|
||||
trash: trashOrFailed.toNullable(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final trashOrFailure = await _trashService.readTrash();
|
||||
final trash = trashOrFailure.toNullable();
|
||||
|
||||
add(CommandPaletteEvent.trashChanged(trash: trash?.items));
|
||||
}
|
||||
|
||||
void _debounceOnSearchChanged(String value) {
|
||||
_debounceOnChanged?.cancel();
|
||||
_debounceOnChanged = Timer(
|
||||
const Duration(milliseconds: 300),
|
||||
() => _performSearch(value),
|
||||
trashOrFailure.fold(
|
||||
(trash) => add(CommandPaletteEvent.trashChanged(trash: trash.items)),
|
||||
(error) => debugPrint('Failed to load trash: $error'),
|
||||
);
|
||||
}
|
||||
|
||||
List<SearchResultPB> _filterDuplicates(List<SearchResultPB> results) {
|
||||
final currentItems = [...state.results];
|
||||
final res = [...results];
|
||||
|
||||
for (final item in results) {
|
||||
final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id);
|
||||
if (duplicateIndex == -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final duplicate = currentItems[duplicateIndex];
|
||||
if (item.score < duplicate.score) {
|
||||
res.remove(item);
|
||||
} else {
|
||||
currentItems.remove(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
return res..addAll(currentItems);
|
||||
FutureOr<void> _onSearchChanged(
|
||||
_SearchChanged event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
_searchDebouncer.run(
|
||||
() {
|
||||
if (!isClosed) {
|
||||
add(CommandPaletteEvent.performSearch(search: event.search));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _performSearch(String value) =>
|
||||
add(CommandPaletteEvent.performSearch(search: value));
|
||||
FutureOr<void> _onPerformSearch(
|
||||
_PerformSearch event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) async {
|
||||
if (event.search.isEmpty && event.search != state.query) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: null,
|
||||
searching: false,
|
||||
serverResponseItems: [],
|
||||
localResponseItems: [],
|
||||
combinedResponseItems: {},
|
||||
resultSummaries: [],
|
||||
generatingAIOverview: false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWith(query: event.search, searching: true));
|
||||
_activeQuery = event.search;
|
||||
|
||||
void _onResultsChanged(SearchResultNotificationPB results) =>
|
||||
add(CommandPaletteEvent.resultsChanged(results: results));
|
||||
unawaited(
|
||||
SearchBackendService.performSearch(
|
||||
event.search,
|
||||
workspaceId: _workspaceId,
|
||||
).then(
|
||||
(result) => result.fold(
|
||||
(stream) {
|
||||
if (!isClosed && _activeQuery == event.search) {
|
||||
add(CommandPaletteEvent.newSearchStream(stream: stream));
|
||||
}
|
||||
},
|
||||
(error) {
|
||||
debugPrint('Search error: $error');
|
||||
if (!isClosed) {
|
||||
add(
|
||||
CommandPaletteEvent.resultsChanged(
|
||||
searchId: '',
|
||||
searching: false,
|
||||
generatingAIOverview: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onNewSearchStream(
|
||||
_NewSearchStream event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
state.searchResponseStream?.dispose();
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchId: event.stream.searchId,
|
||||
searchResponseStream: event.stream,
|
||||
),
|
||||
);
|
||||
|
||||
event.stream.listen(
|
||||
onLocalItems: (items, searchId) => _handleResultsUpdate(
|
||||
searchId: searchId,
|
||||
localItems: items,
|
||||
),
|
||||
onServerItems: (items, searchId, searching, generatingAIOverview) =>
|
||||
_handleResultsUpdate(
|
||||
searchId: searchId,
|
||||
serverItems: items,
|
||||
searching: searching,
|
||||
generatingAIOverview: generatingAIOverview,
|
||||
),
|
||||
onSummaries: (summaries, searchId, searching, generatingAIOverview) =>
|
||||
_handleResultsUpdate(
|
||||
searchId: searchId,
|
||||
summaries: summaries,
|
||||
searching: searching,
|
||||
generatingAIOverview: generatingAIOverview,
|
||||
),
|
||||
onFinished: (searchId) => _handleResultsUpdate(
|
||||
searchId: searchId,
|
||||
searching: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleResultsUpdate({
|
||||
required String searchId,
|
||||
List<SearchResponseItemPB>? serverItems,
|
||||
List<LocalSearchResponseItemPB>? localItems,
|
||||
List<SearchSummaryPB>? summaries,
|
||||
bool searching = true,
|
||||
bool generatingAIOverview = false,
|
||||
}) {
|
||||
if (_isActiveSearch(searchId)) {
|
||||
add(
|
||||
CommandPaletteEvent.resultsChanged(
|
||||
searchId: searchId,
|
||||
serverItems: serverItems,
|
||||
localItems: localItems,
|
||||
summaries: summaries,
|
||||
searching: searching,
|
||||
generatingAIOverview: generatingAIOverview,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onResultsChanged(
|
||||
_ResultsChanged event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) async {
|
||||
if (state.searchId != event.searchId) return;
|
||||
|
||||
final combinedItems = <String, SearchResultItem>{};
|
||||
for (final item in event.serverItems ?? state.serverResponseItems) {
|
||||
combinedItems[item.id] = SearchResultItem(
|
||||
id: item.id,
|
||||
icon: item.icon,
|
||||
displayName: item.displayName,
|
||||
content: item.content,
|
||||
workspaceId: item.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
for (final item in event.localItems ?? state.localResponseItems) {
|
||||
combinedItems.putIfAbsent(
|
||||
item.id,
|
||||
() => SearchResultItem(
|
||||
id: item.id,
|
||||
icon: item.icon,
|
||||
displayName: item.displayName,
|
||||
content: '',
|
||||
workspaceId: item.workspaceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
serverResponseItems: event.serverItems ?? state.serverResponseItems,
|
||||
localResponseItems: event.localItems ?? state.localResponseItems,
|
||||
resultSummaries: event.summaries ?? state.resultSummaries,
|
||||
combinedResponseItems: combinedItems,
|
||||
searching: event.searching,
|
||||
generatingAIOverview: event.generatingAIOverview,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FutureOr<void> _onTrashChanged(
|
||||
_TrashChanged event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) async {
|
||||
if (event.trash != null) {
|
||||
emit(state.copyWith(trash: event.trash!));
|
||||
} else {
|
||||
final trashOrFailure = await _trashService.readTrash();
|
||||
trashOrFailure.fold((trash) {
|
||||
emit(state.copyWith(trash: trash.items));
|
||||
}, (error) {
|
||||
// Optionally handle error; otherwise, we simply do nothing.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onWorkspaceChanged(
|
||||
_WorkspaceChanged event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
_workspaceId = event.workspaceId;
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: '',
|
||||
serverResponseItems: [],
|
||||
localResponseItems: [],
|
||||
combinedResponseItems: {},
|
||||
resultSummaries: [],
|
||||
searching: false,
|
||||
generatingAIOverview: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FutureOr<void> _onClearSearch(
|
||||
_ClearSearch event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
emit(CommandPaletteState.initial().copyWith(trash: state.trash));
|
||||
}
|
||||
|
||||
bool _isActiveSearch(String searchId) =>
|
||||
!isClosed && state.searchId == searchId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class CommandPaletteEvent with _$CommandPaletteEvent {
|
||||
const factory CommandPaletteEvent.searchChanged({required String search}) =
|
||||
_SearchChanged;
|
||||
|
||||
const factory CommandPaletteEvent.performSearch({required String search}) =
|
||||
_PerformSearch;
|
||||
|
||||
const factory CommandPaletteEvent.newSearchStream({
|
||||
required SearchResponseStream stream,
|
||||
}) = _NewSearchStream;
|
||||
const factory CommandPaletteEvent.resultsChanged({
|
||||
required SearchResultNotificationPB results,
|
||||
required String searchId,
|
||||
required bool searching,
|
||||
required bool generatingAIOverview,
|
||||
List<SearchResponseItemPB>? serverItems,
|
||||
List<LocalSearchResponseItemPB>? localItems,
|
||||
List<SearchSummaryPB>? summaries,
|
||||
}) = _ResultsChanged;
|
||||
|
||||
const factory CommandPaletteEvent.trashChanged({
|
||||
@Default(null) List<TrashPB>? trash,
|
||||
}) = _TrashChanged;
|
||||
|
||||
const factory CommandPaletteEvent.workspaceChanged({
|
||||
@Default(null) String? workspaceId,
|
||||
}) = _WorkspaceChanged;
|
||||
|
||||
const factory CommandPaletteEvent.clearSearch() = _ClearSearch;
|
||||
}
|
||||
|
||||
class SearchResultItem {
|
||||
const SearchResultItem({
|
||||
required this.id,
|
||||
required this.icon,
|
||||
required this.content,
|
||||
required this.displayName,
|
||||
this.workspaceId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String content;
|
||||
final ResultIconPB icon;
|
||||
final String displayName;
|
||||
final String? workspaceId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class CommandPaletteState with _$CommandPaletteState {
|
||||
const CommandPaletteState._();
|
||||
|
||||
const factory CommandPaletteState({
|
||||
@Default(null) String? query,
|
||||
required List<SearchResultPB> results,
|
||||
required bool isLoading,
|
||||
@Default([]) List<SearchResponseItemPB> serverResponseItems,
|
||||
@Default([]) List<LocalSearchResponseItemPB> localResponseItems,
|
||||
@Default({}) Map<String, SearchResultItem> combinedResponseItems,
|
||||
@Default([]) List<SearchSummaryPB> resultSummaries,
|
||||
@Default(null) SearchResponseStream? searchResponseStream,
|
||||
required bool searching,
|
||||
required bool generatingAIOverview,
|
||||
@Default([]) List<TrashPB> trash,
|
||||
@Default(null) String? searchId,
|
||||
}) = _CommandPaletteState;
|
||||
|
||||
factory CommandPaletteState.initial() =>
|
||||
const CommandPaletteState(results: [], isLoading: false);
|
||||
factory CommandPaletteState.initial() => const CommandPaletteState(
|
||||
searching: false,
|
||||
generatingAIOverview: false,
|
||||
);
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/core/notification/search_notification.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flowy_infra/notifier.dart';
|
||||
|
||||
// Do not modify!
|
||||
const _searchObjectId = "SEARCH_IDENTIFIER";
|
||||
|
||||
class SearchListener {
|
||||
SearchListener({this.channel});
|
||||
|
||||
/// Use this to filter out search results from other channels.
|
||||
///
|
||||
/// If null, it will receive search results from all
|
||||
/// channels, otherwise it will only receive search results from the specified
|
||||
/// channel.
|
||||
///
|
||||
final String? channel;
|
||||
|
||||
PublishNotifier<SearchResultNotificationPB>? _updateNotifier =
|
||||
PublishNotifier();
|
||||
PublishNotifier<SearchResultNotificationPB>? _updateDidCloseNotifier =
|
||||
PublishNotifier();
|
||||
SearchNotificationListener? _listener;
|
||||
|
||||
void start({
|
||||
void Function(SearchResultNotificationPB)? onResultsChanged,
|
||||
void Function(SearchResultNotificationPB)? onResultsClosed,
|
||||
}) {
|
||||
if (onResultsChanged != null) {
|
||||
_updateNotifier?.addPublishListener(onResultsChanged);
|
||||
}
|
||||
|
||||
if (onResultsClosed != null) {
|
||||
_updateDidCloseNotifier?.addPublishListener(onResultsClosed);
|
||||
}
|
||||
|
||||
_listener = SearchNotificationListener(
|
||||
objectId: _searchObjectId,
|
||||
handler: _handler,
|
||||
channel: channel,
|
||||
);
|
||||
}
|
||||
|
||||
void _handler(
|
||||
SearchNotification ty,
|
||||
FlowyResult<Uint8List, FlowyError> result,
|
||||
) {
|
||||
switch (ty) {
|
||||
case SearchNotification.DidUpdateResults:
|
||||
result.fold(
|
||||
(payload) => _updateNotifier?.value =
|
||||
SearchResultNotificationPB.fromBuffer(payload),
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _listener?.stop();
|
||||
_updateNotifier?.dispose();
|
||||
_updateNotifier = null;
|
||||
_updateDidCloseNotifier?.dispose();
|
||||
_updateDidCloseNotifier = null;
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -5,19 +5,19 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension GetIcon on SearchResultPB {
|
||||
extension GetIcon on ResultIconPB {
|
||||
Widget? getIcon() {
|
||||
final iconValue = icon.value, iconType = icon.ty;
|
||||
final iconValue = value, iconType = ty;
|
||||
if (iconType == ResultIconTypePB.Emoji) {
|
||||
return iconValue.isNotEmpty
|
||||
? FlowyText.emoji(iconValue, fontSize: 18)
|
||||
: null;
|
||||
} else if (icon.ty == ResultIconTypePB.Icon) {
|
||||
} else if (ty == ResultIconTypePB.Icon) {
|
||||
if (_resultIconValueTypes.contains(iconValue)) {
|
||||
return FlowySvg(icon.getViewSvg(), size: const Size.square(18));
|
||||
return FlowySvg(getViewSvg(), size: const Size.square(18));
|
||||
}
|
||||
return RawEmojiIconWidget(
|
||||
emoji: EmojiIconData(iconType.toFlowyIconType(), icon.value),
|
||||
emoji: EmojiIconData(iconType.toFlowyIconType(), value),
|
||||
emojiSize: 18,
|
||||
);
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'search_result_list_bloc.freezed.dart';
|
||||
|
||||
class SearchResultListBloc
|
||||
extends Bloc<SearchResultListEvent, SearchResultListState> {
|
||||
SearchResultListBloc() : super(SearchResultListState.initial()) {
|
||||
// Register event handlers
|
||||
on<_OnHoverSummary>(_onHoverSummary);
|
||||
on<_OnHoverResult>(_onHoverResult);
|
||||
on<_OpenPage>(_onOpenPage);
|
||||
}
|
||||
|
||||
FutureOr<void> _onHoverSummary(
|
||||
_OnHoverSummary event,
|
||||
Emitter<SearchResultListState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
hoveredSummary: event.summary,
|
||||
hoveredResult: null,
|
||||
userHovered: event.userHovered,
|
||||
openPageId: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FutureOr<void> _onHoverResult(
|
||||
_OnHoverResult event,
|
||||
Emitter<SearchResultListState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
hoveredSummary: null,
|
||||
hoveredResult: event.item,
|
||||
userHovered: event.userHovered,
|
||||
openPageId: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
FutureOr<void> _onOpenPage(
|
||||
_OpenPage event,
|
||||
Emitter<SearchResultListState> emit,
|
||||
) {
|
||||
emit(state.copyWith(openPageId: event.pageId));
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SearchResultListEvent with _$SearchResultListEvent {
|
||||
const factory SearchResultListEvent.onHoverSummary({
|
||||
required SearchSummaryPB summary,
|
||||
required bool userHovered,
|
||||
}) = _OnHoverSummary;
|
||||
const factory SearchResultListEvent.onHoverResult({
|
||||
required SearchResultItem item,
|
||||
required bool userHovered,
|
||||
}) = _OnHoverResult;
|
||||
|
||||
const factory SearchResultListEvent.openPage({
|
||||
required String pageId,
|
||||
}) = _OpenPage;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SearchResultListState with _$SearchResultListState {
|
||||
const SearchResultListState._();
|
||||
const factory SearchResultListState({
|
||||
@Default(null) SearchSummaryPB? hoveredSummary,
|
||||
@Default(null) SearchResultItem? hoveredResult,
|
||||
@Default(null) String? openPageId,
|
||||
@Default(false) bool userHovered,
|
||||
}) = _SearchResultListState;
|
||||
|
||||
factory SearchResultListState.initial() => const SearchResultListState();
|
||||
}
|
||||
+113
-4
@@ -1,22 +1,131 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
|
||||
class SearchBackendService {
|
||||
static Future<FlowyResult<void, FlowyError>> performSearch(
|
||||
static Future<FlowyResult<SearchResponseStream, FlowyError>> performSearch(
|
||||
String keyword, {
|
||||
String? workspaceId,
|
||||
String? channel,
|
||||
}) async {
|
||||
final searchId = nanoid(6);
|
||||
final stream = SearchResponseStream(searchId: searchId);
|
||||
|
||||
final filter = SearchFilterPB(workspaceId: workspaceId);
|
||||
final request = SearchQueryPB(
|
||||
search: keyword,
|
||||
filter: filter,
|
||||
channel: channel,
|
||||
searchId: searchId,
|
||||
streamPort: Int64(stream.nativePort),
|
||||
);
|
||||
|
||||
return SearchEventSearch(request).send();
|
||||
unawaited(SearchEventSearch(request).send());
|
||||
return FlowyResult.success(stream);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResponseStream {
|
||||
SearchResponseStream({required this.searchId}) {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(Uint8List data) => _onResultsChanged(data),
|
||||
);
|
||||
}
|
||||
|
||||
final String searchId;
|
||||
final RawReceivePort _port = RawReceivePort();
|
||||
final StreamController<Uint8List> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<Uint8List> _subscription;
|
||||
void Function(
|
||||
List<SearchResponseItemPB> items,
|
||||
String searchId,
|
||||
bool searching,
|
||||
bool generatingAIOverview,
|
||||
)? _onServerItems;
|
||||
void Function(
|
||||
List<SearchSummaryPB> summaries,
|
||||
String searchId,
|
||||
bool searching,
|
||||
bool generatingAIOverview,
|
||||
)? _onSummaries;
|
||||
|
||||
void Function(
|
||||
List<LocalSearchResponseItemPB> items,
|
||||
String searchId,
|
||||
)? _onLocalItems;
|
||||
|
||||
void Function(String searchId)? _onFinished;
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _subscription.cancel();
|
||||
_port.close();
|
||||
}
|
||||
|
||||
void _onResultsChanged(Uint8List data) {
|
||||
final searchState = SearchStatePB.fromBuffer(data);
|
||||
|
||||
if (searchState.hasResponse()) {
|
||||
if (searchState.response.hasSearchResult()) {
|
||||
_onServerItems?.call(
|
||||
searchState.response.searchResult.items,
|
||||
searchId,
|
||||
searchState.response.searching,
|
||||
searchState.response.generatingAiSummary,
|
||||
);
|
||||
}
|
||||
if (searchState.response.hasSearchSummary()) {
|
||||
_onSummaries?.call(
|
||||
searchState.response.searchSummary.items,
|
||||
searchId,
|
||||
searchState.response.searching,
|
||||
searchState.response.generatingAiSummary,
|
||||
);
|
||||
}
|
||||
|
||||
if (searchState.response.hasLocalSearchResult()) {
|
||||
_onLocalItems?.call(
|
||||
searchState.response.localSearchResult.items,
|
||||
searchId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_onFinished?.call(searchId);
|
||||
}
|
||||
}
|
||||
|
||||
void listen({
|
||||
required void Function(
|
||||
List<SearchResponseItemPB> items,
|
||||
String searchId,
|
||||
bool isLoading,
|
||||
bool generatingAIOverview,
|
||||
)? onServerItems,
|
||||
required void Function(
|
||||
List<SearchSummaryPB> summaries,
|
||||
String searchId,
|
||||
bool isLoading,
|
||||
bool generatingAIOverview,
|
||||
)? onSummaries,
|
||||
required void Function(
|
||||
List<LocalSearchResponseItemPB> items,
|
||||
String searchId,
|
||||
)? onLocalItems,
|
||||
required void Function(String searchId)? onFinished,
|
||||
}) {
|
||||
_onServerItems = onServerItems;
|
||||
_onSummaries = onSummaries;
|
||||
_onLocalItems = onLocalItems;
|
||||
_onFinished = onFinished;
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,10 @@ class SidebarSectionsBloc
|
||||
}
|
||||
|
||||
void _initial(UserProfilePB userProfile, String workspaceId) {
|
||||
_workspaceService = WorkspaceService(workspaceId: workspaceId);
|
||||
_workspaceService = WorkspaceService(
|
||||
workspaceId: workspaceId,
|
||||
userId: userProfile.id,
|
||||
);
|
||||
|
||||
_listener = WorkspaceSectionsListener(
|
||||
user: userProfile,
|
||||
|
||||
+11
-3
@@ -1,8 +1,12 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
|
||||
/// The app name used in the local notification.
|
||||
///
|
||||
/// DO NOT Use i18n here, because the i18n plugin is not ready
|
||||
/// before the local notification is initialized.
|
||||
const _localNotifierAppName = 'AppFlowy';
|
||||
|
||||
/// Manages Local Notifications
|
||||
///
|
||||
/// Currently supports:
|
||||
@@ -12,7 +16,11 @@ import 'package:local_notifier/local_notifier.dart';
|
||||
///
|
||||
class NotificationService {
|
||||
static Future<void> initialize() async {
|
||||
await localNotifier.setup(appName: LocaleKeys.appName.tr());
|
||||
await localNotifier.setup(
|
||||
appName: _localNotifierAppName,
|
||||
// Don't create a shortcut on Windows, because the setup.exe will create a shortcut
|
||||
shortcutPolicy: ShortcutPolicy.requireNoCreate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,21 +125,4 @@ class LocalAiPluginState with _$LocalAiPluginState {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -2,7 +2,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 DesktopAppearance extends BaseAppearance {
|
||||
@@ -15,6 +14,8 @@ class DesktopAppearance extends BaseAppearance {
|
||||
) {
|
||||
assert(codeFontFamily.isNotEmpty);
|
||||
|
||||
fontFamily = fontFamily.isEmpty ? defaultFontFamily : fontFamily;
|
||||
|
||||
final isLight = brightness == Brightness.light;
|
||||
final theme = isLight ? appTheme.lightTheme : appTheme.darkTheme;
|
||||
|
||||
@@ -152,11 +153,6 @@ class DesktopAppearance extends BaseAppearance {
|
||||
lightIconColor: theme.lightIconColor,
|
||||
toolbarHoverColor: theme.toolbarHoverColor,
|
||||
),
|
||||
isLight
|
||||
? lightAFThemeV2
|
||||
: darkAFThemeV2.copyWith(
|
||||
icon_primary: theme.icon,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
-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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ class SettingsBillingBloc
|
||||
required Int64 userId,
|
||||
}) : super(const _Initial()) {
|
||||
_userService = UserBackendService(userId: userId);
|
||||
_service = WorkspaceService(workspaceId: workspaceId);
|
||||
_service = WorkspaceService(workspaceId: workspaceId, userId: userId);
|
||||
_successListenable = getIt<SubscriptionSuccessListenable>();
|
||||
_successListenable.addListener(_onPaymentSuccessful);
|
||||
|
||||
|
||||
+5
-2
@@ -23,7 +23,10 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
required this.workspaceId,
|
||||
required Int64 userId,
|
||||
}) : super(const _Initial()) {
|
||||
_service = WorkspaceService(workspaceId: workspaceId);
|
||||
_service = WorkspaceService(
|
||||
workspaceId: workspaceId,
|
||||
userId: userId,
|
||||
);
|
||||
_userService = UserBackendService(userId: userId);
|
||||
_successListenable = getIt<SubscriptionSuccessListenable>();
|
||||
_successListenable.addListener(_onPaymentSuccessful);
|
||||
@@ -43,7 +46,7 @@ class SettingsPlanBloc extends Bloc<SettingsPlanEvent, SettingsPlanState> {
|
||||
FlowyError? error;
|
||||
|
||||
final usageResult = snapshots.first.fold(
|
||||
(s) => s as WorkspaceUsagePB,
|
||||
(s) => s as WorkspaceUsagePB?,
|
||||
(f) {
|
||||
error = f;
|
||||
return null;
|
||||
|
||||
+18
-13
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart';
|
||||
import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart';
|
||||
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/dispatch/error.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -182,20 +183,24 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _checkWorkspaceUsage() {
|
||||
if (state.workspaceId != null) {
|
||||
final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!);
|
||||
UserEventGetWorkspaceUsage(payload).send().then((result) {
|
||||
result.onSuccess(
|
||||
(usage) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
|
||||
},
|
||||
);
|
||||
});
|
||||
Future<void> _checkWorkspaceUsage() async {
|
||||
if (state.workspaceId == null || state.userProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await WorkspaceService(
|
||||
workspaceId: state.workspaceId!,
|
||||
userId: state.userProfile!.id,
|
||||
).getWorkspaceUsage().then((result) {
|
||||
result.fold(
|
||||
(usage) {
|
||||
if (!isClosed && usage != null) {
|
||||
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
|
||||
}
|
||||
},
|
||||
(error) => Log.error("Failed to get workspace usage: $error"),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -486,7 +486,10 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
|
||||
}
|
||||
|
||||
void _initial(UserProfilePB userProfile, String workspaceId) {
|
||||
_workspaceService = WorkspaceService(workspaceId: workspaceId);
|
||||
_workspaceService = WorkspaceService(
|
||||
workspaceId: workspaceId,
|
||||
userId: userProfile.id,
|
||||
);
|
||||
|
||||
this.userProfile = userProfile;
|
||||
this.workspaceId = workspaceId;
|
||||
|
||||
+16
-2
@@ -1,15 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/shared/af_role_pb_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:fixnum/fixnum.dart' as fixnum;
|
||||
|
||||
class WorkspaceService {
|
||||
WorkspaceService({required this.workspaceId});
|
||||
WorkspaceService({required this.workspaceId, required this.userId});
|
||||
|
||||
final String workspaceId;
|
||||
final fixnum.Int64 userId;
|
||||
|
||||
Future<FlowyResult<ViewPB, FlowyError>> createView({
|
||||
required String name,
|
||||
@@ -82,7 +85,18 @@ class WorkspaceService {
|
||||
return FolderEventMoveView(payload).send();
|
||||
}
|
||||
|
||||
Future<FlowyResult<WorkspaceUsagePB, FlowyError>> getWorkspaceUsage() {
|
||||
Future<FlowyResult<WorkspaceUsagePB?, FlowyError>> getWorkspaceUsage() async {
|
||||
final request = WorkspaceMemberIdPB()..uid = userId;
|
||||
final result = await UserEventGetMemberInfo(request).send();
|
||||
final isOwner = result.fold(
|
||||
(member) => member.role.isOwner,
|
||||
(_) => false,
|
||||
);
|
||||
|
||||
if (!isOwner) {
|
||||
return FlowyResult.success(null);
|
||||
}
|
||||
|
||||
final payload = UserWorkspaceIdPB(workspaceId: workspaceId);
|
||||
return UserEventGetWorkspaceUsage(payload).send();
|
||||
}
|
||||
|
||||
+25
-60
@@ -4,7 +4,6 @@ import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_v
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -135,13 +134,17 @@ class CommandPaletteModal extends StatelessWidget {
|
||||
builder: (context, state) => FlowyDialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 100),
|
||||
constraints: const BoxConstraints(maxHeight: 420, maxWidth: 510),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 600,
|
||||
maxWidth: 800,
|
||||
minHeight: 600,
|
||||
),
|
||||
expandHeight: false,
|
||||
child: shortcutBuilder(
|
||||
// Change mainAxisSize to max so Expanded works correctly.
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SearchField(query: state.query, isLoading: state.isLoading),
|
||||
SearchField(query: state.query, isLoading: state.searching),
|
||||
if (state.query?.isEmpty ?? true) ...[
|
||||
const Divider(height: 0),
|
||||
Flexible(
|
||||
@@ -150,23 +153,26 @@ class CommandPaletteModal extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.results.isNotEmpty &&
|
||||
if (state.combinedResponseItems.isNotEmpty &&
|
||||
(state.query?.isNotEmpty ?? false)) ...[
|
||||
const Divider(height: 0),
|
||||
Flexible(
|
||||
child: SearchResultsList(
|
||||
child: SearchResultList(
|
||||
trash: state.trash,
|
||||
results: state.results,
|
||||
resultItems: state.combinedResponseItems.values.toList(),
|
||||
resultSummaries: state.resultSummaries,
|
||||
),
|
||||
),
|
||||
] else if ((state.query?.isNotEmpty ?? false) &&
|
||||
!state.isLoading) ...[
|
||||
const _NoResultsHint(),
|
||||
]
|
||||
// When there are no results and the query is not empty and not loading,
|
||||
// show the no results message, centered in the available space.
|
||||
else if ((state.query?.isNotEmpty ?? false) &&
|
||||
!state.searching) ...[
|
||||
const Divider(height: 0),
|
||||
Expanded(
|
||||
child: const _NoResultsHint(),
|
||||
),
|
||||
],
|
||||
_CommandPaletteFooter(
|
||||
shouldShow: state.results.isNotEmpty &&
|
||||
(state.query?.isNotEmpty ?? false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -175,57 +181,16 @@ class CommandPaletteModal extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updated _NoResultsHint now centers its content.
|
||||
class _NoResultsHint extends StatelessWidget {
|
||||
const _NoResultsHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.commandPalette_noResultsHint.tr(),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommandPaletteFooter extends StatelessWidget {
|
||||
const _CommandPaletteFooter({required this.shouldShow});
|
||||
|
||||
final bool shouldShow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!shouldShow) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const FlowyText.semibold('TAB', fontSize: 10),
|
||||
),
|
||||
const HSpace(4),
|
||||
FlowyText(LocaleKeys.commandPalette_navigateHint.tr(), fontSize: 11),
|
||||
],
|
||||
return Center(
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.commandPalette_noResultsHint.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -52,7 +52,7 @@ class RecentViewsList extends StatelessWidget {
|
||||
)
|
||||
: FlowySvg(view.iconData, size: const Size.square(20));
|
||||
|
||||
return RecentViewTile(
|
||||
return SearchRecentViewCell(
|
||||
icon: SizedBox(width: 24, child: icon),
|
||||
view: view,
|
||||
onSelected: onSelected,
|
||||
|
||||
+93
-100
@@ -7,7 +7,6 @@ import 'package:appflowy/workspace/application/command_palette/command_palette_b
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme_extension.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';
|
||||
@@ -25,28 +24,31 @@ class SearchField extends StatefulWidget {
|
||||
|
||||
class _SearchFieldState extends State<SearchField> {
|
||||
late final FocusNode focusNode;
|
||||
late final controller = TextEditingController(text: widget.query);
|
||||
late final TextEditingController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
focusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (node.hasFocus &&
|
||||
event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
node.nextFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
);
|
||||
controller = TextEditingController(text: widget.query);
|
||||
focusNode = FocusNode(onKeyEvent: _handleKeyEvent);
|
||||
focusNode.requestFocus();
|
||||
controller.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: controller.text.length,
|
||||
);
|
||||
// Update the text selection after the first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
controller.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: controller.text.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (node.hasFocus &&
|
||||
event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
node.nextFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -56,21 +58,83 @@ class _SearchFieldState extends State<SearchField> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildSuffixIcon(BuildContext context) {
|
||||
return ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
final hasText = value.text.trim().isNotEmpty;
|
||||
final clearIcon = Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(16),
|
||||
),
|
||||
);
|
||||
return AnimatedOpacity(
|
||||
opacity: hasText ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: hasText
|
||||
? FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_clearSearchTooltip.tr(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: _clearSearch,
|
||||
child: clearIcon,
|
||||
),
|
||||
),
|
||||
)
|
||||
: clearIcon,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Cache theme and text styles
|
||||
final theme = Theme.of(context);
|
||||
final textStyle = theme.textTheme.bodySmall?.copyWith(fontSize: 14);
|
||||
final hintStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 14,
|
||||
color: theme.hintColor,
|
||||
);
|
||||
|
||||
// Choose the leading icon based on loading state
|
||||
final Widget leadingIcon = widget.isLoading
|
||||
? FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_loadingTooltip.tr(),
|
||||
child: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(3.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_m,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(12),
|
||||
FlowySvg(
|
||||
FlowySvgs.search_m,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
leadingIcon,
|
||||
Expanded(
|
||||
child: FlowyTextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
textStyle:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 14),
|
||||
textStyle: textStyle,
|
||||
decoration: InputDecoration(
|
||||
constraints: const BoxConstraints(maxHeight: 48),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
@@ -80,72 +144,14 @@ class _SearchFieldState extends State<SearchField> {
|
||||
),
|
||||
isDense: false,
|
||||
hintText: LocaleKeys.commandPalette_placeholder.tr(),
|
||||
hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
errorStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
hintStyle: hintStyle,
|
||||
errorStyle: theme.textTheme.bodySmall!
|
||||
.copyWith(color: theme.colorScheme.error),
|
||||
suffix: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedOpacity(
|
||||
opacity: controller.text.trim().isNotEmpty ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final icon = Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(16),
|
||||
),
|
||||
);
|
||||
if (controller.text.isEmpty) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return FlowyTooltip(
|
||||
message:
|
||||
LocaleKeys.commandPalette_clearSearchTooltip.tr(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: controller.text.trim().isNotEmpty
|
||||
? _clearSearch
|
||||
: null,
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildSuffixIcon(context),
|
||||
const HSpace(8),
|
||||
FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_betaTooltip.tr(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.commandPalette_betaLabel.tr(),
|
||||
fontSize: 11,
|
||||
lineHeight: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
counterText: "",
|
||||
@@ -155,9 +161,7 @@ class _SearchFieldState extends State<SearchField> {
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: Corners.s8Border,
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
borderSide: BorderSide(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => context
|
||||
@@ -165,17 +169,6 @@ class _SearchFieldState extends State<SearchField> {
|
||||
.add(CommandPaletteEvent.searchChanged(search: value)),
|
||||
),
|
||||
),
|
||||
if (widget.isLoading) ...[
|
||||
FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_loadingTooltip.tr(),
|
||||
child: const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
),
|
||||
const HSpace(12),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RecentViewTile extends StatelessWidget {
|
||||
const RecentViewTile({
|
||||
class SearchRecentViewCell extends StatelessWidget {
|
||||
const SearchRecentViewCell({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.view,
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.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/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SearchResultCell extends StatefulWidget {
|
||||
const SearchResultCell({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.isTrashed = false,
|
||||
this.isHovered = false,
|
||||
});
|
||||
|
||||
final SearchResultItem item;
|
||||
final bool isTrashed;
|
||||
final bool isHovered;
|
||||
|
||||
@override
|
||||
State<SearchResultCell> createState() => _SearchResultCellState();
|
||||
}
|
||||
|
||||
class _SearchResultCellState extends State<SearchResultCell> {
|
||||
bool _hasFocus = false;
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Helper to handle the selection action.
|
||||
void _handleSelection() {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.openPage(pageId: widget.item.id),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to clean up preview text.
|
||||
String _cleanPreview(String preview) {
|
||||
return preview.replaceAll('\n', ' ').trim();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.item.displayName.orDefault(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
);
|
||||
final icon = widget.item.icon.getIcon();
|
||||
final cleanedPreview = _cleanPreview(widget.item.content);
|
||||
final hasPreview = cleanedPreview.isNotEmpty;
|
||||
final trashHintText =
|
||||
widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null;
|
||||
|
||||
// Build the tile content based on preview availability.
|
||||
Widget tileContent;
|
||||
if (hasPreview) {
|
||||
tileContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
SizedBox(width: 24, child: icon),
|
||||
const HSpace(6),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.isTrashed)
|
||||
FlowyText(
|
||||
trashHintText!,
|
||||
color: AFThemeExtension.of(context)
|
||||
.textColor
|
||||
.withAlpha(175),
|
||||
fontSize: 10,
|
||||
),
|
||||
FlowyText(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(4),
|
||||
_DocumentPreview(preview: cleanedPreview),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
tileContent = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
SizedBox(width: 24, child: icon),
|
||||
const HSpace(6),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isTrashed)
|
||||
FlowyText(
|
||||
trashHintText!,
|
||||
color:
|
||||
AFThemeExtension.of(context).textColor.withAlpha(175),
|
||||
fontSize: 10,
|
||||
),
|
||||
FlowyText(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleSelection,
|
||||
child: Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_handleSelection();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.onHoverResult(
|
||||
item: widget.item,
|
||||
userHovered: true,
|
||||
),
|
||||
);
|
||||
_hasFocus = hasFocus;
|
||||
});
|
||||
},
|
||||
child: FlowyHover(
|
||||
onHover: (value) {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.onHoverResult(
|
||||
item: widget.item,
|
||||
userHovered: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
isSelected: () => _hasFocus || widget.isHovered,
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 30),
|
||||
child: tileContent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DocumentPreview extends StatelessWidget {
|
||||
const _DocumentPreview({required this.preview});
|
||||
|
||||
final String preview;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Combine the horizontal padding for clarity:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(30, 0, 16, 0),
|
||||
child: FlowyText.regular(
|
||||
preview,
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 12,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResultPreview extends StatelessWidget {
|
||||
const SearchResultPreview({
|
||||
super.key,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
final SearchResultItem data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
LocaleKeys.commandPalette_pagePreview.tr(),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const VSpace(6),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
data.content,
|
||||
maxLines: 30,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.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/services.dart';
|
||||
|
||||
class SearchResultTile extends StatefulWidget {
|
||||
const SearchResultTile({
|
||||
super.key,
|
||||
required this.result,
|
||||
required this.onSelected,
|
||||
this.isTrashed = false,
|
||||
});
|
||||
|
||||
final SearchResultPB result;
|
||||
final VoidCallback onSelected;
|
||||
final bool isTrashed;
|
||||
|
||||
@override
|
||||
State<SearchResultTile> createState() => _SearchResultTileState();
|
||||
}
|
||||
|
||||
class _SearchResultTileState extends State<SearchResultTile> {
|
||||
bool _hasFocus = false;
|
||||
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.result.data.orDefault(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
);
|
||||
final icon = widget.result.getIcon();
|
||||
final cleanedPreview = _cleanPreview(widget.result.preview);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
widget.onSelected();
|
||||
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: widget.result.viewId),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
widget.onSelected();
|
||||
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: widget.result.viewId),
|
||||
),
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus),
|
||||
child: FlowyHover(
|
||||
isSelected: () => _hasFocus,
|
||||
style: HoverStyle(
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// page icon
|
||||
if (icon != null) ...[
|
||||
SizedBox(width: 24, child: icon),
|
||||
const HSpace(6),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// if the result is trashed, show a hint
|
||||
if (widget.isTrashed) ...[
|
||||
FlowyText(
|
||||
LocaleKeys.commandPalette_fromTrashHint.tr(),
|
||||
color: AFThemeExtension.of(context)
|
||||
.textColor
|
||||
.withAlpha(175),
|
||||
fontSize: 10,
|
||||
),
|
||||
],
|
||||
// page title
|
||||
FlowyText(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// content preview
|
||||
if (cleanedPreview.isNotEmpty) ...[
|
||||
const VSpace(4),
|
||||
_DocumentPreview(preview: cleanedPreview),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _cleanPreview(String preview) {
|
||||
return preview.replaceAll('\n', ' ').trim();
|
||||
}
|
||||
}
|
||||
|
||||
class _DocumentPreview extends StatelessWidget {
|
||||
const _DocumentPreview({required this.preview});
|
||||
|
||||
final String preview;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16) +
|
||||
const EdgeInsets.only(left: 14),
|
||||
child: FlowyText.regular(
|
||||
preview,
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 12,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user