mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: upload file in document
This commit is contained in:
parent
d5a5a64fcf
commit
3c66fe3164
@ -4,6 +4,7 @@ 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/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_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
|
||||
@ -23,11 +24,13 @@ 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';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _excludeFromDropTarget = [
|
||||
ImageBlockKeys.type,
|
||||
CustomImageBlockKeys.type,
|
||||
MultiImageBlockKeys.type,
|
||||
FileBlockKeys.type,
|
||||
];
|
||||
|
||||
class DocumentPage extends StatefulWidget {
|
||||
@ -79,46 +82,65 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
|
||||
BlocProvider.value(value: documentBloc),
|
||||
],
|
||||
child: BlocBuilder<DocumentBloc, DocumentState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
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: [
|
||||
BlocProvider.value(value: getIt<ActionNavigationBloc>()),
|
||||
BlocProvider.value(value: documentBloc),
|
||||
],
|
||||
child: BlocBuilder<DocumentBloc, DocumentState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
|
||||
final editorState = state.editorState;
|
||||
this.editorState = editorState;
|
||||
final error = state.error;
|
||||
if (error != null || editorState == null) {
|
||||
Log.error(error);
|
||||
return FlowyErrorPage.message(
|
||||
error.toString(),
|
||||
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
|
||||
final editorState = state.editorState;
|
||||
this.editorState = editorState;
|
||||
final error = state.error;
|
||||
if (error != null || editorState == null) {
|
||||
Log.error(error);
|
||||
return FlowyErrorPage.message(
|
||||
error.toString(),
|
||||
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.forceClose) {
|
||||
widget.onDeleted();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
listener: _onNotificationAction,
|
||||
child: Consumer<EditorDropManagerState>(
|
||||
builder: (context, dropState, _) =>
|
||||
_buildEditorPage(context, state, dropState),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.forceClose) {
|
||||
widget.onDeleted();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
listener: _onNotificationAction,
|
||||
child: _buildEditorPage(context, state),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditorPage(BuildContext context, DocumentState state) {
|
||||
Widget _buildEditorPage(
|
||||
BuildContext context,
|
||||
DocumentState state,
|
||||
EditorDropManagerState dropState,
|
||||
) {
|
||||
final Widget child;
|
||||
|
||||
if (PlatformExtension.isMobile) {
|
||||
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
|
||||
builder: (context, styleState) {
|
||||
@ -136,6 +158,7 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
);
|
||||
} else {
|
||||
child = DropTarget(
|
||||
enable: dropState.isDropEnabled,
|
||||
onDragExited: (_) =>
|
||||
state.editorState!.selectionService.removeDropTarget(),
|
||||
onDragUpdated: (details) {
|
||||
|
@ -249,6 +249,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
|
||||
imageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
|
||||
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
@ -421,6 +421,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
autoGeneratorMenuItem,
|
||||
dateMenuItem,
|
||||
multiImageMenuItem,
|
||||
fileMenuItem,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'package:flutter/material.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/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_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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
|
||||
if (inAppJson != null && inAppJson.isNotEmpty) {
|
||||
debugPrint('paste in app json: $inAppJson');
|
||||
await editorState.deleteSelectionIfNeeded();
|
||||
if (await editorState.pasteInAppJson(inAppJson)) {
|
||||
return;
|
||||
|
@ -0,0 +1,2 @@
|
||||
export './file_block_component.dart';
|
||||
export './file_selection_menu.dart';
|
@ -0,0 +1,500 @@
|
||||
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,
|
||||
}) {
|
||||
return Node(
|
||||
type: FileBlockKeys.type,
|
||||
attributes: {
|
||||
FileBlockKeys.url: url,
|
||||
FileBlockKeys.urlType: type.toIntValue(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
final menuMutex = PopoverMutex();
|
||||
|
||||
late final editorState = Provider.of<EditorState>(context, listen: false);
|
||||
|
||||
bool alwaysShowMenu = false;
|
||||
bool isDragging = false;
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final url = node.attributes[FileBlockKeys.url] as String?;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
// If the name attribute is not set, extract the file name from the url.
|
||||
final name = node.attributes[FileBlockKeys.name] as String?;
|
||||
if (name == null || name.isEmpty) {
|
||||
final name = Uri.tryParse(url)?.pathSegments.last ?? url;
|
||||
final attributes = node.attributes;
|
||||
attributes[FileBlockKeys.name] = name;
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, attributes);
|
||||
editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) : null,
|
||||
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,
|
||||
mutex: menuMutex,
|
||||
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 FlowyHover(
|
||||
resetHoverOnRebuild: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.three_dots_s,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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);
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
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/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';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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', '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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
@ -53,3 +53,4 @@ export 'table/table_option_action.dart';
|
||||
export 'todo_list/todo_list_icon.dart';
|
||||
export 'toggle/toggle_block_component.dart';
|
||||
export 'toggle/toggle_block_shortcut_event.dart';
|
||||
export 'file/file_block.dart';
|
||||
|
@ -110,9 +110,6 @@ class SettingsDialogBloc
|
||||
"https://beta.appflowy.cloud",
|
||||
"https://test.appflowy.cloud",
|
||||
];
|
||||
if (kDebugMode) {
|
||||
whiteList.add("http://localhost:8000");
|
||||
}
|
||||
|
||||
return whiteList.contains(cloudSetting.serverUrl);
|
||||
},
|
||||
|
@ -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/locale_keys.g.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/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 {
|
||||
@ -294,6 +295,8 @@ class ConfirmPopup extends StatefulWidget {
|
||||
required this.onConfirm,
|
||||
this.confirmLabel,
|
||||
this.confirmButtonColor,
|
||||
this.child,
|
||||
this.closeOnAction = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@ -309,6 +312,18 @@ class ConfirmPopup extends StatefulWidget {
|
||||
///
|
||||
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
|
||||
State<ConfirmPopup> createState() => _ConfirmPopupState();
|
||||
}
|
||||
@ -337,9 +352,13 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTitle(),
|
||||
const VSpace(6.0),
|
||||
const VSpace(6),
|
||||
_buildDescription(),
|
||||
const VSpace(20.0),
|
||||
if (widget.child != null) ...[
|
||||
const VSpace(12),
|
||||
widget.child!,
|
||||
],
|
||||
const VSpace(20),
|
||||
_buildStyledButton(context),
|
||||
],
|
||||
),
|
||||
@ -384,7 +403,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
||||
return SpaceOkButton(
|
||||
onConfirm: () {
|
||||
widget.onConfirm();
|
||||
Navigator.of(context).pop();
|
||||
if (widget.closeOnAction) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(),
|
||||
confirmButtonColor: widget.confirmButtonColor ??
|
||||
@ -395,7 +416,9 @@ class _ConfirmPopupState extends State<ConfirmPopup> {
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
widget.onConfirm();
|
||||
Navigator.of(context).pop();
|
||||
if (widget.closeOnAction) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
confirmButtonName:
|
||||
widget.confirmLabel ?? LocaleKeys.space_delete.tr(),
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.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/dialog/styled_dialogs.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:toastification/toastification.dart';
|
||||
|
||||
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
||||
@ -471,3 +472,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1555,6 +1555,24 @@
|
||||
"invalidVideoUrl": "The source URL is not supported yet.",
|
||||
"invalidVideoUrlYouTube": "YouTube is not supported yet.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"outlineBlock": {
|
||||
|
Loading…
Reference in New Issue
Block a user