feat: upload file in document (#5843)

* feat: upload file in document

* feat: add uploaded at & improvements

* fix: popover onOpen not triggered by manual show

* test: add basic file test

* test: fix and add rename test
This commit is contained in:
Mathias Mogensen 2024-07-31 15:49:35 +02:00 committed by GitHub
parent dce9231118
commit ed81a0aff2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1462 additions and 54 deletions

View File

@ -12,6 +12,7 @@ import 'document_more_actions_test.dart' as document_more_actions_test;
import 'document_text_direction_test.dart' as document_text_direction_test; import 'document_text_direction_test.dart' as document_text_direction_test;
import 'document_with_cover_image_test.dart' as document_with_cover_image_test; import 'document_with_cover_image_test.dart' as document_with_cover_image_test;
import 'document_with_database_test.dart' as document_with_database_test; import 'document_with_database_test.dart' as document_with_database_test;
import 'document_with_file_test.dart' as document_with_file_test;
import 'document_with_image_block_test.dart' as document_with_image_block_test; import 'document_with_image_block_test.dart' as document_with_image_block_test;
import 'document_with_inline_math_equation_test.dart' import 'document_with_inline_math_equation_test.dart'
as document_with_inline_math_equation_test; as document_with_inline_math_equation_test;
@ -43,4 +44,5 @@ void startTesting() {
document_with_multi_image_block_test.main(); document_with_multi_image_block_test.main();
document_inline_page_reference_test.main(); document_inline_page_reference_test.main();
document_more_actions_test.main(); document_more_actions_test.main();
document_with_file_test.main();
} }

View File

@ -0,0 +1,166 @@
import 'dart:io';
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/file/file_upload_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
group('file block in document', () {
testWidgets('insert a file from local file + rename file', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(name: 'Insert file test');
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('File');
expect(find.byType(FileBlockComponent), findsOneWidget);
await tester.tap(find.byType(FileBlockComponent));
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.byType(FileUploadMenu), findsOneWidget);
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final filePath = p.join(tempDirectory.path, 'sample.jpeg');
final file = File(filePath)..writeAsBytesSync(image.buffer.asUint8List());
mockPickFilePaths(paths: [filePath]);
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
await tester.tap(
find.text(LocaleKeys.document_plugins_file_fileUploadHint.tr()),
);
await tester.pumpAndSettle();
expect(find.byType(FileUploadMenu), findsNothing);
expect(find.byType(FileBlockComponent), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, FileBlockKeys.type);
expect(node.attributes[FileBlockKeys.url], isNotEmpty);
expect(
node.attributes[FileBlockKeys.urlType],
FileUrlType.local.toIntValue(),
);
// Check the name of the file is correctly extracted
expect(node.attributes[FileBlockKeys.name], 'sample.jpeg');
expect(find.text('sample.jpeg'), findsOneWidget);
const newName = "Renamed file";
// Hover on the widget to see the three dots to open FileBlockMenu
await tester.hoverOnWidget(
find.byType(FileBlockComponent),
onHover: () async {
await tester.tap(find.byType(FileMenuTrigger));
await tester.pumpAndSettle();
await tester.tap(
find.text(LocaleKeys.document_plugins_file_renameFile_title.tr()),
);
},
);
await tester.pumpAndSettle();
expect(find.byType(FlowyTextField), findsOneWidget);
await tester.enterText(find.byType(FlowyTextField), newName);
await tester.pump();
await tester.tap(find.text(LocaleKeys.button_save.tr()));
await tester.pumpAndSettle();
final updatedNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(updatedNode.attributes[FileBlockKeys.name], newName);
expect(find.text(newName), findsOneWidget);
// remove the temp file
file.deleteSync();
});
testWidgets('insert a file from network', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(name: 'Insert file test');
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('File');
expect(find.byType(FileBlockComponent), findsOneWidget);
await tester.tap(find.byType(FileBlockComponent));
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.byType(FileUploadMenu), findsOneWidget);
// Navigate to integrate link tab
await tester.tapButtonWithName(
LocaleKeys.document_plugins_file_networkTab.tr(),
);
await tester.pumpAndSettle();
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(FileUploadMenu),
matching: find.byType(FlowyTextField),
),
url,
);
await tester.tapButton(
find.descendant(
of: find.byType(FileUploadMenu),
matching: find.text(
LocaleKeys.document_plugins_file_networkAction.tr(),
findRichText: true,
),
),
);
await tester.pumpAndSettle();
expect(find.byType(FileUploadMenu), findsNothing);
expect(find.byType(FileBlockComponent), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, FileBlockKeys.type);
expect(node.attributes[FileBlockKeys.url], isNotEmpty);
expect(
node.attributes[FileBlockKeys.urlType],
FileUrlType.network.toIntValue(),
);
// Check the name is correctly extracted from the url
expect(
node.attributes[FileBlockKeys.name],
'photo-1469474968028-56623f02e42e',
);
expect(find.text('photo-1469474968028-56623f02e42e'), findsOneWidget);
});
});
}

View File

@ -1,6 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.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_picker.dart';
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
@ -14,8 +17,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/uplo
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart'; 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_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';

View File

@ -4,14 +4,17 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.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/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.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_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.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/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/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/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_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
@ -19,15 +22,18 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
const _excludeFromDropTarget = [ const _excludeFromDropTarget = [
ImageBlockKeys.type, ImageBlockKeys.type,
CustomImageBlockKeys.type, CustomImageBlockKeys.type,
MultiImageBlockKeys.type, MultiImageBlockKeys.type,
FileBlockKeys.type,
]; ];
class DocumentPage extends StatefulWidget { class DocumentPage extends StatefulWidget {
@ -79,7 +85,19 @@ class _DocumentPageState extends State<DocumentPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return ChangeNotifierProvider(
// Due to how DropTarget works, there is no way to differentiate if an overlay is
// blocking the target visibly, so when we have an overlay with a drop target,
// we should disable the drop target for the Editor, until it is closed.
//
// See FileBlockComponent for sample use.
//
// Relates to:
// - https://github.com/MixinNetwork/flutter-plugins/issues/2
// - https://github.com/MixinNetwork/flutter-plugins/issues/331
//
create: (_) => EditorDropManagerState(),
child: MultiBlocProvider(
providers: [ providers: [
BlocProvider.value(value: getIt<ActionNavigationBloc>()), BlocProvider.value(value: getIt<ActionNavigationBloc>()),
BlocProvider.value(value: documentBloc), BlocProvider.value(value: documentBloc),
@ -109,16 +127,23 @@ class _DocumentPageState extends State<DocumentPage>
return BlocListener<ActionNavigationBloc, ActionNavigationState>( return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null, listenWhen: (_, curr) => curr.action != null,
listener: _onNotificationAction, listener: _onNotificationAction,
child: _buildEditorPage(context, state), child: Consumer<EditorDropManagerState>(
builder: (context, dropState, _) =>
_buildEditorPage(context, state, dropState),
),
); );
}, },
), ),
),
); );
} }
Widget _buildEditorPage(BuildContext context, DocumentState state) { Widget _buildEditorPage(
BuildContext context,
DocumentState state,
EditorDropManagerState dropState,
) {
final Widget child; final Widget child;
if (PlatformExtension.isMobile) { if (PlatformExtension.isMobile) {
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>( child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
builder: (context, styleState) { builder: (context, styleState) {
@ -136,6 +161,7 @@ class _DocumentPageState extends State<DocumentPage>
); );
} else { } else {
child = DropTarget( child = DropTarget(
enable: dropState.isDropEnabled,
onDragExited: (_) => onDragExited: (_) =>
state.editorState!.selectionService.removeDropTarget(), state.editorState!.selectionService.removeDropTarget(),
onDragUpdated: (details) { onDragUpdated: (details) {
@ -163,18 +189,31 @@ class _DocumentPageState extends State<DocumentPage>
if (data != null) { if (data != null) {
if (data.cursorNode != null) { if (data.cursorNode != null) {
if ([ if (_excludeFromDropTarget.contains(data.cursorNode?.type)) {
ImageBlockKeys.type,
CustomImageBlockKeys.type,
MultiImageBlockKeys.type,
].contains(data.cursorNode?.type)) {
return; return;
} }
final isLocalMode = context.read<DocumentBloc>().isLocalMode; final isLocalMode = context.read<DocumentBloc>().isLocalMode;
final List<XFile> imageFiles = [];
final List<XFile> otherfiles = [];
for (final file in details.files) {
if (file.mimeType?.startsWith('image/') ??
false || imgExtensionRegex.hasMatch(file.name)) {
imageFiles.add(file);
} else {
otherfiles.add(file);
}
}
await editorState!.dropImages( await editorState!.dropImages(
data.dropTarget!, data.dropTarget!,
details.files, imageFiles,
widget.view.id,
isLocalMode,
);
await editorState!.dropFiles(
data.dropTarget!,
otherfiles,
widget.view.id, widget.view.id,
isLocalMode, isLocalMode,
); );

View File

@ -253,6 +253,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
imageUrl: imageUrl, imageUrl: imageUrl,
), ),
), ),
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration, configuration: configuration,
), ),

View File

@ -0,0 +1,17 @@
import 'package:flutter/widgets.dart';
class EditorDropManagerState extends ChangeNotifier {
final Set<String> _draggedTypes = {};
void add(String type) {
_draggedTypes.add(type);
notifyListeners();
}
void remove(String type) {
_draggedTypes.remove(type);
notifyListeners();
}
bool get isDropEnabled => _draggedTypes.isEmpty;
}

View File

@ -420,6 +420,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
autoGeneratorMenuItem, autoGeneratorMenuItem,
dateMenuItem, dateMenuItem,
multiImageMenuItem, multiImageMenuItem,
fileMenuItem,
]; ];
} }

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
@ -8,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart'; import 'package:string_validator/string_validator.dart';
@ -53,7 +54,6 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
// try to paste the content in order, if any of them is failed, then try the next one // try to paste the content in order, if any of them is failed, then try the next one
if (inAppJson != null && inAppJson.isNotEmpty) { if (inAppJson != null && inAppJson.isNotEmpty) {
debugPrint('paste in app json: $inAppJson');
await editorState.deleteSelectionIfNeeded(); await editorState.deleteSelectionIfNeeded();
if (await editorState.pasteInAppJson(inAppJson)) { if (await editorState.pasteInAppJson(inAppJson)) {
return; return;

View File

@ -0,0 +1,40 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:cross_file/cross_file.dart';
extension PasteFromFile on EditorState {
Future<void> dropFiles(
Node dropNode,
List<XFile> files,
String documentId,
bool isLocalMode,
) async {
for (final file in files) {
String? path;
FileUrlType? type;
if (isLocalMode) {
path = await saveFileToLocalStorage(file.path);
type = FileUrlType.local;
} else {
(path, _) = await saveFileToCloudStorage(file.path, documentId);
type = FileUrlType.cloud;
}
if (path == null) {
continue;
}
final t = transaction
..insertNode(
dropNode.path,
fileNode(
url: path,
type: type,
name: file.name,
),
);
await apply(t);
}
}
}

View File

@ -0,0 +1,2 @@
export './file_block_component.dart';
export './file_selection_menu.dart';

View File

@ -0,0 +1,497 @@
import 'package:flutter/material.dart';
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/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
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_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import 'file_block_menu.dart';
import 'file_upload_menu.dart';
class FileBlockKeys {
const FileBlockKeys._();
static const String type = 'file';
/// The src of the file.
///
/// The value is a String.
/// It can be a url for a network file or a local file path.
///
static const String url = 'url';
/// The name of the file.
///
/// The value is a String.
///
static const String name = 'name';
/// The type of the url.
///
/// The value is a FileUrlType enum.
///
static const String urlType = 'url_type';
/// The date of the file upload.
///
/// The value is a timestamp in ms.
///
static const String uploadedAt = 'uploaded_at';
/// The user who uploaded the file.
///
/// The value is a String, in form of user id.
///
static const String uploadedBy = 'uploaded_by';
}
enum FileUrlType {
local,
network,
cloud;
static FileUrlType fromIntValue(int value) {
switch (value) {
case 0:
return FileUrlType.local;
case 1:
return FileUrlType.network;
case 2:
return FileUrlType.cloud;
default:
throw UnimplementedError();
}
}
int toIntValue() {
switch (this) {
case FileUrlType.local:
return 0;
case FileUrlType.network:
return 1;
case FileUrlType.cloud:
return 2;
}
}
}
Node fileNode({
required String url,
FileUrlType type = FileUrlType.local,
String? name,
}) {
return Node(
type: FileBlockKeys.type,
attributes: {
FileBlockKeys.url: url,
FileBlockKeys.urlType: type.toIntValue(),
FileBlockKeys.name: name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
},
);
}
class FileBlockComponentBuilder extends BlockComponentBuilder {
FileBlockComponentBuilder({super.configuration});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return FileBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
configuration: configuration,
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
);
}
@override
bool validate(Node node) => node.delta == null && node.children.isEmpty;
}
class FileBlockComponent extends BlockComponentStatefulWidget {
const FileBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<FileBlockComponent> createState() => FileBlockComponentState();
}
class FileBlockComponentState extends State<FileBlockComponent>
with SelectableMixin, BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
late EditorDropManagerState dropManagerState =
context.read<EditorDropManagerState>();
final fileKey = GlobalKey();
final showActionsNotifier = ValueNotifier<bool>(false);
final controller = PopoverController();
final menuController = PopoverController();
late final editorState = Provider.of<EditorState>(context, listen: false);
bool alwaysShowMenu = false;
bool isDragging = false;
bool isHovering = false;
@override
void didChangeDependencies() {
dropManagerState = context.read<EditorDropManagerState>();
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final url = node.attributes[FileBlockKeys.url];
Widget child = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) {
setState(() => isHovering = true);
showActionsNotifier.value = true;
},
onExit: (_) {
setState(() => isHovering = false);
if (!alwaysShowMenu) {
showActionsNotifier.value = false;
}
},
opaque: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: url != null && url.isNotEmpty
? () => afLaunchUrlString(url)
: () {
controller.show();
dropManagerState.add(FileBlockKeys.type);
},
child: DecoratedBox(
decoration: BoxDecoration(
color: isHovering
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: isDragging
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: SizedBox(
height: 52,
child: Row(
children: [
const HSpace(10),
const Icon(Icons.upload_file_outlined),
const HSpace(10),
..._buildTrailing(context),
],
),
),
),
),
);
if (PlatformExtension.isDesktopOrWeb) {
if (url == null || url.isEmpty) {
child = DropTarget(
onDragEntered: (_) {
if (dropManagerState.isDropEnabled) {
setState(() => isDragging = true);
}
},
onDragExited: (_) {
if (dropManagerState.isDropEnabled) {
setState(() => isDragging = false);
}
},
onDragDone: (details) {
if (dropManagerState.isDropEnabled) {
insertFileFromLocal(details.files.first.path);
}
},
child: AppFlowyPopover(
controller: controller,
direction: PopoverDirection.bottomWithCenterAligned,
constraints: const BoxConstraints(
maxWidth: 480,
maxHeight: 340,
minHeight: 80,
),
clickHandler: PopoverClickHandler.gestureDetector,
onOpen: () => dropManagerState.add(FileBlockKeys.type),
onClose: () => dropManagerState.remove(FileBlockKeys.type),
popupBuilder: (_) => FileUploadMenu(
onInsertLocalFile: insertFileFromLocal,
onInsertNetworkFile: insertNetworkFile,
),
child: child,
),
);
}
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [BlockSelectionType.block],
child: Padding(key: fileKey, padding: padding, child: child),
);
} else {
child = Padding(key: fileKey, padding: padding, child: child);
}
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: child,
);
}
if (!PlatformExtension.isDesktopOrWeb) {
// show a fixed menu on mobile
child = MobileBlockActionButtons(
showThreeDots: false,
node: node,
editorState: editorState,
child: child,
);
}
return child;
}
List<Widget> _buildTrailing(BuildContext context) {
if (node.attributes[FileBlockKeys.url]?.isNotEmpty == true) {
final name = node.attributes[FileBlockKeys.name] as String;
return [
Expanded(
child: FlowyText(
name,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(8),
if (PlatformExtension.isDesktopOrWeb) ...[
ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (_, value, __) {
final url = node.attributes[FileBlockKeys.url];
if (!value || url == null || url.isEmpty) {
return const SizedBox.shrink();
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: menuController.show,
child: AppFlowyPopover(
controller: menuController,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithRightAligned,
onClose: () {
setState(
() {
alwaysShowMenu = false;
showActionsNotifier.value = false;
},
);
},
popupBuilder: (_) {
alwaysShowMenu = true;
return FileBlockMenu(
controller: menuController,
node: node,
editorState: editorState,
);
},
child: const FileMenuTrigger(),
),
);
},
),
const HSpace(8),
],
];
} else {
return [
Flexible(
child: FlowyText(
isDragging
? LocaleKeys.document_plugins_file_placeholderDragging.tr()
: LocaleKeys.document_plugins_file_placeholderText.tr(),
overflow: TextOverflow.ellipsis,
),
),
];
}
}
Future<void> insertFileFromLocal(String path) async {
final documentBloc = context.read<DocumentBloc>();
final isLocalMode = documentBloc.isLocalMode;
final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud;
String? url;
String? errorMsg;
if (isLocalMode) {
url = await saveFileToLocalStorage(path);
} else {
final result =
await saveFileToCloudStorage(path, documentBloc.documentId);
url = result.$1;
errorMsg = result.$2;
}
if (errorMsg != null && mounted) {
return showSnackBarMessage(context, errorMsg);
}
// Remove the file block from the drop state manager
dropManagerState.remove(FileBlockKeys.type);
final name = Uri.tryParse(path)?.pathSegments.last ?? url;
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
FileBlockKeys.url: url,
FileBlockKeys.urlType: urlType.toIntValue(),
FileBlockKeys.name: name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
});
await editorState.apply(transaction);
}
Future<void> insertNetworkFile(String url) async {
if (url.isEmpty || !isURL(url)) {
// show error
return showSnackBarMessage(
context,
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
);
}
// Remove the file block from the drop state manager
dropManagerState.remove(FileBlockKeys.type);
final name = Uri.tryParse(url)?.pathSegments.last ?? url;
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
FileBlockKeys.url: url,
FileBlockKeys.urlType: FileUrlType.network.toIntValue(),
FileBlockKeys.name: name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
});
await editorState.apply(transaction);
}
@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 renderBox = fileKey.currentContext?.findRenderObject();
if (renderBox is RenderBox) {
return Offset.zero & renderBox.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 renderBox = fileKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && renderBox is RenderBox) {
return [
renderBox.localToGlobal(Offset.zero, ancestor: parentBox) &
renderBox.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);
}
@visibleForTesting
class FileMenuTrigger extends StatelessWidget {
const FileMenuTrigger({super.key});
@override
Widget build(BuildContext context) {
return const FlowyHover(
resetHoverOnRebuild: false,
child: Padding(
padding: EdgeInsets.all(4),
child: FlowySvg(
FlowySvgs.three_dots_s,
),
),
);
}
}

View File

@ -0,0 +1,204 @@
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/presentation/editor_plugins/file/file_block.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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:flutter_bloc/flutter_bloc.dart';
class FileBlockMenu extends StatefulWidget {
const FileBlockMenu({
super.key,
required this.controller,
required this.node,
required this.editorState,
});
final PopoverController controller;
final Node node;
final EditorState editorState;
@override
State<FileBlockMenu> createState() => _FileBlockMenuState();
}
class _FileBlockMenuState extends State<FileBlockMenu> {
final nameController = TextEditingController();
final errorMessage = ValueNotifier<String?>(null);
BuildContext? renameContext;
@override
void initState() {
super.initState();
nameController.text = widget.node.attributes[FileBlockKeys.name] ?? '';
nameController.selection = TextSelection(
baseOffset: 0,
extentOffset: nameController.text.length,
);
}
@override
void dispose() {
errorMessage.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final uploadedAtInMS =
widget.node.attributes[FileBlockKeys.uploadedAt] as int?;
final uploadedAt = uploadedAtInMS != null
? DateTime.fromMillisecondsSinceEpoch(uploadedAtInMS)
: null;
final dateFormat = context.read<AppearanceSettingsCubit>().state.dateFormat;
final urlType =
FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
HoverButton(
itemHeight: 20,
leftIcon: const FlowySvg(FlowySvgs.edit_s),
name: LocaleKeys.document_plugins_file_renameFile_title.tr(),
onTap: () {
widget.controller.close();
showCustomConfirmDialog(
context: context,
title: LocaleKeys.document_plugins_file_renameFile_title.tr(),
description:
LocaleKeys.document_plugins_file_renameFile_description.tr(),
closeOnConfirm: false,
builder: (context) {
renameContext = context;
return _RenameTextField(
nameController: nameController,
errorMessage: errorMessage,
onSubmitted: _saveName,
);
},
confirmLabel: LocaleKeys.button_save.tr(),
onConfirm: _saveName,
);
},
),
const VSpace(4),
HoverButton(
itemHeight: 20,
leftIcon: const FlowySvg(FlowySvgs.delete_s),
name: LocaleKeys.button_delete.tr(),
onTap: () {
final transaction = widget.editorState.transaction
..deleteNode(widget.node);
widget.editorState.apply(transaction);
widget.controller.close();
},
),
if (uploadedAt != null) ...[
const Divider(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FlowyText.regular(
[FileUrlType.cloud, FileUrlType.local].contains(urlType)
? LocaleKeys.document_plugins_file_uploadedAt.tr(
args: [dateFormat.formatDate(uploadedAt, false)],
)
: LocaleKeys.document_plugins_file_linkedAt.tr(
args: [dateFormat.formatDate(uploadedAt, false)],
),
fontSize: 14,
maxLines: 2,
color: Theme.of(context).hintColor,
),
),
const VSpace(2),
],
],
);
}
void _saveName() {
if (nameController.text.isEmpty) {
errorMessage.value =
LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr();
return;
}
final attributes = widget.node.attributes;
attributes[FileBlockKeys.name] = nameController.text;
final transaction = widget.editorState.transaction
..updateNode(widget.node, attributes);
widget.editorState.apply(transaction);
if (renameContext != null) {
Navigator.of(renameContext!).pop();
}
}
}
class _RenameTextField extends StatefulWidget {
const _RenameTextField({
required this.nameController,
required this.errorMessage,
required this.onSubmitted,
});
final TextEditingController nameController;
final ValueNotifier<String?> errorMessage;
final VoidCallback onSubmitted;
@override
State<_RenameTextField> createState() => _RenameTextFieldState();
}
class _RenameTextFieldState extends State<_RenameTextField> {
@override
void initState() {
super.initState();
widget.errorMessage.addListener(_setState);
}
@override
void dispose() {
widget.errorMessage.removeListener(_setState);
widget.nameController.dispose();
super.dispose();
}
void _setState() {
if (mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyTextField(
controller: widget.nameController,
onSubmitted: (_) => widget.onSubmitted(),
),
if (widget.errorMessage.value != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: FlowyText(
widget.errorMessage.value!,
color: Theme.of(context).colorScheme.error,
),
),
],
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
final fileMenuItem = SelectionMenuItem(
getName: () => LocaleKeys.document_plugins_file_name.tr(),
icon: (_, isSelected, style) => SelectionMenuIconWidget(
icon: Icons.file_present_outlined,
isSelected: isSelected,
style: style,
),
keywords: ['file upload', 'pdf', 'zip', 'archive', 'upload'],
handler: (editorState, _, __) async => editorState.insertEmptyFileBlock(),
);
extension InsertFile on EditorState {
Future<void> insertEmptyFileBlock() async {
final selection = this.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final file = fileNode(url: '');
final transaction = this.transaction;
// if the current node is empty paragraph, replace it with the file node
if (node.type == ParagraphBlockKeys.type &&
(node.delta?.isEmpty ?? false)) {
transaction
..insertNode(node.path, file)
..deleteNode(node);
} else {
transaction.insertNode(node.path.next, file);
}
transaction.afterSelection =
Selection.collapsed(Position(path: node.path.next));
return apply(transaction);
}
}

View File

@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
class FileUploadMenu extends StatefulWidget {
const FileUploadMenu({
super.key,
required this.onInsertLocalFile,
required this.onInsertNetworkFile,
});
final void Function(String path) onInsertLocalFile;
final void Function(String url) onInsertNetworkFile;
@override
State<FileUploadMenu> createState() => _FileUploadMenuState();
}
class _FileUploadMenuState extends State<FileUploadMenu> {
int currentTab = 0;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TabBar(
onTap: (value) => setState(() {
currentTab = value;
}),
isScrollable: true,
padding: EdgeInsets.zero,
overlayColor: WidgetStatePropertyAll(
PlatformExtension.isDesktop
? Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
tabs: [
_Tab(
title: LocaleKeys.document_plugins_file_uploadTab.tr(),
),
_Tab(
title: LocaleKeys.document_plugins_file_networkTab.tr(),
),
],
),
const Divider(height: 4),
if (currentTab == 0) ...[
_FileUploadLocal(
onFilePicked: (path) {
if (path != null) {
widget.onInsertLocalFile(path);
}
},
),
] else ...[
_FileUploadNetwork(onSubmit: widget.onInsertNetworkFile),
],
],
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 12.0,
right: 12.0,
bottom: 8.0,
top: PlatformExtension.isMobile ? 0 : 8.0,
),
child: FlowyText(title),
);
}
}
class _FileUploadLocal extends StatefulWidget {
const _FileUploadLocal({required this.onFilePicked});
final void Function(String?) onFilePicked;
@override
State<_FileUploadLocal> createState() => _FileUploadLocalState();
}
class _FileUploadLocalState extends State<_FileUploadLocal> {
bool isDragging = false;
@override
Widget build(BuildContext context) {
final constraints =
PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null;
return Padding(
padding: const EdgeInsets.all(4),
child: DropTarget(
onDragEntered: (_) => setState(() => isDragging = true),
onDragExited: (_) => setState(() => isDragging = false),
onDragDone: (details) => widget.onFilePicked(details.files.first.path),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _uploadFile(context),
child: FlowyHover(
resetHoverOnRebuild: false,
isSelected: () => isDragging,
style: HoverStyle(
borderRadius: BorderRadius.circular(12),
),
child: Container(
padding: const EdgeInsets.all(8),
constraints: constraints,
child: DottedBorder(
dashPattern: const [3, 3],
radius: const Radius.circular(8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 32,
),
borderType: BorderType.RRect,
color: isDragging
? Theme.of(context).colorScheme.primary
: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isDragging) ...[
const VSpace(13.5),
FlowyText(
LocaleKeys.document_plugins_file_dropFileToUpload
.tr(),
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
const VSpace(13.5),
] else ...[
FlowyText(
LocaleKeys.document_plugins_file_fileUploadHint
.tr(),
fontSize: 16,
maxLines: 2,
textAlign: TextAlign.center,
lineHeight: 1.5,
),
],
],
),
),
),
),
),
),
),
),
);
}
Future<void> _uploadFile(BuildContext context) async {
final result = await getIt<FilePickerService>().pickFiles(dialogTitle: '');
widget.onFilePicked(result?.files.first.path);
}
}
class _FileUploadNetwork extends StatefulWidget {
const _FileUploadNetwork({required this.onSubmit});
final void Function(String url) onSubmit;
@override
State<_FileUploadNetwork> createState() => _FileUploadNetworkState();
}
class _FileUploadNetworkState extends State<_FileUploadNetwork> {
bool isUrlValid = true;
String inputText = '';
@override
Widget build(BuildContext context) {
final constraints =
PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null;
return Container(
padding: const EdgeInsets.all(8),
constraints: constraints,
alignment: Alignment.center,
child: Column(
children: [
FlowyTextField(
hintText: LocaleKeys.document_plugins_file_networkHint.tr(),
onChanged: (value) => inputText = value,
onEditingComplete: submit,
),
if (!isUrlValid) ...[
const VSpace(8),
FlowyText(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: Theme.of(context).colorScheme.error,
),
],
const VSpace(8),
SizedBox(
width: 160,
child: FlowyButton(
showDefaultBoxDecorationOnMobile: true,
margin: const EdgeInsets.all(8.0),
text: FlowyText(
LocaleKeys.document_plugins_file_networkAction.tr(),
textAlign: TextAlign.center,
),
onTap: submit,
),
),
],
),
);
}
void submit() {
if (checkUrlValidity(inputText)) {
return widget.onSubmit(inputText);
}
setState(() => isUrlValid = false);
}
bool checkUrlValidity(String url) => hrefRegex.hasMatch(url);
}

View File

@ -0,0 +1,65 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_service.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_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:path/path.dart' as p;
Future<String?> saveFileToLocalStorage(String localFilePath) async {
final path = await getIt<ApplicationDataStorage>().getPath();
final filePath = p.join(path, 'files');
try {
// create the directory if not exists
final directory = Directory(filePath);
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
final copyToPath = p.join(
filePath,
'${uuid()}${p.extension(localFilePath)}',
);
await File(localFilePath).copy(
copyToPath,
);
return copyToPath;
} catch (e) {
Log.error('cannot save file', e);
return null;
}
}
Future<(String? path, String? errorMessage)> saveFileToCloudStorage(
String localFilePath,
String documentId,
) async {
final size = localFilePath.fileSize;
if (size == null || size > 10 * 1024 * 1024) {
// 10MB
return (
null,
LocaleKeys.document_plugins_file_fileTooBigError.tr(),
);
}
final documentService = DocumentService();
Log.debug("Uploading file from local path: $localFilePath");
final result = await documentService.uploadFile(
localFilePath: localFilePath,
documentId: documentId,
);
return result.fold(
(s) => (s.url, null),
(err) {
if (err.isStorageLimitExceeded) {
return (null, LocaleKeys.sideBar_storageLimitDialogTitle.tr());
}
return (null, err.msg);
},
);
}

View File

@ -53,3 +53,4 @@ export 'table/table_option_action.dart';
export 'todo_list/todo_list_icon.dart'; export 'todo_list/todo_list_icon.dart';
export 'toggle/toggle_block_component.dart'; export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcut_event.dart'; export 'toggle/toggle_block_shortcut_event.dart';
export 'file/file_block.dart';

View File

@ -110,9 +110,6 @@ class SettingsDialogBloc
"https://beta.appflowy.cloud", "https://beta.appflowy.cloud",
"https://test.appflowy.cloud", "https://test.appflowy.cloud",
]; ];
if (kDebugMode) {
whiteList.add("http://localhost:8000");
}
return whiteList.contains(cloudSetting.serverUrl); return whiteList.contains(cloudSetting.serverUrl);
}, },

View File

@ -1,3 +1,7 @@
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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/util/theme_extension.dart';
@ -18,9 +22,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.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'; import 'package:flutter_bloc/flutter_bloc.dart';
class SpacePermissionSwitch extends StatefulWidget { class SpacePermissionSwitch extends StatefulWidget {
@ -295,6 +296,8 @@ class ConfirmPopup extends StatefulWidget {
this.onCancel, this.onCancel,
this.confirmLabel, this.confirmLabel,
this.confirmButtonColor, this.confirmButtonColor,
this.child,
this.closeOnAction = true,
}); });
final String title; final String title;
@ -311,6 +314,18 @@ class ConfirmPopup extends StatefulWidget {
/// ///
final String? confirmLabel; final String? confirmLabel;
/// Allows to add a child to the popup.
///
/// This is useful when you want to add more content to the popup.
/// The child will be placed below the description.
///
final Widget? child;
/// Decides whether the popup should be closed when the confirm button is clicked.
/// Defaults to true.
///
final bool closeOnAction;
@override @override
State<ConfirmPopup> createState() => _ConfirmPopupState(); State<ConfirmPopup> createState() => _ConfirmPopupState();
} }
@ -339,9 +354,13 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildTitle(), _buildTitle(),
const VSpace(6.0), const VSpace(6),
_buildDescription(), _buildDescription(),
const VSpace(20.0), if (widget.child != null) ...[
const VSpace(12),
widget.child!,
],
const VSpace(20),
_buildStyledButton(context), _buildStyledButton(context),
], ],
), ),
@ -386,7 +405,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
return SpaceOkButton( return SpaceOkButton(
onConfirm: () { onConfirm: () {
widget.onConfirm(); widget.onConfirm();
if (widget.closeOnAction) {
Navigator.of(context).pop(); Navigator.of(context).pop();
}
}, },
confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(), confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(),
confirmButtonColor: widget.confirmButtonColor ?? confirmButtonColor: widget.confirmButtonColor ??
@ -400,7 +421,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
}, },
onConfirm: () { onConfirm: () {
widget.onConfirm(); widget.onConfirm();
if (widget.closeOnAction) {
Navigator.of(context).pop(); Navigator.of(context).pop();
}
}, },
confirmButtonName: confirmButtonName:
widget.confirmLabel ?? LocaleKeys.space_delete.tr(), widget.confirmLabel ?? LocaleKeys.space_delete.tr(),

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/startup/tasks/app_widget.dart';
@ -11,7 +13,6 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:toastification/toastification.dart'; import 'package:toastification/toastification.dart';
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
@ -473,3 +474,37 @@ Future<void> showCancelAndConfirmDialog({
}, },
); );
} }
Future<void> showCustomConfirmDialog({
required BuildContext context,
required String title,
required String description,
required Widget Function(BuildContext) builder,
VoidCallback? onConfirm,
String? confirmLabel,
ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk,
bool closeOnConfirm = true,
}) {
return showDialog(
context: context,
builder: (context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 440,
child: ConfirmPopup(
title: title,
description: description,
onConfirm: () => onConfirm?.call(),
confirmLabel: confirmLabel,
style: style,
closeOnAction: closeOnConfirm,
child: builder(context),
),
),
);
},
);
}

View File

@ -1603,6 +1603,26 @@
"invalidVideoUrl": "The source URL is not supported yet.", "invalidVideoUrl": "The source URL is not supported yet.",
"invalidVideoUrlYouTube": "YouTube is not supported yet.", "invalidVideoUrlYouTube": "YouTube is not supported yet.",
"supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264" "supportedFormats": "Supported formats: MP4, WebM, MOV, AVI, FLV, MPEG/M4V, H.264"
},
"file": {
"name": "File",
"uploadTab": "Upload",
"networkTab": "Integrate link",
"placeholderText": "Click or drag and drop to upload a file",
"placeholderDragging": "Drop the file to upload",
"dropFileToUpload": "Drop the file to upload",
"fileUploadHint": "Drag and drop a file here\nor click to select a file.",
"networkHint": "Enter a link to a file",
"networkUrlInvalid": "Invalid URL, please correct the URL and try again",
"networkAction": "Embed file link",
"fileTooBigError": "File size is too big, please upload a file with size less than 10MB",
"renameFile": {
"title": "Rename file",
"description": "Enter the new name for this file",
"nameEmptyError": "File name cannot be left empty."
},
"uploadedAt": "Uploaded on {}",
"linkedAt": "Link added on {}"
} }
}, },
"outlineBlock": { "outlineBlock": {