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
206 changed files with 5008 additions and 2955 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
+124 -124
View File
@@ -1,125 +1,125 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "trace",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
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);
});
@@ -54,6 +54,8 @@ extension AppFlowyTestBase on WidgetTester {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
@@ -69,6 +71,9 @@ extension AppFlowyTestBase on WidgetTester {
() async {
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await useTestSelfHostedAppFlowyCloud();
getIt.unregister<AuthService>();
@@ -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 {
+23 -23
View File
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -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
-2
View File
@@ -16,7 +16,6 @@ class AppFlowyConfiguration {
required this.platform,
required this.authenticator_type,
required this.appflowy_cloud_config,
required this.is_anon,
required this.envs,
});
@@ -29,7 +28,6 @@ class AppFlowyConfiguration {
final String origin_app_path;
final String device_id;
final String platform;
final bool is_anon;
final int authenticator_type;
final AppFlowyCloudConfiguration appflowy_cloud_config;
final Map<String, String> envs;
+21 -1
View File
@@ -23,6 +23,9 @@ import 'package:appflowy_backend/log.dart';
///
Future<void> _setAuthenticatorType(AuthenticatorType ty) async {
switch (ty) {
case AuthenticatorType.local:
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 0.toString());
break;
case AuthenticatorType.appflowyCloud:
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 2.toString());
break;
@@ -60,6 +63,8 @@ Future<AuthenticatorType> getAuthenticatorType() async {
}
switch (value ?? "0") {
case "0":
return AuthenticatorType.local;
case "2":
return AuthenticatorType.appflowyCloud;
case "3":
@@ -95,17 +100,24 @@ bool get isAuthEnabled {
return false;
}
bool get isLocalAuthEnabled {
return currentCloudType().isLocal;
}
/// Determines if AppFlowy Cloud is enabled.
bool get isAppFlowyCloudEnabled {
return currentCloudType().isAppFlowyCloudEnabled;
}
enum AuthenticatorType {
local,
appflowyCloud,
appflowyCloudSelfHost,
// The 'appflowyCloudDevelop' type is used for develop purposes only.
appflowyCloudDevelop;
bool get isLocal => this == AuthenticatorType.local;
bool get isAppFlowyCloudEnabled =>
this == AuthenticatorType.appflowyCloudSelfHost ||
this == AuthenticatorType.appflowyCloudDevelop ||
@@ -113,6 +125,8 @@ enum AuthenticatorType {
int get value {
switch (this) {
case AuthenticatorType.local:
return 0;
case AuthenticatorType.appflowyCloud:
return 2;
case AuthenticatorType.appflowyCloudSelfHost:
@@ -124,6 +138,8 @@ enum AuthenticatorType {
static AuthenticatorType fromValue(int value) {
switch (value) {
case 0:
return AuthenticatorType.local;
case 2:
return AuthenticatorType.appflowyCloud;
case 3:
@@ -131,7 +147,7 @@ enum AuthenticatorType {
case 4:
return AuthenticatorType.appflowyCloudDevelop;
default:
return AuthenticatorType.appflowyCloud;
return AuthenticatorType.local;
}
}
}
@@ -164,6 +180,10 @@ Future<void> useAppFlowyBetaCloudWithURL(
await _setAppFlowyCloudUrl(url);
}
Future<void> useLocalServer() async {
await _setAuthenticatorType(AuthenticatorType.local);
}
// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
class AppFlowyCloudSharedEnv {
AppFlowyCloudSharedEnv({
@@ -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,
@@ -1,7 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
@@ -30,6 +32,12 @@ class UserSessionSettingGroup extends StatelessWidget {
if (showThirdPartyLogin) _buildThirdPartySignInButtons(context),
const VSpace(8.0),
// logout button
MobileLogoutButton(
text: LocaleKeys.settings_menu_logout.tr(),
onPressed: () async => _showLogoutDialog(),
),
// delete account button
// only show the delete account button in cloud mode
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[
@@ -55,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),
],
);
},
),
@@ -71,6 +86,31 @@ class UserSessionSettingGroup extends StatelessWidget {
builder: (_) => const _DeleteAccountBottomSheet(),
);
}
Future<void> _showLogoutDialog() async {
return showFlowyCupertinoConfirmDialog(
title: LocaleKeys.settings_menu_logoutPrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_logout.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (context) async {
Navigator.of(context).pop();
await getIt<AuthService>().signOut();
await runAppFlowy();
},
);
}
}
class _DeleteAccountBottomSheet extends StatefulWidget {
@@ -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,
);
@@ -99,6 +99,13 @@ void _resolveCommonService(
void _resolveUserDeps(GetIt getIt, IntegrationMode mode) {
switch (currentCloudType()) {
case AuthenticatorType.local:
getIt.registerFactory<AuthService>(
() => BackendAuthService(
AuthenticatorPB.Local,
),
);
break;
case AuthenticatorType.appflowyCloud:
case AuthenticatorType.appflowyCloudSelfHost:
case AuthenticatorType.appflowyCloudDevelop:
@@ -32,21 +32,6 @@ class FlowyRunnerContext {
final Directory applicationDataDirectory;
}
/// Launches AppFlowy application with optional anonymous mode.
///
/// [isAnon] When set to true, the application runs in anonymous mode where:
/// - No user authentication is required
/// - All data is stored locally in a separate anonymous directory
/// - The data is isolated from the main application storage
///
/// Example:
/// ```dart
/// // Launch in normal mode
/// await runAppFlowy();
///
/// // Launch in anonymous mode
/// await runAppFlowy(isAnon: true);
/// ```
Future<void> runAppFlowy({bool isAnon = false}) async {
Log.info('restart AppFlowy: isAnon: $isAnon');
@@ -144,10 +129,7 @@ class FlowyRunner {
// init the app window
InitAppWindowTask(),
// Init Rust SDK
InitRustSDKTask(
customApplicationPath: applicationDataDirectory,
isAnon: isAnon,
),
InitRustSDKTask(customApplicationPath: applicationDataDirectory),
// Load Plugins, like document, grid ...
const PluginLoadTask(),
const FileStorageTask(),
@@ -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,
);
}
@@ -120,6 +120,18 @@ GoRouter generateRouter(Widget child) {
);
},
),
GoRoute(
path: SignUpScreen.routeName,
pageBuilder: (context, state) {
return CustomTransitionPage(
child: SignUpScreen(
router: getIt<AuthRouter>(),
),
transitionsBuilder: _buildFadeTransition,
transitionDuration: _slowDuration,
);
},
),
],
);
}
@@ -12,13 +12,11 @@ import '../startup.dart';
class InitRustSDKTask extends LaunchTask {
const InitRustSDKTask({
required this.isAnon,
required this.customApplicationPath,
this.customApplicationPath,
});
// Customize the RustSDK initialization path
final Directory? customApplicationPath;
final bool isAnon;
@override
LaunchTaskType get type => LaunchTaskType.dataProcessing;
@@ -26,16 +24,8 @@ class InitRustSDKTask extends LaunchTask {
@override
Future<void> initialize(LaunchContext context) async {
final root = await getApplicationSupportDirectory();
// Only one anon user no matter what the cloud URL is
final applicationPath = isAnon
? await appFlowyAnonDirectory()
: await appFlowyApplicationDataDirectory();
final dir =
isAnon ? applicationPath : (customApplicationPath ?? applicationPath);
// Get device ID in parallel with path resolution
final applicationPath = await appFlowyApplicationDataDirectory();
final dir = customApplicationPath ?? applicationPath;
final deviceId = await getDeviceId();
// Pass the environment variables to the Rust SDK
@@ -45,7 +35,6 @@ class InitRustSDKTask extends LaunchTask {
dir.path,
applicationPath.path,
deviceId,
isAnon,
rustEnvs: context.config.rustEnvs,
);
await context.getIt<FlowySDK>().init(jsonEncode(env.toJson()));
@@ -60,8 +49,7 @@ AppFlowyConfiguration _makeAppFlowyConfiguration(
String appVersion,
String customAppPath,
String originAppPath,
String deviceId,
bool isAnon, {
String deviceId, {
required Map<String, String> rustEnvs,
}) {
final env = getIt<AppFlowyCloudSharedEnv>();
@@ -74,7 +62,6 @@ AppFlowyConfiguration _makeAppFlowyConfiguration(
platform: Platform.operatingSystem,
authenticator_type: env.authenticatorType.value,
appflowy_cloud_config: env.appflowyCloudConfig,
is_anon: isAnon,
envs: rustEnvs,
);
}
@@ -95,10 +82,3 @@ Future<Directory> appFlowyApplicationDataDirectory() async {
return Directory(path.join(Directory.current.path, '.sandbox'));
}
}
/// The anon directory.
Future<Directory> appFlowyAnonDirectory() async {
final Directory documentsDir =
await getApplicationSupportDirectory().then((directory) => directory);
return Directory(path.join(documentsDir.path, 'anon'));
}
@@ -1,4 +1,6 @@
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -9,7 +11,9 @@ class AnonUserBloc extends Bloc<AnonUserEvent, AnonUserState> {
AnonUserBloc() : super(AnonUserState.initial()) {
on<AnonUserEvent>((event, emit) async {
await event.when(
initial: () async {},
initial: () async {
await _loadHistoricalUsers();
},
didLoadAnonUsers: (List<UserProfilePB> anonUsers) {
emit(state.copyWith(anonUsers: anonUsers));
},
@@ -20,6 +24,20 @@ class AnonUserBloc extends Bloc<AnonUserEvent, AnonUserState> {
);
});
}
Future<void> _loadHistoricalUsers() async {
final result = await UserBackendService.getAnonUser();
result.fold(
(anonUser) {
add(AnonUserEvent.didLoadAnonUsers([anonUser]));
},
(error) {
if (error.code != ErrorCode.RecordNotFound) {
Log.error(error);
}
},
);
}
}
@freezed
@@ -94,10 +94,10 @@ class AppFlowyCloudAuthService implements AuthService {
}
@override
Future<void> signUpAsGuest({
Future<FlowyResult<UserProfilePB, FlowyError>> signUpAsGuest({
Map<String, String> params = const {},
}) async {
await _backendAuthService.signUpAsGuest();
return _backendAuthService.signUpAsGuest();
}
@override
@@ -29,16 +29,7 @@ class AppFlowyCloudMockAuthService implements AuthService {
required String password,
Map<String, String> params = const {},
}) async {
final request = SignUpPayloadPB.create()
..name = name
..email = email
..password = password
..authType = _appFlowyAuthService.authType
..deviceId = await getDeviceId();
final response = await UserEventSignUp(request).send().then(
(value) => value,
);
return response;
throw UnimplementedError();
}
@override
@@ -98,10 +89,10 @@ class AppFlowyCloudMockAuthService implements AuthService {
}
@override
Future<void> signUpAsGuest({
Future<FlowyResult<UserProfilePB, FlowyError>> signUpAsGuest({
Map<String, String> params = const {},
}) async {
await _appFlowyAuthService.signUpAsGuest();
return _appFlowyAuthService.signUpAsGuest();
}
@override
@@ -61,7 +61,7 @@ abstract class AuthService {
/// - `params`: Additional parameters for guest registration (optional).
///
/// Returns a default [UserProfilePB].
Future<void> signUpAsGuest({
Future<FlowyResult<UserProfilePB, FlowyError>> signUpAsGuest({
Map<String, String> params,
});
@@ -1,4 +1,3 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@@ -6,8 +5,11 @@ import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show SignInPayloadPB, UserProfilePB;
show SignInPayloadPB, SignUpPayloadPB, UserProfilePB;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import '../../../generated/locale_keys.g.dart';
import 'device_id.dart';
class BackendAuthService implements AuthService {
@@ -37,17 +39,16 @@ class BackendAuthService implements AuthService {
required String password,
Map<String, String> params = const {},
}) async {
throw UnimplementedError();
// final request = SignUpPayloadPB.create()
// ..name = name
// ..email = email
// ..password = password
// ..authType = authType
// ..deviceId = await getDeviceId();
// final response = await UserEventSignUp(request).send().then(
// (value) => value,
// );
// return response;
final request = SignUpPayloadPB.create()
..name = name
..email = email
..password = password
..authType = authType
..deviceId = await getDeviceId();
final response = await UserEventSignUp(request).send().then(
(value) => value,
);
return response;
}
@override
@@ -59,10 +60,23 @@ class BackendAuthService implements AuthService {
}
@override
Future<void> signUpAsGuest({
Future<FlowyResult<UserProfilePB, FlowyError>> signUpAsGuest({
Map<String, String> params = const {},
}) async {
await runAppFlowy(isAnon: true);
const password = "Guest!@123456";
final userEmail = "anon@appflowy.io";
final request = SignUpPayloadPB.create()
..name = LocaleKeys.defaultUsername.tr()
..email = userEmail
..password = password
// When sign up as guest, the auth type is always local.
..authType = AuthenticatorPB.Local
..deviceId = await getDeviceId();
final response = await UserEventSignUp(request).send().then(
(value) => value,
);
return response;
}
@override
@@ -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,
@@ -251,7 +267,16 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
),
);
await authService.signUpAsGuest();
final result = await authService.signUpAsGuest();
emit(
result.fold(
(userProfile) => state.copyWith(
isSubmitting: false,
successOrFail: FlowyResult.success(userProfile),
),
(error) => _stateFromCode(error),
),
);
}
SignInState _stateFromCode(FlowyError error) {
@@ -273,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(
@@ -21,6 +21,10 @@ class AuthRouter {
getIt<SplashRouter>().pushWorkspaceStartScreen(context, userProfile);
}
void pushSignUpScreen(BuildContext context) {
context.push(SignUpScreen.routeName);
}
/// Navigates to the home screen based on the current workspace and platform.
///
/// This function takes in a [BuildContext] and a [UserProfilePB] object to
@@ -1,6 +1,7 @@
export 'sign_in_screen/sign_in_screen.dart';
export 'skip_log_in_screen.dart';
export 'splash_screen.dart';
export 'sign_up_screen.dart';
export 'encrypt_secret_screen.dart';
export 'workspace_error_screen.dart';
export 'workspace_start_screen/workspace_start_screen.dart';
@@ -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,33 +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 SignInAnonymousButtonV3(),
const ContinueWithEmailAndPassword(),
const VSpace(spacing),
if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme),
const VSpace(spacing * 1.5),
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(),
FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()),
VSpace(theme.spacing.xxl),
isLocalAuthEnabled
? const SignInAnonymousButtonV3()
: const ContinueWithEmailAndPassword(),
VSpace(theme.spacing.xxl),
if (isAuthEnabled) ...[
_buildThirdPartySignInButtons(context),
VSpace(theme.spacing.xxl),
],
const SignInAgreement(),
const Spacer(),
_buildSettingsButton(context),
],
),
),
@@ -56,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(
@@ -83,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()),
@@ -128,8 +110,9 @@ class MobileSignInScreen extends StatelessWidget {
},
),
const HSpace(24),
const ChangeCloudModeButton(),
const SignInAnonymousButtonV2(),
isLocalAuthEnabled
? const ChangeCloudModeButton()
: const SignInAnonymousButtonV2(),
],
);
}
@@ -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.signIn_anonymousMode.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,
);
@@ -19,13 +19,12 @@ class SignInAgreement extends StatelessWidget {
final underlinedTextStyle = theme.textStyle.caption.underline(
color: theme.textColorScheme.secondary,
);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: '${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;
@@ -0,0 +1,220 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/sign_up_bloc.dart';
import 'package:appflowy/user/presentation/router.dart';
import 'package:appflowy/user/presentation/widgets/widgets.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flowy_infra_ui/widget/rounded_input_field.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SignUpScreen extends StatelessWidget {
const SignUpScreen({
super.key,
required this.router,
});
static const routeName = '/SignUpScreen';
final AuthRouter router;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<SignUpBloc>(),
child: BlocListener<SignUpBloc, SignUpState>(
listener: (context, state) {
final successOrFail = state.successOrFail;
if (successOrFail != null) {
_handleSuccessOrFail(context, successOrFail);
}
},
child: const Scaffold(body: SignUpForm()),
),
);
}
void _handleSuccessOrFail(
BuildContext context,
FlowyResult<UserProfilePB, FlowyError> result,
) {
result.fold(
(user) => router.pushWorkspaceStartScreen(context, user),
(error) => showSnapBar(context, error.msg),
);
}
}
class SignUpForm extends StatelessWidget {
const SignUpForm({
super.key,
});
@override
Widget build(BuildContext context) {
return Align(
child: AuthFormContainer(
children: [
FlowyLogoTitle(
title: LocaleKeys.signUp_title.tr(),
logoSize: const Size(60, 60),
),
const VSpace(30),
const EmailTextField(),
const VSpace(5),
const PasswordTextField(),
const VSpace(5),
const RepeatPasswordTextField(),
const VSpace(30),
const SignUpButton(),
const VSpace(10),
const SignUpPrompt(),
if (context.read<SignUpBloc>().state.isSubmitting) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
],
),
);
}
}
class SignUpPrompt extends StatelessWidget {
const SignUpPrompt({
super.key,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowyText.medium(
LocaleKeys.signUp_alreadyHaveAnAccount.tr(),
color: Theme.of(context).hintColor,
),
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.bodyMedium,
),
onPressed: () => Navigator.pop(context),
child: FlowyText.medium(
LocaleKeys.signIn_buttonText.tr(),
color: Theme.of(context).colorScheme.primary,
),
),
],
);
}
}
class SignUpButton extends StatelessWidget {
const SignUpButton({
super.key,
});
@override
Widget build(BuildContext context) {
return RoundedTextButton(
title: LocaleKeys.signUp_getStartedText.tr(),
height: 48,
onPressed: () {
context
.read<SignUpBloc>()
.add(const SignUpEvent.signUpWithUserEmailAndPassword());
},
);
}
}
class PasswordTextField extends StatelessWidget {
const PasswordTextField({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SignUpBloc, SignUpState>(
buildWhen: (previous, current) =>
previous.passwordError != current.passwordError,
builder: (context, state) {
return RoundedInputField(
obscureText: true,
obscureIcon: const FlowySvg(FlowySvgs.hide_m),
obscureHideIcon: const FlowySvg(FlowySvgs.show_m),
hintText: LocaleKeys.signUp_passwordHint.tr(),
normalBorderColor: Theme.of(context).colorScheme.outline,
errorBorderColor: Theme.of(context).colorScheme.error,
cursorColor: Theme.of(context).colorScheme.primary,
errorText: context.read<SignUpBloc>().state.passwordError ?? '',
onChanged: (value) => context
.read<SignUpBloc>()
.add(SignUpEvent.passwordChanged(value)),
);
},
);
}
}
class RepeatPasswordTextField extends StatelessWidget {
const RepeatPasswordTextField({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SignUpBloc, SignUpState>(
buildWhen: (previous, current) =>
previous.repeatPasswordError != current.repeatPasswordError,
builder: (context, state) {
return RoundedInputField(
obscureText: true,
obscureIcon: const FlowySvg(FlowySvgs.hide_m),
obscureHideIcon: const FlowySvg(FlowySvgs.show_m),
hintText: LocaleKeys.signUp_repeatPasswordHint.tr(),
normalBorderColor: Theme.of(context).colorScheme.outline,
errorBorderColor: Theme.of(context).colorScheme.error,
cursorColor: Theme.of(context).colorScheme.primary,
errorText: context.read<SignUpBloc>().state.repeatPasswordError ?? '',
onChanged: (value) => context
.read<SignUpBloc>()
.add(SignUpEvent.repeatPasswordChanged(value)),
);
},
);
}
}
class EmailTextField extends StatelessWidget {
const EmailTextField({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SignUpBloc, SignUpState>(
buildWhen: (previous, current) =>
previous.emailError != current.emailError,
builder: (context, state) {
return RoundedInputField(
hintText: LocaleKeys.signUp_emailHint.tr(),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
normalBorderColor: Theme.of(context).colorScheme.outline,
errorBorderColor: Theme.of(context).colorScheme.error,
cursorColor: Theme.of(context).colorScheme.primary,
errorText: context.read<SignUpBloc>().state.emailError ?? '',
onChanged: (value) =>
context.read<SignUpBloc>().add(SignUpEvent.emailChanged(value)),
);
},
);
}
}
@@ -5,8 +5,10 @@ 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/auth/auth_service.dart';
import 'package:appflowy/user/presentation/router.dart';
import 'package:appflowy/user/presentation/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/language.dart';
import 'package:flowy_infra/size.dart';
@@ -77,7 +79,11 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
}
Future<void> _autoRegister(BuildContext context) async {
await getIt<AuthService>().signUpAsGuest();
final result = await getIt<AuthService>().signUpAsGuest();
result.fold(
(user) => getIt<AuthRouter>().goHomeScreen(context, user),
(error) => Log.error(error),
);
}
Future<void> _relaunchAppAndAutoRegister() async => runAppFlowy(isAnon: true);
@@ -1,6 +1,7 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/splash_bloc.dart';
import 'package:appflowy/user/domain/auth_state.dart';
import 'package:appflowy/user/presentation/helpers/helpers.dart';
@@ -21,7 +22,19 @@ class SplashScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _buildChild(context);
if (isAnon) {
return FutureBuilder<void>(
future: _registerIfNeeded(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox.shrink();
}
return _buildChild(context);
},
);
} else {
return _buildChild(context);
}
}
BlocProvider<SplashBloc> _buildChild(BuildContext context) {
@@ -86,6 +99,13 @@ class SplashScreen extends StatelessWidget {
context.go(SkipLogInScreen.routeName);
}
}
Future<void> _registerIfNeeded() async {
final result = await UserEventGetUserProfile().send();
if (result.isFailure) {
await getIt<AuthService>().signUpAsGuest();
}
}
}
class Body extends StatelessWidget {
@@ -95,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,
),
@@ -121,7 +121,7 @@ class _MobileWorkspaceStartScreenState
selectedWorkspace!,
);
},
child: Text(LocaleKeys.signIn_anonymousMode.tr()),
child: Text(LocaleKeys.signUp_getStartedText.tr()),
),
const VSpace(spacing),
],
@@ -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,
);
}
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more