mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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
This commit is contained in:
parent
175c90e379
commit
d5a5a64fcf
@ -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';
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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<ImageBlockData> images;
|
||||
final ValueNotifier<int> indexNotifier;
|
||||
final bool isLocalMode;
|
||||
}
|
||||
|
||||
class ImageBrowserLayout extends ImageBlockMultiLayout {
|
||||
const ImageBrowserLayout({
|
||||
super.key,
|
||||
@ -97,115 +82,118 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
|
||||
(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<ImageBrowserLayout> {
|
||||
|
||||
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<ThumbnailItem> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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<ImageGridLayout> createState() => _ImageGridLayoutState();
|
||||
}
|
||||
|
||||
class _ImageGridLayoutState extends State<ImageGridLayout> {
|
||||
@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<DocumentBloc>().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<ImageBlockData> images;
|
||||
final void Function(int) onImageDoubleTapped;
|
||||
|
||||
@override
|
||||
State<StaggeredGridBuilder> createState() => _StaggeredGridBuilderState();
|
||||
}
|
||||
|
||||
class _StaggeredGridBuilderState extends State<StaggeredGridBuilder> {
|
||||
late final UserProfilePB? _userProfile;
|
||||
final List<List<ImageBlockData>> _splitImages = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_userProfile = context.read<DocumentBloc>().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<Widget> _buildTilesForImages((int, List<ImageBlockData>) 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
@ -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<ImageBlockData> images;
|
||||
final ValueNotifier<int> 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<ImageBlockData> images;
|
||||
final ValueNotifier<int> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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<MultiImageBlockComponent>
|
||||
|
||||
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<MultiImageBlockComponent>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
@ -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<MultiImageMenu> {
|
||||
);
|
||||
|
||||
final PopoverController controller = PopoverController();
|
||||
final PopoverController layoutController = PopoverController();
|
||||
late List<ImageBlockData> images;
|
||||
late final EditorState editorState;
|
||||
|
||||
@ -73,6 +76,7 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
|
||||
void dispose() {
|
||||
allowMenuClose();
|
||||
controller.close();
|
||||
layoutController.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -88,6 +92,9 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
|
||||
@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<MultiImageMenu> {
|
||||
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<MultiImageMenu> {
|
||||
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<MultiImageMenu> {
|
||||
],
|
||||
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<MultiImageMenu> {
|
||||
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<MultiImageMenu> {
|
||||
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<MultiImageMenu> {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -222,8 +222,9 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
|
||||
|
||||
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<MultiImagePlaceholder> {
|
||||
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);
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ extension _StartWithsSort on List<InlineActionsResult> {
|
||||
);
|
||||
}
|
||||
|
||||
const _invalidSearchesAmount = 20;
|
||||
const _invalidSearchesAmount = 10;
|
||||
|
||||
class InlineActionsHandler extends StatefulWidget {
|
||||
const InlineActionsHandler({
|
||||
@ -81,8 +81,6 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
|
||||
final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler');
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
Timer? _debounce;
|
||||
|
||||
late List<InlineActionsResult> results = widget.results;
|
||||
int invalidCounter = 0;
|
||||
late int startOffset;
|
||||
@ -90,8 +88,7 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
|
||||
String _search = '';
|
||||
set search(String search) {
|
||||
_search = search;
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 200), _doSearch);
|
||||
_doSearch();
|
||||
}
|
||||
|
||||
Future<void> _doSearch() async {
|
||||
@ -109,10 +106,13 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
|
||||
: 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<InlineActionsHandler> {
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -208,11 +208,6 @@ void _resolveFolderDeps(GetIt getIt) {
|
||||
),
|
||||
);
|
||||
|
||||
// Settings
|
||||
getIt.registerFactoryParam<SettingsDialogBloc, UserProfilePB, void>(
|
||||
(user, _) => SettingsDialogBloc(user),
|
||||
);
|
||||
|
||||
// User
|
||||
getIt.registerFactoryParam<SettingsUserViewBloc, UserProfilePB, void>(
|
||||
(user, _) => SettingsUserViewBloc(user),
|
||||
|
@ -43,6 +43,9 @@ class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
|
||||
emit(state.copyWith(userProfile: userProfile));
|
||||
},
|
||||
toggleAISearch: () {
|
||||
emit(
|
||||
state.copyWith(enableSearchIndexing: !state.enableSearchIndexing),
|
||||
);
|
||||
_updateUserWorkspaceSetting(
|
||||
disableSearchIndexing:
|
||||
!(state.aiSettings?.disableSearchIndexing ?? false),
|
||||
|
@ -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<SettingsDialogEvent, SettingsDialogState> {
|
||||
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<bool> _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,
|
||||
);
|
||||
}
|
||||
|
@ -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<LocalAIToggleBloc>()
|
||||
.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<LocalAIToggleBloc>()
|
||||
.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<SettingsDialogBloc>(
|
||||
create: (context) => SettingsDialogBloc(
|
||||
user,
|
||||
context.read<UserWorkspaceBloc>().state.currentWorkspaceMember,
|
||||
initPage: initPage,
|
||||
)..add(const SettingsDialogEvent.initial()),
|
||||
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
|
||||
@ -60,6 +61,7 @@ class SettingsDialog extends StatelessWidget {
|
||||
.add(SettingsDialogEvent.setSelectedPage(index)),
|
||||
currentPage:
|
||||
context.read<SettingsDialogBloc>().state.page,
|
||||
isBillingEnabled: state.isBillingEnabled,
|
||||
member: context
|
||||
.read<UserWorkspaceBloc>()
|
||||
.state
|
||||
|
@ -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<bool> isBillingEnabled() async {
|
||||
final result = await UserEventGetCloudConfig().send();
|
||||
return result.fold((cloudSetting) {
|
||||
final whiteList = [
|
||||
"https://beta.appflowy.cloud",
|
||||
"https://test.appflowy.cloud",
|
||||
];
|
||||
if (kDebugMode) {
|
||||
whiteList.add("http://localhost:8000");
|
||||
}
|
||||
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;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -110,10 +110,14 @@ class _InteractiveImageViewerState extends State<InteractiveImageViewer> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
4
frontend/resources/flowy_icons/16x/close_viewer.svg
Normal file
4
frontend/resources/flowy_icons/16x/close_viewer.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="11.6765" y="3.27295" width="1.48562" height="11.8849" rx="0.742808" transform="rotate(45 11.6765 3.27295)" fill="#333333"/>
|
||||
<rect x="12.7271" y="11.6766" width="1.48562" height="11.8849" rx="0.742808" transform="rotate(135 12.7271 11.6766)" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 370 B |
1
frontend/resources/flowy_icons/16x/edit_layout.svg
Normal file
1
frontend/resources/flowy_icons/16x/edit_layout.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M215.74-528Q186-528 165-549.5 144-571 144-600v-144q0-29.7 21.18-50.85Q186.35-816 216.09-816h144.17Q390-816 411-794.85q21 21.15 21 50.85v144q0 29-21.18 50.5-21.17 21.5-50.91 21.5H215.74Zm0 384Q186-144 165-165.18q-21-21.17-21-50.91v-144.17Q144-390 165.18-411q21.17-21 50.91-21h144.17Q390-432 411-410.82q21 21.17 21 50.91v144.17Q432-186 410.82-165q-21.17 21-50.91 21H215.74ZM600-528q-29 0-50.5-21.5T528-600v-144q0-29.7 21.5-50.85Q571-816 600-816h144q29.7 0 50.85 21.15Q816-773.7 816-744v144q0 29-21.15 50.5T744-528H600Zm0 384q-29 0-50.5-21.18-21.5-21.17-21.5-50.91v-144.17Q528-390 549.5-411q21.5-21 50.5-21h144q29.7 0 50.85 21.18Q816-389.65 816-359.91v144.17Q816-186 794.85-165 773.7-144 744-144H600ZM216-600h144v-144H216v144Zm384 0h144v-144H600v144Zm0 384h144v-144H600v144Zm-384 0h144v-144H216v144Zm384-384Zm0 240Zm-240 0Zm0-240Z"/></svg>
|
After Width: | Height: | Size: 952 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M96-384v-192q0-29.7 21.21-50.85 21.21-21.15 51-21.15T219-626.85q21 21.15 21 50.85v192q0 29.7-21.21 50.85-21.21 21.15-51 21.15T117-333.15Q96-354.3 96-384Zm264 144q-33 0-52.5-19.5T288-312v-336q0-33 19.5-52.5T360-720h240q33 0 52.5 19.5T672-648v336q0 33-19.5 52.5T600-240H360Zm360-144v-192q0-29.7 21.21-50.85 21.21-21.15 51-21.15T843-626.85q21 21.15 21 50.85v192q0 29.7-21.21 50.85-21.21 21.15-51 21.15T741-333.15Q720-354.3 720-384Zm-360 72h240v-336H360v336Zm120-168Z"/></svg>
|
After Width: | Height: | Size: 588 B |
1
frontend/resources/flowy_icons/16x/photo_layout_grid.svg
Normal file
1
frontend/resources/flowy_icons/16x/photo_layout_grid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#e8eaed"><path d="M216-144q-29 0-50.5-21.5T144-216v-528q0-29.7 21.5-50.85Q187-816 216-816h528q29.7 0 50.85 21.15Q816-773.7 816-744v528q0 29-21.15 50.5T744-144H216Zm0-72h228v-528H216v528Zm300 0h228v-264H516v264Zm0-336h228v-192H516v192Z"/></svg>
|
After Width: | Height: | Size: 341 B |
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user