Compare commits

...
37 Commits
Author SHA1 Message Date
Morn fbf928e6e4 chore: update CHANGELOG.md (#7765) 2025-04-17 11:01:30 +08:00
Lucas 8f63667282 feat: upgrade ubuntu version to 22.04 in github action (#7764)
* feat: upgrade ubuntu version to 22.04 in github action

* fix: cargo clippy
2025-04-17 09:48:23 +08:00
Nathan.fooo 954e844a21 Merge pull request #7761 from AppFlowy-IO/do_not_initialize_collab
fix: 0.8.9 release bugs
2025-04-16 17:06:31 +08:00
Morn 13acc3af86 fix: auto focus on link name textfield after open the link_edit_menu (#7757)
* fix: auto focus on link name textfield after open the link_edit_menu

* chore: update pubspec.yaml
2025-04-16 16:56:37 +08:00
Nathan 079112c9d2 chore: clippy 2025-04-16 16:30:07 +08:00
Nathan 0be8dcc000 chore: remove duplciate 2025-04-16 16:29:28 +08:00
Nathan d7d040b0f9 Merge branch 'main' into do_not_initialize_collab 2025-04-16 16:08:29 +08:00
Nathan f652229718 fix: open relation database row 2025-04-16 16:08:11 +08:00
Lucas f727dde74b fix: lose nested children when accepting AI responses (#7760)
* chore: add gitkeep in assets/font dir

* Revert "feat: improve white label scripts on Windows (#7755)"

This reverts commit a5eb2cdd9a.

* chore: use --verbose

* fix: lose nested children when accept ai response

* chore: lock analyzer version

* Reapply "feat: improve white label scripts on Windows (#7755)"

This reverts commit c73186306e.

* chore: lock analyzer version

* chore: update editor version
2025-04-16 15:59:43 +08:00
Nathan 92179fe61c chore: do not get workspace usage when current user is not owner 2025-04-16 15:16:06 +08:00
Nathan ecfcf3be4d chore: commit pub lock 2025-04-16 14:37:45 +08:00
Nathan be132d867a chore: lock analyzer version 2025-04-16 14:31:10 +08:00
Nathan e6951012f0 chore: do not generate search summary if result is empty 2025-04-16 14:21:16 +08:00
Nathan 3214ec075b chore: fix flaky initialize folder indexer 2025-04-16 14:21:16 +08:00
Nathan be1d2b4b92 chore: delay collab initialization until schema is configured; finalize to ensure collab includes its schema 2025-04-16 14:21:16 +08:00
Lucas a5eb2cdd9a feat: improve white label scripts on Windows (#7755)
* feat: improve white label scripts on Windows

* feat: add font white label script

* chore: integrate font white label script
2025-04-16 10:15:40 +08:00
Morn 9b077969e7 fix: error position for slash search result (#7758) 2025-04-16 10:11:31 +08:00
Nathan.fooo 7160790596 Merge pull request #7748 from AppFlowy-IO/adjust_search_ui
chore: loading for search summary
2025-04-15 22:52:49 +08:00
Nathan 5c3e81e6dc chore: adjust ui 2025-04-15 22:49:34 +08:00
Nathan 846172a709 chore: adjust ui 2025-04-15 22:19:16 +08:00
Morn c6511cfb55 fix: mobile slash menu position error (#7747)
* fix: mobile slash menu position error

* fix: mobile slash menu sometimes not showing up
2025-04-15 17:43:50 +08:00
Richard Shiue 69b5452af5 fix: undo not working in database row full page (#7749) 2025-04-15 17:17:39 +08:00
Nathan 9291236733 chore: clippy 2025-04-15 16:49:33 +08:00
Nathan 3b3ae7fde9 chore: loading for search summary 2025-04-15 15:59:15 +08:00
Morn d9748d5ef1 fix: block does not expand with grid size (#7745)
* fix: block does not expand with grid size

* fix: replace listenForSizeChanged with SizeChangedLayoutNotifier
2025-04-15 13:50:53 +08:00
Richard Shiue d01909830d fix: ai writer not working in database rows (#7746)
* fix: ai writer not working in database rows

* chore: dart analysis
2025-04-15 11:14:20 +08:00
Nathan.fooo 03ecc3d6c9 Merge pull request #7727 from AppFlowy-IO/search_summary
chore: search summary
2025-04-15 10:37:17 +08:00
Lucas 1630e11c4d chore: update login page icons and i18n (#7742)
* feat: update i18n and icons

* chore: replace appflowy with welcome to appflowy

* fix: protenial delete page error

* fix: flutter analyze
2025-04-15 09:39:33 +08:00
Nathan 99fb6ab743 chore: fix linux watch 2025-04-14 23:07:42 +08:00
Nathan 35bc095760 chore: local and server result 2025-04-14 22:58:37 +08:00
Nathan a44ad63230 chore: display preview 2025-04-14 16:40:54 +08:00
Lucas ddbaf0d530 feat: otp improvement on mobile (#7738) 2025-04-14 14:58:00 +08:00
Nathan 437deaf986 chore: update logs 2025-04-14 14:38:25 +08:00
Morn 54df6b2863 fix: link_preview launch review issues (#7731)
* fix: some link_preview launch review issues

* fix: some UI issues

* chore: pasting a link will not check whether it is an image

* fix: copy link to block not supported well

* fix: mention UI issues

* feat: support get youtube channel info

* chore: update translation

* feat: add shadow in appflowy theme

* chore: remove AFThemeExtensionV2

* fix: some UI issues
2025-04-14 14:14:05 +08:00
Richard Shiue 69b98cb323 fix: open board row as page (#7735) 2025-04-14 10:47:55 +08:00
Nathan 4d172761ce chore: search with ai summary 2025-04-14 10:36:42 +08:00
Lucas 8c4324ee9d fix: otp launch review issues (#7730)
* fix: add error text under text field

* chore: update translation
2025-04-14 10:29:46 +08:00
175 changed files with 4344 additions and 2569 deletions

No files matched your search

+1 -1
View File
@@ -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,
}
+20
View File
@@ -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.
@@ -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));
@@ -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);
});
});
}
@@ -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
@@ -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,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
@@ -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);
@@ -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(),
];
}
}
@@ -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),
],
@@ -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,
);
}
}
}
}
@@ -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(),
],
),
);
}
@@ -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,
@@ -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,
),
@@ -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(),
),
),
),
@@ -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(
@@ -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)) {
@@ -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({
@@ -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
@@ -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),
),
);
@@ -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(),
);
}
@@ -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) {
@@ -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,
),
@@ -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);
@@ -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
@@ -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;
}
}
@@ -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],
);
}
@@ -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 {
@@ -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;
}
@@ -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();
}
}
}
@@ -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,
}
@@ -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 }
@@ -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;
}
@@ -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);
}
@@ -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;
}
}
}
@@ -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,
};
}
@@ -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()),
);
}
}
@@ -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);
@@ -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,
);
}
}
@@ -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,
@@ -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';
@@ -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,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,
@@ -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();
@@ -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,
),
],
),
@@ -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,
@@ -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,
),
],
),
@@ -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();
}
@@ -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(
@@ -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),
],
),
),
@@ -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()),
@@ -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');
},
);
}
}
@@ -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,
);
},
),
@@ -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
// },
// ),
// ),
// );
@@ -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,
];
}
}
}
@@ -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;
},
),
@@ -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,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(
@@ -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,
);
},
);
}
}
@@ -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(),
);
}
@@ -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,
);
@@ -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,
);
}
@@ -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,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,
);
}
@@ -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();
}
@@ -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,
@@ -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,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,
),
],
);
}
@@ -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,
),
],
);
}
@@ -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);
@@ -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;
@@ -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;
@@ -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();
}
@@ -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,
),
);
}
@@ -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,
@@ -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),
],
],
);
}
@@ -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,
@@ -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,
),
),
],
);
}
}
@@ -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