From d5a5a64fcfd263a1fe745e135150ee4f74656837 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Tue, 30 Jul 2024 09:28:40 +0200 Subject: [PATCH] fix: photo gallery improvements + launch review fixes (#5830) * fix: photo gallery improvements * fix: improve when to show billing/plan pages * feat: add grid photo gallery layout * fix: close inline actions menu --- .../document_with_multi_image_block_test.dart | 3 +- .../image_render.dart | 42 +++ .../layouts/image_browser_layout.dart} | 265 ++++++-------- .../layouts/image_grid_layout.dart | 323 ++++++++++++++++++ .../layouts/multi_image_layouts.dart | 79 +++++ .../multi_image_block_component.dart | 56 ++- .../multi_image_menu.dart | 136 +++++++- .../multi_image_placeholder.dart | 10 +- .../widgets/inline_actions_handler.dart | 13 +- .../lib/startup/deps_resolver.dart | 5 - .../settings/ai/settings_ai_bloc.dart | 3 + .../settings/settings_dialog_bloc.dart | 56 ++- .../setting_ai_view/local_ai_setting.dart | 51 +-- .../settings/settings_dialog.dart | 6 +- .../widgets/setting_appflowy_cloud.dart | 41 +-- .../settings/widgets/settings_menu.dart | 9 +- .../interactive_image_toolbar.dart | 12 +- .../interactive_image_viewer.dart | 12 +- frontend/appflowy_flutter/pubspec.lock | 8 + frontend/appflowy_flutter/pubspec.yaml | 2 + .../flowy_icons/16x/close_viewer.svg | 4 + .../resources/flowy_icons/16x/edit_layout.svg | 1 + .../flowy_icons/16x/photo_layout_browser.svg | 1 + .../flowy_icons/16x/photo_layout_grid.svg | 1 + frontend/resources/translations/en.json | 9 +- 25 files changed, 877 insertions(+), 271 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart rename frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/{multi_image_layouts.dart => multi_image_block_component/layouts/image_browser_layout.dart} (64%) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart create mode 100644 frontend/resources/flowy_icons/16x/close_viewer.svg create mode 100644 frontend/resources/flowy_icons/16x/edit_layout.svg create mode 100644 frontend/resources/flowy_icons/16x/photo_layout_browser.svg create mode 100644 frontend/resources/flowy_icons/16x/photo_layout_grid.svg diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart index 6f27375c0f..e787df18f6 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart @@ -8,9 +8,10 @@ import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart new file mode 100644 index 0000000000..66a14d2c4a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra/size.dart'; + +@visibleForTesting +class ImageRender extends StatelessWidget { + const ImageRender({ + super.key, + required this.image, + this.userProfile, + this.fit = BoxFit.cover, + this.borderRadius = Corners.s6Border, + }); + + final ImageBlockData image; + final UserProfilePB? userProfile; + final BoxFit fit; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final child = switch (image.type) { + CustomImageType.internal || CustomImageType.external => FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: fit, + ), + CustomImageType.local => Image.file(File(image.url), fit: fit), + }; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: borderRadius), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart similarity index 64% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart index 3b5962196a..fd9e1f5169 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart @@ -7,13 +7,13 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; import 'package:collection/collection.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -25,25 +25,10 @@ import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:provider/provider.dart'; +import '../image_render.dart'; + const _thumbnailItemSize = 100.0; -abstract class ImageBlockMultiLayout extends StatefulWidget { - const ImageBlockMultiLayout({ - super.key, - required this.node, - required this.editorState, - required this.images, - required this.indexNotifier, - required this.isLocalMode, - }); - - final Node node; - final EditorState editorState; - final List images; - final ValueNotifier indexNotifier; - final bool isLocalMode; -} - class ImageBrowserLayout extends ImageBlockMultiLayout { const ImageBrowserLayout({ super.key, @@ -97,115 +82,118 @@ class _ImageBrowserLayoutState extends State { (constraints.maxWidth / (_thumbnailItemSize + 4)).floor(); final items = widget.images.take(maxItems).toList(); - return Wrap( - children: items.mapIndexed((index, image) { - final isLast = items.last == image; - final amountLeft = widget.images.length - items.length; - if (isLast && amountLeft > 0) { + return Center( + child: Wrap( + children: items.mapIndexed((index, image) { + final isLast = items.last == image; + final amountLeft = widget.images.length - items.length; + if (isLast && amountLeft > 0) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _openInteractiveViewer( + context, + maxItems - 1, + ), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: Theme.of(context).dividerColor, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Corners.s6Border, + image: image.type == CustomImageType.local + ? DecorationImage( + image: FileImage(File(image.url)), + fit: BoxFit.cover, + opacity: 0.5, + ) + : null, + ), + child: Stack( + children: [ + if (image.type != CustomImageType.local) + Positioned.fill( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration( + borderRadius: Corners.s6Border, + ), + child: FlowyNetworkImage( + url: image.url, + userProfilePB: _userProfile, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + ), + child: Center( + child: FlowyText( + '+$amountLeft', + color: AFThemeExtension.of(context) + .strongText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( - onTap: () => _openInteractiveViewer( - context, - maxItems - 1, - ), - child: Container( - width: _thumbnailItemSize, - height: _thumbnailItemSize, - padding: const EdgeInsets.all(2), - margin: const EdgeInsets.all(2), - decoration: BoxDecoration( - borderRadius: Corners.s8Border, - border: Border.all( - width: 2, - color: Theme.of(context).dividerColor, - ), - ), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: Corners.s6Border, - image: image.type == CustomImageType.local - ? DecorationImage( - image: FileImage(File(image.url)), - fit: BoxFit.cover, - opacity: 0.5, - ) - : null, - ), - child: Stack( - children: [ - if (image.type != CustomImageType.local) - Positioned.fill( - child: Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration( - borderRadius: Corners.s6Border, - ), - child: FlowyNetworkImage( - url: image.url, - userProfilePB: _userProfile, - ), - ), - ), - DecoratedBox( - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.5), - ), - child: Center( - child: FlowyText( - '+$amountLeft', - color: AFThemeExtension.of(context) - .strongText, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), + onTap: () => widget.onIndexChanged(index), + child: ThumbnailItem( + images: widget.images, + index: index, + selectedIndex: widget.indexNotifier.value, + userProfile: _userProfile, + onDeleted: () async { + final transaction = + widget.editorState.transaction; + + final images = widget.images.toList(); + images.removeAt(index); + + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + images.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: widget.node + .attributes[MultiImageBlockKeys.layout], + }, + ); + + await widget.editorState.apply(transaction); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + }, ), ), ); - } - - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => widget.onIndexChanged(index), - child: ThumbnailItem( - images: widget.images, - index: index, - selectedIndex: widget.indexNotifier.value, - userProfile: _userProfile, - onDeleted: () async { - final transaction = widget.editorState.transaction; - - final images = widget.images.toList(); - images.removeAt(index); - - transaction.updateNode( - widget.node, - { - MultiImageBlockKeys.images: - images.map((e) => e.toJson()).toList(), - MultiImageBlockKeys.layout: widget.node - .attributes[MultiImageBlockKeys.layout], - }, - ); - - await widget.editorState.apply(transaction); - - widget.onIndexChanged( - widget.indexNotifier.value > 0 - ? widget.indexNotifier.value - 1 - : 0, - ); - }, - ), - ), - ); - }).toList(), + }).toList(), + ), ); }, ), @@ -324,7 +312,8 @@ class _ImageBrowserLayoutState extends State { transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, - MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], }); await widget.editorState.apply(transaction); @@ -417,35 +406,3 @@ class _ThumbnailItemState extends State { ); } } - -@visibleForTesting -class ImageRender extends StatelessWidget { - const ImageRender({ - super.key, - required this.image, - this.userProfile, - this.fit = BoxFit.cover, - }); - - final ImageBlockData image; - final UserProfilePB? userProfile; - final BoxFit fit; - - @override - Widget build(BuildContext context) { - final child = switch (image.type) { - CustomImageType.internal || CustomImageType.external => FlowyNetworkImage( - url: image.url, - userProfilePB: userProfile, - fit: fit, - ), - CustomImageType.local => Image.file(File(image.url), fit: fit), - }; - - return Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration(borderRadius: Corners.s6Border), - child: child, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart new file mode 100644 index 0000000000..1abe57146e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:provider/provider.dart'; + +class ImageGridLayout extends ImageBlockMultiLayout { + const ImageGridLayout({ + super.key, + required super.node, + required super.editorState, + required super.images, + required super.indexNotifier, + required super.isLocalMode, + }); + + @override + State createState() => _ImageGridLayoutState(); +} + +class _ImageGridLayoutState extends State { + @override + Widget build(BuildContext context) { + return StaggeredGridBuilder( + images: widget.images, + onImageDoubleTapped: (index) { + _openInteractiveViewer(context, index); + }, + ); + } + + void _openInteractiveViewer(BuildContext context, int index) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: widget.images, + initialIndex: index, + onDeleteImage: (index) async { + final transaction = widget.editorState.transaction; + final newImages = widget.images.toList(); + newImages.removeAt(index); + + if (newImages.isNotEmpty) { + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + newImages.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }, + ); + } else { + transaction.deleteNode(widget.node); + } + + await widget.editorState.apply(transaction); + }, + ), + ), + ); +} + +/// Draws a staggered grid of images, where the pattern is based +/// on the amount of images to fill the grid at all times. +/// +/// They will be alternating depending on the current index of the images, such that +/// the layout is reversed in odd segments. +/// +/// If there are 4 images in the last segment, this layout will be used: +/// ┌─────┐┌─┐┌─┐ +/// │ │└─┘└─┘ +/// │ │┌────┐ +/// └─────┘└────┘ +/// +/// If there are 3 images in the last segment, this layout will be used: +/// ┌─────┐┌────┐ +/// │ │└────┘ +/// │ │┌────┐ +/// └─────┘└────┘ +/// +/// If there are 2 images in the last segment, this layout will be used: +/// ┌─────┐┌─────┐ +/// │ ││ │ +/// └─────┘└─────┘ +/// +/// If there is 1 image in the last segment, this layout will be used: +/// ┌──────────┐ +/// │ │ +/// └──────────┘ +class StaggeredGridBuilder extends StatefulWidget { + const StaggeredGridBuilder({ + super.key, + required this.images, + required this.onImageDoubleTapped, + }); + + final List images; + final void Function(int) onImageDoubleTapped; + + @override + State createState() => _StaggeredGridBuilderState(); +} + +class _StaggeredGridBuilderState extends State { + late final UserProfilePB? _userProfile; + final List> _splitImages = []; + + @override + void initState() { + super.initState(); + _userProfile = context.read().state.userProfilePB; + + for (int i = 0; i < widget.images.length; i += 4) { + final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length; + _splitImages.add(widget.images.sublist(i, end)); + } + } + + @override + void didUpdateWidget(covariant StaggeredGridBuilder oldWidget) { + if (widget.images.length != oldWidget.images.length) { + _splitImages.clear(); + for (int i = 0; i < widget.images.length; i += 4) { + final end = + (i + 4 < widget.images.length) ? i + 4 : widget.images.length; + _splitImages.add(widget.images.sublist(i, end)); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return StaggeredGrid.count( + crossAxisCount: 4, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + children: + _splitImages.indexed.map(_buildTilesForImages).flattened.toList(), + ); + } + + List _buildTilesForImages((int, List) data) { + final index = data.$1; + final images = data.$2; + + final isReversed = index.isOdd; + + if (images.length == 4) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: isReversed ? 1 : 2, + mainAxisCellCount: isReversed ? 1 : 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 1, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: isReversed ? 2 : 1, + mainAxisCellCount: isReversed ? 2 : 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 2; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[2], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 3; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[3], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else if (images.length == 3) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: isReversed ? 1 : 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: isReversed ? 2 : 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 2; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[2], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else if (images.length == 2) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 4, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart new file mode 100644 index 0000000000..00919a20cc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; + +abstract class ImageBlockMultiLayout extends StatefulWidget { + const ImageBlockMultiLayout({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; +} + +class ImageLayoutRender extends StatelessWidget { + const ImageLayoutRender({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + required this.onIndexChanged, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; + final void Function(int) onIndexChanged; + + @override + Widget build(BuildContext context) { + final layout = _getLayout(); + + return _buildLayout(layout); + } + + MultiImageLayout _getLayout() { + return MultiImageLayout.fromIntValue( + node.attributes[MultiImageBlockKeys.layout] ?? 0, + ); + } + + Widget _buildLayout(MultiImageLayout layout) { + switch (layout) { + case MultiImageLayout.grid: + return ImageGridLayout( + node: node, + editorState: editorState, + images: images, + indexNotifier: indexNotifier, + isLocalMode: isLocalMode, + ); + case MultiImageLayout.browser: + default: + return ImageBrowserLayout( + node: node, + editorState: editorState, + images: images, + indexNotifier: indexNotifier, + isLocalMode: isLocalMode, + onIndexChanged: onIndexChanged, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart index a7dd9fdb2a..648f5f978c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:provider/provider.dart'; const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; @@ -109,6 +112,38 @@ class MultiImageBlockComponentState extends State bool alwaysShowMenu = false; + static const _interceptorKey = 'multi-image-block-interceptor'; + + late final interceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => _isTapInBounds(details.globalPosition), + canPanStart: (details) => _isTapInBounds(details.globalPosition), + ); + + @override + void initState() { + super.initState(); + editorState.selectionService.registerGestureInterceptor(interceptor); + } + + @override + void dispose() { + editorState.selectionService.unregisterGestureInterceptor(_interceptorKey); + super.dispose(); + } + + bool _isTapInBounds(Offset offset) { + if (_renderBox == null) { + // We shouldn't block any actions if the render box is not available. + // This has the potential to break taps on the editor completely if we + // accidentally return false here. + return true; + } + + final localPosition = _renderBox!.globalToLocal(offset); + return !_renderBox!.paintBounds.contains(localPosition); + } + @override Widget build(BuildContext context) { final data = MultiImageData.fromJson( @@ -127,7 +162,7 @@ class MultiImageBlockComponentState extends State node: node, ); } else { - child = ImageBrowserLayout( + child = ImageLayoutRender( node: node, images: data.images, editorState: editorState, @@ -302,17 +337,14 @@ class MultiImageData { enum MultiImageLayout { browser, - masonry, grid; int toIntValue() { switch (this) { case MultiImageLayout.browser: return 0; - case MultiImageLayout.masonry: - return 1; case MultiImageLayout.grid: - return 2; + return 1; } } @@ -321,11 +353,19 @@ enum MultiImageLayout { case 0: return MultiImageLayout.browser; case 1: - return MultiImageLayout.masonry; - case 2: return MultiImageLayout.grid; default: throw UnimplementedError(); } } + + String get label => switch (this) { + browser => LocaleKeys.document_plugins_photoGallery_browserLayout.tr(), + grid => LocaleKeys.document_plugins_photoGallery_gridLayout.tr(), + }; + + FlowySvgData get icon => switch (this) { + browser => FlowySvgs.photo_layout_browser_s, + grid => FlowySvgs.photo_layout_grid_s, + }; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart index bbfb4a6462..d419446328 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -21,6 +21,8 @@ import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu, Log; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:http/http.dart'; @@ -57,6 +59,7 @@ class _MultiImageMenuState extends State { ); final PopoverController controller = PopoverController(); + final PopoverController layoutController = PopoverController(); late List images; late final EditorState editorState; @@ -73,6 +76,7 @@ class _MultiImageMenuState extends State { void dispose() { allowMenuClose(); controller.close(); + layoutController.close(); super.dispose(); } @@ -88,6 +92,9 @@ class _MultiImageMenuState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final layout = MultiImageLayout.fromIntValue( + widget.node.attributes[MultiImageBlockKeys.layout] ?? 0, + ); return Container( height: 32, decoration: BoxDecoration( @@ -104,11 +111,6 @@ class _MultiImageMenuState extends State { child: Row( children: [ const HSpace(4), - MenuBlockButton( - tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), - iconData: FlowySvgs.full_view_s, - onTap: openFullScreen, - ), AppFlowyPopover( controller: controller, direction: PopoverDirection.bottomWithRightAligned, @@ -141,9 +143,57 @@ class _MultiImageMenuState extends State { onTap: () {}, ), ), + const HSpace(4), + AppFlowyPopover( + controller: layoutController, + onClose: allowMenuClose, + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints( + maxHeight: 300, + maxWidth: 300, + ), + popupBuilder: (context) { + preventMenuClose(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _LayoutSelector( + selectedLayout: layout, + onSelected: (layout) { + allowMenuClose(); + layoutController.close(); + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: + widget.node.attributes[MultiImageBlockKeys.images], + MultiImageBlockKeys.layout: layout.toIntValue(), + }); + editorState.apply(transaction); + }, + ), + ], + ); + }, + child: MenuBlockButton( + tooltip: LocaleKeys + .document_plugins_photoGallery_changeLayoutTooltip + .tr(), + iconData: FlowySvgs.edit_layout_s, + onTap: () {}, + ), + ), + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), + // disable the copy link button if the image is hosted on appflowy cloud // because the url needs the verification token to be accessible - if (!images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[ + if (layout == MultiImageLayout.browser && + !images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[ const HSpace(4), MenuBlockButton( tooltip: LocaleKeys.editor_copyLink.tr(), @@ -153,7 +203,8 @@ class _MultiImageMenuState extends State { ], const _Divider(), MenuBlockButton( - tooltip: LocaleKeys.button_delete.tr(), + tooltip: LocaleKeys.document_plugins_photoGallery_deleteBlockTooltip + .tr(), iconData: FlowySvgs.delete_s, onTap: deleteImage, ), @@ -202,8 +253,8 @@ class _MultiImageMenuState extends State { newImages.map((image) => image.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, - // Default to Browser layout - MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], }); await editorState.apply(transaction); @@ -242,12 +293,12 @@ class _MultiImageMenuState extends State { return; } - newImages.addAll(images); - final imagesJson = newImages.map((image) => image.toJson()).toList(); + final imagesJson = + [...images, ...newImages].map((i) => i.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, - // Default to Browser layout - MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], }); await editorState.apply(transaction); @@ -310,8 +361,8 @@ class _MultiImageMenuState extends State { final imagesJson = newImages.map((image) => image.toJson()).toList(); transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, - // Default to Browser layout - MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], }); await editorState.apply(transaction); @@ -330,3 +381,58 @@ class _Divider extends StatelessWidget { ); } } + +class _LayoutSelector extends StatelessWidget { + const _LayoutSelector({ + required this.selectedLayout, + required this.onSelected, + }); + + final MultiImageLayout selectedLayout; + final Function(MultiImageLayout) onSelected; + + @override + Widget build(BuildContext context) { + return SeparatedRow( + separatorBuilder: () => const HSpace(6), + mainAxisSize: MainAxisSize.min, + children: MultiImageLayout.values + .map( + (layout) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => onSelected(layout), + child: Container( + height: 80, + width: 80, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: selectedLayout == layout + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + borderRadius: Corners.s8Border, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowySvg( + layout.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(24), + ), + const VSpace(6), + FlowyText(layout.label), + ], + ), + ), + ), + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart index 92c979144c..cdad9fe039 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart @@ -222,8 +222,9 @@ class MultiImagePlaceholderState extends State { transaction.updateNode(widget.node, { MultiImageBlockKeys.images: imagesJson, - // Default to Browser layout - MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout] ?? + MultiImageLayout.browser.toIntValue(), }); await editorState.apply(transaction); @@ -282,8 +283,9 @@ class MultiImagePlaceholderState extends State { transaction.updateNode(widget.node, { MultiImageBlockKeys.images: images.map((image) => image.toJson()).toList(), - // Default to Browser layout - MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout] ?? + MultiImageLayout.browser.toIntValue(), }); await editorState.apply(transaction); } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart index 94c9d62a74..7f2c3de8f3 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart @@ -49,7 +49,7 @@ extension _StartWithsSort on List { ); } -const _invalidSearchesAmount = 20; +const _invalidSearchesAmount = 10; class InlineActionsHandler extends StatefulWidget { const InlineActionsHandler({ @@ -81,8 +81,6 @@ class _InlineActionsHandlerState extends State { final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler'); final _scrollController = ScrollController(); - Timer? _debounce; - late List results = widget.results; int invalidCounter = 0; late int startOffset; @@ -90,8 +88,7 @@ class _InlineActionsHandlerState extends State { String _search = ''; set search(String search) { _search = search; - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 200), _doSearch); + _doSearch(); } Future _doSearch() async { @@ -109,10 +106,13 @@ class _InlineActionsHandlerState extends State { : 0; if (invalidCounter >= _invalidSearchesAmount) { + widget.onDismiss(); + // Workaround to bring focus back to editor await widget.editorState .updateSelectionWithReason(widget.editorState.selection); - return widget.onDismiss(); + + return; } _resetSelection(); @@ -143,7 +143,6 @@ class _InlineActionsHandlerState extends State { void dispose() { _scrollController.dispose(); _focusNode.dispose(); - _debounce?.cancel(); super.dispose(); } diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index 59fa0b1025..3532bc8420 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -208,11 +208,6 @@ void _resolveFolderDeps(GetIt getIt) { ), ); - // Settings - getIt.registerFactoryParam( - (user, _) => SettingsDialogBloc(user), - ); - // User getIt.registerFactoryParam( (user, _) => SettingsUserViewBloc(user), diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart index 7adfc43a81..d2050637f1 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -43,6 +43,9 @@ class SettingsAIBloc extends Bloc { emit(state.copyWith(userProfile: userProfile)); }, toggleAISearch: () { + emit( + state.copyWith(enableSearchIndexing: !state.enableSearchIndexing), + ); _updateUserWorkspaceSetting( disableSearchIndexing: !(state.aiSettings?.disableSearchIndexing ?? false), diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 4e622fa9c9..874851759e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -1,7 +1,12 @@ +import 'package:flutter/foundation.dart'; + import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -27,7 +32,8 @@ enum SettingsPage { class SettingsDialogBloc extends Bloc { SettingsDialogBloc( - this.userProfile, { + this.userProfile, + this.workspaceMember, { SettingsPage? initPage, }) : _userListener = UserListener(userProfile: userProfile), super(SettingsDialogState.initial(userProfile, initPage)) { @@ -35,6 +41,7 @@ class SettingsDialogBloc } final UserProfilePB userProfile; + final WorkspaceMemberPB? workspaceMember; final UserListener _userListener; @override @@ -49,6 +56,12 @@ class SettingsDialogBloc await event.when( initial: () async { _userListener.start(onProfileUpdated: _profileUpdated); + + final isBillingEnabled = + await _isBillingEnabled(userProfile, workspaceMember); + if (isBillingEnabled) { + emit(state.copyWith(isBillingEnabled: true)); + } }, didReceiveUserProfile: (UserProfilePB newUserProfile) { emit(state.copyWith(userProfile: newUserProfile)); @@ -70,6 +83,45 @@ class SettingsDialogBloc (err) => Log.error(err), ); } + + Future _isBillingEnabled( + UserProfilePB userProfile, [ + WorkspaceMemberPB? member, + ]) async { + if ([ + AuthenticatorPB.Local, + AuthenticatorPB.Supabase, + ].contains(userProfile.authenticator)) { + return false; + } + + if (member == null || member.role != AFRolePB.Owner) { + return false; + } + + if (kDebugMode) { + return true; + } + + final result = await UserEventGetCloudConfig().send(); + return result.fold( + (cloudSetting) { + final whiteList = [ + "https://beta.appflowy.cloud", + "https://test.appflowy.cloud", + ]; + if (kDebugMode) { + whiteList.add("http://localhost:8000"); + } + + return whiteList.contains(cloudSetting.serverUrl); + }, + (err) { + Log.error("Failed to get cloud config: $err"); + return false; + }, + ); + } } @freezed @@ -87,6 +139,7 @@ class SettingsDialogState with _$SettingsDialogState { const factory SettingsDialogState({ required UserProfilePB userProfile, required SettingsPage page, + required bool isBillingEnabled, }) = _SettingsDialogState; factory SettingsDialogState.initial( @@ -96,5 +149,6 @@ class SettingsDialogState with _$SettingsDialogState { SettingsDialogState( userProfile: userProfile, page: page ?? SettingsPage.account, + isBillingEnabled: false, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart index 26bd0d8426..041dd117e0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -1,5 +1,8 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; @@ -7,9 +10,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:expandable/expandable.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class LocalAISetting extends StatefulWidget { @@ -108,20 +108,18 @@ class LocalAISettingHeader extends StatelessWidget { value: isEnabled, onChanged: (value) { if (isEnabled) { - showDialog( + showConfirmDialog( context: context, - barrierDismissible: false, - useRootNavigator: false, - builder: (dialogContext) { - return _ToggleLocalAIDialog( - onOkPressed: () { - context - .read() - .add(const LocalAIToggleEvent.toggle()); - }, - onCancelPressed: () {}, - ); - }, + title: LocaleKeys + .settings_aiPage_keys_disableLocalAITitle + .tr(), + description: LocaleKeys + .settings_aiPage_keys_disableLocalAIDescription + .tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context + .read() + .add(const LocalAIToggleEvent.toggle()), ); } else { context @@ -138,24 +136,3 @@ class LocalAISettingHeader extends StatelessWidget { ); } } - -class _ToggleLocalAIDialog extends StatelessWidget { - const _ToggleLocalAIDialog({ - required this.onOkPressed, - required this.onCancelPressed, - }); - final VoidCallback onOkPressed; - final VoidCallback onCancelPressed; - - @override - Widget build(BuildContext context) { - return NavigatorOkCancelDialog( - message: LocaleKeys.settings_aiPage_keys_disableLocalAIDialog.tr(), - okTitle: LocaleKeys.button_confirm.tr(), - cancelTitle: LocaleKeys.button_cancel.tr(), - onOkPressed: onOkPressed, - onCancelPressed: onCancelPressed, - titleUpperCase: false, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index 40ca1c4816..6f6d605673 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart'; @@ -25,7 +25,7 @@ class SettingsDialog extends StatelessWidget { required this.dismissDialog, required this.didLogout, required this.restartApp, - this.initPage, + this.initPage, }) : super(key: ValueKey(user.id)); final VoidCallback dismissDialog; @@ -39,6 +39,7 @@ class SettingsDialog extends StatelessWidget { return BlocProvider( create: (context) => SettingsDialogBloc( user, + context.read().state.currentWorkspaceMember, initPage: initPage, )..add(const SettingsDialogEvent.initial()), child: BlocBuilder( @@ -60,6 +61,7 @@ class SettingsDialog extends StatelessWidget { .add(SettingsDialogEvent.setSelectedPage(index)), currentPage: context.read().state.page, + isBillingEnabled: state.isBillingEnabled, member: context .read() .state diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart index c6057094bb..4ef6e068b4 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -1,4 +1,3 @@ -import 'package:appflowy_backend/log.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -13,7 +12,9 @@ import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_bu import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -355,25 +356,25 @@ class BillingGateGuard extends StatelessWidget { Future isBillingEnabled() async { final result = await UserEventGetCloudConfig().send(); - return result.fold((cloudSetting) { - final whiteList = [ - "https://beta.appflowy.cloud", - "https://test.appflowy.cloud", - ]; - if (kDebugMode) { - whiteList.add("http://localhost:8000"); - } + return result.fold( + (cloudSetting) { + final whiteList = [ + "https://beta.appflowy.cloud", + "https://test.appflowy.cloud", + ]; + if (kDebugMode) { + whiteList.add("http://localhost:8000"); + } - if (whiteList.contains(cloudSetting.serverUrl)) { - return true; - } else { - Log.warn( - "Billing is not enabled for this server:${cloudSetting.serverUrl}", - ); + final isWhiteListed = whiteList.contains(cloudSetting.serverUrl); + if (!isWhiteListed) { + Log.warn("Billing is not enabled for server ${cloudSetting.serverUrl}"); + } + return isWhiteListed; + }, + (err) { + Log.error("Failed to get cloud config: $err"); return false; - } - }, (err) { - Log.error("Failed to get cloud config: $err"); - return false; - }); + }, + ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index 08fb46d58d..38cd49831d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; @@ -17,12 +16,14 @@ class SettingsMenu extends StatelessWidget { required this.changeSelectedPage, required this.currentPage, required this.userProfile, + required this.isBillingEnabled, this.member, }); final Function changeSelectedPage; final SettingsPage currentPage; final UserProfilePB userProfile; + final bool isBillingEnabled; final WorkspaceMemberPB? member; @override @@ -112,11 +113,7 @@ class SettingsMenu extends StatelessWidget { ), changeSelectedPage: changeSelectedPage, ), - if (FeatureFlag.planBilling.isOn && - userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud && - member != null && - member!.role.isOwner) ...[ + if (FeatureFlag.planBilling.isOn && isBillingEnabled) ...[ SettingsMenuElement( page: SettingsPage.plan, selectedPage: currentPage, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart index c3d9cbdbea..1431cb45b8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +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'; @@ -14,7 +16,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart'; @@ -190,7 +191,7 @@ class InteractiveImageToolbar extends StatelessWidget { tooltip: LocaleKeys .document_imageBlock_interactiveViewer_toolbar_closeViewer .tr(), - icon: FlowySvgs.close_s, + icon: FlowySvgs.close_viewer_s, onTap: () => Navigator.of(context).pop(), ), ], @@ -291,11 +292,12 @@ class _ToolbarItem extends StatelessWidget { isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(4), ), - child: Padding( - padding: const EdgeInsets.all(8.0), + child: Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(8), child: FlowySvg( icon, - size: const Size.square(16), color: isDisabled ? Colors.grey : Colors.white, ), ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart index 1b1a3353c3..37960b3e79 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -110,10 +110,14 @@ class _InteractiveImageViewerState extends State { child: SizedBox( height: size.height, width: size.width, - child: widget.imageProvider.renderImage( - context, - currentIndex, - userProfile, + child: GestureDetector( + // We can consider adding zoom behavior instead in a later iteration + onDoubleTap: () => Navigator.of(context).pop(), + child: widget.imageProvider.renderImage( + context, + currentIndex, + userProfile, + ), ), ), ), diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 9cf8451f3f..a0c4bef0da 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -806,6 +806,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_sticky_header: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 4d526e7461..0f9ba4080f 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -149,6 +149,8 @@ dependencies: desktop_drop: ^0.4.4 cross_file: ^0.3.4+1 + flutter_staggered_grid_view: ^0.7.0 + # Window Manager for MacOS and Linux window_manager: ^0.3.9 diff --git a/frontend/resources/flowy_icons/16x/close_viewer.svg b/frontend/resources/flowy_icons/16x/close_viewer.svg new file mode 100644 index 0000000000..4fa8061bae --- /dev/null +++ b/frontend/resources/flowy_icons/16x/close_viewer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/edit_layout.svg b/frontend/resources/flowy_icons/16x/edit_layout.svg new file mode 100644 index 0000000000..a2f875742e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/edit_layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/photo_layout_browser.svg b/frontend/resources/flowy_icons/16x/photo_layout_browser.svg new file mode 100644 index 0000000000..9f9240da5f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/photo_layout_browser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/photo_layout_grid.svg b/frontend/resources/flowy_icons/16x/photo_layout_grid.svg new file mode 100644 index 0000000000..dfc3cfd1e2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/photo_layout_grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2cdddd9c03..407456a2c5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -664,7 +664,8 @@ "localAIStopped": "Local AI stopped", "failToLoadLocalAI": "Failed to start local AI", "restartLocalAI": "Restart Local AI", - "disableLocalAIDialog": "Do you want to disable local AI?", + "disableLocalAITitle": "Disable local AI", + "disableLocalAIDescription": "Do you want to disable local AI?", "localAIToggleTitle": "Toggle to enable or disable local AI", "fetchLocalModel": "Fetch local model configuration", "openModelDirectory": "Open folder" @@ -1505,7 +1506,11 @@ "photoKeyword": "photo", "photoBrowserKeyword": "photo browser", "galleryKeyword": "gallery", - "addImageTooltip": "Add image" + "addImageTooltip": "Add image", + "changeLayoutTooltip": "Change layout", + "browserLayout": "Browser", + "gridLayout": "Grid", + "deleteBlockTooltip": "Delete whole gallery" }, "math": { "copiedToPasteBoard": "The math equation has been copied to the clipboard"