diff --git a/.github/actions/flutter_build/action.yml b/.github/actions/flutter_build/action.yml index bfcb501327a87..89153b8ea8f1f 100644 --- a/.github/actions/flutter_build/action.yml +++ b/.github/actions/flutter_build/action.yml @@ -98,5 +98,5 @@ runs: - uses: actions/upload-artifact@v4 with: - name: ${{ github.run_id }}-${{ matrix.os }} + name: ${{ github.run_id }}-${{ inputs.os }} path: appflowy_flutter.tar.gz diff --git a/.github/actions/flutter_integration_test/action.yml b/.github/actions/flutter_integration_test/action.yml index e0fa508adece2..e6274e7b0e995 100644 --- a/.github/actions/flutter_integration_test/action.yml +++ b/.github/actions/flutter_integration_test/action.yml @@ -2,6 +2,9 @@ name: Flutter Integration Test description: Run integration tests for AppFlowy inputs: + os: + description: "The OS to run the tests on" + required: true test_path: description: "The path to the integration test file" required: true @@ -49,20 +52,39 @@ runs: - name: Install prerequisites working-directory: frontend run: | - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager + if [ "$RUNNER_OS" == "Linux" ]; then + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager + elif [ "$RUNNER_OS" == "Windows" ]; then + vcpkg integrate install + elif [ "$RUNNER_OS" == "macOS" ]; then + echo 'do nothing' + fi shell: bash - name: Enable Flutter Desktop run: | - flutter config --enable-linux-desktop + if [ "$RUNNER_OS" == "Linux" ]; then + flutter config --enable-linux-desktop + elif [ "$RUNNER_OS" == "Windows" ]; then + flutter config --enable-windows-desktop + elif [ "$RUNNER_OS" == "macOS" ]; then + flutter config --enable-macos-desktop + fi shell: bash - uses: actions/download-artifact@v4 with: - name: ${{ github.run_id }}-ubuntu-latest + name: ${{ github.run_id }}-${{ inputs.os }} + + - name: Configure Windows long paths + if: runner.os == 'Windows' + shell: pwsh + run: | + New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force + git config --system core.longpaths true - name: Uncompressed appflowy_flutter run: tar -xf appflowy_flutter.tar.gz @@ -71,8 +93,14 @@ runs: - name: Run Flutter integration tests working-directory: frontend/appflowy_flutter run: | - export DISPLAY=:99 - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & - sudo apt-get install network-manager - flutter test ${{ inputs.test_path }} -d Linux --coverage + if [ "$RUNNER_OS" == "Linux" ]; then + export DISPLAY=:99 + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + sudo apt-get install network-manager + flutter test ${{ inputs.test_path }} -d Linux --coverage + elif [ "$RUNNER_OS" == "Windows" ]; then + flutter test ${{ inputs.test_path }} -d Windows --coverage + elif [ "$RUNNER_OS" == "macOS" ]; then + flutter test ${{ inputs.test_path }} -d macOS --coverage + fi shell: bash diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 1fc1b0e0527c9..c20ad814cd25a 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -104,8 +104,8 @@ jobs: os: [macos-latest] include: - os: macos-latest - flutter_profile: development-mac-x86_64 - target: x86_64-apple-darwin + flutter_profile: development-mac-arm64 + target: aarch64-apple-darwin runs-on: ${{ matrix.os }} steps: @@ -339,7 +339,7 @@ jobs: flutter test integration_test/desktop/cloud/cloud_runner.dart -d Linux --coverage shell: bash - integration_test: + linux_integration_test: needs: [prepare-linux] if: github.event.pull_request.draft != true strategy: @@ -358,8 +358,10 @@ jobs: - name: Flutter Integration Test ${{ matrix.test_number }} uses: ./.github/actions/flutter_integration_test with: + os: ${{ matrix.os }} test_path: integration_test/desktop_runner_${{ matrix.test_number }}.dart flutter_version: ${{ env.FLUTTER_VERSION }} rust_toolchain: ${{ env.RUST_TOOLCHAIN }} cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} rust_target: ${{ matrix.target }} + diff --git a/.github/workflows/windows_flutter_ci.yml b/.github/workflows/windows_flutter_ci.yml new file mode 100644 index 0000000000000..09ce3ee450dee --- /dev/null +++ b/.github/workflows/windows_flutter_ci.yml @@ -0,0 +1,116 @@ +name: Windows Tests (Flutter) + +on: + push: + branches: + - "main" + - "release/*" + paths: + - ".github/workflows/flutter_ci.yaml" + - ".github/actions/flutter_build/**" + - "frontend/rust-lib/**" + - "frontend/appflowy_flutter/**" + - "frontend/resources/**" + + pull_request: + branches: + - "main" + - "release/*" + paths: + - ".github/workflows/flutter_ci.yaml" + - ".github/actions/flutter_build/**" + - "frontend/rust-lib/**" + - "frontend/appflowy_flutter/**" + - "frontend/resources/**" + +env: + CARGO_TERM_COLOR: always + FLUTTER_VERSION: "3.27.4" + RUST_TOOLCHAIN: "1.81.0" + CARGO_MAKE_VERSION: "0.37.18" + CLOUD_VERSION: 0.6.54-amd64 + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + windows_tests: + strategy: + fail-fast: false + matrix: + os: [windows-latest] + include: + - os: windows-latest + flutter_profile: development-windows-x86 + target: x86_64-pc-windows-msvc + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: ${{ matrix.target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ matrix.os }} + workspaces: | + frontend/rust-lib + cache-all-crates: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}, duckscript_cli + + - name: Install prerequisites + working-directory: frontend + shell: bash + run: | + vcpkg integrate install + vcpkg update + cargo make appflowy-flutter-deps-tools + + - name: Build AppFlowy + working-directory: frontend + run: cargo make --profile ${{ matrix.flutter_profile }} appflowy-core-dev + shell: bash + + - name: Run code generation + working-directory: frontend + run: cargo make code_generation + shell: bash + + - name: Flutter Analyzer + working-directory: frontend/appflowy_flutter + run: flutter analyze . + shell: bash + + - name: Run Flutter unit tests + env: + DISABLE_EVENT_LOG: true + DISABLE_CI_TEST_LOG: "true" + working-directory: frontend + run: cargo make dart_unit_test_no_build + shell: bash + + - name: Run Flutter integration tests + working-directory: frontend/appflowy_flutter + run: flutter test integration_test/runner.dart -d Windows --coverage + shell: bash diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart index ee82f01d3f42b..d1e34edcb5612 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -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 diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart index 43320509ce2ee..c2e00a4b48213 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart @@ -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'; diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart index f74011ee2b0b8..39f8bfd4f6e25 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_link_preview_test.dart @@ -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 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); }); diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 4a117a71ff921..d7a505d152f72 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -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 tapContinousAnotherWay() async { diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index fa61e7f5b8103..0502e79604d46 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -45,16 +45,18 @@ Future 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 diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index 6609b101368a1..be134e0a92c90 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -66,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { break; case MobileViewBottomSheetBodyAction.delete: context.read().add(const ViewEvent.delete()); - context.pop(); + Navigator.of(context).pop(); break; case MobileViewBottomSheetBodyAction.addToFavorites: _addFavorite(context); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart index 991cf82b5d4d6..9497774298e4e 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -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(), ]; } } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index 09e22fc7461a8..a01df205496ee 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -71,12 +71,6 @@ class _MobileHomeSettingPageState extends State { } 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 { const AboutSettingGroup(), UserSessionSettingGroup( userProfile: userProfile, - showThirdPartyLogin: isLocalAuthEnabled, + showThirdPartyLogin: false, ), const VSpace(20), ], diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index cfdf3defb0c2a..37191a2ae2b65 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -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, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index b3b7cb71c5f14..617de1db509bd 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -63,8 +63,15 @@ class UserSessionSettingGroup extends StatelessWidget { ); }, builder: (context, state) { - return const ThirdPartySignInButtons( - expanded: true, + return Column( + children: [ + const ContinueWithEmailAndPassword(), + const VSpace(12.0), + const ThirdPartySignInButtons( + expanded: true, + ), + const VSpace(16.0), + ], ); }, ), diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 386be9cc15c18..70d00bcd25d19 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -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.value( - value: context.read(), + value: cardContext.read(), ), BlocProvider.value( - value: context.read(), + value: cardContext.read(), ), BlocProvider( create: (_) => ViewLockStatusBloc(view: widget.view) @@ -402,7 +402,7 @@ class _BoardContentState extends State<_BoardContent> { ), ], child: BlocBuilder( - 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() + .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().rowController.rowMeta, + onTap: (context) => widget.onOpenCard( + context.read().rowController.rowMeta, ), onShiftTap: (_) { Focus.of(context).requestFocus(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index b3d99ab84b31a..5e7eefc24eae9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -983,7 +983,6 @@ CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( return const EdgeInsets.symmetric(vertical: 10); }, ), - cache: LinkPreviewDataCache(), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index c2c18e48ebea4..0ffb7de73ae38 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -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 final isLocked = context.read()?.state.isLocked ?? false; - final themeV2 = AFThemeExtensionV2.of(context); final editor = Directionality( textDirection: textDirection, child: AppFlowyEditor( @@ -431,6 +430,7 @@ class _AppFlowyEditorPageState extends State ), ); } + final appTheme = AppFlowyTheme.of(context); return Center( child: BlocProvider.value( value: context.read(), @@ -443,15 +443,9 @@ class _AppFlowyEditorPageState extends State ), 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( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart index 35575fe85a1a9..467a847c53a0d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart @@ -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 { } 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 { 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)) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart index 429cd51221a39..0dea3d2864e59 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -324,6 +324,12 @@ class MarkdownTextRobot { required Selection selection, required String markdownText, }) async { + if (markdownText.isEmpty) { + assert(false, 'Expected non-empty markdown text'); + Log.error('Expected non-empty markdown text'); + return; + } + selection = selection.normalized; // If the selection is not a single node, do nothing. @@ -376,15 +382,42 @@ class MarkdownTextRobot { // } // ] final document = customMarkdownToDocument(markdownText); + final nodes = document.root.children; final decoder = DeltaMarkdownDecoder(); final markdownDelta = - document.nodeAtPath([0])?.delta ?? decoder.convert(markdownText); + nodes.firstOrNull?.delta ?? decoder.convert(markdownText); + + if (markdownDelta.isEmpty) { + assert(false, 'Expected non-empty markdown delta'); + Log.error('Expected non-empty markdown delta'); + return; + } // Replace the delta of the selected node. final transaction = editorState.transaction; - transaction - ..deleteText(node, startIndex, length) - ..insertTextDelta(node, startIndex, markdownDelta); + + // it means the user selected the entire sentence, we just replace the node + if (startIndex == 0 && length == node.delta?.length) { + transaction + ..insertNodes(node.path.next, nodes) + ..deleteNode(node); + } else { + // it means the user selected a part of the sentence, we need to delete the + // selected part and insert the new delta. + transaction + ..deleteText(node, startIndex, length) + ..insertTextDelta(node, startIndex, markdownDelta); + + // Add the remaining nodes to the document. + final remainingNodes = nodes.skip(1); + if (remainingNodes.isNotEmpty) { + transaction.insertNodes( + node.path.next, + remainingNodes, + ); + } + } + await editorState.apply(transaction); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart index dd3f7362a97bc..6399d3b11f860 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -186,7 +186,7 @@ Future _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 _pasteAsLinkPreview( return false; } - if (!isMobile && !isImageUrl) return false; + if (!isImageUrl) return false; // insert the text with link format final textTransaction = editorState.transaction diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart index 5beba66c3256f..905c033bda979 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart @@ -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; } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart index a6e1a78299c37..6213896feb9fe 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -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], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart index 6dc6501c03ea7..baf9702a363bd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart @@ -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 createState() => + DefaultSelectableMixinState createState() => LinkEmbedBlockComponentState(); } -class LinkEmbedBlockComponentState extends State +class LinkEmbedBlockComponentState + extends DefaultSelectableMixinState with BlockComponentConfigurable { @override BlockComponentConfiguration get configuration => widget.configuration; @@ -49,11 +53,11 @@ class LinkEmbedBlockComponentState extends State @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(false); bool isMenuShowing = false, isHovering = false; @@ -62,13 +66,14 @@ class LinkEmbedBlockComponentState extends State 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 }, 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 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 } 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 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 ), ), ), - 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 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 ), 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 ), ); } -} -enum EmbedLoadingStatus { loading, idle, error } + @override + Node get currentNode => node; + + @override + EdgeInsets get boxPadding => padding; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart index 8b9ac122a8d21..bddfcb9b5404e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -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 { 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 { 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 { 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 { 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 { 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(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart index 0032d9cd3319d..1907f68d29ec8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart @@ -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> _listeners = >{}; + static final Map _hostToParsers = { + 'www.youtube.com': YoutubeParser(), + 'youtube.com': YoutubeParser(), + 'youtu.be': YoutubeParser(), + }; - Future start(String url) async { - final data = await _cache.get(url); + Future 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 _getLinkInfo(String url) async { + Future _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 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 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 get(String url) async { + static Future get(Uri uri) async { final option = await getIt().getWithFormat( - _linkInfoPrefix + url, + '$_linkInfoPrefix$uri', (value) => LinkInfo.fromJson(jsonDecode(value)), ); return option; } - Future set(String url, LinkInfo data) async { + static Future set(Uri uri, LinkInfo data) async { await getIt().set( - _linkInfoPrefix + url, + '$_linkInfoPrefix$uri', jsonEncode(data.toJson()), ); } } + +enum LinkLoadingStatus { + loading, + idle, + error, +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index 0b6ad9fd7b560..880a33b8173cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -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, - ), - ), - 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, - ), + 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, + ), + ], ), - 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 } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart index 377216933182d..3f2128db52343 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart @@ -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 createState() => + DefaultSelectableMixinState createState() => CustomLinkPreviewBlockComponentState(); } class CustomLinkPreviewBlockComponentState - extends State + extends DefaultSelectableMixinState 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 future; + final parser = LinkParser(); + LinkLoadingStatus status = LinkLoadingStatus.loading; + late LinkInfo linkInfo = LinkInfo(url: url); final showActionsNotifier = ValueNotifier(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( + 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; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart new file mode 100644 index 0000000000000..c894811522da1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/default_selectable_mixin.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +abstract class DefaultSelectableMixinState + extends State 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 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); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart new file mode 100644 index 0000000000000..f05f4a97060d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart @@ -0,0 +1,88 @@ +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; +// ignore: depend_on_referenced_packages +import 'package:html/parser.dart' as html_parser; + +abstract class LinkInfoParser { + Future parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? 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 parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? 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.google.com/s2/favicons?sz=64&domain=${link.host}'; + + 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; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart new file mode 100644 index 0000000000000..6f1ac6fb22871 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_parsers/youtube_parser.dart @@ -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 parse( + Uri link, { + Duration timeout = const Duration(seconds: 8), + Map? 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 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 toJson() => { + 'title': title, + 'author_name': authorName, + 'version': version, + 'provider_name': providerName, + 'provider_url': providerUrl, + 'thumbnail_url': thumbnailUrl, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart deleted file mode 100644 index 6688cfe304ef3..0000000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart +++ /dev/null @@ -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 get(String url) async { - final option = - await getIt().getWithFormat( - url, - (value) => LinkPreviewData.fromJson(jsonDecode(value)), - ); - return option; - } - - @override - Future set(String url, LinkPreviewData data) async { - await getIt().set( - url, - jsonEncode(data.toJson()), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart index 16febe54cb668..d31b2f8fd9457 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -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 onSelect; final VoidCallback onDismiss; + final EditorState editorState; @override State createState() => _PasteAsMenuState(); @@ -113,24 +116,28 @@ class _PasteAsMenuState extends State { final focusNode = FocusNode(debugLabel: 'paste_as_menu'); final ValueNotifier 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 { 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 { 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 { 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 { 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 { index++; } changeIndex(index); + return KeyEventResult.handled; } - return KeyEventResult.handled; + return KeyEventResult.ignored; } void onSelect(PasteMenuType type) => widget.onSelect.call(type); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart index 7a807fef64104..06ebcb5002e6c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart @@ -42,8 +42,8 @@ class MentionLinkBlock extends StatefulWidget { class _MentionLinkBlockState extends State { 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 { 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 { 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 { 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 { maxHeight: 48 + size.height, ); } + final hasImage = linkInfo.imageUrl?.isNotEmpty ?? false; return BoxConstraints( maxWidth: max(300, size.width), - maxHeight: 300, + maxHeight: hasImage ? 300 : 180, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart index 00082f127aa38..00b161379e138 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart @@ -56,7 +56,9 @@ class _MentionLinkPreviewState extends State { 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 { 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 { ), ), 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 { HSpace(6), Expanded( child: FlowyText( - linkInfo.description ?? '', + linkInfo.siteName ?? linkInfo.url, fontSize: 12, figmaLineHeight: 16, color: textColorScheme.primary, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index 63aaf34e8d2c6..4161036a0839b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -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'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart index 77d94451c8e89..7dbf192baea11 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_format_toolbar_items.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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart index cd332e14ef440..2e115d240dbfd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart @@ -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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index 693c7a64ce81b..cbbce9c943036 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -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().hideToolbar(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart index d7517285268f6..2a1688db19f82 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_align_toolbar_item.dart @@ -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 { } 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 { 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, ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart index 525eebe917bbd..80f2d3138da81 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_text_color_toolbar_item.dart @@ -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 { @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 { } 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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart index 625fedff793ea..8140a7b7f3ebf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_heading_toolbar_item.dart @@ -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 { } 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 { 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, ), ], ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart index a0aded3f8e57d..3c8f55caef5fa 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart @@ -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 { } 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 { FlowySvg( FlowySvgs.toolbar_arrow_down_m, size: Size(12, 20), - color: themeV2.icon_tertiary, + color: iconColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart index 5c5e788ddbe01..59422712066d1 100644 --- a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -100,7 +100,6 @@ class _DesktopSyncErrorPage extends StatelessWidget { onTapUp: () { getIt().setPlainText(error.toString()); showToastNotification( - message: LocaleKeys.message_copy_success.tr(), bottomPadding: 0, ); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart index d8dc4dd1215a5..184cc9dd09b5d 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -136,7 +136,6 @@ class AppFlowyCloudDeepLink { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( - message: err.msg, ); } diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart index 54a60702c8e3d..339af51f9fad6 100644 --- a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -186,6 +186,13 @@ class SignInBloc extends Bloc { Emitter 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 { emit( result.fold( - (userProfile) => state.copyWith(isSubmitting: true), + (userProfile) => state.copyWith( + isSubmitting: false, + ), (error) => _stateFromCode(error), ), ); @@ -210,6 +219,13 @@ class SignInBloc extends Bloc { required String email, required String passcode, }) async { + if (state.isSubmitting) { + Log.error('Sign in with passcode is already in progress'); + return; + } + + Log.info('Sign in with passcode: $email, $passcode'); + emit( state.copyWith( isSubmitting: true, @@ -282,8 +298,9 @@ class SignInBloc extends Bloc { 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(); } diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart index 51c693843f73d..ccad6c0a26876 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart @@ -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( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 3a906ee0c4e55..40901e92e1de1 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_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( 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), ], ), ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index 6326e1a8113bd..9eb7d5a96526e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -7,6 +7,7 @@ import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/user/presentation/widgets/flowy_logo_title.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -21,34 +22,29 @@ class MobileSignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const double spacing = 16; - final colorScheme = Theme.of(context).colorScheme; return BlocBuilder( builder: (context, state) { + final theme = AppFlowyTheme.of(context); return Scaffold( resizeToAvoidBottomInset: false, body: Padding( padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), child: Column( children: [ - const Spacer(flex: 4), - _buildLogo(), - const VSpace(spacing), - _buildAppNameText(colorScheme), - const VSpace(spacing * 2), + const Spacer(), + FlowyLogoTitle(title: LocaleKeys.welcomeText.tr()), + VSpace(theme.spacing.xxl), isLocalAuthEnabled ? const SignInAnonymousButtonV3() : const ContinueWithEmailAndPassword(), - const VSpace(spacing), - if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), - const VSpace(spacing * 1.5), + VSpace(theme.spacing.xxl), + if (isAuthEnabled) ...[ + _buildThirdPartySignInButtons(context), + VSpace(theme.spacing.xxl), + ], const SignInAgreement(), - const VSpace(spacing), - if (!isAuthEnabled) const Spacer(flex: 2), - const Spacer(flex: 2), const Spacer(), - Expanded(child: _buildSettingsButton(context)), - if (Platform.isAndroid) const Spacer(), + _buildSettingsButton(context), ], ), ), @@ -57,25 +53,8 @@ class MobileSignInScreen extends StatelessWidget { ); } - Widget _buildLogo() { - return const FlowySvg( - FlowySvgs.flowy_logo_xl, - size: Size.square(56), - blendMode: null, - ); - } - - Widget _buildAppNameText(ColorScheme colorScheme) { - return FlowyText( - LocaleKeys.appName.tr(), - textAlign: TextAlign.center, - fontSize: 28, - color: const Color(0xFF00BCF0), - fontWeight: FontWeight.w700, - ); - } - - Widget _buildThirdPartySignInButtons(ColorScheme colorScheme) { + Widget _buildThirdPartySignInButtons(BuildContext context) { + final theme = AppFlowyTheme.of(context); return Column( children: [ Row( @@ -84,10 +63,12 @@ class MobileSignInScreen extends StatelessWidget { const Expanded(child: Divider()), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: FlowyText( + child: Text( LocaleKeys.signIn_or.tr(), - fontSize: 12, - color: colorScheme.onSecondary, + style: TextStyle( + fontSize: 16, + color: theme.textColorScheme.secondary, + ), ), ), const Expanded(child: Divider()), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart index 5b99ad83f3335..afae06d50a28d 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -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( listener: _showSignInError, builder: (context, state) { - final isLoading = context.read().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(), + successOrFail.fold( + (userProfile) { + if (userProfile.encryptionType == EncryptionTypePB.Symmetric) { + getIt().pushEncryptionScreen(context, userProfile); + } else { + getIt().goHomeScreen(context, userProfile); + } + }, + (error) { + Log.error('Sign in error: $error'); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart index c6b2d5401c43f..a7a1b9722d889 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/anonymous_sign_in_button.dart @@ -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( builder: (context, state) { - final text = LocaleKeys.signUp_getStartedText.tr(); + final text = LocaleKeys.signIn_continueWithLocalModel.tr(); final onTap = state.anonUsers.isEmpty ? () { context @@ -41,17 +41,11 @@ class SignInAnonymousButtonV3 extends StatelessWidget { final user = bloc.state.anonUsers.first; bloc.add(AnonUserEvent.openAnonUser(user)); }; - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 32), - maximumSize: const Size(double.infinity, 38), - ), - onPressed: onTap, - child: FlowyText( - text, - fontSize: 14, - color: Theme.of(context).colorScheme.onPrimary, - ), + return AFFilledTextButton.primary( + text: text, + size: AFButtonSize.l, + alignment: Alignment.center, + onTap: onTap, ); }, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart index e918c3f4f1701..8034dccd32954 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_email_and_password.dart @@ -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 { final controller = TextEditingController(); final focusNode = FocusNode(); + final emailKey = GlobalKey(); + + 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( + 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() + .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.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 - // }, // ), // ), // ); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart index ea7ff087ff8f9..5be30ef84cef5 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_magic_link_or_passcode_page.dart @@ -33,6 +33,8 @@ class _ContinueWithMagicLinkOrPasscodePageState ToastificationItem? toastificationItem; + final inputPasscodeKey = GlobalKey(); + @override void dispose() { passcodeController.dispose(); @@ -44,10 +46,13 @@ class _ContinueWithMagicLinkOrPasscodePageState Widget build(BuildContext context) { return BlocListener( 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 _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, - ]; - } - - void _showLoadingDialog() { - _dismissLoadingDialog(); + spacing, + ]; + } else { + return [ + // logo + const AFLogo(), + spacing, - toastificationItem = showToastNotification( - message: LocaleKeys.signIn_signingIn.tr(), - ); - } + // title + Text( + LocaleKeys.signIn_enterCode.tr(), + style: theme.textStyle.heading.h3( + color: theme.textColorScheme.primary, + ), + ), + spacing, - 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, + ]; } } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart index 3a281889ad8b0..4ab40011d2109 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/continue_with/continue_with_password_page.dart @@ -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 { final passwordController = TextEditingController(); + final inputPasswordKey = GlobalKey(); @override void dispose() { @@ -38,18 +41,29 @@ class _ContinueWithPasswordPageState extends State { body: Center( child: SizedBox( width: 320, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Logo and title - ..._buildLogoAndTitle(), + child: BlocListener( + 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 { 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 { 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; }, ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart index 4ea819d99789d..76ce87ffc18fd 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -1,5 +1,4 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -25,9 +24,7 @@ class SignInAgreement extends StatelessWidget { text: TextSpan( children: [ TextSpan( - text: isLocalAuthEnabled - ? '${LocaleKeys.web_signInLocalAgreement.tr()} \n' - : '${LocaleKeys.web_signInAgreement.tr()} \n', + text: LocaleKeys.web_signInAgreement.tr(), style: textStyle, ), TextSpan( diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart index a0b31c2fe55ef..33ef1d7bb087b 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -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, + ); + }, ); } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart index ed2e060c49f2f..8d27846c46080 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button/third_party_sign_in_buttons.dart @@ -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; diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart index 0435c5fe52fd0..b8dd390627346 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -25,7 +25,6 @@ Future 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 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 shareLogFiles(BuildContext? context) async { } catch (e) { if (context != null && context.mounted) { showToastNotification( - message: e.toString(), type: ToastificationType.error, ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart index 568fd76db003f..2a707b6b2de36 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -2,7 +2,6 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; class DesktopAppearance extends BaseAppearance { @@ -152,11 +151,6 @@ class DesktopAppearance extends BaseAppearance { lightIconColor: theme.lightIconColor, toolbarHoverColor: theme.toolbarHoverColor, ), - isLight - ? lightAFThemeV2 - : darkAFThemeV2.copyWith( - icon_primary: theme.icon, - ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index 7778f1c9ce4b5..eda3153459987 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -4,7 +4,6 @@ import 'package:appflowy/workspace/application/settings/appearance/base_appearan import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/theme_extension_v2.dart'; import 'package:flutter/material.dart'; class MobileAppearance extends BaseAppearance { @@ -30,7 +29,6 @@ class MobileAppearance extends BaseAppearance { ); final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); - final isLight = brightness == Brightness.light; final theme = brightness == Brightness.light ? appTheme.lightTheme @@ -285,11 +283,6 @@ class MobileAppearance extends BaseAppearance { toolbarHoverColor: theme.toolbarHoverColor, ), ToolbarColorExtension.fromBrightness(brightness), - isLight - ? lightAFThemeV2 - : darkAFThemeV2.copyWith( - icon_primary: theme.icon, - ), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart index 20160d32ec5f1..50ea9d83c728f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -169,7 +169,6 @@ class _SidebarWorkspaceState extends State { if (message != null) { showToastNotification( - message: message, type: result.fold( (_) => ToastificationType.success, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart index 8cabffc6e3a63..e6c011156b1a4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -193,7 +193,6 @@ Future deleteMyAccount( if (!isChecked) { showToastNotification( - type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -208,7 +207,6 @@ Future deleteMyAccount( if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { showToastNotification( - type: ToastificationType.warning, bottomPadding: bottomPadding, message: LocaleKeys @@ -226,7 +224,6 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - message: LocaleKeys .newSettings_myAccount_deleteAccount_deleteAccountSuccess .tr(), @@ -245,7 +242,6 @@ Future deleteMyAccount( loading.stop(); showToastNotification( - type: ToastificationType.error, bottomPadding: bottomPadding, message: f.msg, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 194ad02beb31b..a2d911ea40d22 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -157,7 +157,6 @@ class SettingsManageDataView extends StatelessWidget { if (context.mounted) { showToastNotification( - message: LocaleKeys .settings_manageDataPage_cache_dialog_successHint .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index 7e30c4fa55b00..435993f363f05 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -125,7 +125,9 @@ class _NavigatorTextFieldDialogState extends State { @override Widget build(BuildContext context) { return StyledDialog( + shrinkWrap: false, child: Column( + mainAxisSize: MainAxisSize.min, children: [ FlowyText.medium( widget.title, diff --git a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig index da469610ebce8..d2b3d7e9b395e 100644 --- a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = AppFlowy PRODUCT_BUNDLE_IDENTIFIER = io.appflowy.appflowy // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2024 AppFlowy.IO. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 AppFlowy.IO. All rights reserved. diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart index 9fed31027ffd1..595d4bb859223 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/textfield/textfield.dart @@ -5,6 +5,11 @@ typedef AFTextFieldValidator = (bool result, String errorText) Function( TextEditingController controller, ); +abstract class AFTextFieldState extends State { + void syncError({required String errorText}) {} + void clearError() {} +} + class AFTextField extends StatefulWidget { const AFTextField({ super.key, @@ -17,8 +22,12 @@ class AFTextField extends StatefulWidget { this.onChanged, this.onSubmitted, this.autoFocus, + this.height = 40.0, }); + /// The height of the text field. + final double height; + /// The hint text to display when the text field is empty. final String? hintText; @@ -52,7 +61,7 @@ class AFTextField extends StatefulWidget { State createState() => _AFTextFieldState(); } -class _AFTextFieldState extends State { +class _AFTextFieldState extends AFTextFieldState { late final TextEditingController effectiveController; bool hasError = false; @@ -146,8 +155,11 @@ class _AFTextFieldState extends State { ), ); + child = SizedBox(height: widget.height, child: child); + if (hasError && errorText.isNotEmpty) { child = Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ child, SizedBox(height: theme.spacing.xs), @@ -174,4 +186,22 @@ class _AFTextFieldState extends State { }); } } + + @override + void syncError({ + required String errorText, + }) { + setState(() { + hasError = true; + this.errorText = errorText; + }); + } + + @override + void clearError() { + setState(() { + hasError = false; + errorText = ''; + }); + } } diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart index c17dd14578f03..8923f61e1f160 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/builder.dart @@ -8,6 +8,7 @@ import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; import 'package:appflowy_ui/src/theme/dimensions.dart'; +import 'package:appflowy_ui/src/theme/shadow/shadow.dart'; import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; import 'package:flutter/material.dart'; @@ -92,6 +93,35 @@ class AppFlowyThemeBuilder { }; } + AppFlowyShadow buildShadow(Brightness brightness) { + return switch (brightness) { + Brightness.light => AppFlowyShadow( + small: const BoxShadow( + offset: Offset(0.0, 2.0), + blurRadius: 16.0, + color: Color(0x1F000000), + ), + medium: const BoxShadow( + offset: Offset(0.0, 4.0), + blurRadius: 32.0, + color: Color(0x1F000000), + ), + ), + Brightness.dark => AppFlowyShadow( + small: BoxShadow( + offset: Offset(0.0, 2.0), + blurRadius: 16.0, + color: Color(0x7A000000), + ), + medium: BoxShadow( + offset: Offset(0.0, 4.0), + blurRadius: 32.0, + color: Color(0x7A000000), + ), + ), + }; + } + AppFlowyBorderColorScheme buildBorderColorScheme( AppFlowyBaseColorScheme colorScheme, Brightness brightness, @@ -161,7 +191,7 @@ class AppFlowyThemeBuilder { quaternary: colorScheme.neutral.neutral1000, quaternaryHover: colorScheme.neutral.neutral900, transparent: colorScheme.neutral.alphaWhite0, - primaryAlpha5: colorScheme.neutral.alphaGrey100005, + primaryAlpha5: colorScheme.neutral.alphaGrey10005, primaryAlpha5Hover: colorScheme.neutral.alphaGrey10010, primaryAlpha80: colorScheme.neutral.alphaGrey100080, primaryAlpha80Hover: colorScheme.neutral.alphaGrey100070, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart index 68fb378c0ff71..9494bdf0e226d 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/data/data.dart @@ -8,6 +8,7 @@ import 'package:appflowy_ui/src/theme/color_scheme/icon/icon_color_theme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/surface/surface_color_scheme.dart'; import 'package:appflowy_ui/src/theme/color_scheme/text/text_color_scheme.dart'; import 'package:appflowy_ui/src/theme/data/builder.dart'; +import 'package:appflowy_ui/src/theme/shadow/shadow.dart'; import 'package:appflowy_ui/src/theme/spacing/spacing.dart'; import 'package:appflowy_ui/src/theme/text_style/text_style.dart'; import 'package:flutter/material.dart'; @@ -36,6 +37,8 @@ abstract class AppFlowyBaseTheme { AppFlowySpacing get spacing; AppFlowyBrandColorScheme get brandColorScheme; + + AppFlowyShadow get shadow; } class AppFlowyThemeData extends AppFlowyBaseTheme { @@ -67,10 +70,10 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { colorScheme, Brightness.light, ); + final shadow = themeBuilder.buildShadow(Brightness.light); final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); final borderRadius = themeBuilder.buildBorderRadius(colorScheme); final spacing = themeBuilder.buildSpacing(colorScheme); - return AppFlowyThemeData( colorScheme: colorScheme, textColorScheme: textColorScheme, @@ -83,6 +86,7 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { borderRadius: borderRadius, spacing: spacing, brandColorScheme: brandColorScheme, + shadow: shadow, ); } @@ -113,10 +117,10 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { colorScheme, Brightness.dark, ); + final shadow = themeBuilder.buildShadow(Brightness.dark); final brandColorScheme = themeBuilder.buildBrandColorScheme(colorScheme); final borderRadius = themeBuilder.buildBorderRadius(colorScheme); final spacing = themeBuilder.buildSpacing(colorScheme); - return AppFlowyThemeData( colorScheme: colorScheme, textColorScheme: textColorScheme, @@ -129,6 +133,7 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { borderRadius: borderRadius, spacing: spacing, brandColorScheme: brandColorScheme, + shadow: shadow, ); } @@ -144,6 +149,7 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { required this.brandColorScheme, required this.iconColorTheme, required this.backgroundColorScheme, + required this.shadow, this.brightness = Brightness.light, }); @@ -184,6 +190,9 @@ class AppFlowyThemeData extends AppFlowyBaseTheme { @override final AppFlowyBackgroundColorScheme backgroundColorScheme; + @override + final AppFlowyShadow shadow; + static AppFlowyTextColorScheme buildTextColorScheme( AppFlowyBaseColorScheme colorScheme, Brightness brightness, diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart new file mode 100644 index 0000000000000..9bb2ac111658f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/theme/shadow/shadow.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +class AppFlowyShadow { + AppFlowyShadow({required this.small, required this.medium}); + + final BoxShadow small; + final BoxShadow medium; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index 5ad2435e99eae..6f37058f00866 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -81,7 +81,7 @@ String languageFromLocale(Locale locale) { case "ur": return "اردو"; case "hin": - return "हिन्दी"; + return "हिन्दी"; } // If not found then the language code will be displayed return locale.languageCode; diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart deleted file mode 100644 index b9136a00bc106..0000000000000 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension_v2.dart +++ /dev/null @@ -1,99 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'package:flutter/material.dart'; - -@immutable -class AFThemeExtensionV2 extends ThemeExtension { - static AFThemeExtensionV2 of(BuildContext context) => - Theme.of(context).extension()!; - - static AFThemeExtensionV2? maybeOf(BuildContext context) => - Theme.of(context).extension(); - - const AFThemeExtensionV2({ - required this.icon_primary, - required this.icon_tertiary, - required this.text_tertiary, - required this.border_grey_quaternary, - required this.fill_theme_select, - required this.fill_grey_thick_alpha_1, - required this.shadow_medium, - }); - - final Color icon_primary; - final Color icon_tertiary; - final Color text_tertiary; - final Color border_grey_quaternary; - final Color fill_theme_select; - final Color fill_grey_thick_alpha_1; - final Color shadow_medium; - - @override - AFThemeExtensionV2 copyWith({ - Color? icon_primary, - Color? icon_tertiary, - Color? text_tertiary, - Color? border_grey_quaternary, - Color? fill_theme_select, - Color? fill_grey_thick_alpha_1, - Color? shadow_medium, - }) => - AFThemeExtensionV2( - icon_primary: icon_primary ?? this.icon_primary, - icon_tertiary: icon_tertiary ?? this.icon_tertiary, - text_tertiary: text_tertiary ?? this.text_tertiary, - border_grey_quaternary: - border_grey_quaternary ?? this.border_grey_quaternary, - fill_theme_select: fill_theme_select ?? this.fill_theme_select, - fill_grey_thick_alpha_1: - fill_grey_thick_alpha_1 ?? this.fill_grey_thick_alpha_1, - shadow_medium: shadow_medium ?? this.shadow_medium, - ); - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! AFThemeExtensionV2) { - return this; - } - return AFThemeExtensionV2( - icon_primary: - Color.lerp(icon_primary, other.icon_primary, t) ?? icon_primary, - icon_tertiary: - Color.lerp(icon_tertiary, other.icon_tertiary, t) ?? icon_tertiary, - text_tertiary: - Color.lerp(text_tertiary, other.text_tertiary, t) ?? text_tertiary, - border_grey_quaternary: - Color.lerp(border_grey_quaternary, other.border_grey_quaternary, t) ?? - border_grey_quaternary, - fill_theme_select: - Color.lerp(fill_theme_select, other.fill_theme_select, t) ?? - fill_theme_select, - fill_grey_thick_alpha_1: Color.lerp( - fill_grey_thick_alpha_1, other.fill_grey_thick_alpha_1, t) ?? - fill_grey_thick_alpha_1, - shadow_medium: - Color.lerp(shadow_medium, other.shadow_medium, t) ?? shadow_medium, - ); - } -} - -const AFThemeExtensionV2 darkAFThemeV2 = AFThemeExtensionV2( - icon_primary: Color(0xFF1F2329), - icon_tertiary: Color(0xFF99A1A8), - text_tertiary: Color(0xFFB5BBD3), - border_grey_quaternary: Color(0xFFE8ECF3), - fill_theme_select: Color(0x00BCF01F), - fill_grey_thick_alpha_1: Color(0x1F23290F), - shadow_medium: Color(0x1F22251F), -); - -const AFThemeExtensionV2 lightAFThemeV2 = AFThemeExtensionV2( - icon_primary: Color(0xFF1F2329), - icon_tertiary: Color(0xFF99A1A8), - text_tertiary: Color(0xFFB5BBD3), - border_grey_quaternary: Color(0xFFE8ECF3), - fill_theme_select: Color(0x00BCF01F), - fill_grey_thick_alpha_1: Color(0x1F23290F), - shadow_medium: Color(0x1F22251F), -); diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index ae44b95eb3e98..1c6d66431d4d9 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -673,14 +673,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - favicon: - dependency: "direct main" - description: - name: favicon - sha256: ebb7423ba7ccc87d3cce9b60de1b5f72004de3cddeedd88d98463fc7f66faf0c - url: "https://pub.dev" - source: hosted - version: "1.1.2" ffi: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index de26c31c1e316..0cc553d1a75ee 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -31,7 +31,6 @@ dependencies: auto_size_text_field: ^2.2.3 auto_updater: ^1.0.0 avatar_stack: ^3.0.0 - favicon: ^1.1.2 # BitsDojo Window for Windows bitsdojo_window: ^0.1.6 diff --git a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart index f2fcf8cd65136..8b1b710f4e803 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart @@ -487,6 +487,70 @@ void main() { ); expect(d7.attributes, null); }); + + test('replace markdown text with selection from start to end', () async { + final text1 = + '''The introduction of the World Wide Web in the early 1990s marked a turning point.'''; + final text2 = + '''Tim Berners-Lee's invention made the internet accessible to non-technical users, opening the floodgates for mass adoption.'''; + final text3 = + '''Email became widespread, and instant messaging services like ICQ and AOL Instant Messenger gained popularity, allowing for real-time text communication.'''; + + final document = Document( + root: pageNode( + children: [ + paragraphNode(delta: Delta()..insert(text1)), + paragraphNode(delta: Delta()..insert(text2)), + paragraphNode(delta: Delta()..insert(text3)), + ], + ), + ); + final editorState = EditorState(document: document); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: text1.length), + ); + + final markdownText = '''1. $text1 + +2. $text1 + +3. $text1'''; + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + await markdownTextRobot.replace( + selection: editorState.selection!, + markdownText: markdownText, + ); + + final nodes = editorState.document.root.children; + expect(nodes.length, 5); + + final d1 = nodes[0].delta!.toList()[0] as TextInsert; + expect(d1.text, text1); + expect(d1.attributes, null); + expect(nodes[0].type, NumberedListBlockKeys.type); + + final d2 = nodes[1].delta!.toList()[0] as TextInsert; + expect(d2.text, text1); + expect(d2.attributes, null); + expect(nodes[1].type, NumberedListBlockKeys.type); + + final d3 = nodes[2].delta!.toList()[0] as TextInsert; + expect(d3.text, text1); + expect(d3.attributes, null); + expect(nodes[2].type, NumberedListBlockKeys.type); + + final d4 = nodes[3].delta!.toList()[0] as TextInsert; + expect(d4.text, text2); + expect(d4.attributes, null); + + final d5 = nodes[4].delta!.toList()[0] as TextInsert; + expect(d5.text, text3); + expect(d5.attributes, null); + }); }); group('markdown text robot - replace in multiple lines:', () { diff --git a/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart new file mode 100644 index 0000000000000..5b6f88801a28d --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/link_preview/link_preview_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_parsers/default_parser.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + test( + 'description', + () async { + final links = [ + 'https://www.baidu.com/', + 'https://appflowy.io/', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://github.com/', + 'https://www.figma.com/design/3K0ai4FhDOJ3Lts8G3KOVP/Page?node-id=7282-4007&p=f&t=rpfvEvh9K9J9WkIo-0', + 'https://www.figma.com/files/drafts', + 'https://www.youtube.com/watch?v=LyY5Rh9qBvA', + 'https://www.youtube.com/', + 'https://www.youtube.com/watch?v=a6GDT7', + 'http://www.test.com/', + 'https://www.baidu.com/s?wd=test&rsv_spt=1&rsv_iqid=0xb6a7840b00e5324a&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=22073068_7_oem_dg&rsv_dl=tb&rsv_enter=1&rsv_sug3=5&rsv_sug1=4&rsv_sug7=100&rsv_sug2=0&rsv_btype=i&prefixsug=test&rsp=9&inputT=478&rsv_sug4=547', + 'https://www.google.com/', + 'https://www.google.com.hk/search?q=test&oq=test&gs_lcrp=EgZjaHJvbWUyCQgAEEUYORiABDIHCAEQABiABDIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIHCAUQABiABDIHCAYQABiABDIHCAcQABiABDIHCAgQLhiABDIHCAkQABiABNIBCTE4MDJqMGoxNagCCLACAfEFAQs7K9PprSfxBQELOyvT6a0n&sourceid=chrome&ie=UTF-8', + 'www.baidu.com', + 'baidu.com', + 'com', + 'https://www.baidu.com', + 'https://github.com/AppFlowy-IO/AppFlowy', + 'https://appflowy.com/app/c29fafc4-b7c0-4549-8702-71339b0fd9ea/59f36be8-9b2f-4d3e-b6a1-816c6c2043e5?blockId=GCY_T4', + ]; + + final parser = DefaultParser(); + int i = 1; + for (final link in links) { + final formatLink = LinkInfoParser.formatUrl(link); + final siteInfo = await parser + .parse(Uri.tryParse(formatLink) ?? Uri.parse(formatLink)); + if (siteInfo?.isEmpty() ?? true) { + debugPrint('$i : $formatLink ---- empty \n'); + } else { + debugPrint('$i : $formatLink ---- \n$siteInfo \n'); + } + i++; + } + }, + timeout: const Timeout(Duration(seconds: 120)), + ); +} diff --git a/frontend/resources/flowy_icons/20x/cloud_mode.svg b/frontend/resources/flowy_icons/20x/cloud_mode.svg new file mode 100644 index 0000000000000..5aaf68e3db34d --- /dev/null +++ b/frontend/resources/flowy_icons/20x/cloud_mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/sign_in_settings.svg b/frontend/resources/flowy_icons/20x/sign_in_settings.svg new file mode 100644 index 0000000000000..5d88d23086da6 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/sign_in_settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 29473d2e00b70..351dc54de4221 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -36,7 +36,8 @@ "loginButtonText": "Login", "loginStartWithAnonymous": "Continue with an anonymous session", "continueAnonymousUser": "Continue with an anonymous session", - "anonymous": "Anonymous", + "continueWithLocalModel": "Continue with local model", + "switchToAppFlowyCloud": "AppFlowy Cloud", "anonymousMode": "Anonymous mode", "buttonText": "Sign In", "signingInText": "Signing in...", @@ -48,7 +49,7 @@ "repeatPasswordEmptyError": "Repeat password can't be empty", "unmatchedPasswordError": "Repeat password is not the same as password", "syncPromptMessage": "Syncing the data might take a while. Please don't close this page", - "or": "OR", + "or": "or", "signInWithGoogle": "Continue with Google", "signInWithGithub": "Continue with GitHub", "signInWithDiscord": "Continue with Discord", @@ -70,15 +71,18 @@ "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes.", - "tokenHasExpiredOrInvalid": "The token has expired or is invalid. Please try again.", + "tokenHasExpiredOrInvalid": "The code has expired or is invalid. Please try again.", "signingIn": "Signing in...", "checkYourEmail": "Check your email", - "temporaryVerificationSent": "A temporary verification link has been sent. Please check your inbox at", + "temporaryVerificationLinkSent": "A temporary verification link has been sent.\nPlease check your inbox at", + "temporaryVerificationCodeSent": "A temporary verification code has been sent.\nPlease check your inbox at", "continueToSignIn": "Continue to sign in", "backToLogin": "Back to login", "enterCode": "Enter code", "enterCodeManually": "Enter code manually", - "continueWithEmail": "Continue with email" + "continueWithEmail": "Continue with email", + "invalidVerificationCode": "Please enter a valid verification code", + "tooFrequentVerificationCodeRequest": "You have made too many requests. Please try again later." }, "workspace": { "chooseWorkspace": "Choose your workspace", @@ -2038,7 +2042,7 @@ "reload": "Reload", "removeLink": "Remove Link", "pasteHint": "Paste in https://...", - "refuseConnect": "refued to connect." + "unableToDisplay": "unable to display" } } }, @@ -2814,8 +2818,8 @@ "continueWithApple": "Continue with Apple ", "moreOptions": "More options", "collapse": "Collapse", - "signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's", - "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to AppFlowy's", + "signInAgreement": "By clicking \"Continue\" above, you agreed to \nAppFlowy's ", + "signInLocalAgreement": "By clicking \"Get Started\" above, you agreed to \nAppFlowy's ", "and": "and", "termOfUse": "Terms", "privacyPolicy": "Privacy Policy", diff --git a/frontend/resources/translations/zh-CN.json b/frontend/resources/translations/zh-CN.json index 2731a0fb2ec9a..d1433929bca3e 100644 --- a/frontend/resources/translations/zh-CN.json +++ b/frontend/resources/translations/zh-CN.json @@ -1517,6 +1517,27 @@ "placeholder": "粘贴视频链接", "copiedToPasteBoard": "视频链接已复制到剪贴板", "insertVideo": "添加视频" + }, + "linkPreview": { + "typeSelection": { + "pasteAs": "粘贴为", + "mention": "提及", + "URL": "URL", + "bookmark": "书签", + "embed": "嵌入" + }, + "linkPreviewMenu": { + "toMetion": "转换为提及", + "toUrl": "转换为URL", + "toEmbed": "转换为嵌入", + "toBookmark": "转换为书签", + "copyLink": "复制链接", + "replace": "替换", + "reload": "重新加载", + "removeLink": "移除链接", + "pasteHint": "粘贴 https://...", + "unableToDisplay": "无法显示" + } } }, "outlineBlock": { diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index fdf8c8348e20f..006c43d696e0a 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "af-plugin", "anyhow", @@ -362,7 +362,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "anyhow", "futures-util", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=9d731d89ea2e0fd764da2effa8a456210c5a39c3#9d731d89ea2e0fd764da2effa8a456210c5a39c3" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa#093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index d3427ef99c4ea..475cefb3d43d9 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "9d731d89ea2e0fd764da2effa8a456210c5a39c3" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }