Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12198dddb7 | ||
|
|
5efdd6e8b9 | ||
|
|
d1c867d089 | ||
|
|
22b108df70 | ||
|
|
aff9a61919 | ||
|
|
043cd3f3bb | ||
|
|
29fb4af40a | ||
|
|
a2e211555e | ||
|
|
d1af172fb7 | ||
|
|
23b6f94e82 | ||
|
|
82fffba45a | ||
|
|
0373088fb8 | ||
|
|
0da0f320b7 | ||
|
|
4abdcd9478 | ||
|
|
1e54c2dc7c |
No files matched your search
@@ -248,10 +248,13 @@ jobs:
|
||||
env:
|
||||
BACKEND_VERSION: 0.3.24-amd64
|
||||
run: |
|
||||
docker compose down -v --remove-orphans
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
else
|
||||
echo "Docker container 'appflowy-cloud' is already running."
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -479,6 +479,24 @@ jobs:
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache,mode=max
|
||||
|
||||
notify-failure:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-for-macOS-x86_64
|
||||
- build-for-windows
|
||||
- build-for-linux
|
||||
if: failure()
|
||||
steps:
|
||||
- uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
text: |
|
||||
🔴🔴🔴Workflow ${{ github.workflow }} in repository ${{ github.repository }} was failed 🔴🔴🔴.
|
||||
fields: repo,message,author,eventName,ref,workflow
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }}
|
||||
if: always()
|
||||
|
||||
notify-discord:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
|
||||
@@ -106,11 +106,14 @@ jobs:
|
||||
|
||||
- name: Run Docker-Compose
|
||||
working-directory: AppFlowy-Cloud
|
||||
env:
|
||||
BACKEND_VERSION: 0.3.24-amd64
|
||||
run: |
|
||||
docker pull appflowyinc/appflowy_cloud:latest
|
||||
docker compose up -d
|
||||
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
else
|
||||
echo "Docker container 'appflowy-cloud' is already running."
|
||||
fi
|
||||
|
||||
- name: Run rust-lib tests
|
||||
working-directory: frontend/rust-lib
|
||||
|
||||
@@ -12,6 +12,8 @@ env:
|
||||
NODE_VERSION: "18.16.0"
|
||||
PNPM_VERSION: "8.5.0"
|
||||
RUST_TOOLCHAIN: "1.77.2"
|
||||
CARGO_MAKE_VERSION: "0.36.6"
|
||||
CI: true
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -22,9 +24,6 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: self-hosted
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install frontend dependencies
|
||||
@@ -49,14 +48,11 @@ jobs:
|
||||
|
||||
tauri-build-ubuntu:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
CI: true
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Maximize build space (ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
- uses: actions/checkout@v4
|
||||
- name: Maximize build space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
@@ -85,36 +81,27 @@ jobs:
|
||||
override: true
|
||||
profile: minimal
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./frontend/appflowy_web_app/src-tauri -> target"
|
||||
|
||||
- name: Node_modules cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: frontend/appflowy_web_app/node_modules
|
||||
key: node-modules-${{ runner.os }}
|
||||
|
||||
- name: install dependencies (windows only)
|
||||
if: matrix.os == 'windows-latest'
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force duckscript_cli
|
||||
vcpkg integrate install
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
- name: install dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: install cargo-make
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}
|
||||
|
||||
- name: install tauri deps tools
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo make appflowy-tauri-deps-tools
|
||||
shell: bash
|
||||
|
||||
- name: install frontend dependencies
|
||||
working-directory: frontend/appflowy_web_app
|
||||
|
||||
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
APPFLOWY_VERSION = "0.6.5"
|
||||
APPFLOWY_VERSION = "0.6.6"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -42,7 +42,7 @@ void main() {
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
// reanme the name of the anon user
|
||||
// rename the name of the anon user
|
||||
await tester.openSettings();
|
||||
await tester.openSettingsPage(SettingsPage.account);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -47,11 +47,11 @@ void main() {
|
||||
);
|
||||
await tester.tapButton(find.byType(SignInOutButton));
|
||||
|
||||
tester.expectToSeeText(LocaleKeys.button_confirm.tr());
|
||||
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
|
||||
tester.expectToSeeText(LocaleKeys.button_ok.tr());
|
||||
await tester.tapButtonWithName(LocaleKeys.button_ok.tr());
|
||||
|
||||
// Go to the sign in page again
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 5));
|
||||
tester.expectToSeeGoogleLoginButton();
|
||||
});
|
||||
|
||||
|
||||
@@ -50,26 +50,28 @@ void main() {
|
||||
await tester.tapEscButton();
|
||||
|
||||
// wait 2 seconds for the sync to finish
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 6));
|
||||
});
|
||||
|
||||
|
||||
testWidgets('get user icon and name from server', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
email: email,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.openSettings();
|
||||
await tester.openSettingsPage(SettingsPage.account);
|
||||
|
||||
// Verify name
|
||||
final profileSetting =
|
||||
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
|
||||
|
||||
expect(profileSetting.name, name);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('get user icon and name from server', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
email: email,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.openSettings();
|
||||
await tester.openSettingsPage(SettingsPage.account);
|
||||
|
||||
// Verify name
|
||||
final profileSetting =
|
||||
tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting;
|
||||
|
||||
expect(profileSetting.name, name);
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import 'document_with_image_block_test.dart' as document_with_image_block_test;
|
||||
import 'document_with_inline_math_equation_test.dart'
|
||||
as document_with_inline_math_equation_test;
|
||||
import 'document_with_inline_page_test.dart' as document_with_inline_page_test;
|
||||
import 'document_with_multi_image_block_test.dart'
|
||||
as document_with_multi_image_block_test;
|
||||
import 'document_with_outline_block_test.dart' as document_with_outline_block;
|
||||
import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
|
||||
import 'edit_document_test.dart' as document_edit_test;
|
||||
@@ -38,6 +40,7 @@ void startTesting() {
|
||||
document_text_direction_test.main();
|
||||
document_option_action_test.main();
|
||||
document_with_image_block_test.main();
|
||||
document_with_multi_image_block_test.main();
|
||||
document_inline_page_reference_test.main();
|
||||
document_more_actions_test.main();
|
||||
}
|
||||
+71
-8
@@ -1,21 +1,22 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
hide UploadImageMenu, ResizableImage;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -36,7 +37,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
|
||||
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@@ -84,7 +85,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
|
||||
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@@ -137,7 +138,7 @@ void main() {
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: LocaleKeys.document_plugins_image_addAnImage.tr(),
|
||||
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
@@ -161,5 +162,67 @@ void main() {
|
||||
expect(find.byType(UnsplashImageWidget), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('insert two images from local file at once', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName('Image');
|
||||
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(ImagePlaceholder), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ImagePlaceholder),
|
||||
matching: find.byType(AppFlowyPopover),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(UploadImageMenu), findsOneWidget);
|
||||
|
||||
final firstImage =
|
||||
await rootBundle.load('assets/test/images/sample.jpeg');
|
||||
final secondImage =
|
||||
await rootBundle.load('assets/test/images/sample.gif');
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
|
||||
final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg');
|
||||
final firstFile = File(firstImagePath)
|
||||
..writeAsBytesSync(firstImage.buffer.asUint8List());
|
||||
|
||||
final secondImagePath = p.join(tempDirectory.path, 'sample.gif');
|
||||
final secondFile = File(secondImagePath)
|
||||
..writeAsBytesSync(secondImage.buffer.asUint8List());
|
||||
|
||||
mockPickFilePaths(paths: [firstImagePath, secondImagePath]);
|
||||
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ResizableImage), findsNWidgets(2));
|
||||
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(firstNode.type, ImageBlockKeys.type);
|
||||
expect(firstNode.attributes[ImageBlockKeys.url], isNotEmpty);
|
||||
|
||||
final secondNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(secondNode.type, ImageBlockKeys.type);
|
||||
expect(secondNode.attributes[ImageBlockKeys.url], isNotEmpty);
|
||||
|
||||
// remove the temp files
|
||||
await Future.wait([firstFile.delete(), secondFile.delete()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.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/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
import '../board/board_hide_groups_test.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
group('multi image block in document', () {
|
||||
testWidgets('insert images from local and use interactive viewer',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'multi image block test',
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName('Photo gallery');
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(MultiImagePlaceholder));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(UploadImageMenu), findsOneWidget);
|
||||
|
||||
final firstImage =
|
||||
await rootBundle.load('assets/test/images/sample.jpeg');
|
||||
final secondImage =
|
||||
await rootBundle.load('assets/test/images/sample.gif');
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
|
||||
final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg');
|
||||
final firstFile = File(firstImagePath)
|
||||
..writeAsBytesSync(firstImage.buffer.asUint8List());
|
||||
|
||||
final secondImagePath = p.join(tempDirectory.path, 'sample.gif');
|
||||
final secondFile = File(secondImagePath)
|
||||
..writeAsBytesSync(secondImage.buffer.asUint8List());
|
||||
|
||||
mockPickFilePaths(paths: [firstImagePath, secondImagePath]);
|
||||
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ImageBrowserLayout), findsOneWidget);
|
||||
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(node.type, MultiImageBlockKeys.type);
|
||||
|
||||
final data = MultiImageData.fromJson(
|
||||
node.attributes[MultiImageBlockKeys.images],
|
||||
);
|
||||
|
||||
expect(data.images.length, 2);
|
||||
|
||||
// Start using the interactive viewer to view the image(s)
|
||||
final imageFinder = find
|
||||
.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Image &&
|
||||
w.image is FileImage &&
|
||||
(w.image as FileImage).file.path.endsWith('.jpeg'),
|
||||
)
|
||||
.first;
|
||||
await tester.tap(imageFinder);
|
||||
await tester.pump(kDoubleTapMinTime);
|
||||
await tester.tap(imageFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final ivFinder = find.byType(InteractiveImageViewer);
|
||||
expect(ivFinder, findsOneWidget);
|
||||
|
||||
// go to next image
|
||||
await tester.tap(find.byFlowySvg(FlowySvgs.arrow_right_s));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expect image to end with .gif
|
||||
final gifImageFinder = find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Image &&
|
||||
w.image is FileImage &&
|
||||
(w.image as FileImage).file.path.endsWith('.gif'),
|
||||
);
|
||||
|
||||
gifImageFinder.evaluate();
|
||||
expect(gifImageFinder.found.length, 2);
|
||||
|
||||
// go to previous image
|
||||
await tester.tap(find.byFlowySvg(FlowySvgs.arrow_left_s));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
gifImageFinder.evaluate();
|
||||
expect(gifImageFinder.found.length, 1);
|
||||
|
||||
// remove the temp files
|
||||
await Future.wait([firstFile.delete(), secondFile.delete()]);
|
||||
});
|
||||
|
||||
testWidgets('insert and delete images from network', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'multi image block test',
|
||||
);
|
||||
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName('Photo gallery');
|
||||
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
|
||||
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(MultiImagePlaceholder));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(UploadImageMenu), findsOneWidget);
|
||||
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
);
|
||||
|
||||
const url =
|
||||
'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.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
url,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.text(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
findRichText: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ImageBrowserLayout), findsOneWidget);
|
||||
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(node.type, MultiImageBlockKeys.type);
|
||||
|
||||
final data = MultiImageData.fromJson(
|
||||
node.attributes[MultiImageBlockKeys.images],
|
||||
);
|
||||
|
||||
expect(data.images.length, 1);
|
||||
|
||||
final imageFinder = find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is FlowyNetworkImage && w.url == url,
|
||||
)
|
||||
.first;
|
||||
|
||||
// Insert two images from network
|
||||
for (int i = 0; i < 2; i++) {
|
||||
// Hover on the image to show the image toolbar
|
||||
await tester.hoverOnWidget(
|
||||
imageFinder,
|
||||
onHover: () async {
|
||||
// Click on the add
|
||||
final addFinder = find.descendant(
|
||||
of: find.byType(MultiImageMenu),
|
||||
matching: find.byFlowySvg(FlowySvgs.add_s),
|
||||
);
|
||||
|
||||
expect(addFinder, findsOneWidget);
|
||||
await tester.tap(addFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
url,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(EmbedImageUrlWidget),
|
||||
matching: find.text(
|
||||
LocaleKeys.document_imageBlock_embedLink_label.tr(),
|
||||
findRichText: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// There should be 4 images visible now, where 2 are thumbnails
|
||||
expect(find.byType(ThumbnailItem), findsNWidgets(3));
|
||||
|
||||
// And all three use ImageRender
|
||||
expect(find.byType(ImageRender), findsNWidgets(4));
|
||||
|
||||
// Hover on and delete the first thumbnail image
|
||||
await tester.hoverOnWidget(find.byType(ThumbnailItem).first);
|
||||
|
||||
final deleteFinder = find
|
||||
.descendant(
|
||||
of: find.byType(ThumbnailItem),
|
||||
matching: find.byFlowySvg(FlowySvgs.delete_s),
|
||||
)
|
||||
.first;
|
||||
|
||||
expect(deleteFinder, findsOneWidget);
|
||||
await tester.tap(deleteFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ImageRender), findsNWidgets(3));
|
||||
|
||||
// Delete one from interactive viewer
|
||||
await tester.tap(imageFinder);
|
||||
await tester.pump(kDoubleTapMinTime);
|
||||
await tester.tap(imageFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final ivFinder = find.byType(InteractiveImageViewer);
|
||||
expect(ivFinder, findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(InteractiveImageToolbar),
|
||||
matching: find.byFlowySvg(FlowySvgs.delete_s),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(InteractiveImageViewer), findsNothing);
|
||||
|
||||
// There should be 1 image and the thumbnail for said image still visible
|
||||
expect(find.byType(ImageRender), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
|
||||
@@ -7,6 +5,7 @@ import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflow
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'util.dart';
|
||||
@@ -29,8 +28,8 @@ extension AppFlowyAuthTest on WidgetTester {
|
||||
|
||||
await tapButton(find.byType(SignInOutButton));
|
||||
|
||||
expectToSeeText(LocaleKeys.button_confirm.tr());
|
||||
await tapButtonWithName(LocaleKeys.button_confirm.tr());
|
||||
expectToSeeText(LocaleKeys.button_ok.tr());
|
||||
await tapButtonWithName(LocaleKeys.button_ok.tr());
|
||||
}
|
||||
|
||||
Future<void> tapSignInAsGuest() async {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
|
||||
@@ -10,12 +13,10 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/window_title_bar.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class CocoaWindowChannel {
|
||||
@@ -104,19 +102,17 @@ class MoveWindowDetectorState extends State<MoveWindowDetector> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final color = Theme.of(context).isLightMode ? Colors.white : Colors.black;
|
||||
final textSpan = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.sideBar_openSidebar.tr()}\n',
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: color),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.copyWith(color: Theme.of(context).hintColor),
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -125,7 +125,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
return child;
|
||||
},
|
||||
)
|
||||
: child;
|
||||
: SafeArea(child: child);
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: isDocument,
|
||||
appBar: appBar,
|
||||
|
||||
@@ -17,7 +17,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
required this.questionId,
|
||||
}) : super(ChatAIMessageState.initial(message)) {
|
||||
if (state.stream != null) {
|
||||
_subscription = state.stream!.listen(
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
@@ -108,13 +108,6 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_subscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
StreamSubscription<String>? _subscription;
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ class AnswerStream {
|
||||
_port.close();
|
||||
}
|
||||
|
||||
StreamSubscription<String> listen({
|
||||
void listen({
|
||||
void Function(String text)? onData,
|
||||
void Function()? onStart,
|
||||
void Function()? onEnd,
|
||||
@@ -602,7 +602,5 @@ class AnswerStream {
|
||||
if (_onStart != null) {
|
||||
_onStart!();
|
||||
}
|
||||
|
||||
return _subscription;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -13,6 +15,11 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
}) : listener = LocalLLMListener(),
|
||||
super(const ChatFileState()) {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(ChatFileEvent.updatePluginState(pluginState));
|
||||
}
|
||||
},
|
||||
chatStateCallback: (chatState) {
|
||||
if (!isClosed) {
|
||||
add(ChatFileEvent.updateChatState(chatState));
|
||||
@@ -38,18 +45,55 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
},
|
||||
);
|
||||
},
|
||||
newFile: (String filePath) {
|
||||
newFile: (String filePath, String fileName) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indexFileIndicator: IndexFileIndicator.indexing(fileName),
|
||||
),
|
||||
);
|
||||
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
|
||||
ChatEventChatWithFile(payload).send();
|
||||
unawaited(
|
||||
ChatEventChatWithFile(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold((_) {
|
||||
add(
|
||||
ChatFileEvent.updateIndexFile(
|
||||
IndexFileIndicator.finish(fileName),
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
add(
|
||||
ChatFileEvent.updateIndexFile(
|
||||
IndexFileIndicator.error(err.msg),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
updateChatState: (LocalAIChatPB chatState) {
|
||||
// Only user enable chat with file and the plugin is already running
|
||||
final supportChatWithFile = chatState.fileEnabled &&
|
||||
chatState.pluginState.state == RunningStatePB.Running;
|
||||
emit(
|
||||
state.copyWith(supportChatWithFile: supportChatWithFile),
|
||||
state.copyWith(
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
chatState: chatState,
|
||||
),
|
||||
);
|
||||
},
|
||||
updateIndexFile: (IndexFileIndicator indicator) {
|
||||
emit(
|
||||
state.copyWith(indexFileIndicator: indicator),
|
||||
);
|
||||
},
|
||||
updatePluginState: (LocalAIPluginStatePB chatState) {
|
||||
final fileEnabled = state.chatState?.fileEnabled ?? false;
|
||||
final supportChatWithFile =
|
||||
fileEnabled && chatState.state == RunningStatePB.Running;
|
||||
emit(state.copyWith(supportChatWithFile: supportChatWithFile));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -67,20 +111,29 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
@freezed
|
||||
class ChatFileEvent with _$ChatFileEvent {
|
||||
const factory ChatFileEvent.initial() = Initial;
|
||||
const factory ChatFileEvent.newFile(String filePath) = _NewFile;
|
||||
const factory ChatFileEvent.newFile(String filePath, String fileName) =
|
||||
_NewFile;
|
||||
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
|
||||
_UpdateChatState;
|
||||
const factory ChatFileEvent.updatePluginState(
|
||||
LocalAIPluginStatePB chatState,
|
||||
) = _UpdatePluginState;
|
||||
const factory ChatFileEvent.updateIndexFile(IndexFileIndicator indicator) =
|
||||
_UpdateIndexFile;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatFileState with _$ChatFileState {
|
||||
const factory ChatFileState({
|
||||
@Default(false) bool supportChatWithFile,
|
||||
IndexFileIndicator? indexFileIndicator,
|
||||
LocalAIChatPB? chatState,
|
||||
}) = _ChatFileState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIChatFileIndicator with _$LocalAIChatFileIndicator {
|
||||
const factory LocalAIChatFileIndicator.ready(bool isEnabled) = _Ready;
|
||||
const factory LocalAIChatFileIndicator.loading() = _Loading;
|
||||
class IndexFileIndicator with _$IndexFileIndicator {
|
||||
const factory IndexFileIndicator.finish(String fileName) = _Finish;
|
||||
const factory IndexFileIndicator.indexing(String fileName) = _Indexing;
|
||||
const factory IndexFileIndicator.error(String error) = _Error;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -71,7 +73,8 @@ class AIChatPage extends StatelessWidget {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => ChatFileBloc(chatId: view.id.toString()),
|
||||
create: (_) => ChatFileBloc(chatId: view.id.toString())
|
||||
..add(const ChatFileEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
@@ -81,28 +84,40 @@ class AIChatPage extends StatelessWidget {
|
||||
),
|
||||
BlocProvider(create: (_) => ChatInputBloc()),
|
||||
],
|
||||
child: BlocBuilder<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
Widget child = _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
);
|
||||
|
||||
// If the chat supports file upload, wrap the chat content with a drop target
|
||||
if (state.supportChatWithFile) {
|
||||
child = DropTarget(
|
||||
child: BlocListener<ChatFileBloc, ChatFileState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.indexFileIndicator != current.indexFileIndicator,
|
||||
listener: (context, state) {
|
||||
_handleIndexIndicator(state.indexFileIndicator, context);
|
||||
},
|
||||
child: BlocBuilder<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
return DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path));
|
||||
if (state.supportChatWithFile) {
|
||||
await showConfirmDialog(
|
||||
context: context,
|
||||
style: ConfirmPopupStyle.cancelAndOk,
|
||||
title: LocaleKeys.chat_chatWithFilePrompt.tr(),
|
||||
confirmLabel: LocaleKeys.button_confirm.tr(),
|
||||
onConfirm: () {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path, file.name));
|
||||
}
|
||||
},
|
||||
description: '',
|
||||
);
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -114,6 +129,35 @@ class AIChatPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleIndexIndicator(
|
||||
IndexFileIndicator? indicator,
|
||||
BuildContext context,
|
||||
) {
|
||||
if (indicator != null) {
|
||||
indicator.when(
|
||||
finish: (fileName) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.chat_indexFileSuccess.tr(args: [fileName]),
|
||||
);
|
||||
},
|
||||
indexing: (fileName) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.chat_indexingFile.tr(args: [fileName]),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
},
|
||||
error: (err) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
err,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatContentPage extends StatefulWidget {
|
||||
|
||||
@@ -67,8 +67,7 @@ class _ChatInputState extends State<ChatInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController =
|
||||
widget.options.textEditingController ?? InputTextFieldController();
|
||||
_textController = InputTextFieldController();
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
|
||||
@@ -85,9 +84,7 @@ class _ChatInputState extends State<ChatInput> {
|
||||
final partialText = types.PartialText(text: trimmedText);
|
||||
widget.onSendPressed(partialText);
|
||||
|
||||
if (widget.options.inputClearMode == InputClearMode.always) {
|
||||
_textController.clear();
|
||||
}
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +103,6 @@ class _ChatInputState extends State<ChatInput> {
|
||||
const inputPadding = EdgeInsets.all(6);
|
||||
|
||||
return Focus(
|
||||
autofocus: !widget.options.autofocus,
|
||||
child: Padding(
|
||||
padding: inputPadding,
|
||||
child: Material(
|
||||
@@ -148,15 +144,11 @@ class _ChatInputState extends State<ChatInput> {
|
||||
style: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
autocorrect: widget.options.autocorrect,
|
||||
autofocus: widget.options.autofocus,
|
||||
enableSuggestions: widget.options.enableSuggestions,
|
||||
keyboardType: widget.options.keyboardType,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
maxLines: 10,
|
||||
minLines: 1,
|
||||
onChanged: widget.options.onTextChanged,
|
||||
onTap: widget.options.onTextFieldTap,
|
||||
onChanged: (_) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -207,53 +199,6 @@ class _ChatInputState extends State<ChatInput> {
|
||||
);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class InputOptions {
|
||||
const InputOptions({
|
||||
this.inputClearMode = InputClearMode.always,
|
||||
this.keyboardType = TextInputType.multiline,
|
||||
this.onTextChanged,
|
||||
this.onTextFieldTap,
|
||||
this.textEditingController,
|
||||
this.autocorrect = true,
|
||||
this.autofocus = false,
|
||||
this.enableSuggestions = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// Controls the [ChatInput] clear behavior. Defaults to [InputClearMode.always].
|
||||
final InputClearMode inputClearMode;
|
||||
|
||||
/// Controls the [ChatInput] keyboard type. Defaults to [TextInputType.multiline].
|
||||
final TextInputType keyboardType;
|
||||
|
||||
/// Will be called whenever the text inside [TextField] changes.
|
||||
final void Function(String)? onTextChanged;
|
||||
|
||||
/// Will be called on [TextField] tap.
|
||||
final VoidCallback? onTextFieldTap;
|
||||
|
||||
/// Custom [TextEditingController]. If not provided, defaults to the
|
||||
/// [InputTextFieldController], which extends [TextEditingController] and has
|
||||
/// additional fatures like markdown support. If you want to keep additional
|
||||
/// features but still need some methods from the default [TextEditingController],
|
||||
/// you can create your own [InputTextFieldController] (imported from this lib)
|
||||
/// and pass it here.
|
||||
final TextEditingController? textEditingController;
|
||||
|
||||
/// Controls the [TextInput] autocorrect behavior. Defaults to [true].
|
||||
final bool autocorrect;
|
||||
|
||||
/// Whether [TextInput] should have focus. Defaults to [false].
|
||||
final bool autofocus;
|
||||
|
||||
/// Controls the [TextInput] enableSuggestions behavior. Defaults to [true].
|
||||
final bool enableSuggestions;
|
||||
|
||||
/// Controls the [TextInput] enabled behavior. Defaults to [true].
|
||||
final bool enabled;
|
||||
}
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,6 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class RelatedQuestionList extends StatelessWidget {
|
||||
const RelatedQuestionList({
|
||||
required this.chatId,
|
||||
@@ -97,6 +96,7 @@ class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
|
||||
style: TextStyle(
|
||||
color: _isHovered ? Theme.of(context).colorScheme.primary : null,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
|
||||
+11
-9
@@ -52,15 +52,6 @@ class _AppFlowyEditorMarkdown extends StatefulWidget {
|
||||
|
||||
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
late EditorState editorState;
|
||||
late final styleCustomizer = EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
late final editorStyle = styleCustomizer.style().copyWith(
|
||||
// hide the cursor
|
||||
cursorColor: Colors.transparent,
|
||||
cursorWidth: 0,
|
||||
);
|
||||
late EditorScrollController scrollController;
|
||||
|
||||
@override
|
||||
@@ -99,6 +90,17 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// don't lazy load the styleCustomizer and blockBuilders,
|
||||
// it needs the context to get the theme.
|
||||
final styleCustomizer = EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
final editorStyle = styleCustomizer.style().copyWith(
|
||||
// hide the cursor
|
||||
cursorColor: Colors.transparent,
|
||||
cursorWidth: 0,
|
||||
);
|
||||
final blockBuilders = getEditorBuilderMap(
|
||||
context: context,
|
||||
editorState: editorState,
|
||||
|
||||
+5
-3
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/field_info.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
|
||||
@@ -16,6 +14,7 @@ import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class CalculateCell extends StatefulWidget {
|
||||
@@ -141,11 +140,14 @@ class _CalculateCellState extends State<CalculateCell> {
|
||||
TextSpan(
|
||||
text: widget.calculation!.calculationType.shortLabel
|
||||
.toUpperCase(),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
const TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: calculateValue,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,7 +6,10 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -16,10 +19,17 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
const _excludeFromDropTarget = [
|
||||
ImageBlockKeys.type,
|
||||
CustomImageBlockKeys.type,
|
||||
MultiImageBlockKeys.type,
|
||||
];
|
||||
|
||||
class DocumentPage extends StatefulWidget {
|
||||
const DocumentPage({
|
||||
super.key,
|
||||
@@ -125,15 +135,62 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
},
|
||||
);
|
||||
} else {
|
||||
child = AppFlowyEditorPage(
|
||||
editorState: state.editorState!,
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
// the 44 is the width of the left action list
|
||||
padding: EditorStyleCustomizer.documentPadding,
|
||||
child = DropTarget(
|
||||
onDragExited: (_) =>
|
||||
state.editorState!.selectionService.removeDropTarget(),
|
||||
onDragUpdated: (details) {
|
||||
final data = state.editorState!.selectionService
|
||||
.getDropTargetRenderData(details.globalPosition);
|
||||
|
||||
if (data != null &&
|
||||
data.dropTarget != null &&
|
||||
|
||||
// We implement custom Drop logic for image blocks, this is
|
||||
// how we can exclude them from the Drop Target
|
||||
!_excludeFromDropTarget.contains(data.cursorNode?.type)) {
|
||||
// Render the drop target
|
||||
state.editorState!.selectionService
|
||||
.renderDropTargetForOffset(details.globalPosition);
|
||||
} else {
|
||||
state.editorState!.selectionService.removeDropTarget();
|
||||
}
|
||||
},
|
||||
onDragDone: (details) async {
|
||||
state.editorState!.selectionService.removeDropTarget();
|
||||
|
||||
final data = state.editorState!.selectionService
|
||||
.getDropTargetRenderData(details.globalPosition);
|
||||
|
||||
if (data != null) {
|
||||
if (data.cursorNode != null) {
|
||||
if ([
|
||||
ImageBlockKeys.type,
|
||||
CustomImageBlockKeys.type,
|
||||
MultiImageBlockKeys.type,
|
||||
].contains(data.cursorNode?.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
||||
await editorState!.dropImages(
|
||||
data.dropTarget!,
|
||||
details.files,
|
||||
widget.view.id,
|
||||
isLocalMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: AppFlowyEditorPage(
|
||||
editorState: state.editorState!,
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
// the 44 is the width of the left action list
|
||||
padding: EditorStyleCustomizer.documentPadding,
|
||||
),
|
||||
header: _buildCoverAndIcon(context, state),
|
||||
initialSelection: widget.initialSelection,
|
||||
),
|
||||
header: _buildCoverAndIcon(context, state),
|
||||
initialSelection: widget.initialSelection,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+28
-5
@@ -1,17 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
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_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
@@ -107,13 +110,33 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
ImageBlockKeys.type: CustomImageBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
showMenu: true,
|
||||
menuBuilder: (Node node, CustomImageBlockComponentState state) =>
|
||||
Positioned(
|
||||
menuBuilder: (node, state) => Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: ImageMenu(node: node, state: state),
|
||||
),
|
||||
),
|
||||
MultiImageBlockKeys.type: MultiImageBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
showMenu: true,
|
||||
menuBuilder: (
|
||||
Node node,
|
||||
MultiImageBlockComponentState state,
|
||||
ValueNotifier<int> indexNotifier,
|
||||
VoidCallback onImageDeleted,
|
||||
) =>
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: MultiImageMenu(
|
||||
node: node,
|
||||
state: state,
|
||||
indexNotifier: indexNotifier,
|
||||
isLocalMode: context.read<DocumentBloc>().isLocalMode,
|
||||
onImageDeleted: onImageDeleted,
|
||||
),
|
||||
),
|
||||
),
|
||||
TableBlockKeys.type: TableBlockComponentBuilder(
|
||||
menuBuilder: (node, editorState, position, dir, onBuild, onClose) =>
|
||||
TableMenu(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
|
||||
@@ -28,8 +31,6 @@ import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
final codeBlockLocalization = CodeBlockLocalizations(
|
||||
@@ -352,6 +353,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
},
|
||||
child: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400),
|
||||
),
|
||||
dropTargetStyle: AppFlowyDropTargetStyle(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.8),
|
||||
margin: const EdgeInsets.only(left: 44),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -415,6 +420,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
emojiMenuItem,
|
||||
autoGeneratorMenuItem,
|
||||
dateMenuItem,
|
||||
multiImageMenuItem,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -91,10 +91,8 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> {
|
||||
margin: const EdgeInsets.all(4),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
offset: const Offset(0, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onTertiary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
decorationColor: Theme.of(context).colorScheme.onTertiary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
popupBuilder: (_) {
|
||||
keepEditorFocusNotifier.increase();
|
||||
return _AlignButtons(onAlignChanged: widget.onAlignChanged);
|
||||
|
||||
+40
@@ -3,12 +3,16 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -21,6 +25,42 @@ extension PasteFromImage on EditorState {
|
||||
'gif',
|
||||
];
|
||||
|
||||
Future<void> dropImages(
|
||||
Node dropNode,
|
||||
List<XFile> files,
|
||||
String documentId,
|
||||
bool isLocalMode,
|
||||
) async {
|
||||
final imageFiles = files.where(
|
||||
(file) =>
|
||||
file.mimeType?.startsWith('image/') ??
|
||||
false || imgExtensionRegex.hasMatch(file.name),
|
||||
);
|
||||
|
||||
for (final file in imageFiles) {
|
||||
String? path;
|
||||
CustomImageType? type;
|
||||
if (isLocalMode) {
|
||||
path = await saveImageToLocalStorage(file.path);
|
||||
type = CustomImageType.local;
|
||||
} else {
|
||||
(path, _) = await saveImageToCloudStorage(file.path, documentId);
|
||||
type = CustomImageType.internal;
|
||||
}
|
||||
|
||||
if (path == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final t = transaction
|
||||
..insertNode(
|
||||
dropNode.path,
|
||||
customImageNode(url: path, type: type),
|
||||
);
|
||||
await apply(t);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> pasteImage(
|
||||
String format,
|
||||
Uint8List imageBytes,
|
||||
|
||||
+11
-7
@@ -1,5 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
@@ -9,9 +11,9 @@ import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
@@ -23,7 +25,6 @@ import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
@@ -482,9 +483,12 @@ class DocumentCoverState extends State<DocumentCover> {
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
],
|
||||
onSelectedLocalImage: (path) async {
|
||||
onSelectedLocalImages: (paths) async {
|
||||
context.pop();
|
||||
widget.onChangeCover(CoverType.file, path);
|
||||
widget.onChangeCover(
|
||||
CoverType.file,
|
||||
paths.first,
|
||||
);
|
||||
},
|
||||
onSelectedAIImage: (_) {
|
||||
throw UnimplementedError();
|
||||
@@ -608,9 +612,9 @@ class DocumentCoverState extends State<DocumentCover> {
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
],
|
||||
onSelectedLocalImage: (path) {
|
||||
onSelectedLocalImages: (paths) {
|
||||
popoverController.close();
|
||||
onCoverChanged(CoverType.file, path);
|
||||
onCoverChanged(CoverType.file, paths.first);
|
||||
},
|
||||
onSelectedAIImage: (_) {
|
||||
throw UnimplementedError();
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum CustomImageType {
|
||||
local,
|
||||
internal, // the images saved in self-host cloud
|
||||
external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg
|
||||
|
||||
static CustomImageType fromIntValue(int value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return CustomImageType.local;
|
||||
case 1:
|
||||
return CustomImageType.internal;
|
||||
case 2:
|
||||
return CustomImageType.external;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
int toIntValue() {
|
||||
switch (this) {
|
||||
case CustomImageType.local:
|
||||
return 0;
|
||||
case CustomImageType.internal:
|
||||
return 1;
|
||||
case CustomImageType.external:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImageBlockData {
|
||||
factory ImageBlockData.fromJson(Map<String, dynamic> json) {
|
||||
return ImageBlockData(
|
||||
url: json['url'] as String? ?? '',
|
||||
type: CustomImageType.fromIntValue(json['type'] as int),
|
||||
);
|
||||
}
|
||||
|
||||
ImageBlockData({required this.url, required this.type});
|
||||
|
||||
final String url;
|
||||
final CustomImageType type;
|
||||
|
||||
bool get isLocal => type == CustomImageType.local;
|
||||
bool get isNotInternal => type != CustomImageType.internal;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'url': url, 'type': type.toIntValue()};
|
||||
}
|
||||
|
||||
ImageProvider toImageProvider() {
|
||||
switch (type) {
|
||||
case CustomImageType.internal:
|
||||
case CustomImageType.external:
|
||||
return NetworkImage(url);
|
||||
case CustomImageType.local:
|
||||
return FileImage(File(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-54
@@ -4,53 +4,28 @@ import 'package:flutter/material.dart';
|
||||
|
||||
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/mobile/presentation/widgets/flowy_option_tile.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsupport_image_widget.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
import '../common.dart';
|
||||
|
||||
const kImagePlaceholderKey = 'imagePlaceholderKey';
|
||||
|
||||
enum CustomImageType {
|
||||
local,
|
||||
internal, // the images saved in self-host cloud
|
||||
external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg
|
||||
|
||||
static CustomImageType fromIntValue(int value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return CustomImageType.local;
|
||||
case 1:
|
||||
return CustomImageType.internal;
|
||||
case 2:
|
||||
return CustomImageType.external;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
int toIntValue() {
|
||||
switch (this) {
|
||||
case CustomImageType.local:
|
||||
return 0;
|
||||
case CustomImageType.internal:
|
||||
return 1;
|
||||
case CustomImageType.external:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomImageBlockKeys {
|
||||
const CustomImageBlockKeys._();
|
||||
|
||||
@@ -84,6 +59,25 @@ class CustomImageBlockKeys {
|
||||
static const String imageType = 'image_type';
|
||||
}
|
||||
|
||||
Node customImageNode({
|
||||
required String url,
|
||||
String align = 'center',
|
||||
double? height,
|
||||
double? width,
|
||||
CustomImageType type = CustomImageType.local,
|
||||
}) {
|
||||
return Node(
|
||||
type: CustomImageBlockKeys.type,
|
||||
attributes: {
|
||||
CustomImageBlockKeys.url: url,
|
||||
CustomImageBlockKeys.align: align,
|
||||
CustomImageBlockKeys.height: height,
|
||||
CustomImageBlockKeys.width: width,
|
||||
CustomImageBlockKeys.imageType: type.toIntValue(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
typedef CustomImageBlockComponentMenuBuilder = Widget Function(
|
||||
Node node,
|
||||
CustomImageBlockComponentState state,
|
||||
@@ -182,7 +176,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
);
|
||||
} else if (imageType != CustomImageType.internal &&
|
||||
!_checkIfURLIsValid(src)) {
|
||||
child = const UnSupportImageWidget();
|
||||
child = const UnsupportedImageWidget();
|
||||
} else {
|
||||
child = ResizableImage(
|
||||
src: src,
|
||||
@@ -191,11 +185,22 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
editable: editorState.editable,
|
||||
alignment: alignment,
|
||||
type: imageType,
|
||||
onDoubleTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => InteractiveImageViewer(
|
||||
userProfile: context.read<DocumentBloc>().state.userProfilePB,
|
||||
imageProvider: AFBlockImageProvider(
|
||||
images: [ImageBlockData(url: src, type: imageType)],
|
||||
onDeleteImage: (_) async {
|
||||
final transaction = editorState.transaction..deleteNode(node);
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
onResize: (width) {
|
||||
final transaction = editorState.transaction
|
||||
..updateNode(node, {
|
||||
CustomImageBlockKeys.width: width,
|
||||
});
|
||||
..updateNode(node, {CustomImageBlockKeys.width: width});
|
||||
editorState.apply(transaction);
|
||||
},
|
||||
);
|
||||
@@ -207,21 +212,11 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
delegate: this,
|
||||
listenable: editorState.selectionNotifier,
|
||||
blockColor: editorState.editorStyle.selectionColor,
|
||||
supportTypes: const [
|
||||
BlockSelectionType.block,
|
||||
],
|
||||
child: Padding(
|
||||
key: imageKey,
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
supportTypes: const [BlockSelectionType.block],
|
||||
child: Padding(key: imageKey, padding: padding, child: child),
|
||||
);
|
||||
} else {
|
||||
child = Padding(
|
||||
key: imageKey,
|
||||
padding: padding,
|
||||
child: child,
|
||||
);
|
||||
child = Padding(key: imageKey, padding: padding, child: child);
|
||||
}
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
@@ -246,7 +241,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
opaque: false,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: showActionsNotifier,
|
||||
builder: (context, value, child) {
|
||||
builder: (_, value, child) {
|
||||
final url = node.attributes[CustomImageBlockKeys.url];
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -259,10 +254,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
|
||||
child: child!,
|
||||
),
|
||||
if (value && url.isNotEmpty == true)
|
||||
widget.menuBuilder!(
|
||||
widget.node,
|
||||
this,
|
||||
),
|
||||
widget.menuBuilder!(widget.node, this),
|
||||
],
|
||||
);
|
||||
},
|
||||
+50
-36
@@ -1,26 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ImageMenu extends StatefulWidget {
|
||||
const ImageMenu({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.state,
|
||||
});
|
||||
const ImageMenu({super.key, required this.node, required this.state});
|
||||
|
||||
final Node node;
|
||||
final CustomImageBlockComponentState state;
|
||||
@@ -30,7 +31,7 @@ class ImageMenu extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ImageMenuState extends State<ImageMenu> {
|
||||
late final String? url = widget.node.attributes[ImageBlockKeys.url];
|
||||
late final String? url = widget.node.attributes[CustomImageBlockKeys.url];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -50,6 +51,12 @@ class _ImageMenuState extends State<ImageMenu> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(4),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
|
||||
iconData: FlowySvgs.full_view_s,
|
||||
onTap: openFullScreen,
|
||||
),
|
||||
const HSpace(4),
|
||||
// disable the copy link button if the image is hosted on appflowy cloud
|
||||
// because the url needs the verification token to be accessible
|
||||
@@ -61,10 +68,7 @@ class _ImageMenuState extends State<ImageMenu> {
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
_ImageAlignButton(
|
||||
node: widget.node,
|
||||
state: widget.state,
|
||||
),
|
||||
_ImageAlignButton(node: widget.node, state: widget.state),
|
||||
const _Divider(),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.button_delete.tr(),
|
||||
@@ -95,13 +99,34 @@ class _ImageMenuState extends State<ImageMenu> {
|
||||
transaction.afterSelection = null;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void openFullScreen() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => InteractiveImageViewer(
|
||||
userProfile: context.read<DocumentBloc>().state.userProfilePB,
|
||||
imageProvider: AFBlockImageProvider(
|
||||
images: [
|
||||
ImageBlockData(
|
||||
url: url!,
|
||||
type: CustomImageType.fromIntValue(
|
||||
widget.node.attributes[CustomImageBlockKeys.imageType] ?? 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
onDeleteImage: (_) async {
|
||||
final transaction = widget.state.editorState.transaction;
|
||||
transaction.deleteNode(widget.node);
|
||||
await widget.state.editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageAlignButton extends StatefulWidget {
|
||||
const _ImageAlignButton({
|
||||
required this.node,
|
||||
required this.state,
|
||||
});
|
||||
const _ImageAlignButton({required this.node, required this.state});
|
||||
|
||||
final Node node;
|
||||
final CustomImageBlockComponentState state;
|
||||
@@ -110,30 +135,28 @@ class _ImageAlignButton extends StatefulWidget {
|
||||
State<_ImageAlignButton> createState() => _ImageAlignButtonState();
|
||||
}
|
||||
|
||||
const interceptorKey = 'image-align';
|
||||
const _interceptorKey = 'image-align';
|
||||
|
||||
class _ImageAlignButtonState extends State<_ImageAlignButton> {
|
||||
final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: interceptorKey,
|
||||
key: _interceptorKey,
|
||||
canTap: (details) => false,
|
||||
);
|
||||
|
||||
String get align =>
|
||||
widget.node.attributes[ImageBlockKeys.align] ?? centerAlignmentKey;
|
||||
widget.node.attributes[CustomImageBlockKeys.align] ?? centerAlignmentKey;
|
||||
final popoverController = PopoverController();
|
||||
late final EditorState editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
editorState = context.read<EditorState>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
allowMenuClose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -153,9 +176,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
|
||||
),
|
||||
popupBuilder: (_) {
|
||||
preventMenuClose();
|
||||
return _AlignButtons(
|
||||
onAlignChanged: onAlignChanged,
|
||||
);
|
||||
return _AlignButtons(onAlignChanged: onAlignChanged);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -165,9 +186,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
|
||||
popoverController.close();
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
ImageBlockKeys.align: align,
|
||||
});
|
||||
transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align});
|
||||
editorState.apply(transaction);
|
||||
|
||||
allowMenuClose();
|
||||
@@ -183,7 +202,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
|
||||
void allowMenuClose() {
|
||||
widget.state.alwaysShowMenu = false;
|
||||
editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
interceptorKey,
|
||||
_interceptorKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,9 +220,7 @@ class _ImageAlignButtonState extends State<_ImageAlignButton> {
|
||||
}
|
||||
|
||||
class _AlignButtons extends StatelessWidget {
|
||||
const _AlignButtons({
|
||||
required this.onAlignChanged,
|
||||
});
|
||||
const _AlignButtons({required this.onAlignChanged});
|
||||
|
||||
final Function(String align) onAlignChanged;
|
||||
|
||||
@@ -246,10 +263,7 @@ class _Divider extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
width: 1,
|
||||
color: Colors.grey,
|
||||
),
|
||||
child: Container(width: 1, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-11
@@ -1,14 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UnSupportImageWidget extends StatelessWidget {
|
||||
const UnSupportImageWidget({
|
||||
super.key,
|
||||
});
|
||||
class UnsupportedImageWidget extends StatelessWidget {
|
||||
const UnsupportedImageWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -18,9 +17,7 @@ class UnSupportImageWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FlowyHover(
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
style: HoverStyle(borderRadius: BorderRadius.circular(4)),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
@@ -31,9 +28,7 @@ class UnSupportImageWidget extends StatelessWidget {
|
||||
size: Size.square(24),
|
||||
),
|
||||
const HSpace(10),
|
||||
FlowyText(
|
||||
LocaleKeys.document_imageBlock_unableToLoadImage.tr(),
|
||||
),
|
||||
FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()),
|
||||
],
|
||||
),
|
||||
),
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImagePickerPage extends StatefulWidget {
|
||||
const ImagePickerPage({
|
||||
super.key,
|
||||
// required this.onSelected,
|
||||
});
|
||||
|
||||
// final void Function(EmojiPickerResult) onSelected;
|
||||
|
||||
@override
|
||||
State<ImagePickerPage> createState() => _ImagePickerPageState();
|
||||
}
|
||||
|
||||
class _ImagePickerPageState extends State<ImagePickerPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
titleSpacing: 0,
|
||||
title: FlowyText.semibold(
|
||||
LocaleKeys.titleBar_pageIcon.tr(),
|
||||
fontSize: 14.0,
|
||||
),
|
||||
leading: const AppBarBackButton(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: UploadImageMenu(
|
||||
onSubmitted: (_) {},
|
||||
onUpload: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -1,13 +1,40 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/flowy_image_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
|
||||
class MobileImagePickerScreen extends StatelessWidget {
|
||||
const MobileImagePickerScreen({super.key});
|
||||
|
||||
static const routeName = '/image_picker';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const ImagePickerPage();
|
||||
}
|
||||
|
||||
class ImagePickerPage extends StatelessWidget {
|
||||
const ImagePickerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ImagePickerPage();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
titleSpacing: 0,
|
||||
title: FlowyText.semibold(
|
||||
LocaleKeys.titleBar_pageIcon.tr(),
|
||||
fontSize: 14.0,
|
||||
),
|
||||
leading: const AppBarBackButton(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: UploadImageMenu(
|
||||
onSubmitted: (_) {},
|
||||
onUpload: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+152
-78
@@ -1,24 +1,29 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log, UploadImageMenu;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart';
|
||||
@@ -26,10 +31,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class ImagePlaceholder extends StatefulWidget {
|
||||
const ImagePlaceholder({
|
||||
super.key,
|
||||
required this.node,
|
||||
});
|
||||
const ImagePlaceholder({super.key, required this.node});
|
||||
|
||||
final Node node;
|
||||
|
||||
@@ -45,12 +47,20 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
bool showLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
bool isDraggingFiles = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget child = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: isDraggingFiles
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: FlowyHover(
|
||||
style: HoverStyle(
|
||||
@@ -85,18 +95,23 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
clickHandler: PopoverClickHandler.gestureDetector,
|
||||
popupBuilder: (context) {
|
||||
return UploadImageMenu(
|
||||
allowMultipleImages: true,
|
||||
limitMaximumImageSize: !_isLocalMode(),
|
||||
supportTypes: const [
|
||||
UploadImageType.local,
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
// UploadImageType.openAI,
|
||||
UploadImageType.stabilityAI,
|
||||
],
|
||||
onSelectedLocalImage: (path) {
|
||||
onSelectedLocalImages: (paths) {
|
||||
controller.close();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
await insertLocalImage(path);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final List<String> items = List.from(
|
||||
paths.where((url) => url != null && url.isNotEmpty),
|
||||
);
|
||||
if (items.isNotEmpty) {
|
||||
await insertMultipleLocalImages(items);
|
||||
}
|
||||
});
|
||||
},
|
||||
onSelectedAIImage: (url) {
|
||||
@@ -113,7 +128,27 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
},
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
child: DropTarget(
|
||||
onDragEntered: (_) => setState(() => isDraggingFiles = true),
|
||||
onDragExited: (_) => setState(() => isDraggingFiles = false),
|
||||
onDragDone: (details) {
|
||||
// Only accept files where the mimetype is an image,
|
||||
// otherwise we assume it's a file we cannot display.
|
||||
final imageFiles = details.files
|
||||
.where(
|
||||
(file) =>
|
||||
file.mimeType?.startsWith('image/') ??
|
||||
false || imgExtensionRegex.hasMatch(file.name),
|
||||
)
|
||||
.toList();
|
||||
final paths = imageFiles.map((file) => file.path).toList();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) async => insertMultipleLocalImages(paths),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return MobileBlockActionButtons(
|
||||
@@ -133,8 +168,11 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
List<Widget> _buildTrailing(BuildContext context) {
|
||||
if (errorMessage != null) {
|
||||
return [
|
||||
FlowyText(
|
||||
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
'${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}',
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
];
|
||||
} else if (showLoading) {
|
||||
@@ -147,8 +185,14 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_image_addAnImage.tr(),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
PlatformExtension.isDesktop
|
||||
? isDraggingFiles
|
||||
? LocaleKeys.document_plugins_image_dropImageToInsert.tr()
|
||||
: LocaleKeys.document_plugins_image_addAnImageDesktop.tr()
|
||||
: LocaleKeys.document_plugins_image_addAnImageMobile.tr(),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -179,9 +223,14 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
],
|
||||
onSelectedLocalImage: (path) async {
|
||||
onSelectedLocalImages: (paths) async {
|
||||
context.pop();
|
||||
await insertLocalImage(path);
|
||||
|
||||
final List<String> items = List.from(
|
||||
paths.where((url) => url != null && url.isNotEmpty),
|
||||
);
|
||||
|
||||
await insertMultipleLocalImages(items);
|
||||
},
|
||||
onSelectedAIImage: (url) async {
|
||||
context.pop();
|
||||
@@ -198,77 +247,102 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertLocalImage(String? url) async {
|
||||
Future<void> insertMultipleLocalImages(List<String> urls) async {
|
||||
controller.close();
|
||||
|
||||
if (url == null || url.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
String? path;
|
||||
String? errorMessage;
|
||||
CustomImageType imageType = CustomImageType.local;
|
||||
|
||||
// if the user is using local authenticator, we need to save the image to local storage
|
||||
if (_isLocalMode()) {
|
||||
// don't limit the image size for local mode.
|
||||
path = await saveImageToLocalStorage(url);
|
||||
} else {
|
||||
final documentId = context.read<DocumentBloc>().documentId;
|
||||
if (documentId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// else we should save the image to cloud storage
|
||||
setState(() {
|
||||
showLoading = true;
|
||||
this.errorMessage = null;
|
||||
});
|
||||
(path, errorMessage) = await saveImageToCloudStorage(url, documentId);
|
||||
setState(() {
|
||||
showLoading = false;
|
||||
this.errorMessage = errorMessage;
|
||||
});
|
||||
imageType = CustomImageType.internal;
|
||||
}
|
||||
|
||||
if (mounted && path == null) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
errorMessage == null
|
||||
? LocaleKeys.document_imageBlock_error_invalidImage.tr()
|
||||
: ': $errorMessage',
|
||||
);
|
||||
setState(() {
|
||||
this.errorMessage = errorMessage;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
transaction.updateNode(widget.node, {
|
||||
CustomImageBlockKeys.url: path,
|
||||
CustomImageBlockKeys.imageType: imageType.toIntValue(),
|
||||
setState(() {
|
||||
showLoading = true;
|
||||
errorMessage = null;
|
||||
});
|
||||
|
||||
await editorState.apply(transaction);
|
||||
bool hasError = false;
|
||||
|
||||
if (_isLocalMode()) {
|
||||
final first = urls.removeAt(0);
|
||||
final firstPath = await saveImageToLocalStorage(first);
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CustomImageBlockKeys.url: firstPath,
|
||||
CustomImageBlockKeys.imageType: CustomImageType.local.toIntValue(),
|
||||
});
|
||||
|
||||
if (urls.isNotEmpty) {
|
||||
// Create new nodes for the rest of the images:
|
||||
final paths = await Future.wait(urls.map(saveImageToLocalStorage));
|
||||
paths.removeWhere((url) => url == null || url.isEmpty);
|
||||
|
||||
transaction.insertNodes(
|
||||
widget.node.path.next,
|
||||
paths.map((url) => customImageNode(url: url!)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
await editorState.apply(transaction);
|
||||
} else {
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
bool isFirst = true;
|
||||
for (final url in urls) {
|
||||
// Upload to cloud
|
||||
final (path, error) = await saveImageToCloudStorage(
|
||||
url,
|
||||
context.read<DocumentBloc>().documentId,
|
||||
);
|
||||
|
||||
if (error != null) {
|
||||
hasError = true;
|
||||
|
||||
if (isFirst) {
|
||||
setState(() => errorMessage = error);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path != null) {
|
||||
if (isFirst) {
|
||||
isFirst = false;
|
||||
transaction.updateNode(widget.node, {
|
||||
CustomImageBlockKeys.url: path,
|
||||
CustomImageBlockKeys.imageType:
|
||||
CustomImageType.internal.toIntValue(),
|
||||
});
|
||||
} else {
|
||||
transaction.insertNode(
|
||||
widget.node.path.next,
|
||||
customImageNode(
|
||||
url: path,
|
||||
type: CustomImageType.internal,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
setState(() => showLoading = false);
|
||||
|
||||
if (hasError && mounted) {
|
||||
showSnapBar(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertAIImage(String url) async {
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
showSnackBarMessage(
|
||||
return showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(
|
||||
path,
|
||||
'images',
|
||||
);
|
||||
final imagePath = p.join(path, 'images');
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
@@ -283,7 +357,7 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
|
||||
final response = await get(uri);
|
||||
await File(copyToPath).writeAsBytes(response.bodyBytes);
|
||||
await insertLocalImage(copyToPath);
|
||||
await insertMultipleLocalImages([copyToPath]);
|
||||
await File(copyToPath).delete();
|
||||
} catch (e) {
|
||||
Log.error('cannot save image file', e);
|
||||
@@ -293,16 +367,16 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
Future<void> insertNetworkImage(String url) async {
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
showSnackBarMessage(
|
||||
return showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
ImageBlockKeys.url: url,
|
||||
CustomImageBlockKeys.url: url,
|
||||
CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(),
|
||||
});
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
+67
-22
@@ -1,27 +1,54 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
final customImageMenuItem = SelectionMenuItem(
|
||||
getName: () => AppFlowyEditorL10n.current.image,
|
||||
icon: (editorState, isSelected, style) => SelectionMenuIconWidget(
|
||||
icon: (_, isSelected, style) => SelectionMenuIconWidget(
|
||||
name: 'image',
|
||||
isSelected: isSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: ['image', 'picture', 'img', 'photo'],
|
||||
handler: (editorState, menuService, context) async {
|
||||
handler: (editorState, _, __) async {
|
||||
// use the key to retrieve the state of the image block to show the popover automatically
|
||||
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
|
||||
await editorState.insertEmptyImageBlock(imagePlaceholderKey);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
imagePlaceholderKey.currentState?.controller.show();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
final multiImageMenuItem = SelectionMenuItem(
|
||||
getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(),
|
||||
icon: (_, isSelected, style) => SelectionMenuIconWidget(
|
||||
icon: Icons.photo_library_outlined,
|
||||
isSelected: isSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: [
|
||||
LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(),
|
||||
LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(),
|
||||
LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(),
|
||||
LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(),
|
||||
LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(),
|
||||
],
|
||||
handler: (editorState, _, __) async {
|
||||
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
|
||||
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => imagePlaceholderKey.currentState?.controller.show(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
extension InsertImage on EditorState {
|
||||
Future<void> insertEmptyImageBlock(GlobalKey key) async {
|
||||
final selection = this.selection;
|
||||
@@ -33,31 +60,49 @@ extension InsertImage on EditorState {
|
||||
return;
|
||||
}
|
||||
final emptyImage = imageNode(url: '')
|
||||
..extraInfos = {
|
||||
kImagePlaceholderKey: key,
|
||||
};
|
||||
..extraInfos = {kImagePlaceholderKey: key};
|
||||
final transaction = this.transaction;
|
||||
// if the current node is empty paragraph, replace it with image node
|
||||
if (node.type == ParagraphBlockKeys.type &&
|
||||
(node.delta?.isEmpty ?? false)) {
|
||||
transaction
|
||||
..insertNode(
|
||||
node.path,
|
||||
emptyImage,
|
||||
)
|
||||
..insertNode(node.path, emptyImage)
|
||||
..deleteNode(node);
|
||||
} else {
|
||||
transaction.insertNode(
|
||||
node.path.next,
|
||||
emptyImage,
|
||||
);
|
||||
transaction.insertNode(node.path.next, emptyImage);
|
||||
}
|
||||
|
||||
transaction.afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
path: node.path.next,
|
||||
),
|
||||
);
|
||||
transaction.afterSelection =
|
||||
Selection.collapsed(Position(path: node.path.next));
|
||||
transaction.selectionExtraInfo = {};
|
||||
|
||||
return apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> insertEmptyMultiImageBlock(GlobalKey key) async {
|
||||
final selection = this.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = getNodeAtPath(selection.end.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final emptyBlock = multiImageNode()
|
||||
..extraInfos = {kMultiImagePlaceholderKey: key};
|
||||
final transaction = this.transaction;
|
||||
// if the current node is empty paragraph, replace it with image node
|
||||
if (node.type == ParagraphBlockKeys.type &&
|
||||
(node.delta?.isEmpty ?? false)) {
|
||||
transaction
|
||||
..insertNode(node.path, emptyBlock)
|
||||
..deleteNode(node);
|
||||
} else {
|
||||
transaction.insertNode(node.path.next, emptyBlock);
|
||||
}
|
||||
|
||||
transaction.afterSelection =
|
||||
Selection.collapsed(Position(path: node.path.next));
|
||||
transaction.selectionExtraInfo = {};
|
||||
|
||||
return apply(transaction);
|
||||
|
||||
+51
@@ -1,15 +1,20 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/shared/custom_image_cache_manager.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/file_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/dispatch/error.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
Future<String?> saveImageToLocalStorage(String localImagePath) async {
|
||||
@@ -73,3 +78,49 @@ Future<(String? path, String? errorMessage)> saveImageToCloudStorage(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ImageBlockData>> extractAndUploadImages(
|
||||
BuildContext context,
|
||||
List<String?> urls,
|
||||
bool isLocalMode,
|
||||
) async {
|
||||
final List<ImageBlockData> images = [];
|
||||
|
||||
bool hasError = false;
|
||||
for (final url in urls) {
|
||||
if (url == null || url.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String? path;
|
||||
String? errorMsg;
|
||||
CustomImageType imageType = CustomImageType.local;
|
||||
|
||||
// If the user is using local authenticator, we save the image to local storage
|
||||
if (isLocalMode) {
|
||||
path = await saveImageToLocalStorage(url);
|
||||
} else {
|
||||
// Else we save the image to cloud storage
|
||||
(path, errorMsg) = await saveImageToCloudStorage(
|
||||
url,
|
||||
context.read<DocumentBloc>().documentId,
|
||||
);
|
||||
imageType = CustomImageType.internal;
|
||||
}
|
||||
|
||||
if (path != null && errorMsg == null) {
|
||||
images.add(ImageBlockData(url: path, type: imageType));
|
||||
} else {
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted && hasError) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final imageMobileToolbarItem = MobileToolbarItem.action(
|
||||
itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg),
|
||||
@@ -10,7 +11,7 @@ final imageMobileToolbarItem = MobileToolbarItem.action(
|
||||
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
|
||||
await editorState.insertEmptyImageBlock(imagePlaceholderKey);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
imagePlaceholderKey.currentState?.showUploadImageMenu();
|
||||
});
|
||||
},
|
||||
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey';
|
||||
|
||||
Node multiImageNode() => Node(
|
||||
type: MultiImageBlockKeys.type,
|
||||
attributes: {
|
||||
MultiImageBlockKeys.images: MultiImageData(images: []).toJson(),
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
},
|
||||
);
|
||||
|
||||
class MultiImageBlockKeys {
|
||||
const MultiImageBlockKeys._();
|
||||
|
||||
static const String type = 'multi_image';
|
||||
|
||||
/// The image data for the block, stored as a JSON encoded list of [ImageBlockData].
|
||||
///
|
||||
static const String images = 'images';
|
||||
|
||||
/// The layout of the images.
|
||||
///
|
||||
/// The value is a MultiImageLayout enum.
|
||||
///
|
||||
static const String layout = 'layout';
|
||||
}
|
||||
|
||||
typedef MultiImageBlockComponentMenuBuilder = Widget Function(
|
||||
Node node,
|
||||
MultiImageBlockComponentState state,
|
||||
ValueNotifier<int> indexNotifier,
|
||||
VoidCallback onImageDeleted,
|
||||
);
|
||||
|
||||
class MultiImageBlockComponentBuilder extends BlockComponentBuilder {
|
||||
MultiImageBlockComponentBuilder({
|
||||
super.configuration,
|
||||
this.showMenu = false,
|
||||
this.menuBuilder,
|
||||
});
|
||||
|
||||
final bool showMenu;
|
||||
final MultiImageBlockComponentMenuBuilder? menuBuilder;
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return MultiImageBlockComponent(
|
||||
key: node.key,
|
||||
node: node,
|
||||
showActions: showActions(node),
|
||||
configuration: configuration,
|
||||
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
|
||||
showMenu: showMenu,
|
||||
menuBuilder: menuBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool validate(Node node) => node.delta == null && node.children.isEmpty;
|
||||
}
|
||||
|
||||
class MultiImageBlockComponent extends BlockComponentStatefulWidget {
|
||||
const MultiImageBlockComponent({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
this.showMenu = false,
|
||||
this.menuBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
super.actionBuilder,
|
||||
});
|
||||
|
||||
final bool showMenu;
|
||||
|
||||
final MultiImageBlockComponentMenuBuilder? menuBuilder;
|
||||
|
||||
@override
|
||||
State<MultiImageBlockComponent> createState() =>
|
||||
MultiImageBlockComponentState();
|
||||
}
|
||||
|
||||
class MultiImageBlockComponentState extends State<MultiImageBlockComponent>
|
||||
with SelectableMixin, BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
final multiImageKey = GlobalKey();
|
||||
|
||||
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
late final editorState = Provider.of<EditorState>(context, listen: false);
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
ValueNotifier<int> indexNotifier = ValueNotifier(0);
|
||||
|
||||
bool alwaysShowMenu = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = MultiImageData.fromJson(
|
||||
node.attributes[MultiImageBlockKeys.images],
|
||||
);
|
||||
|
||||
Widget child;
|
||||
if (data.images.isEmpty) {
|
||||
final multiImagePlaceholderKey =
|
||||
node.extraInfos?[kMultiImagePlaceholderKey];
|
||||
|
||||
child = MultiImagePlaceholder(
|
||||
key: multiImagePlaceholderKey is GlobalKey
|
||||
? multiImagePlaceholderKey
|
||||
: null,
|
||||
node: node,
|
||||
);
|
||||
} else {
|
||||
child = ImageBrowserLayout(
|
||||
node: node,
|
||||
images: data.images,
|
||||
editorState: editorState,
|
||||
indexNotifier: indexNotifier,
|
||||
isLocalMode: context.read<DocumentBloc>().isLocalMode,
|
||||
onIndexChanged: (index) => setState(() => indexNotifier.value = index),
|
||||
);
|
||||
}
|
||||
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
child = BlockSelectionContainer(
|
||||
node: node,
|
||||
delegate: this,
|
||||
listenable: editorState.selectionNotifier,
|
||||
blockColor: editorState.editorStyle.selectionColor,
|
||||
supportTypes: const [BlockSelectionType.block],
|
||||
child: Padding(key: multiImageKey, padding: padding, child: child),
|
||||
);
|
||||
} else {
|
||||
child = Padding(key: multiImageKey, padding: padding, child: child);
|
||||
}
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
if (widget.showMenu && widget.menuBuilder != null) {
|
||||
child = MouseRegion(
|
||||
onEnter: (_) => showActionsNotifier.value = true,
|
||||
onExit: (_) {
|
||||
if (!alwaysShowMenu) {
|
||||
showActionsNotifier.value = false;
|
||||
}
|
||||
},
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
opaque: false,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: showActionsNotifier,
|
||||
builder: (context, value, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
BlockSelectionContainer(
|
||||
node: node,
|
||||
delegate: this,
|
||||
listenable: editorState.selectionNotifier,
|
||||
cursorColor: editorState.editorStyle.cursorColor,
|
||||
selectionColor: editorState.editorStyle.selectionColor,
|
||||
child: child!,
|
||||
),
|
||||
if (value && data.images.isNotEmpty)
|
||||
widget.menuBuilder!(
|
||||
widget.node,
|
||||
this,
|
||||
indexNotifier,
|
||||
() => setState(
|
||||
() => indexNotifier.value = indexNotifier.value > 0
|
||||
? indexNotifier.value - 1
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// show a fixed menu on mobile
|
||||
child = MobileBlockActionButtons(
|
||||
showThreeDots: false,
|
||||
node: node,
|
||||
editorState: editorState,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: widget.node.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 imageBox = multiImageKey.currentContext?.findRenderObject();
|
||||
if (imageBox is RenderBox) {
|
||||
return Offset.zero & imageBox.size;
|
||||
}
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(
|
||||
Position position, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final rects = getRectsInSelection(Selection.collapsed(position));
|
||||
return rects.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(
|
||||
Selection selection, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
if (_renderBox == null) {
|
||||
return [];
|
||||
}
|
||||
final parentBox = context.findRenderObject();
|
||||
final imageBox = multiImageKey.currentContext?.findRenderObject();
|
||||
if (parentBox is RenderBox && imageBox is RenderBox) {
|
||||
return [
|
||||
imageBox.localToGlobal(Offset.zero, ancestor: parentBox) &
|
||||
imageBox.size,
|
||||
];
|
||||
}
|
||||
return [Offset.zero & _renderBox!.size];
|
||||
}
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: widget.node.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(
|
||||
Offset offset, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) =>
|
||||
_renderBox!.localToGlobal(offset);
|
||||
}
|
||||
|
||||
/// The data for a multi-image block, primarily used for
|
||||
/// serializing and deserializing the block's images.
|
||||
///
|
||||
class MultiImageData {
|
||||
factory MultiImageData.fromJson(List<dynamic> json) {
|
||||
final images = json
|
||||
.map((e) => ImageBlockData.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return MultiImageData(images: images);
|
||||
}
|
||||
|
||||
MultiImageData({required this.images});
|
||||
|
||||
final List<ImageBlockData> images;
|
||||
|
||||
List<dynamic> toJson() => images.map((e) => e.toJson()).toList();
|
||||
}
|
||||
|
||||
enum MultiImageLayout {
|
||||
browser,
|
||||
masonry,
|
||||
grid;
|
||||
|
||||
int toIntValue() {
|
||||
switch (this) {
|
||||
case MultiImageLayout.browser:
|
||||
return 0;
|
||||
case MultiImageLayout.masonry:
|
||||
return 1;
|
||||
case MultiImageLayout.grid:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
static MultiImageLayout fromIntValue(int value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return MultiImageLayout.browser;
|
||||
case 1:
|
||||
return MultiImageLayout.masonry;
|
||||
case 2:
|
||||
return MultiImageLayout.grid;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
const _interceptorKey = 'add-image';
|
||||
|
||||
class MultiImageMenu extends StatefulWidget {
|
||||
const MultiImageMenu({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.state,
|
||||
required this.indexNotifier,
|
||||
this.isLocalMode = true,
|
||||
required this.onImageDeleted,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final MultiImageBlockComponentState state;
|
||||
final ValueNotifier<int> indexNotifier;
|
||||
final bool isLocalMode;
|
||||
final VoidCallback onImageDeleted;
|
||||
|
||||
@override
|
||||
State<MultiImageMenu> createState() => _MultiImageMenuState();
|
||||
}
|
||||
|
||||
class _MultiImageMenuState extends State<MultiImageMenu> {
|
||||
final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: _interceptorKey,
|
||||
canTap: (details) => false,
|
||||
);
|
||||
|
||||
final PopoverController controller = PopoverController();
|
||||
late List<ImageBlockData> images;
|
||||
late final EditorState editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
editorState = context.read<EditorState>();
|
||||
images = MultiImageData.fromJson(
|
||||
widget.node.attributes[MultiImageBlockKeys.images] ?? {},
|
||||
).images;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
allowMenuClose();
|
||||
controller.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MultiImageMenu oldWidget) {
|
||||
images = MultiImageData.fromJson(
|
||||
widget.node.attributes[MultiImageBlockKeys.images] ?? {},
|
||||
).images;
|
||||
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(4),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
|
||||
iconData: FlowySvgs.full_view_s,
|
||||
onTap: openFullScreen,
|
||||
),
|
||||
AppFlowyPopover(
|
||||
controller: controller,
|
||||
direction: PopoverDirection.bottomWithRightAligned,
|
||||
onClose: allowMenuClose,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 540,
|
||||
maxHeight: 360,
|
||||
minHeight: 80,
|
||||
),
|
||||
offset: const Offset(0, 10),
|
||||
popupBuilder: (context) {
|
||||
preventMenuClose();
|
||||
return UploadImageMenu(
|
||||
allowMultipleImages: true,
|
||||
supportTypes: const [
|
||||
UploadImageType.local,
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
UploadImageType.stabilityAI,
|
||||
],
|
||||
onSelectedLocalImages: insertLocalImages,
|
||||
onSelectedAIImage: insertAIImage,
|
||||
onSelectedNetworkImage: insertNetworkImage,
|
||||
);
|
||||
},
|
||||
child: MenuBlockButton(
|
||||
tooltip:
|
||||
LocaleKeys.document_plugins_photoGallery_addImageTooltip.tr(),
|
||||
iconData: FlowySvgs.add_s,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
// disable the copy link button if the image is hosted on appflowy cloud
|
||||
// because the url needs the verification token to be accessible
|
||||
if (!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[
|
||||
const HSpace(4),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.editor_copyLink.tr(),
|
||||
iconData: FlowySvgs.copy_s,
|
||||
onTap: copyImageLink,
|
||||
),
|
||||
],
|
||||
const _Divider(),
|
||||
MenuBlockButton(
|
||||
tooltip: LocaleKeys.button_delete.tr(),
|
||||
iconData: FlowySvgs.delete_s,
|
||||
onTap: deleteImage,
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void copyImageLink() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: images[widget.indexNotifier.value].url),
|
||||
);
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteImage() async {
|
||||
final node = widget.node;
|
||||
final editorState = context.read<EditorState>();
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNode(node);
|
||||
transaction.afterSelection = null;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void openFullScreen() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => InteractiveImageViewer(
|
||||
userProfile: context.read<DocumentBloc>().state.userProfilePB,
|
||||
imageProvider: AFBlockImageProvider(
|
||||
images: images,
|
||||
initialIndex: widget.indexNotifier.value,
|
||||
onDeleteImage: (index) async {
|
||||
final transaction = editorState.transaction;
|
||||
final newImages = List<ImageBlockData>.from(images);
|
||||
newImages.removeAt(index);
|
||||
|
||||
images = newImages;
|
||||
widget.onImageDeleted();
|
||||
|
||||
final imagesJson =
|
||||
newImages.map((image) => image.toJson()).toList();
|
||||
transaction.updateNode(widget.node, {
|
||||
MultiImageBlockKeys.images: imagesJson,
|
||||
// Default to Browser layout
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
});
|
||||
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void preventMenuClose() {
|
||||
widget.state.alwaysShowMenu = true;
|
||||
editorState.service.selectionService.registerGestureInterceptor(
|
||||
gestureInterceptor,
|
||||
);
|
||||
}
|
||||
|
||||
void allowMenuClose() {
|
||||
widget.state.alwaysShowMenu = false;
|
||||
editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
_interceptorKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertLocalImages(List<String?> urls) async {
|
||||
controller.close();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
final newImages =
|
||||
await extractAndUploadImages(context, urls, widget.isLocalMode);
|
||||
if (newImages.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
newImages.addAll(images);
|
||||
final imagesJson = newImages.map((image) => image.toJson()).toList();
|
||||
transaction.updateNode(widget.node, {
|
||||
MultiImageBlockKeys.images: imagesJson,
|
||||
// Default to Browser layout
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
});
|
||||
|
||||
await editorState.apply(transaction);
|
||||
setState(() => images = newImages);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> insertAIImage(String url) async {
|
||||
controller.close();
|
||||
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
return showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(path, 'images');
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
final uri = Uri.parse(url);
|
||||
final copyToPath = p.join(
|
||||
imagePath,
|
||||
'${uuid()}${p.extension(uri.path)}',
|
||||
);
|
||||
|
||||
final response = await get(uri);
|
||||
await File(copyToPath).writeAsBytes(response.bodyBytes);
|
||||
await insertLocalImages([copyToPath]);
|
||||
await File(copyToPath).delete();
|
||||
} catch (e) {
|
||||
Log.error('cannot save image file', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertNetworkImage(String url) async {
|
||||
controller.close();
|
||||
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
return showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
final newImages = [
|
||||
...images,
|
||||
ImageBlockData(url: url, type: CustomImageType.external),
|
||||
];
|
||||
|
||||
final imagesJson = newImages.map((image) => image.toJson()).toList();
|
||||
transaction.updateNode(widget.node, {
|
||||
MultiImageBlockKeys.images: imagesJson,
|
||||
// Default to Browser layout
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
});
|
||||
|
||||
await editorState.apply(transaction);
|
||||
setState(() => images = newImages);
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
const _Divider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(width: 1, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class MultiImagePlaceholder extends StatefulWidget {
|
||||
const MultiImagePlaceholder({super.key, required this.node});
|
||||
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
State<MultiImagePlaceholder> createState() => MultiImagePlaceholderState();
|
||||
}
|
||||
|
||||
class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
|
||||
final controller = PopoverController();
|
||||
final documentService = DocumentService();
|
||||
late final editorState = context.read<EditorState>();
|
||||
|
||||
bool isDraggingFiles = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: isDraggingFiles
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: FlowyHover(
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.photo_library_outlined, size: 24),
|
||||
const HSpace(10),
|
||||
FlowyText(
|
||||
PlatformExtension.isDesktop
|
||||
? isDraggingFiles
|
||||
? LocaleKeys.document_plugins_image_dropImageToInsert
|
||||
.tr()
|
||||
: LocaleKeys.document_plugins_image_addAnImageDesktop
|
||||
.tr()
|
||||
: LocaleKeys.document_plugins_image_addAnImageMobile.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
return AppFlowyPopover(
|
||||
controller: controller,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 540,
|
||||
maxHeight: 360,
|
||||
minHeight: 80,
|
||||
),
|
||||
clickHandler: PopoverClickHandler.gestureDetector,
|
||||
popupBuilder: (_) {
|
||||
return UploadImageMenu(
|
||||
allowMultipleImages: true,
|
||||
limitMaximumImageSize: !_isLocalMode(),
|
||||
supportTypes: const [
|
||||
UploadImageType.local,
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
UploadImageType.stabilityAI,
|
||||
],
|
||||
onSelectedLocalImages: (paths) {
|
||||
controller.close();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
await insertLocalImages(paths);
|
||||
});
|
||||
},
|
||||
onSelectedAIImage: (url) {
|
||||
controller.close();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
await insertAIImage(url);
|
||||
});
|
||||
},
|
||||
onSelectedNetworkImage: (url) {
|
||||
controller.close();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
await insertNetworkImage(url);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
child: DropTarget(
|
||||
onDragEntered: (_) => setState(() => isDraggingFiles = true),
|
||||
onDragExited: (_) => setState(() => isDraggingFiles = false),
|
||||
onDragDone: (details) {
|
||||
// Only accept files where the mimetype is an image,
|
||||
// or the file extension is a known image format,
|
||||
// otherwise we assume it's a file we cannot display.
|
||||
final imageFiles = details.files
|
||||
.where(
|
||||
(file) =>
|
||||
file.mimeType?.startsWith('image/') ??
|
||||
false || imgExtensionRegex.hasMatch(file.name),
|
||||
)
|
||||
.toList();
|
||||
final paths = imageFiles.map((file) => file.path).toList();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) async => insertLocalImages(paths),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return MobileBlockActionButtons(
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
editorState.updateSelectionWithReason(null, extraInfo: {});
|
||||
showUploadImageMenu();
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showUploadImageMenu() {
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
controller.show();
|
||||
} else {
|
||||
final isLocalMode = _isLocalMode();
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
title: LocaleKeys.editor_image.tr(),
|
||||
showHeader: true,
|
||||
showCloseButton: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 12.0),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 340,
|
||||
minHeight: 80,
|
||||
),
|
||||
child: UploadImageMenu(
|
||||
limitMaximumImageSize: !isLocalMode,
|
||||
allowMultipleImages: true,
|
||||
supportTypes: const [
|
||||
UploadImageType.local,
|
||||
UploadImageType.url,
|
||||
UploadImageType.unsplash,
|
||||
],
|
||||
onSelectedLocalImages: (paths) async {
|
||||
context.pop();
|
||||
await insertLocalImages(paths);
|
||||
},
|
||||
onSelectedAIImage: (url) async {
|
||||
context.pop();
|
||||
await insertAIImage(url);
|
||||
},
|
||||
onSelectedNetworkImage: (url) async {
|
||||
context.pop();
|
||||
await insertNetworkImage(url);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertLocalImages(List<String?> urls) async {
|
||||
controller.close();
|
||||
|
||||
if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
final images = await extractAndUploadImages(context, urls, _isLocalMode());
|
||||
if (images.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imagesJson = images.map((image) => image.toJson()).toList();
|
||||
|
||||
transaction.updateNode(widget.node, {
|
||||
MultiImageBlockKeys.images: imagesJson,
|
||||
// Default to Browser layout
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
});
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> insertAIImage(String url) async {
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
return showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(path, 'images');
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
final uri = Uri.parse(url);
|
||||
final copyToPath = p.join(
|
||||
imagePath,
|
||||
'${uuid()}${p.extension(uri.path)}',
|
||||
);
|
||||
|
||||
final response = await get(uri);
|
||||
await File(copyToPath).writeAsBytes(response.bodyBytes);
|
||||
await insertLocalImages([copyToPath]);
|
||||
await File(copyToPath).delete();
|
||||
} catch (e) {
|
||||
Log.error('cannot save image file', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertNetworkImage(String url) async {
|
||||
if (url.isEmpty || !isURL(url)) {
|
||||
// show error
|
||||
return showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.document_imageBlock_error_invalidImage.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
|
||||
final images = [
|
||||
ImageBlockData(
|
||||
url: url,
|
||||
type: CustomImageType.external,
|
||||
),
|
||||
];
|
||||
|
||||
transaction.updateNode(widget.node, {
|
||||
MultiImageBlockKeys.images:
|
||||
images.map((image) => image.toJson()).toList(),
|
||||
// Default to Browser layout
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
});
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
bool _isLocalMode() {
|
||||
return context.read<DocumentBloc>().isLocalMode;
|
||||
}
|
||||
}
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _thumbnailItemSize = 100.0;
|
||||
|
||||
abstract class ImageBlockMultiLayout extends StatefulWidget {
|
||||
const ImageBlockMultiLayout({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
required this.images,
|
||||
required this.indexNotifier,
|
||||
required this.isLocalMode,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final List<ImageBlockData> images;
|
||||
final ValueNotifier<int> indexNotifier;
|
||||
final bool isLocalMode;
|
||||
}
|
||||
|
||||
class ImageBrowserLayout extends ImageBlockMultiLayout {
|
||||
const ImageBrowserLayout({
|
||||
super.key,
|
||||
required super.node,
|
||||
required super.editorState,
|
||||
required super.images,
|
||||
required super.indexNotifier,
|
||||
required super.isLocalMode,
|
||||
required this.onIndexChanged,
|
||||
});
|
||||
|
||||
final void Function(int) onIndexChanged;
|
||||
|
||||
@override
|
||||
State<ImageBrowserLayout> createState() => _ImageBrowserLayoutState();
|
||||
}
|
||||
|
||||
class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
|
||||
UserProfilePB? _userProfile;
|
||||
bool isDraggingFiles = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_userProfile = context.read<DocumentBloc>().state.userProfilePB;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 400,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: GestureDetector(
|
||||
onDoubleTap: () => _openInteractiveViewer(context),
|
||||
child: ImageRender(
|
||||
image: widget.images[widget.indexNotifier.value],
|
||||
userProfile: _userProfile,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const VSpace(8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxItems =
|
||||
(constraints.maxWidth / (_thumbnailItemSize + 4)).floor();
|
||||
final items = widget.images.take(maxItems).toList();
|
||||
|
||||
return Wrap(
|
||||
children: items.mapIndexed((index, image) {
|
||||
final isLast = items.last == image;
|
||||
final amountLeft = widget.images.length - items.length;
|
||||
if (isLast && amountLeft > 0) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _openInteractiveViewer(
|
||||
context,
|
||||
maxItems - 1,
|
||||
),
|
||||
child: Container(
|
||||
width: _thumbnailItemSize,
|
||||
height: _thumbnailItemSize,
|
||||
padding: const EdgeInsets.all(2),
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: Corners.s8Border,
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: Corners.s6Border,
|
||||
image: image.type == CustomImageType.local
|
||||
? DecorationImage(
|
||||
image: FileImage(File(image.url)),
|
||||
fit: BoxFit.cover,
|
||||
opacity: 0.5,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (image.type != CustomImageType.local)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
child: FlowyNetworkImage(
|
||||
url: image.url,
|
||||
userProfilePB: _userProfile,
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
child: Center(
|
||||
child: FlowyText(
|
||||
'+$amountLeft',
|
||||
color: AFThemeExtension.of(context)
|
||||
.strongText,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onIndexChanged(index),
|
||||
child: ThumbnailItem(
|
||||
images: widget.images,
|
||||
index: index,
|
||||
selectedIndex: widget.indexNotifier.value,
|
||||
userProfile: _userProfile,
|
||||
onDeleted: () async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
|
||||
final images = widget.images.toList();
|
||||
images.removeAt(index);
|
||||
|
||||
transaction.updateNode(
|
||||
widget.node,
|
||||
{
|
||||
MultiImageBlockKeys.images:
|
||||
images.map((e) => e.toJson()).toList(),
|
||||
MultiImageBlockKeys.layout: widget.node
|
||||
.attributes[MultiImageBlockKeys.layout],
|
||||
},
|
||||
);
|
||||
|
||||
await widget.editorState.apply(transaction);
|
||||
|
||||
widget.onIndexChanged(
|
||||
widget.indexNotifier.value > 0
|
||||
? widget.indexNotifier.value - 1
|
||||
: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DropTarget(
|
||||
onDragEntered: (_) => setState(() => isDraggingFiles = true),
|
||||
onDragExited: (_) => setState(() => isDraggingFiles = false),
|
||||
onDragDone: (details) {
|
||||
setState(() => isDraggingFiles = false);
|
||||
// Only accept files where the mimetype is an image,
|
||||
// or the file extension is a known image format,
|
||||
// otherwise we assume it's a file we cannot display.
|
||||
final imageFiles = details.files
|
||||
.where(
|
||||
(file) =>
|
||||
file.mimeType?.startsWith('image/') ??
|
||||
false || imgExtensionRegex.hasMatch(file.name),
|
||||
)
|
||||
.toList();
|
||||
final paths = imageFiles.map((file) => file.path).toList();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) async => insertLocalImages(paths),
|
||||
);
|
||||
},
|
||||
child: !isDraggingFiles
|
||||
? const SizedBox.shrink()
|
||||
: SizedBox.expand(
|
||||
child: DecoratedBox(
|
||||
decoration:
|
||||
BoxDecoration(color: Colors.white.withOpacity(0.5)),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.import_s,
|
||||
size: Size.square(28),
|
||||
),
|
||||
const HSpace(12),
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
LocaleKeys
|
||||
.document_plugins_image_dropImageToInsert
|
||||
.tr(),
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openInteractiveViewer(BuildContext context, [int? index]) => showDialog(
|
||||
context: context,
|
||||
builder: (_) => InteractiveImageViewer(
|
||||
userProfile: _userProfile,
|
||||
imageProvider: AFBlockImageProvider(
|
||||
images: widget.images,
|
||||
initialIndex: index ?? widget.indexNotifier.value,
|
||||
onDeleteImage: (index) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
final newImages = widget.images.toList();
|
||||
newImages.removeAt(index);
|
||||
|
||||
widget.onIndexChanged(
|
||||
widget.indexNotifier.value > 0
|
||||
? widget.indexNotifier.value - 1
|
||||
: 0,
|
||||
);
|
||||
|
||||
if (newImages.isNotEmpty) {
|
||||
transaction.updateNode(
|
||||
widget.node,
|
||||
{
|
||||
MultiImageBlockKeys.images:
|
||||
newImages.map((e) => e.toJson()).toList(),
|
||||
MultiImageBlockKeys.layout:
|
||||
widget.node.attributes[MultiImageBlockKeys.layout],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
transaction.deleteNode(widget.node);
|
||||
}
|
||||
|
||||
await widget.editorState.apply(transaction);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> insertLocalImages(List<String?> urls) async {
|
||||
if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
||||
final transaction = widget.editorState.transaction;
|
||||
final images = await extractAndUploadImages(context, urls, isLocalMode);
|
||||
if (images.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newImages = [...widget.images, ...images];
|
||||
final imagesJson = newImages.map((image) => image.toJson()).toList();
|
||||
|
||||
transaction.updateNode(widget.node, {
|
||||
MultiImageBlockKeys.images: imagesJson,
|
||||
MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(),
|
||||
});
|
||||
|
||||
await widget.editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class ThumbnailItem extends StatefulWidget {
|
||||
const ThumbnailItem({
|
||||
super.key,
|
||||
required this.images,
|
||||
required this.index,
|
||||
required this.selectedIndex,
|
||||
required this.onDeleted,
|
||||
this.userProfile,
|
||||
});
|
||||
|
||||
final List<ImageBlockData> images;
|
||||
final int index;
|
||||
final int selectedIndex;
|
||||
final VoidCallback onDeleted;
|
||||
final UserProfilePB? userProfile;
|
||||
|
||||
@override
|
||||
State<ThumbnailItem> createState() => _ThumbnailItemState();
|
||||
}
|
||||
|
||||
class _ThumbnailItemState extends State<ThumbnailItem> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHovering = true),
|
||||
onExit: (_) => setState(() => isHovering = false),
|
||||
child: Container(
|
||||
width: _thumbnailItemSize,
|
||||
height: _thumbnailItemSize,
|
||||
padding: const EdgeInsets.all(2),
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: Corners.s8Border,
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: widget.index == widget.selectedIndex
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ImageRender(
|
||||
image: widget.images[widget.index],
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: AnimatedOpacity(
|
||||
opacity: isHovering ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: FlowyTooltip(
|
||||
message: LocaleKeys.button_delete.tr(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onDeleted,
|
||||
child: FlowyHover(
|
||||
resetHoverOnRebuild: false,
|
||||
style: HoverStyle(
|
||||
backgroundColor: Colors.black.withOpacity(0.6),
|
||||
hoverColor: Colors.black.withOpacity(0.9),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.delete_s,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class ImageRender extends StatelessWidget {
|
||||
const ImageRender({
|
||||
super.key,
|
||||
required this.image,
|
||||
this.userProfile,
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
final ImageBlockData image;
|
||||
final UserProfilePB? userProfile;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = switch (image.type) {
|
||||
CustomImageType.internal || CustomImageType.external => FlowyNetworkImage(
|
||||
url: image.url,
|
||||
userProfilePB: userProfile,
|
||||
fit: fit,
|
||||
),
|
||||
CustomImageType.local => Image.file(File(image.url), fit: fit),
|
||||
};
|
||||
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: const BoxDecoration(borderRadius: Corners.s6Border),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-42
@@ -1,14 +1,15 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
@@ -23,6 +24,7 @@ class ResizableImage extends StatefulWidget {
|
||||
required this.width,
|
||||
required this.src,
|
||||
this.height,
|
||||
this.onDoubleTap,
|
||||
});
|
||||
|
||||
final String src;
|
||||
@@ -31,6 +33,7 @@ class ResizableImage extends StatefulWidget {
|
||||
final double? height;
|
||||
final Alignment alignment;
|
||||
final bool editable;
|
||||
final VoidCallback? onDoubleTap;
|
||||
|
||||
final void Function(double width) onResize;
|
||||
|
||||
@@ -41,26 +44,23 @@ class ResizableImage extends StatefulWidget {
|
||||
const _kImageBlockComponentMinWidth = 30.0;
|
||||
|
||||
class _ResizableImageState extends State<ResizableImage> {
|
||||
late double imageWidth;
|
||||
final documentService = DocumentService();
|
||||
|
||||
double initialOffset = 0;
|
||||
double moveDistance = 0;
|
||||
|
||||
Widget? _cacheImage;
|
||||
|
||||
late double imageWidth;
|
||||
|
||||
@visibleForTesting
|
||||
bool onFocus = false;
|
||||
|
||||
final documentService = DocumentService();
|
||||
|
||||
UserProfilePB? _userProfilePB;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
imageWidth = widget.width;
|
||||
|
||||
_userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
|
||||
}
|
||||
|
||||
@@ -72,13 +72,12 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance),
|
||||
height: widget.height,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setState(() {
|
||||
onFocus = true;
|
||||
}),
|
||||
onExit: (event) => setState(() {
|
||||
onFocus = false;
|
||||
}),
|
||||
child: _buildResizableImage(context),
|
||||
onEnter: (_) => setState(() => onFocus = true),
|
||||
onExit: (_) => setState(() => onFocus = false),
|
||||
child: GestureDetector(
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
child: _buildResizableImage(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -97,12 +96,11 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
url: widget.src,
|
||||
width: imageWidth - moveDistance,
|
||||
userProfilePB: _userProfilePB,
|
||||
errorWidgetBuilder: (context, url, error) => _ImageLoadFailedWidget(
|
||||
progressIndicatorBuilder: (context, _, __) => _buildLoading(context),
|
||||
errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget(
|
||||
width: imageWidth,
|
||||
error: error,
|
||||
),
|
||||
progressIndicatorBuilder: (context, url, progress) =>
|
||||
_buildLoading(context),
|
||||
);
|
||||
|
||||
child = _cacheImage!;
|
||||
@@ -121,11 +119,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
left: 5,
|
||||
bottom: 0,
|
||||
width: 5,
|
||||
onUpdate: (distance) {
|
||||
setState(() {
|
||||
moveDistance = distance;
|
||||
});
|
||||
},
|
||||
onUpdate: (distance) => setState(() => moveDistance = distance),
|
||||
),
|
||||
_buildEdgeGesture(
|
||||
context,
|
||||
@@ -133,11 +127,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
right: 5,
|
||||
bottom: 0,
|
||||
width: 5,
|
||||
onUpdate: (distance) {
|
||||
setState(() {
|
||||
moveDistance = -distance;
|
||||
});
|
||||
},
|
||||
onUpdate: (distance) => setState(() => moveDistance = -distance),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -154,9 +144,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
size: const Size(18, 18),
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
SizedBox.fromSize(
|
||||
size: const Size(10, 10),
|
||||
),
|
||||
SizedBox.fromSize(size: const Size(10, 10)),
|
||||
Text(AppFlowyEditorL10n.current.loading),
|
||||
],
|
||||
),
|
||||
@@ -184,7 +172,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (onUpdate != null) {
|
||||
var offset = details.globalPosition.dx - initialOffset;
|
||||
double offset = details.globalPosition.dx - initialOffset;
|
||||
if (widget.alignment == Alignment.center) {
|
||||
offset *= 2.0;
|
||||
}
|
||||
@@ -222,10 +210,7 @@ class _ResizableImageState extends State<ResizableImage> {
|
||||
}
|
||||
|
||||
class _ImageLoadFailedWidget extends StatelessWidget {
|
||||
const _ImageLoadFailedWidget({
|
||||
required this.width,
|
||||
required this.error,
|
||||
});
|
||||
const _ImageLoadFailedWidget({required this.width, required this.error});
|
||||
|
||||
final double width;
|
||||
final Object error;
|
||||
@@ -240,9 +225,7 @@ class _ImageLoadFailedWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
border: Border.all(
|
||||
color: Colors.grey.withOpacity(0.6),
|
||||
),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.6)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -251,9 +234,7 @@ class _ImageLoadFailedWidget extends StatelessWidget {
|
||||
FlowySvgs.broken_image_xl,
|
||||
size: Size.square(48),
|
||||
),
|
||||
FlowyText(
|
||||
AppFlowyEditorL10n.current.imageLoadFailed,
|
||||
),
|
||||
FlowyText(AppFlowyEditorL10n.current.imageLoadFailed),
|
||||
const VSpace(6),
|
||||
if (error != null)
|
||||
FlowyText(
|
||||
|
||||
+17
-33
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:unsplash_client/unsplash_client.dart';
|
||||
|
||||
const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_';
|
||||
@@ -48,7 +49,6 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
randomPhotos = unsplash.photos
|
||||
.random(count: 18, orientation: PhotoOrientation.landscape)
|
||||
.goAndGet();
|
||||
@@ -57,7 +57,6 @@ class _UnsplashImageWidgetState extends State<UnsplashImageWidget> {
|
||||
@override
|
||||
void dispose() {
|
||||
unsplash.close();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -132,18 +131,16 @@ class _UnsplashImagesState extends State<_UnsplashImages> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const mainAxisSpacing = 16.0;
|
||||
final crossAxisCount = switch (widget.type) {
|
||||
UnsplashImageType.halfScreen => 3,
|
||||
UnsplashImageType.fullScreen => 2,
|
||||
};
|
||||
final mainAxisSpacing = switch (widget.type) {
|
||||
UnsplashImageType.halfScreen => 16.0,
|
||||
UnsplashImageType.fullScreen => 16.0,
|
||||
};
|
||||
final crossAxisSpacing = switch (widget.type) {
|
||||
UnsplashImageType.halfScreen => 10.0,
|
||||
UnsplashImageType.fullScreen => 16.0,
|
||||
};
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: mainAxisSpacing,
|
||||
@@ -155,15 +152,11 @@ class _UnsplashImagesState extends State<_UnsplashImages> {
|
||||
return _UnsplashImage(
|
||||
type: widget.type,
|
||||
photo: photo,
|
||||
onTap: () {
|
||||
widget.onSelectUnsplashImage(
|
||||
photo.urls.regular.toString(),
|
||||
);
|
||||
setState(() {
|
||||
_selectedPhotoIndex = index;
|
||||
});
|
||||
},
|
||||
isSelected: index == _selectedPhotoIndex,
|
||||
onTap: () {
|
||||
widget.onSelectUnsplashImage(photo.urls.regular.toString());
|
||||
setState(() => _selectedPhotoIndex = index);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
@@ -219,10 +212,7 @@ class _UnsplashImage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const HSpace(2.0),
|
||||
FlowyText(
|
||||
'by ${photo.name}',
|
||||
fontSize: 10.0,
|
||||
),
|
||||
FlowyText('by ${photo.name}', fontSize: 10.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -233,14 +223,12 @@ class _UnsplashImage extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Image.network(
|
||||
photo.urls.thumb.toString(),
|
||||
fit: BoxFit.cover,
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
);
|
||||
},
|
||||
builder: (_, constraints) => Image.network(
|
||||
photo.urls.thumb.toString(),
|
||||
fit: BoxFit.cover,
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 9,
|
||||
@@ -261,13 +249,9 @@ extension on Photo {
|
||||
String get name {
|
||||
if (user.username.isNotEmpty) {
|
||||
return user.username;
|
||||
}
|
||||
|
||||
if (user.name.isNotEmpty) {
|
||||
} else if (user.name.isNotEmpty) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
if (user.email?.isNotEmpty == true) {
|
||||
} else if (user.email?.isNotEmpty == true) {
|
||||
return user.email!;
|
||||
}
|
||||
|
||||
|
||||
+14
-28
@@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/stability_ai_image_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_file_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/stability_ai_image_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption;
|
||||
@@ -11,7 +12,8 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'widgets/embed_image_url_widget.dart';
|
||||
|
||||
enum UploadImageType {
|
||||
local,
|
||||
@@ -42,20 +44,22 @@ enum UploadImageType {
|
||||
class UploadImageMenu extends StatefulWidget {
|
||||
const UploadImageMenu({
|
||||
super.key,
|
||||
required this.onSelectedLocalImage,
|
||||
required this.onSelectedLocalImages,
|
||||
required this.onSelectedAIImage,
|
||||
required this.onSelectedNetworkImage,
|
||||
this.onSelectedColor,
|
||||
this.supportTypes = UploadImageType.values,
|
||||
this.limitMaximumImageSize = false,
|
||||
this.allowMultipleImages = false,
|
||||
});
|
||||
|
||||
final void Function(String? path) onSelectedLocalImage;
|
||||
final void Function(List<String?>) onSelectedLocalImages;
|
||||
final void Function(String url) onSelectedAIImage;
|
||||
final void Function(String url) onSelectedNetworkImage;
|
||||
final void Function(String color)? onSelectedColor;
|
||||
final List<UploadImageType> supportTypes;
|
||||
final bool limitMaximumImageSize;
|
||||
final bool allowMultipleImages;
|
||||
|
||||
@override
|
||||
State<UploadImageMenu> createState() => _UploadImageMenuState();
|
||||
@@ -133,9 +137,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
const Divider(
|
||||
height: 2,
|
||||
),
|
||||
const Divider(height: 2),
|
||||
_buildTab(),
|
||||
],
|
||||
),
|
||||
@@ -155,7 +157,8 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
||||
child: Column(
|
||||
children: [
|
||||
UploadImageFileWidget(
|
||||
onPickFile: widget.onSelectedLocalImage,
|
||||
allowMultipleImages: widget.allowMultipleImages,
|
||||
onPickFiles: widget.onSelectedLocalImages,
|
||||
),
|
||||
if (widget.limitMaximumImageSize) ...[
|
||||
const VSpace(6.0),
|
||||
@@ -185,30 +188,13 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
|
||||
),
|
||||
),
|
||||
);
|
||||
// case UploadImageType.openAI:
|
||||
// return supportOpenAI
|
||||
// ? Expanded(
|
||||
// child: Container(
|
||||
// padding: const EdgeInsets.all(8.0),
|
||||
// constraints: constraints,
|
||||
// child: OpenAIImageWidget(
|
||||
// onSelectNetworkImage: widget.onSelectedAIImage,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// : Padding(
|
||||
// padding: const EdgeInsets.all(8.0),
|
||||
// child: FlowyText(
|
||||
// LocaleKeys.document_imageBlock_pleaseInputYourOpenAIKey.tr(),
|
||||
// ),
|
||||
// );
|
||||
case UploadImageType.stabilityAI:
|
||||
return supportStabilityAI
|
||||
? Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: StabilityAIImageWidget(
|
||||
onSelectImage: widget.onSelectedLocalImage,
|
||||
onSelectImage: (url) => widget.onSelectedLocalImages([url]),
|
||||
),
|
||||
),
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+11
-9
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/permission/permission_checker.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -7,18 +9,19 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
class UploadImageFileWidget extends StatelessWidget {
|
||||
const UploadImageFileWidget({
|
||||
super.key,
|
||||
required this.onPickFile,
|
||||
required this.onPickFiles,
|
||||
this.allowedExtensions = const ['jpg', 'png', 'jpeg'],
|
||||
this.allowMultipleImages = false,
|
||||
});
|
||||
|
||||
final void Function(String? path) onPickFile;
|
||||
final void Function(List<String?>) onPickFiles;
|
||||
final List<String> allowedExtensions;
|
||||
final bool allowMultipleImages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -35,9 +38,7 @@ class UploadImageFileWidget extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
return FlowyHover(
|
||||
child: child,
|
||||
);
|
||||
return FlowyHover(child: child);
|
||||
}
|
||||
|
||||
return child;
|
||||
@@ -50,8 +51,9 @@ class UploadImageFileWidget extends StatelessWidget {
|
||||
dialogTitle: '',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: allowedExtensions,
|
||||
allowMultiple: allowMultipleImages,
|
||||
);
|
||||
onPickFile(result?.files.firstOrNull?.path);
|
||||
onPickFiles(result?.files.map((f) => f.path).toList() ?? const []);
|
||||
} else {
|
||||
final photoPermission =
|
||||
await PermissionChecker.checkPhotoPermission(context);
|
||||
@@ -60,8 +62,8 @@ class UploadImageFileWidget extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
// on mobile, the users can pick a image file from camera or image library
|
||||
final result = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
onPickFile(result?.path);
|
||||
final result = await ImagePicker().pickMultiImage();
|
||||
onPickFiles(result.map((f) => f.path).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/block_menu/block_menu_button.dart';
|
||||
@@ -7,10 +10,10 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../image/custom_image_block_component/custom_image_block_component.dart';
|
||||
|
||||
class LinkPreviewMenu extends StatefulWidget {
|
||||
const LinkPreviewMenu({
|
||||
super.key,
|
||||
@@ -72,7 +75,7 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
|
||||
}
|
||||
|
||||
void copyImageLink() {
|
||||
final url = widget.node.attributes[ImageBlockKeys.url];
|
||||
final url = widget.node.attributes[CustomImageBlockKeys.url];
|
||||
if (url != null) {
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
showSnackBarMessage(
|
||||
|
||||
+15
@@ -7,6 +7,8 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
|
||||
@@ -196,6 +198,19 @@ class _AddBlockMenu extends StatelessWidget {
|
||||
});
|
||||
},
|
||||
),
|
||||
TypeOptionMenuItemValue(
|
||||
value: MultiImageBlockKeys.type,
|
||||
backgroundColor: colorMap[ImageBlockKeys.type]!,
|
||||
text: LocaleKeys.document_plugins_photoGallery_name.tr(),
|
||||
icon: FlowySvgs.m_add_block_photo_gallery_s,
|
||||
onTap: (_, __) async {
|
||||
AppGlobals.rootNavKey.currentContext?.pop(true);
|
||||
Future.delayed(const Duration(milliseconds: 400), () async {
|
||||
final imagePlaceholderKey = GlobalKey<MultiImagePlaceholderState>();
|
||||
await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// date
|
||||
TypeOptionMenuItemValue(
|
||||
|
||||
-5
@@ -12,7 +12,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -118,10 +117,6 @@ class _SmartEditBlockComponentWidgetState
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
margin: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(maxWidth: width),
|
||||
decoration: FlowyDecoration.decoration(
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
),
|
||||
child: const SizedBox(
|
||||
width: double.infinity,
|
||||
),
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
import '../image/custom_image_block_component/custom_image_block_component.dart';
|
||||
|
||||
class CustomImageNodeParser extends NodeParser {
|
||||
const CustomImageNodeParser();
|
||||
|
||||
@@ -9,7 +11,7 @@ class CustomImageNodeParser extends NodeParser {
|
||||
@override
|
||||
String transform(Node node, DocumentMarkdownEncoder? encoder) {
|
||||
assert(node.children.isEmpty);
|
||||
final url = node.attributes[ImageBlockKeys.url];
|
||||
final url = node.attributes[CustomImageBlockKeys.url];
|
||||
assert(url != null);
|
||||
return '\n';
|
||||
}
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ export 'font/customize_font_toolbar_item.dart';
|
||||
export 'header/cover_editor_bloc.dart';
|
||||
export 'header/custom_cover_picker.dart';
|
||||
export 'header/document_header_node_widget.dart';
|
||||
export 'image/image_menu.dart';
|
||||
export 'image/custom_image_block_component/image_menu.dart';
|
||||
export 'image/multi_image_block_component/multi_image_menu.dart';
|
||||
export 'image/image_selection_menu.dart';
|
||||
export 'image/mobile_image_toolbar_item.dart';
|
||||
export 'inline_math_equation/inline_math_equation.dart';
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
typedef FeatureFlagMap = Map<FeatureFlag, bool>;
|
||||
|
||||
@@ -91,7 +92,7 @@ enum FeatureFlag {
|
||||
|
||||
bool get isOn {
|
||||
if ([
|
||||
// FeatureFlag.planBilling,
|
||||
FeatureFlag.planBilling,
|
||||
// release this feature in version 0.6.1
|
||||
FeatureFlag.spaceDesign,
|
||||
// release this feature in version 0.5.9
|
||||
|
||||
@@ -13,6 +13,9 @@ const _imgUrlPattern =
|
||||
r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?';
|
||||
final imgUrlRegex = RegExp(_imgUrlPattern);
|
||||
|
||||
const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$';
|
||||
final imgExtensionRegex = RegExp(_imgExtensionPattern);
|
||||
|
||||
/// This pattern allows for both HTTP and HTTPS Scheme
|
||||
/// It allows for query parameters
|
||||
/// It only allows the following video extensions:
|
||||
|
||||
@@ -35,7 +35,7 @@ class WindowSizeManager {
|
||||
|
||||
Future<Size> getSize() async {
|
||||
final defaultWindowSize = jsonEncode(
|
||||
{WindowSizeManager.height: 600.0, WindowSizeManager.width: 800.0},
|
||||
{WindowSizeManager.height: minWindowHeight, WindowSizeManager.width: minWindowWidth},
|
||||
);
|
||||
final windowSize = await getIt<KeyValueStorage>().get(KVKeys.windowSize);
|
||||
final size = json.decode(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Only used for testing.
|
||||
class AppFlowyCloudMockAuthService implements AuthService {
|
||||
@@ -64,12 +65,18 @@ class AppFlowyCloudMockAuthService implements AuthService {
|
||||
);
|
||||
Log.info("UserEventOauthSignIn with payload: $payload");
|
||||
return UserEventOauthSignIn(payload).send().then((value) {
|
||||
value.fold((l) => null, (err) => Log.error(err));
|
||||
value.fold(
|
||||
(l) => null,
|
||||
(err) {
|
||||
debugPrint("Error: $err");
|
||||
Log.error(err);
|
||||
},
|
||||
);
|
||||
return value;
|
||||
});
|
||||
},
|
||||
(r) {
|
||||
Log.error(r);
|
||||
debugPrint("Error: $r");
|
||||
return FlowyResult.failure(r);
|
||||
},
|
||||
);
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'local_ai_on_boarding_bloc.freezed.dart';
|
||||
|
||||
class LocalAIOnBoardingBloc
|
||||
extends Bloc<LocalAIOnBoardingEvent, LocalAIOnBoardingState> {
|
||||
LocalAIOnBoardingBloc(this.workspaceId)
|
||||
: super(const LocalAIOnBoardingState()) {
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
final String workspaceId;
|
||||
|
||||
void _dispatch() {
|
||||
on<LocalAIOnBoardingEvent>((event, emit) {
|
||||
event.when(
|
||||
started: () {
|
||||
_loadSubscriptionPlans();
|
||||
},
|
||||
didGetSubscriptionPlans: (result) {
|
||||
result.fold(
|
||||
(workspaceSubInfo) {
|
||||
final isPurchaseAILocal = workspaceSubInfo.addOns.any((addOn) {
|
||||
return addOn.type == WorkspaceAddOnPBType.AddOnAiLocal;
|
||||
});
|
||||
|
||||
emit(
|
||||
state.copyWith(isPurchaseAILocal: isPurchaseAILocal),
|
||||
);
|
||||
},
|
||||
(err) {
|
||||
Log.error("Failed to get subscription plans: $err");
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _loadSubscriptionPlans() {
|
||||
final payload = UserWorkspaceIdPB()..workspaceId = workspaceId;
|
||||
UserEventGetWorkspaceSubscriptionInfo(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
add(LocalAIOnBoardingEvent.didGetSubscriptionPlans(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIOnBoardingEvent with _$LocalAIOnBoardingEvent {
|
||||
const factory LocalAIOnBoardingEvent.started() = _Started;
|
||||
const factory LocalAIOnBoardingEvent.didGetSubscriptionPlans(
|
||||
FlowyResult<WorkspaceSubscriptionInfoPB, FlowyError> result,
|
||||
) = _LoadSubscriptionPlans;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LocalAIOnBoardingState with _$LocalAIOnBoardingState {
|
||||
const factory LocalAIOnBoardingState({
|
||||
@Default(false) bool isPurchaseAILocal,
|
||||
}) = _LocalAIOnBoardingState;
|
||||
}
|
||||
+20
-4
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/core/helpers/url_launcher.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -67,8 +68,20 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
|
||||
break;
|
||||
}
|
||||
},
|
||||
restartLocalAI: () {
|
||||
ChatEventRestartLocalAIChat().send();
|
||||
restartLocalAI: () async {
|
||||
emit(
|
||||
const PluginStateState(action: PluginStateAction.loadingPlugin()),
|
||||
);
|
||||
unawaited(ChatEventRestartLocalAIChat().send());
|
||||
},
|
||||
openModelDirectory: () async {
|
||||
final result = await ChatEventGetModelStorageDirectory().send();
|
||||
result.fold(
|
||||
(data) {
|
||||
afLaunchUrl(Uri.file(data.filePath));
|
||||
},
|
||||
(err) => Log.error(err.toString()),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -80,12 +93,15 @@ class PluginStateEvent with _$PluginStateEvent {
|
||||
const factory PluginStateEvent.updateState(LocalAIPluginStatePB pluginState) =
|
||||
_UpdatePluginState;
|
||||
const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI;
|
||||
const factory PluginStateEvent.openModelDirectory() =
|
||||
_OpenModelStorageDirectory;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class PluginStateState with _$PluginStateState {
|
||||
const factory PluginStateState({required PluginStateAction action}) =
|
||||
_PluginStateState;
|
||||
const factory PluginStateState({
|
||||
required PluginStateAction action,
|
||||
}) = _PluginStateState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
+10
-14
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DesktopAppearance extends BaseAppearance {
|
||||
@override
|
||||
@@ -75,18 +74,12 @@ class DesktopAppearance extends BaseAppearance {
|
||||
contentTextStyle: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.any(scrollbarInteractiveStates.contains)) {
|
||||
return theme.shader3;
|
||||
}
|
||||
return theme.shader5;
|
||||
}),
|
||||
thickness: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.any(scrollbarInteractiveStates.contains)) {
|
||||
return 4;
|
||||
}
|
||||
return 3.0;
|
||||
}),
|
||||
thumbColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.any(scrollbarInteractiveStates.contains)
|
||||
? theme.scrollbarHoverColor
|
||||
: theme.scrollbarColor,
|
||||
),
|
||||
thickness: WidgetStateProperty.resolveWith((_) => 4.0),
|
||||
crossAxisMargin: 0.0,
|
||||
mainAxisMargin: 6.0,
|
||||
radius: Corners.s10Radius,
|
||||
@@ -147,6 +140,9 @@ class DesktopAppearance extends BaseAppearance {
|
||||
),
|
||||
onBackground: theme.text,
|
||||
background: theme.surface,
|
||||
borderColor: theme.borderColor,
|
||||
scrollbarColor: theme.scrollbarColor,
|
||||
scrollbarHoverColor: theme.scrollbarHoverColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+4
-2
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ThemeData in mobile
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileAppearance extends BaseAppearance {
|
||||
static const _primaryColor = Color(0xFF00BCF0); //primary 100
|
||||
@@ -276,6 +275,9 @@ class MobileAppearance extends BaseAppearance {
|
||||
),
|
||||
onBackground: onBackground,
|
||||
background: background,
|
||||
borderColor: theme.borderColor,
|
||||
scrollbarColor: theme.scrollbarColor,
|
||||
scrollbarHoverColor: theme.scrollbarHoverColor,
|
||||
),
|
||||
ToolbarColorExtension.fromBrightness(brightness),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/blank/blank.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -27,12 +24,13 @@ import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:flowy_infra_ui/style_widget/container.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:sized_context/sized_context.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
import '../widgets/edit_panel/edit_panel.dart';
|
||||
|
||||
import '../widgets/sidebar_resizer.dart';
|
||||
import 'home_layout.dart';
|
||||
import 'home_stack.dart';
|
||||
|
||||
@@ -164,7 +162,9 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
userProfile: userProfile,
|
||||
workspaceSetting: workspaceSetting,
|
||||
);
|
||||
final homeMenuResizer = _buildHomeMenuResizer(context, layout: layout);
|
||||
|
||||
final homeMenuResizer =
|
||||
layout.showMenu ? const SidebarResizer() : const SizedBox.shrink();
|
||||
final editPanel = _buildEditPanel(context, layout: layout);
|
||||
|
||||
return _layoutWidgets(
|
||||
@@ -218,39 +218,6 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomeMenuResizer(
|
||||
BuildContext context, {
|
||||
required HomeLayout layout,
|
||||
}) {
|
||||
if (!layout.showMenu) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
child: GestureDetector(
|
||||
dragStartBehavior: DragStartBehavior.down,
|
||||
onHorizontalDragStart: (details) => context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(const HomeSettingEvent.editPanelResizeStart()),
|
||||
onHorizontalDragUpdate: (details) => context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(HomeSettingEvent.editPanelResized(details.localPosition.dx)),
|
||||
onHorizontalDragEnd: (details) => context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(const HomeSettingEvent.editPanelResizeEnd()),
|
||||
onHorizontalDragCancel: () => context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(const HomeSettingEvent.editPanelResizeEnd()),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SizedBox(
|
||||
width: 10,
|
||||
height: MediaQuery.of(context).size.height,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _layoutWidgets({
|
||||
required HomeLayout layout,
|
||||
required Widget sidebar,
|
||||
@@ -296,7 +263,7 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
)
|
||||
.positioned(left: 0, top: 0, width: layout.menuWidth, bottom: 0),
|
||||
homeMenuResizer
|
||||
.positioned(left: layout.menuWidth - 5)
|
||||
.positioned(left: layout.menuWidth)
|
||||
.animate(layout.animDuration, Curves.easeOutQuad),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
@@ -16,6 +17,8 @@ class HomeLayout {
|
||||
menuWidth = Sizes.sideBarWidth;
|
||||
menuWidth += homeSetting.resizeOffset;
|
||||
|
||||
menuWidth = max(menuWidth, HomeSizes.minimumSidebarWidth);
|
||||
|
||||
final screenWidthPx = context.widthPx;
|
||||
context
|
||||
.read<HomeSettingBloc>()
|
||||
|
||||
@@ -8,6 +8,7 @@ class HomeSizes {
|
||||
static const double workspaceSectionHeight = 32;
|
||||
static const double searchSectionHeight = 30;
|
||||
static const double newPageSectionHeight = 30;
|
||||
static const double minimumSidebarWidth = 268;
|
||||
}
|
||||
|
||||
class HomeInsets {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
@@ -11,6 +9,7 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:scaled_app/scaled_app.dart';
|
||||
@@ -18,6 +17,7 @@ import 'package:scaled_app/scaled_app.dart';
|
||||
typedef KeyDownHandler = void Function(HotKey hotKey);
|
||||
|
||||
ValueNotifier<int> switchToTheNextSpace = ValueNotifier(0);
|
||||
ValueNotifier<int> createNewPageNotifier = ValueNotifier(0);
|
||||
|
||||
/// Helper class that utilizes the global [HotKeyManager] to easily
|
||||
/// add a [HotKey] with different handlers.
|
||||
@@ -180,6 +180,16 @@ class _HomeHotKeysState extends State<HomeHotKeys> {
|
||||
keyDownHandler: (_) => switchToTheNextSpace.value++,
|
||||
),
|
||||
|
||||
// Create a new page
|
||||
HotKeyItem(
|
||||
hotKey: HotKey(
|
||||
KeyCode.keyN,
|
||||
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
|
||||
scope: HotKeyScope.inapp,
|
||||
),
|
||||
keyDownHandler: (_) => createNewPageNotifier.value++,
|
||||
),
|
||||
|
||||
// Open settings dialog
|
||||
openSettingsHotKey(context, widget.userProfile),
|
||||
];
|
||||
|
||||
-6
@@ -11,7 +11,6 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -172,11 +171,6 @@ class FavoriteMoreButton extends StatelessWidget {
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: minWidth,
|
||||
),
|
||||
decoration: FlowyDecoration.decoration(
|
||||
Theme.of(context).cardColor,
|
||||
Theme.of(context).colorScheme.shadow,
|
||||
borderRadius: 10.0,
|
||||
),
|
||||
popupBuilder: (_) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
|
||||
+9
-12
@@ -137,6 +137,7 @@ class _FavoriteGroups extends StatelessWidget {
|
||||
state.otherViews,
|
||||
LocaleKeys.sideBar_others.tr(),
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: minWidth - 2 * _kHorizontalPadding,
|
||||
constraints: const BoxConstraints(
|
||||
@@ -149,15 +150,14 @@ class _FavoriteGroups extends StatelessWidget {
|
||||
children: [
|
||||
if (today.isNotEmpty) ...[
|
||||
...today,
|
||||
const VSpace(8),
|
||||
const Divider(height: 1),
|
||||
const VSpace(8),
|
||||
const FlowyDivider(),
|
||||
const VSpace(16),
|
||||
],
|
||||
if (thisWeek.isNotEmpty) ...[
|
||||
...thisWeek,
|
||||
const VSpace(8),
|
||||
const Divider(height: 1),
|
||||
const VSpace(8),
|
||||
const FlowyDivider(),
|
||||
const VSpace(16),
|
||||
],
|
||||
...others.isNotEmpty && (today.isNotEmpty || thisWeek.isNotEmpty)
|
||||
? others
|
||||
@@ -182,13 +182,10 @@ class _FavoriteGroups extends StatelessWidget {
|
||||
return [
|
||||
if (views.isNotEmpty) ...[
|
||||
if (showHeader)
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: FlowyText(
|
||||
title,
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
FlowyText(
|
||||
title,
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
const VSpace(2),
|
||||
_FavoriteGroupedViews(views: views),
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ class FavoritePinAction extends StatelessWidget {
|
||||
: LocaleKeys.favorite_addToSidebar.tr();
|
||||
final icon = FlowySvg(
|
||||
view.isPinned
|
||||
? FlowySvgs.favorite_section_pin_s
|
||||
: FlowySvgs.favorite_section_unpin_s,
|
||||
? FlowySvgs.favorite_section_unpin_s
|
||||
: FlowySvgs.favorite_section_pin_s,
|
||||
);
|
||||
return FlowyTooltip(
|
||||
message: tooltip,
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -19,7 +20,12 @@ class SidebarFooter extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (FeatureFlag.planBilling.isOn) const SidebarToast(),
|
||||
if (FeatureFlag.planBilling.isOn)
|
||||
BillingGateGuard(
|
||||
builder: (context) {
|
||||
return const SidebarToast();
|
||||
},
|
||||
),
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: SidebarTrashButton()),
|
||||
|
||||
+4
-8
@@ -3,7 +3,6 @@ import 'dart:io' show Platform;
|
||||
import 'package:appflowy/core/frameless_window.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
@@ -65,20 +64,17 @@ class SidebarTopMenu extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildCollapseMenuButton(BuildContext context) {
|
||||
final color = Theme.of(context).isLightMode ? Colors.white : Colors.black;
|
||||
final textSpan = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.sideBar_closeSidebar.tr()}\n',
|
||||
style:
|
||||
Theme.of(context).tooltipTheme.textStyle!.copyWith(color: color),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\',
|
||||
style: Theme.of(context)
|
||||
.tooltipTheme
|
||||
.textStyle!
|
||||
.copyWith(color: Theme.of(context).hintColor),
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+13
-6
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_search_bloc.dart';
|
||||
@@ -5,9 +6,12 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -123,12 +127,15 @@ class _MovePageMenuState extends State<MovePageMenu> {
|
||||
expand: true,
|
||||
height: 30,
|
||||
showCreateButton: false,
|
||||
child: CurrentSpace(
|
||||
onTapBlankArea: () {
|
||||
// move the page to current space
|
||||
widget.onSelected(space, space);
|
||||
},
|
||||
space: space,
|
||||
child: FlowyTooltip(
|
||||
message: LocaleKeys.space_switchSpace.tr(),
|
||||
child: CurrentSpace(
|
||||
onTapBlankArea: () {
|
||||
// move the page to current space
|
||||
widget.onSelected(space, space);
|
||||
},
|
||||
space: space,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
+21
-3
@@ -4,6 +4,7 @@ import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/rename_view_dialog.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -11,18 +12,35 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SidebarNewPageButton extends StatelessWidget {
|
||||
class SidebarNewPageButton extends StatefulWidget {
|
||||
const SidebarNewPageButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SidebarNewPageButton> createState() => _SidebarNewPageButtonState();
|
||||
}
|
||||
|
||||
class _SidebarNewPageButtonState extends State<SidebarNewPageButton> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
createNewPageNotifier.addListener(_createNewPage);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
createNewPageNotifier.removeListener(_createNewPage);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
height: HomeSizes.newPageSectionHeight,
|
||||
child: FlowyButton(
|
||||
onTap: () async => _createNewPage(context),
|
||||
onTap: () async => _createNewPage(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.new_app_m,
|
||||
blendMode: null,
|
||||
@@ -38,7 +56,7 @@ class SidebarNewPageButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createNewPage(BuildContext context) async {
|
||||
Future<void> _createNewPage() async {
|
||||
return createViewAndShowRenameDialogIfNeeded(
|
||||
context,
|
||||
LocaleKeys.newPageText.tr(),
|
||||
|
||||
+13
-16
@@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/blank/blank.dart';
|
||||
@@ -35,9 +33,8 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
Loading? _duplicateSpaceLoading;
|
||||
@@ -333,10 +330,7 @@ class _SidebarState extends State<_Sidebar> {
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: const Divider(
|
||||
color: Color(0x141F2329),
|
||||
height: 0.5,
|
||||
),
|
||||
child: const FlowyDivider(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -346,7 +340,7 @@ class _SidebarState extends State<_Sidebar> {
|
||||
Padding(
|
||||
padding: menuHorizontalInset +
|
||||
const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: const Divider(height: 0.5, color: Color(0x141F2329)),
|
||||
child: const FlowyDivider(),
|
||||
),
|
||||
const VSpace(8),
|
||||
|
||||
@@ -403,13 +397,16 @@ class _SidebarState extends State<_Sidebar> {
|
||||
: Expanded(
|
||||
child: Padding(
|
||||
padding: menuHorizontalInset - const EdgeInsets.only(right: 6),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: FlowyScrollbar(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: SidebarSpace(
|
||||
userProfile: widget.userProfile,
|
||||
isHoverEnabled: !_isScrolling,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: SidebarSpace(
|
||||
userProfile: widget.userProfile,
|
||||
isHoverEnabled: !_isScrolling,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension SpacePermissionColorExtension on BuildContext {
|
||||
Color get enableBorderColor => Theme.of(this).isLightMode
|
||||
? const Color(0x1E171717)
|
||||
: const Color(0xFF3A3F49);
|
||||
}
|
||||
+10
-4
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -24,19 +25,22 @@ class _CreateSpacePopupState extends State<CreateSpacePopup> {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
|
||||
width: 500,
|
||||
width: 524,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
LocaleKeys.space_createNewSpace.tr(),
|
||||
fontSize: 18.0,
|
||||
figmaLineHeight: 24.0,
|
||||
),
|
||||
const VSpace(6.0),
|
||||
FlowyText.regular(
|
||||
const VSpace(2.0),
|
||||
FlowyText(
|
||||
LocaleKeys.space_createSpaceDescription.tr(),
|
||||
fontSize: 14.0,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: Theme.of(context).hintColor,
|
||||
figmaLineHeight: 18.0,
|
||||
maxLines: 2,
|
||||
),
|
||||
const VSpace(16.0),
|
||||
@@ -106,14 +110,16 @@ class _SpaceNameTextField extends StatelessWidget {
|
||||
LocaleKeys.space_spaceName.tr(),
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
figmaLineHeight: 18.0,
|
||||
),
|
||||
const VSpace(6.0),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: FlowyTextField(
|
||||
hintText: LocaleKeys.space_spaceName.tr(),
|
||||
hintText: LocaleKeys.space_spaceNamePlaceholder.tr(),
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
enableBorderColor: context.enableBorderColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+32
-36
@@ -1,7 +1,3 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
@@ -10,6 +6,7 @@ import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
@@ -19,9 +16,11 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/decoration.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SpacePermissionSwitch extends StatefulWidget {
|
||||
@@ -55,6 +54,7 @@ class _SpacePermissionSwitchState extends State<SpacePermissionSwitch> {
|
||||
LocaleKeys.space_permission.tr(),
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
figmaLineHeight: 18.0,
|
||||
),
|
||||
const VSpace(6.0),
|
||||
AppFlowyPopover(
|
||||
@@ -63,16 +63,11 @@ class _SpacePermissionSwitchState extends State<SpacePermissionSwitch> {
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
offset: const Offset(0, 4),
|
||||
margin: EdgeInsets.zero,
|
||||
decoration: FlowyDecoration.decoration(
|
||||
Theme.of(context).cardColor,
|
||||
Theme.of(context).colorScheme.shadow,
|
||||
borderRadius: 10,
|
||||
),
|
||||
popupBuilder: (_) => _buildPermissionButtons(),
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outline),
|
||||
side: BorderSide(color: context.enableBorderColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
@@ -148,9 +143,13 @@ class SpacePermissionButton extends StatelessWidget {
|
||||
radius: BorderRadius.circular(10),
|
||||
iconPadding: 16.0,
|
||||
leftIcon: FlowySvg(icon),
|
||||
leftIconSize: const Size.square(20),
|
||||
rightIcon: showArrow
|
||||
? const FlowySvg(FlowySvgs.space_permission_dropdown_s)
|
||||
: null,
|
||||
borderColor: Theme.of(context).isLightMode
|
||||
? const Color(0x1E171717)
|
||||
: const Color(0xFF3A3F49),
|
||||
text: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -462,32 +461,29 @@ class CurrentSpace extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = FlowyTooltip(
|
||||
message: LocaleKeys.space_switchSpace.tr(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SpaceIcon(
|
||||
dimension: 20,
|
||||
space: space,
|
||||
cornerRadius: 6.0,
|
||||
final child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SpaceIcon(
|
||||
dimension: 20,
|
||||
space: space,
|
||||
cornerRadius: 6.0,
|
||||
),
|
||||
const HSpace(10),
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
space.name,
|
||||
fontSize: 14.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const HSpace(10),
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
space.name,
|
||||
fontSize: 14.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
FlowySvg(
|
||||
context.read<SpaceBloc>().state.isExpanded
|
||||
? FlowySvgs.workspace_drop_down_menu_show_s
|
||||
: FlowySvgs.workspace_drop_down_menu_hide_s,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
FlowySvg(
|
||||
context.read<SpaceBloc>().state.isExpanded
|
||||
? FlowySvgs.workspace_drop_down_menu_show_s
|
||||
: FlowySvgs.workspace_drop_down_menu_hide_s,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (onTapBlankArea != null) {
|
||||
|
||||
+4
-8
@@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart';
|
||||
@@ -105,20 +104,17 @@ class _SidebarSpaceHeaderState extends State<SidebarSpaceHeader> {
|
||||
}
|
||||
|
||||
Widget _buildChild() {
|
||||
final color = Theme.of(context).isLightMode ? Colors.white : Colors.black;
|
||||
final textSpan = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.space_quicklySwitch.tr()}\n',
|
||||
style:
|
||||
Theme.of(context).tooltipTheme.textStyle!.copyWith(color: color),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: Platform.isMacOS ? '⌘+O' : 'Ctrl+O',
|
||||
style: Theme.of(context)
|
||||
.tooltipTheme
|
||||
.textStyle!
|
||||
.copyWith(color: Theme.of(context).hintColor),
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+1
-3
@@ -40,9 +40,7 @@ class SidebarSpaceMenu extends StatelessWidget {
|
||||
if (showCreateButton) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Divider(
|
||||
height: 0.5,
|
||||
),
|
||||
child: FlowyDivider(),
|
||||
),
|
||||
const SizedBox(
|
||||
height: HomeSpaceViewSizes.viewHeight,
|
||||
|
||||
+9
-9
@@ -36,24 +36,24 @@ extension ViewMoreActionTypeExtension on SpaceMoreActionType {
|
||||
}
|
||||
}
|
||||
|
||||
Widget get leftIcon {
|
||||
FlowySvgData get leftIconSvg {
|
||||
switch (this) {
|
||||
case SpaceMoreActionType.delete:
|
||||
return const FlowySvg(FlowySvgs.trash_s, blendMode: null);
|
||||
return FlowySvgs.trash_s;
|
||||
case SpaceMoreActionType.rename:
|
||||
return const FlowySvg(FlowySvgs.view_item_rename_s);
|
||||
return FlowySvgs.view_item_rename_s;
|
||||
case SpaceMoreActionType.changeIcon:
|
||||
return const FlowySvg(FlowySvgs.change_icon_s);
|
||||
return FlowySvgs.change_icon_s;
|
||||
case SpaceMoreActionType.collapseAllPages:
|
||||
return const FlowySvg(FlowySvgs.collapse_all_page_s);
|
||||
return FlowySvgs.collapse_all_page_s;
|
||||
case SpaceMoreActionType.addNewSpace:
|
||||
return const FlowySvg(FlowySvgs.space_add_s);
|
||||
return FlowySvgs.space_add_s;
|
||||
case SpaceMoreActionType.manage:
|
||||
return const FlowySvg(FlowySvgs.space_manage_s);
|
||||
return FlowySvgs.space_manage_s;
|
||||
case SpaceMoreActionType.duplicate:
|
||||
return const FlowySvg(FlowySvgs.duplicate_s);
|
||||
return FlowySvgs.duplicate_s;
|
||||
case SpaceMoreActionType.divider:
|
||||
return const SizedBox.shrink();
|
||||
throw UnsupportedError('Divider does not have an icon');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-6
@@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final builtInSpaceColors = [
|
||||
@@ -59,11 +58,6 @@ class _SpaceIconPopupState extends State<SpaceIconPopup> {
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
offset: const Offset(0, 4),
|
||||
decoration: FlowyDecoration.decoration(
|
||||
Theme.of(context).cardColor,
|
||||
Theme.of(context).colorScheme.shadow,
|
||||
borderRadius: 10,
|
||||
),
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
|
||||
+15
-13
@@ -129,7 +129,7 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell {
|
||||
Widget _buildDivider() {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Divider(height: 1.0),
|
||||
child: FlowyDivider(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,22 +158,24 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell {
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Opacity(
|
||||
opacity: disable ? 0.3 : 1.0,
|
||||
child: FlowyButton(
|
||||
child: FlowyIconTextButton(
|
||||
disable: disable,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
leftIcon: inner.leftIcon,
|
||||
rightIcon: inner.rightIcon,
|
||||
iconPadding: 10.0,
|
||||
text: SizedBox(
|
||||
// height: 16.0,
|
||||
child: FlowyText.regular(
|
||||
inner.name,
|
||||
color: inner == SpaceMoreActionType.delete
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
leftIconBuilder: (onHover) => FlowySvg(
|
||||
inner.leftIconSvg,
|
||||
color: inner == SpaceMoreActionType.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
rightIconBuilder: (_) => inner.rightIcon,
|
||||
textBuilder: (onHover) => FlowyText.regular(
|
||||
inner.name,
|
||||
color: inner == SpaceMoreActionType.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+12
-13
@@ -49,32 +49,31 @@ extension ViewMoreActionTypeExtension on ViewMoreActionType {
|
||||
}
|
||||
}
|
||||
|
||||
Widget get leftIcon {
|
||||
FlowySvgData get leftIconSvg {
|
||||
switch (this) {
|
||||
case ViewMoreActionType.delete:
|
||||
return const FlowySvg(FlowySvgs.trash_s, blendMode: null);
|
||||
return FlowySvgs.trash_s;
|
||||
case ViewMoreActionType.favorite:
|
||||
return const FlowySvg(FlowySvgs.favorite_s);
|
||||
return FlowySvgs.favorite_s;
|
||||
case ViewMoreActionType.unFavorite:
|
||||
return const FlowySvg(FlowySvgs.unfavorite_s);
|
||||
return FlowySvgs.unfavorite_s;
|
||||
case ViewMoreActionType.duplicate:
|
||||
return const FlowySvg(FlowySvgs.duplicate_s);
|
||||
case ViewMoreActionType.copyLink:
|
||||
return const Icon(Icons.copy);
|
||||
return FlowySvgs.duplicate_s;
|
||||
case ViewMoreActionType.rename:
|
||||
return const FlowySvg(FlowySvgs.view_item_rename_s);
|
||||
return FlowySvgs.view_item_rename_s;
|
||||
case ViewMoreActionType.moveTo:
|
||||
return const FlowySvg(FlowySvgs.move_to_s);
|
||||
return FlowySvgs.move_to_s;
|
||||
case ViewMoreActionType.openInNewTab:
|
||||
return const FlowySvg(FlowySvgs.view_item_open_in_new_tab_s);
|
||||
return FlowySvgs.view_item_open_in_new_tab_s;
|
||||
case ViewMoreActionType.changeIcon:
|
||||
return const FlowySvg(FlowySvgs.change_icon_s);
|
||||
return FlowySvgs.change_icon_s;
|
||||
case ViewMoreActionType.collapseAllPages:
|
||||
return const FlowySvg(FlowySvgs.collapse_all_page_s);
|
||||
return FlowySvgs.collapse_all_page_s;
|
||||
case ViewMoreActionType.divider:
|
||||
case ViewMoreActionType.lastModified:
|
||||
case ViewMoreActionType.copyLink:
|
||||
case ViewMoreActionType.created:
|
||||
return const SizedBox.shrink();
|
||||
throw UnsupportedError('No left icon for $this');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -491,6 +491,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
final name = FlowyText.regular(
|
||||
widget.view.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// figmaLineHeight: 18.0,
|
||||
);
|
||||
final children = [
|
||||
const HSpace(2),
|
||||
|
||||
+15
-8
@@ -101,7 +101,8 @@ class ViewMoreActionButton extends StatelessWidget {
|
||||
]);
|
||||
|
||||
// Chat doesn't change collapse
|
||||
if (view.layout != ViewLayoutPB.Chat) {
|
||||
// Only show collapse all pages if the view has child views
|
||||
if (view.layout != ViewLayoutPB.Chat && view.childViews.isNotEmpty) {
|
||||
actionTypes.add(ViewMoreActionType.collapseAllPages);
|
||||
actionTypes.add(ViewMoreActionType.divider);
|
||||
}
|
||||
@@ -204,7 +205,7 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
|
||||
Widget _buildDivider() {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Divider(height: 1.0),
|
||||
child: FlowyDivider(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,18 +238,24 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
|
||||
return Container(
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: FlowyButton(
|
||||
child: FlowyIconTextButton(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
leftIcon: inner.leftIcon,
|
||||
rightIcon: inner.rightIcon,
|
||||
onTap: onTap,
|
||||
// show the error color when delete is hovered
|
||||
leftIconBuilder: (onHover) => FlowySvg(
|
||||
inner.leftIconSvg,
|
||||
color: inner == ViewMoreActionType.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
rightIconBuilder: (_) => inner.rightIcon,
|
||||
iconPadding: 10.0,
|
||||
text: FlowyText.regular(
|
||||
textBuilder: (onHover) => FlowyText.regular(
|
||||
inner.name,
|
||||
color: inner == ViewMoreActionType.delete
|
||||
color: inner == ViewMoreActionType.delete && onHover
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -65,23 +64,17 @@ class FlowyNavigation extends StatelessWidget {
|
||||
buildWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed,
|
||||
builder: (context, state) {
|
||||
if (!PlatformExtension.isWindows && state.isMenuCollapsed) {
|
||||
final color =
|
||||
Theme.of(context).isLightMode ? Colors.white : Colors.black;
|
||||
final textSpan = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.sideBar_openSidebar.tr()}\n',
|
||||
style: Theme.of(context)
|
||||
.tooltipTheme
|
||||
.textStyle!
|
||||
.copyWith(color: color),
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\',
|
||||
style: Theme.of(context)
|
||||
.tooltipTheme
|
||||
.textStyle!
|
||||
.copyWith(color: Theme.of(context).hintColor),
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+10
-12
@@ -37,15 +37,10 @@ class DownloadingIndicator extends StatelessWidget {
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// const DownloadingPrompt(),
|
||||
// const VSpace(12),
|
||||
DownloadingProgressBar(onCancel: onCancel),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
DownloadingProgressBar(onCancel: onCancel),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -65,9 +60,12 @@ class DownloadingProgressBar extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FlowyText(
|
||||
"${LocaleKeys.settings_aiPage_keys_downloadingModel.tr()}: ${state.object}",
|
||||
fontSize: 11,
|
||||
Opacity(
|
||||
opacity: 0.6,
|
||||
child: FlowyText(
|
||||
"${LocaleKeys.settings_aiPage_keys_downloadingModel.tr()}: ${state.object}",
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
+9
-6
@@ -1,7 +1,7 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
@@ -70,7 +70,7 @@ class LocalAIChatSetting extends StatelessWidget {
|
||||
header: const LocalAIChatSettingHeader(),
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -240,7 +240,7 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
finishDownload: () => const InitLocalAIIndicator(),
|
||||
checkPluginState: () => const CheckPluginStateIndicator(),
|
||||
checkPluginState: () => const PluginStateIndicator(),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
@@ -253,9 +253,12 @@ class _LocalLLMInfoWidget extends StatelessWidget {
|
||||
} else {
|
||||
return Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
error.msg,
|
||||
maxLines: 10,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: FlowyText(
|
||||
error.msg,
|
||||
maxLines: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+4
-5
@@ -6,6 +6,7 @@ import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:expandable/expandable.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
|
||||
@@ -55,6 +56,7 @@ class _LocalAISettingState extends State<LocalAISetting> {
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: Column(
|
||||
children: [
|
||||
const VSpace(6),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
@@ -64,11 +66,8 @@ class _LocalAISettingState extends State<LocalAISetting> {
|
||||
const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 12.0,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
),
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: LocalAIChatSetting(),
|
||||
),
|
||||
),
|
||||
|
||||
+23
-6
@@ -8,8 +8,8 @@ import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class CheckPluginStateIndicator extends StatelessWidget {
|
||||
const CheckPluginStateIndicator({super.key});
|
||||
class PluginStateIndicator extends StatelessWidget {
|
||||
const PluginStateIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -20,7 +20,7 @@ class CheckPluginStateIndicator extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
return state.action.when(
|
||||
init: () => const _InitPlugin(),
|
||||
ready: () => const _ReadyToUse(),
|
||||
ready: () => const _LocalAIReadyToUse(),
|
||||
restart: () => const _ReloadButton(),
|
||||
loadingPlugin: () => const _InitPlugin(),
|
||||
);
|
||||
@@ -74,8 +74,8 @@ class _ReloadButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadyToUse extends StatelessWidget {
|
||||
const _ReadyToUse();
|
||||
class _LocalAIReadyToUse extends StatelessWidget {
|
||||
const _LocalAIReadyToUse();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -87,7 +87,7 @@ class _ReadyToUse extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8),
|
||||
@@ -101,6 +101,23 @@ class _ReadyToUse extends StatelessWidget {
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
LocaleKeys.settings_aiPage_keys_openModelDirectory.tr(),
|
||||
fontSize: 11,
|
||||
color: const Color(0xFF1E4620),
|
||||
),
|
||||
onTap: () {
|
||||
context.read<PluginStateBloc>().add(
|
||||
const PluginStateEvent.openModelDirectory(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+124
-2
@@ -1,4 +1,13 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@@ -43,9 +52,12 @@ class SettingsAIView extends StatelessWidget {
|
||||
const AIModelSelection(),
|
||||
];
|
||||
|
||||
// children.add(const LocalAISetting());
|
||||
|
||||
children.add(const _AISearchToggle(value: false));
|
||||
children.add(
|
||||
_LocalAIOnBoarding(
|
||||
workspaceId: userProfile.workspaceId,
|
||||
),
|
||||
);
|
||||
|
||||
return SettingsBody(
|
||||
title: LocaleKeys.settings_aiPage_title.tr(),
|
||||
@@ -101,3 +113,113 @@ class _AISearchToggle extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalAIOnBoarding extends StatelessWidget {
|
||||
const _LocalAIOnBoarding({required this.workspaceId});
|
||||
final String workspaceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (FeatureFlag.planBilling.isOn) {
|
||||
return BillingGateGuard(
|
||||
builder: (context) {
|
||||
return BlocProvider(
|
||||
create: (context) => LocalAIOnBoardingBloc(workspaceId)
|
||||
..add(const LocalAIOnBoardingEvent.started()),
|
||||
child: BlocBuilder<LocalAIOnBoardingBloc, LocalAIOnBoardingState>(
|
||||
builder: (context, state) {
|
||||
// Show the local AI settings if the user has purchased the AI Local plan
|
||||
if (kDebugMode || state.isPurchaseAILocal) {
|
||||
return const LocalAISetting();
|
||||
} else {
|
||||
// Show the upgrade to AI Local plan button if the user has not purchased the AI Local plan
|
||||
return _UpgradeToAILocalPlan(
|
||||
onTap: () {
|
||||
context.read<SettingsDialogBloc>().add(
|
||||
const SettingsDialogEvent.setSelectedPage(
|
||||
SettingsPage.plan,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _UpgradeToAILocalPlan extends StatefulWidget {
|
||||
const _UpgradeToAILocalPlan({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_UpgradeToAILocalPlan> createState() => _UpgradeToAILocalPlanState();
|
||||
}
|
||||
|
||||
class _UpgradeToAILocalPlanState extends State<_UpgradeToAILocalPlan> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const textGradient = LinearGradient(
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF8032FF), Color(0xFFEF35FF)],
|
||||
stops: [0.1545, 0.8225],
|
||||
);
|
||||
|
||||
final backgroundGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_isHovered
|
||||
? const Color(0xFF8032FF).withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
_isHovered
|
||||
? const Color(0xFFEF35FF).withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
],
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
gradient: backgroundGradient,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.upgrade_storage_s,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(6),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => textGradient.createShader(bounds),
|
||||
blendMode: BlendMode.srcIn,
|
||||
child: FlowyText(
|
||||
LocaleKeys.sideBar_upgradeToAILocal.tr(),
|
||||
color: AFThemeExtension.of(context).strongText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-22
@@ -411,30 +411,32 @@ class _UserProfileSettingState extends State<UserProfileSetting> {
|
||||
),
|
||||
const HSpace(16),
|
||||
if (!isEditing) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
widget.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const HSpace(4),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => isEditing = true),
|
||||
child: const FlowyHover(
|
||||
resetHoverOnRebuild: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: FlowySvg(FlowySvgs.edit_s),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText.medium(
|
||||
widget.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const HSpace(4),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => isEditing = true),
|
||||
child: const FlowyHover(
|
||||
resetHoverOnRebuild: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: FlowySvg(FlowySvgs.edit_s),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
|
||||
+21
-15
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
@@ -209,22 +211,26 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
|
||||
),
|
||||
),
|
||||
const SettingsDashedDivider(),
|
||||
_AITile(
|
||||
plan: SubscriptionPlanPB.AiLocal,
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_label
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_description,
|
||||
activeDescription: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_activeDescription,
|
||||
canceledDescription: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_canceledDescription,
|
||||
subscriptionInfo:
|
||||
state.subscriptionInfo.addOns.firstWhereOrNull(
|
||||
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
|
||||
|
||||
// Currently, the AI Local tile is only available on macOS
|
||||
// TODO(nathan): enable windows and linux
|
||||
if (Platform.isMacOS)
|
||||
_AITile(
|
||||
plan: SubscriptionPlanPB.AiLocal,
|
||||
label: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_label
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_description,
|
||||
activeDescription: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_activeDescription,
|
||||
canceledDescription: LocaleKeys
|
||||
.settings_billingPage_addons_aiOnDevice_canceledDescription,
|
||||
subscriptionInfo:
|
||||
state.subscriptionInfo.addOns.firstWhereOrNull(
|
||||
(a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
+1
@@ -65,6 +65,7 @@ class SettingsManageDataView extends StatelessWidget {
|
||||
label: LocaleKeys.settings_common_reset.tr(),
|
||||
onPressed: () => showConfirmDialog(
|
||||
context: context,
|
||||
confirmLabel: LocaleKeys.button_confirm.tr(),
|
||||
title: LocaleKeys
|
||||
.settings_manageDataPage_dataStorage_resetDialog_title
|
||||
.tr(),
|
||||
|
||||
+40
-30
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -136,38 +138,46 @@ class _SettingsPlanViewState extends State<SettingsPlanView> {
|
||||
),
|
||||
),
|
||||
const HSpace(8),
|
||||
Flexible(
|
||||
child: _AddOnBox(
|
||||
title: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_title
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_description
|
||||
.tr(),
|
||||
price: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_price
|
||||
.tr(
|
||||
args: [SubscriptionPlanPB.AiLocal.priceAnnualBilling],
|
||||
|
||||
// Currently, the AI Local tile is only available on macOS
|
||||
// TODO(nathan): enable windows and linux
|
||||
if (Platform.isMacOS)
|
||||
Flexible(
|
||||
child: _AddOnBox(
|
||||
title: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_title
|
||||
.tr(),
|
||||
description: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_description
|
||||
.tr(),
|
||||
price: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_price
|
||||
.tr(
|
||||
args: [
|
||||
SubscriptionPlanPB.AiLocal.priceAnnualBilling,
|
||||
],
|
||||
),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
|
||||
.tr(),
|
||||
billingInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
|
||||
.tr(
|
||||
args: [
|
||||
SubscriptionPlanPB.AiLocal.priceMonthBilling,
|
||||
],
|
||||
),
|
||||
buttonText: state.subscriptionInfo.hasAIOnDevice
|
||||
? LocaleKeys
|
||||
.settings_planPage_planUsage_addons_activeLabel
|
||||
.tr()
|
||||
: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_addLabel
|
||||
.tr(),
|
||||
isActive: state.subscriptionInfo.hasAIOnDevice,
|
||||
plan: SubscriptionPlanPB.AiLocal,
|
||||
),
|
||||
priceInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_priceInfo
|
||||
.tr(),
|
||||
billingInfo: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_aiOnDevice_billingInfo
|
||||
.tr(
|
||||
args: [SubscriptionPlanPB.AiLocal.priceMonthBilling],
|
||||
),
|
||||
buttonText: state.subscriptionInfo.hasAIOnDevice
|
||||
? LocaleKeys
|
||||
.settings_planPage_planUsage_addons_activeLabel
|
||||
.tr()
|
||||
: LocaleKeys
|
||||
.settings_planPage_planUsage_addons_addLabel
|
||||
.tr(),
|
||||
isActive: state.subscriptionInfo.hasAIOnDevice,
|
||||
plan: SubscriptionPlanPB.AiLocal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
+2
-12
@@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
|
||||
@@ -45,6 +43,7 @@ import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
@@ -879,16 +878,7 @@ class _FontSelectorDropdownState extends State<_FontSelectorDropdown> {
|
||||
maxHeight: 150,
|
||||
maxWidth: constraints.maxWidth - 90,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.10),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
popupBuilder: (_) => _FontListPopup(
|
||||
currentFont: appearance.font,
|
||||
scrollController: _scrollController,
|
||||
|
||||
+50
@@ -1,3 +1,5 @@
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@@ -327,3 +329,51 @@ class AppFlowyCloudEnableSync extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BillingGateGuard extends StatelessWidget {
|
||||
const BillingGateGuard({required this.builder, super.key});
|
||||
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: isBillingEnabled(),
|
||||
builder: (context, snapshot) {
|
||||
final isBillingEnabled = snapshot.data ?? false;
|
||||
if (isBillingEnabled &&
|
||||
snapshot.connectionState == ConnectionState.done) {
|
||||
return builder(context);
|
||||
}
|
||||
|
||||
// If the billing is not enabled, show nothing
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isBillingEnabled() async {
|
||||
final result = await UserEventGetCloudConfig().send();
|
||||
return result.fold((cloudSetting) {
|
||||
final whiteList = [
|
||||
"https://beta.appflowy.cloud",
|
||||
"https://test.appflowy.cloud",
|
||||
];
|
||||
if (kDebugMode) {
|
||||
whiteList.add("http://localhost:8000");
|
||||
}
|
||||
|
||||
if (whiteList.contains(cloudSetting.serverUrl)) {
|
||||
return true;
|
||||
} else {
|
||||
Log.warn(
|
||||
"Billing is not enabled for this server:${cloudSetting.serverUrl}",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to get cloud config: $err");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
|
||||
/// Abstract class for providing images to the [InteractiveImageViewer].
|
||||
///
|
||||
abstract class AFImageProvider {
|
||||
const AFImageProvider({this.onDeleteImage});
|
||||
|
||||
/// Provide this callback if you want it to be possible to
|
||||
/// delete the Image through the [InteractiveImageViewer].
|
||||
///
|
||||
final Function(int index)? onDeleteImage;
|
||||
|
||||
int get imageCount;
|
||||
int get initialIndex;
|
||||
|
||||
ImageBlockData getImage(int index);
|
||||
Widget renderImage(
|
||||
BuildContext context,
|
||||
int index, [
|
||||
UserProfilePB? userProfile,
|
||||
]);
|
||||
}
|
||||
|
||||
class AFBlockImageProvider implements AFImageProvider {
|
||||
const AFBlockImageProvider({
|
||||
required this.images,
|
||||
this.initialIndex = 0,
|
||||
required this.onDeleteImage,
|
||||
});
|
||||
|
||||
final List<ImageBlockData> images;
|
||||
|
||||
@override
|
||||
final Function(int) onDeleteImage;
|
||||
|
||||
@override
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
int get imageCount => images.length;
|
||||
|
||||
@override
|
||||
ImageBlockData getImage(int index) => images[index];
|
||||
|
||||
@override
|
||||
Widget renderImage(
|
||||
BuildContext context,
|
||||
int index, [
|
||||
UserProfilePB? userProfile,
|
||||
]) {
|
||||
final image = getImage(index);
|
||||
|
||||
if (image.type == CustomImageType.local) {
|
||||
return Image(image: image.toImageProvider());
|
||||
}
|
||||
|
||||
return FlowyNetworkImage(
|
||||
url: image.url,
|
||||
userProfilePB: userProfile,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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/image/common.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_impl.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart';
|
||||
|
||||
class InteractiveImageToolbar extends StatelessWidget {
|
||||
const InteractiveImageToolbar({
|
||||
super.key,
|
||||
required this.currentImage,
|
||||
required this.imageCount,
|
||||
required this.isFirstIndex,
|
||||
required this.isLastIndex,
|
||||
required this.currentScale,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
required this.onZoomIn,
|
||||
required this.onZoomOut,
|
||||
required this.onScaleChanged,
|
||||
this.onDelete,
|
||||
this.userProfile,
|
||||
});
|
||||
|
||||
final ImageBlockData currentImage;
|
||||
final int imageCount;
|
||||
final bool isFirstIndex;
|
||||
final bool isLastIndex;
|
||||
final int currentScale;
|
||||
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onZoomIn;
|
||||
final VoidCallback onZoomOut;
|
||||
final Function(double scale) onScaleChanged;
|
||||
final UserProfilePB? userProfile;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (imageCount > 1)
|
||||
_renderToolbarItems(
|
||||
children: [
|
||||
_ToolbarItem(
|
||||
isDisabled: isFirstIndex,
|
||||
tooltip: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_previousImageTooltip
|
||||
.tr(),
|
||||
icon: FlowySvgs.arrow_left_s,
|
||||
onTap: () {
|
||||
if (!isFirstIndex) {
|
||||
onPrevious();
|
||||
}
|
||||
},
|
||||
),
|
||||
_ToolbarItem(
|
||||
isDisabled: isLastIndex,
|
||||
tooltip: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_nextImageTooltip
|
||||
.tr(),
|
||||
icon: FlowySvgs.arrow_right_s,
|
||||
onTap: () {
|
||||
if (!isLastIndex) {
|
||||
onNext();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const HSpace(10),
|
||||
_renderToolbarItems(
|
||||
children: [
|
||||
_ToolbarItem(
|
||||
tooltip: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_zoomOutTooltip
|
||||
.tr(),
|
||||
icon: FlowySvgs.minus_s,
|
||||
onTap: onZoomOut,
|
||||
),
|
||||
AppFlowyPopover(
|
||||
offset: const Offset(0, -8),
|
||||
decorationColor: Colors.transparent,
|
||||
direction: PopoverDirection.topWithCenterAligned,
|
||||
constraints: const BoxConstraints(maxHeight: 50),
|
||||
popupBuilder: (context) => _renderToolbarItems(
|
||||
children: [
|
||||
_ScaleSlider(
|
||||
currentScale: currentScale,
|
||||
onScaleChanged: onScaleChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: FlowyTooltip(
|
||||
message: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_changeZoomLevelTooltip
|
||||
.tr(),
|
||||
child: FlowyHover(
|
||||
resetHoverOnRebuild: false,
|
||||
style: HoverStyle(
|
||||
hoverColor: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
child: Center(
|
||||
child: FlowyText(
|
||||
LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_scalePercentage
|
||||
.tr(args: [currentScale.toString()]),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_ToolbarItem(
|
||||
tooltip: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_zoomInTooltip
|
||||
.tr(),
|
||||
icon: FlowySvgs.add_s,
|
||||
onTap: onZoomIn,
|
||||
),
|
||||
],
|
||||
),
|
||||
const HSpace(10),
|
||||
_renderToolbarItems(
|
||||
children: [
|
||||
if (onDelete != null)
|
||||
_ToolbarItem(
|
||||
tooltip: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_deleteImageTooltip
|
||||
.tr(),
|
||||
icon: FlowySvgs.delete_s,
|
||||
onTap: () {
|
||||
onDelete!();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
if (!PlatformExtension.isMobile) ...[
|
||||
_ToolbarItem(
|
||||
tooltip: currentImage.isNotInternal
|
||||
? LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_openLocalImage
|
||||
.tr()
|
||||
: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_downloadImage
|
||||
.tr(),
|
||||
icon: currentImage.isNotInternal
|
||||
? currentImage.isLocal
|
||||
? FlowySvgs.folder_m
|
||||
: FlowySvgs.m_aa_link_s
|
||||
: FlowySvgs.import_s,
|
||||
onTap: () => _locateOrDownloadImage(context),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const HSpace(10),
|
||||
_renderToolbarItems(
|
||||
children: [
|
||||
_ToolbarItem(
|
||||
tooltip: LocaleKeys
|
||||
.document_imageBlock_interactiveViewer_toolbar_closeViewer
|
||||
.tr(),
|
||||
icon: FlowySvgs.close_s,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _renderToolbarItems({required List<Widget> children}) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () => const HSpace(4),
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _locateOrDownloadImage(BuildContext context) async {
|
||||
if (currentImage.isLocal) {
|
||||
/// If the image type is local, we simply open the image
|
||||
await afLaunchUrl(Uri.file(currentImage.url));
|
||||
} else if (currentImage.isNotInternal) {
|
||||
// In case of eg. Unsplash images (images without extension type in URL),
|
||||
// we don't know their mimetype. In the future we can write a parser
|
||||
// using the Mime package and read the image to get the proper extension.
|
||||
await afLaunchUrl(Uri.parse(currentImage.url));
|
||||
} else {
|
||||
if (userProfile == null) {
|
||||
return showSnapBar(
|
||||
context,
|
||||
LocaleKeys.document_plugins_image_imageDownloadFailedToken.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.parse(currentImage.url);
|
||||
final imgFile = File(uri.pathSegments.last);
|
||||
final savePath = await FilePicker().saveFile(
|
||||
fileName: basename(imgFile.path),
|
||||
);
|
||||
|
||||
if (savePath != null) {
|
||||
final uri = Uri.parse(currentImage.url);
|
||||
|
||||
final token = jsonDecode(userProfile!.token)['access_token'];
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final imgFile = File(savePath);
|
||||
await imgFile.writeAsBytes(response.bodyBytes);
|
||||
} else if (context.mounted) {
|
||||
showSnapBar(
|
||||
context,
|
||||
LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarItem extends StatelessWidget {
|
||||
const _ToolbarItem({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.isDisabled = false,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final FlowySvgData icon;
|
||||
final VoidCallback onTap;
|
||||
final bool isDisabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: FlowyTooltip(
|
||||
message: tooltip,
|
||||
child: FlowyHover(
|
||||
resetHoverOnRebuild: false,
|
||||
style: HoverStyle(
|
||||
hoverColor:
|
||||
isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FlowySvg(
|
||||
icon,
|
||||
size: const Size.square(16),
|
||||
color: isDisabled ? Colors.grey : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScaleSlider extends StatefulWidget {
|
||||
const _ScaleSlider({
|
||||
required this.currentScale,
|
||||
required this.onScaleChanged,
|
||||
});
|
||||
|
||||
final int currentScale;
|
||||
final Function(double scale) onScaleChanged;
|
||||
|
||||
@override
|
||||
State<_ScaleSlider> createState() => __ScaleSliderState();
|
||||
}
|
||||
|
||||
class __ScaleSliderState extends State<_ScaleSlider> {
|
||||
late int _currentScale = widget.currentScale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Slider(
|
||||
max: 5.0,
|
||||
min: 0.5,
|
||||
value: _currentScale / 100,
|
||||
onChanged: (scale) {
|
||||
widget.onScaleChanged(scale);
|
||||
setState(
|
||||
() => _currentScale = (scale * 100).toInt(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user