mirror of
https://github.com/AppFlowy-IO/AppFlowy
synced 2026-05-24 09:38:25 +00:00
Merge 20586e70f2 into 4af02cdc87
This commit is contained in:
commit
dbf427360c
11 changed files with 856 additions and 59 deletions
|
|
@ -434,6 +434,7 @@ class PageStyleCover {
|
|||
|
||||
bool get isPresets => isPureColor || isGradient || isBuiltInImage;
|
||||
bool get isPhoto => isCustomImage || isLocalImage;
|
||||
bool get isAlignEnable => isPhoto || isUnsplashImage || isBuiltInImage;
|
||||
|
||||
bool get isNone => type == PageStyleCoverImageType.none;
|
||||
bool get isPureColor => type == PageStyleCoverImageType.pureColor;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover_align.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/shared/flowy_gradient_colors.dart';
|
||||
|
|
@ -24,6 +25,8 @@ class DesktopCover extends StatefulWidget {
|
|||
required this.node,
|
||||
required this.coverType,
|
||||
this.coverDetails,
|
||||
this.enableAlign = false,
|
||||
this.onAlignControllerCreated,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
|
@ -31,7 +34,9 @@ class DesktopCover extends StatefulWidget {
|
|||
final EditorState editorState;
|
||||
final CoverType coverType;
|
||||
final String? coverDetails;
|
||||
|
||||
final bool enableAlign;
|
||||
final Function(DesktopCoverAlignController? alignController)?
|
||||
onAlignControllerCreated;
|
||||
@override
|
||||
State<DesktopCover> createState() => _DesktopCoverState();
|
||||
}
|
||||
|
|
@ -42,6 +47,27 @@ class _DesktopCoverState extends State<DesktopCover> {
|
|||
);
|
||||
String? get coverDetails =>
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
|
||||
String? get coverAlign =>
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverOffset];
|
||||
|
||||
late final DesktopCoverAlignController coverAlignController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
coverAlignController = DesktopCoverAlignController(coverAlign);
|
||||
if (widget.onAlignControllerCreated != null) {
|
||||
widget.onAlignControllerCreated!(coverAlignController);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DesktopCover oldWidget) {
|
||||
if (widget.coverDetails != oldWidget.coverDetails) {
|
||||
coverAlignController.reset();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -74,6 +100,13 @@ class _DesktopCoverState extends State<DesktopCover> {
|
|||
child: FlowyNetworkImage(
|
||||
url: cover.value,
|
||||
userProfilePB: userProfilePB,
|
||||
imageBuilder: (context, provider) {
|
||||
return DesktopCoverAlign(
|
||||
controller: coverAlignController,
|
||||
imageProvider: provider,
|
||||
alignEnable: widget.enableAlign,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -82,9 +115,12 @@ class _DesktopCoverState extends State<DesktopCover> {
|
|||
return SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Image.asset(
|
||||
PageStyleCoverImageType.builtInImagePath(cover.value),
|
||||
fit: BoxFit.cover,
|
||||
child: DesktopCoverAlign(
|
||||
controller: coverAlignController,
|
||||
imageProvider: AssetImage(
|
||||
PageStyleCoverImageType.builtInImagePath(cover.value),
|
||||
),
|
||||
alignEnable: widget.enableAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -115,9 +151,12 @@ class _DesktopCoverState extends State<DesktopCover> {
|
|||
return SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Image.file(
|
||||
File(cover.value),
|
||||
fit: BoxFit.cover,
|
||||
child: DesktopCoverAlign(
|
||||
controller: coverAlignController,
|
||||
imageProvider: FileImage(
|
||||
File(cover.value),
|
||||
),
|
||||
alignEnable: widget.enableAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -134,6 +173,7 @@ class _DesktopCoverState extends State<DesktopCover> {
|
|||
if (detail == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
switch (widget.coverType) {
|
||||
case CoverType.file:
|
||||
if (isURL(detail)) {
|
||||
|
|
@ -144,20 +184,33 @@ class _DesktopCoverState extends State<DesktopCover> {
|
|||
userProfilePB: userProfilePB,
|
||||
errorWidgetBuilder: (context, url, error) =>
|
||||
const SizedBox.shrink(),
|
||||
imageBuilder: (context, provider) {
|
||||
return DesktopCoverAlign(
|
||||
controller: coverAlignController,
|
||||
imageProvider: provider,
|
||||
alignEnable: widget.enableAlign,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
final imageFile = File(detail);
|
||||
if (!imageFile.existsSync()) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Image.file(
|
||||
imageFile,
|
||||
fit: BoxFit.cover,
|
||||
final provider = FileImage(imageFile);
|
||||
return DesktopCoverAlign(
|
||||
controller: coverAlignController,
|
||||
imageProvider: provider,
|
||||
alignEnable: widget.enableAlign,
|
||||
);
|
||||
|
||||
case CoverType.asset:
|
||||
return Image.asset(
|
||||
PageStyleCoverImageType.builtInImagePath(detail),
|
||||
fit: BoxFit.cover,
|
||||
final provider =
|
||||
AssetImage(PageStyleCoverImageType.builtInImagePath(detail));
|
||||
return DesktopCoverAlign(
|
||||
controller: coverAlignController,
|
||||
imageProvider: provider,
|
||||
alignEnable: widget.enableAlign,
|
||||
);
|
||||
case CoverType.color:
|
||||
final color = widget.coverDetails?.tryToColor() ?? Colors.white;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class DesktopCoverAlignController extends ChangeNotifier {
|
||||
DesktopCoverAlignController(String? offset) {
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
if (offset != null) {
|
||||
final splits = offset.split(',');
|
||||
|
||||
try {
|
||||
x = double.parse(splits.first);
|
||||
} catch (e) {
|
||||
x = 0;
|
||||
}
|
||||
try {
|
||||
y = double.parse(splits.last);
|
||||
} catch (e) {
|
||||
y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_initialAlignment = Alignment(x, y);
|
||||
_adjustedAlign = _initialAlignment;
|
||||
}
|
||||
|
||||
late final Alignment _initialAlignment;
|
||||
|
||||
late Alignment _adjustedAlign;
|
||||
|
||||
Alignment get alignment => _adjustedAlign;
|
||||
|
||||
void reset() {
|
||||
_adjustedAlign = Alignment.center;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_adjustedAlign = _initialAlignment;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void changeAlign(double x, double y) {
|
||||
_adjustedAlign = Alignment(x, y);
|
||||
}
|
||||
|
||||
bool get isModified => _adjustedAlign != _initialAlignment;
|
||||
|
||||
String getAlignAttribute() {
|
||||
return "${_adjustedAlign.x.toStringAsFixed(1)},${_adjustedAlign.y.toStringAsFixed(1)}";
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopCoverAlign extends StatefulWidget {
|
||||
const DesktopCoverAlign({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.imageProvider,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignEnable = false,
|
||||
});
|
||||
final DesktopCoverAlignController controller;
|
||||
final ImageProvider imageProvider;
|
||||
final BoxFit fit;
|
||||
final bool alignEnable;
|
||||
|
||||
@override
|
||||
State<DesktopCoverAlign> createState() => _DesktopCoverAlignState();
|
||||
}
|
||||
|
||||
class _DesktopCoverAlignState extends State<DesktopCoverAlign> {
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStream? _imageStream;
|
||||
Size? _imageSize;
|
||||
|
||||
Size? _frameSize;
|
||||
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
late final DesktopCoverAlignController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = widget.controller;
|
||||
final alignment = controller.alignment;
|
||||
x = alignment.x;
|
||||
y = alignment.y;
|
||||
controller.addListener(updateAlign);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.removeListener(updateAlign);
|
||||
super.dispose();
|
||||
|
||||
_stopImageStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_resolveImage();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DesktopCoverAlign oldWidget) {
|
||||
if (widget.imageProvider != oldWidget.imageProvider) {
|
||||
controller.reset();
|
||||
_resolveImage();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
void updateAlign() {
|
||||
setState(() {
|
||||
x = controller.alignment.x;
|
||||
y = controller.alignment.y;
|
||||
});
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener _getOrCreateListener() {
|
||||
void handleImageFrame(ImageInfo info, bool synchronousCall) {
|
||||
void setupCB() {
|
||||
_imageSize = Size(
|
||||
info.image.width.toDouble(),
|
||||
info.image.height.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
synchronousCall ? setupCB() : setState(setupCB);
|
||||
}
|
||||
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
handleImageFrame,
|
||||
);
|
||||
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
if (_imageStreamListener != null) {
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
}
|
||||
_imageStream = newStream;
|
||||
_imageStream!.addListener(_getOrCreateListener());
|
||||
}
|
||||
|
||||
void _stopImageStream() {
|
||||
if (_imageStreamListener != null) {
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
}
|
||||
}
|
||||
|
||||
void _changeAlignOffset(Offset offset) {
|
||||
setState(() {
|
||||
if (_imageSize == null || _frameSize == null) return;
|
||||
|
||||
final imageRatio = _imageSize!.aspectRatio;
|
||||
final frameRatio = _frameSize!.aspectRatio;
|
||||
final isVertical = imageRatio < frameRatio;
|
||||
|
||||
final imageFrameHeight =
|
||||
_frameSize!.width / _imageSize!.width * _imageSize!.height;
|
||||
final imageFrameWidth =
|
||||
_frameSize!.height / _imageSize!.height * _imageSize!.width;
|
||||
final exceedWidth = imageFrameWidth - _frameSize!.width;
|
||||
final exceedHeight = imageFrameHeight - _frameSize!.height;
|
||||
|
||||
if (isVertical) {
|
||||
final targetY = y + offset.dy / exceedHeight * 2;
|
||||
if (targetY >= -1 && targetY <= 1) {
|
||||
y = targetY;
|
||||
}
|
||||
} else {
|
||||
final targetX = x + offset.dx / exceedWidth * 2;
|
||||
if (targetX >= -1 && targetX <= 1) {
|
||||
x = targetX;
|
||||
}
|
||||
}
|
||||
widget.controller.changeAlign(x, y);
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_frameSize =
|
||||
Size(constraints.biggest.width, constraints.biggest.height);
|
||||
_imageSize ??= _frameSize;
|
||||
|
||||
Widget child = Image(
|
||||
image: widget.imageProvider,
|
||||
width: _frameSize!.width,
|
||||
height: _frameSize!.height,
|
||||
fit: widget.fit,
|
||||
alignment: Alignment(-x, -y),
|
||||
);
|
||||
if (widget.alignEnable && _imageSize != null) {
|
||||
child = GestureDetector(
|
||||
onHorizontalDragUpdate: (details) {
|
||||
final delta = details.delta;
|
||||
_changeAlignOffset(delta);
|
||||
},
|
||||
onVerticalDragUpdate: (details) {
|
||||
final delta = details.delta;
|
||||
_changeAlignOffset(delta);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
|
|||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu;
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
|
|
@ -32,6 +33,7 @@ import 'package:string_validator/string_validator.dart';
|
|||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'cover_title.dart';
|
||||
import 'desktop_cover_align.dart';
|
||||
|
||||
const double kCoverHeight = 280.0;
|
||||
const double kIconHeight = 60.0;
|
||||
|
|
@ -44,6 +46,11 @@ class DocumentHeaderBlockKeys {
|
|||
static const String coverType = 'cover_selection_type';
|
||||
static const String coverDetails = 'cover_selection';
|
||||
static const String icon = 'selected_icon';
|
||||
// CoverOffset indicates the offset of the cover image within the container,
|
||||
// expressed as a comma-separated pair. The default value is "0.0,0.0", which
|
||||
// is equivalent to Alignment.center. Values must be comma-separated, precise
|
||||
// to one decimal place, and within the range of -1 to 1.
|
||||
static const String coverOffset = 'cover_selection_offset';
|
||||
}
|
||||
|
||||
// for the version under 0.5.5, including 0.5.5
|
||||
|
|
@ -62,6 +69,8 @@ enum CoverType {
|
|||
orElse: () => CoverType.none,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isPhoto => this == file || this == asset;
|
||||
}
|
||||
|
||||
// This key is used to intercept the selection event in the document cover widget.
|
||||
|
|
@ -133,11 +142,11 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
|||
|
||||
viewListener = ViewListener(viewId: widget.view.id)
|
||||
..start(
|
||||
onViewUpdated: (view) {
|
||||
onViewUpdated: (value) {
|
||||
setState(() {
|
||||
viewIcon = EmojiIconData.fromViewIconPB(view.icon);
|
||||
cover = view.cover;
|
||||
view = view;
|
||||
viewIcon = EmojiIconData.fromViewIconPB(value.icon);
|
||||
cover = value.cover;
|
||||
view = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -187,8 +196,8 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
|||
node: widget.node,
|
||||
coverType: coverType,
|
||||
coverDetails: coverDetails,
|
||||
onChangeCover: (type, details) =>
|
||||
_saveIconOrCover(cover: (type, details)),
|
||||
onChangeCover: (type, details, align) =>
|
||||
_saveIconOrCover(cover: (type, details, align)),
|
||||
),
|
||||
_buildAlignedCoverIcon(context),
|
||||
],
|
||||
|
|
@ -301,7 +310,7 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
|||
}
|
||||
|
||||
void _saveIconOrCover({
|
||||
(CoverType, String?)? cover,
|
||||
(CoverType, String?, String?)? cover,
|
||||
EmojiIconData? icon,
|
||||
}) async {
|
||||
if (!widget.editorState.editable) {
|
||||
|
|
@ -322,6 +331,7 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
|||
if (cover != null) {
|
||||
attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString();
|
||||
attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2;
|
||||
attributes[DocumentHeaderBlockKeys.coverOffset] = cover.$3;
|
||||
}
|
||||
if (icon != null) {
|
||||
attributes[DocumentHeaderBlockKeys.icon] = icon.emoji;
|
||||
|
|
@ -378,8 +388,10 @@ class DocumentHeaderToolbar extends StatefulWidget {
|
|||
final EditorState editorState;
|
||||
final bool hasCover;
|
||||
final bool hasIcon;
|
||||
final void Function({(CoverType, String?)? cover, EmojiIconData? icon})
|
||||
onIconOrCoverChanged;
|
||||
final void Function({
|
||||
(CoverType, String?, String?)? cover,
|
||||
EmojiIconData? icon,
|
||||
}) onIconOrCoverChanged;
|
||||
final double offset;
|
||||
final String? documentId;
|
||||
final ValueNotifier<bool> isCoverTitleHovered;
|
||||
|
|
@ -443,8 +455,8 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
|
|||
leftIconSize: const Size.square(18),
|
||||
onTap: () => widget.onIconOrCoverChanged(
|
||||
cover: UniversalPlatform.isDesktopOrWeb
|
||||
? (CoverType.asset, '1')
|
||||
: (CoverType.color, '0xffe8e0ff'),
|
||||
? (CoverType.asset, '1', null)
|
||||
: (CoverType.color, '0xffe8e0ff', null),
|
||||
),
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const FlowySvg(FlowySvgs.add_cover_s),
|
||||
|
|
@ -544,7 +556,8 @@ class DocumentCover extends StatefulWidget {
|
|||
final EditorState editorState;
|
||||
final CoverType coverType;
|
||||
final String? coverDetails;
|
||||
final void Function(CoverType type, String? details) onChangeCover;
|
||||
final void Function(CoverType type, String? details, String? align)
|
||||
onChangeCover;
|
||||
|
||||
@override
|
||||
State<DocumentCover> createState() => DocumentCoverState();
|
||||
|
|
@ -555,6 +568,22 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
|
||||
bool isOverlayButtonsHidden = true;
|
||||
bool isPopoverOpen = false;
|
||||
bool isAlignOpen = false;
|
||||
DesktopCoverAlignController? coverAlignController;
|
||||
|
||||
bool get isCoverAlignSupport {
|
||||
if (widget.view.extra.isEmpty) {
|
||||
// version <= 0.5.5
|
||||
return widget.coverType.isPhoto;
|
||||
}
|
||||
return widget.view.cover?.isAlignEnable ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
coverAlignController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -581,8 +610,13 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
node: widget.node,
|
||||
coverType: widget.coverType,
|
||||
coverDetails: widget.coverDetails,
|
||||
enableAlign: isAlignOpen,
|
||||
onAlignControllerCreated: (alignController) {
|
||||
coverAlignController = alignController;
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isAlignOpen) _buildConverAlignOverlayButtons(context),
|
||||
if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context),
|
||||
],
|
||||
),
|
||||
|
|
@ -642,6 +676,7 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
widget.onChangeCover(
|
||||
CoverType.file,
|
||||
files.first.path,
|
||||
null,
|
||||
);
|
||||
},
|
||||
onSelectedAIImage: (_) {
|
||||
|
|
@ -649,11 +684,19 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
},
|
||||
onSelectedNetworkImage: (url) async {
|
||||
context.pop();
|
||||
widget.onChangeCover(CoverType.file, url);
|
||||
widget.onChangeCover(
|
||||
CoverType.file,
|
||||
url,
|
||||
null,
|
||||
);
|
||||
},
|
||||
onSelectedColor: (color) {
|
||||
context.pop();
|
||||
widget.onChangeCover(CoverType.color, color);
|
||||
widget.onChangeCover(
|
||||
CoverType.color,
|
||||
color,
|
||||
null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -673,7 +716,8 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
SizedBox.square(
|
||||
dimension: 32.0,
|
||||
child: DeleteCoverButton(
|
||||
onTap: () => widget.onChangeCover(CoverType.none, null),
|
||||
onTap: () =>
|
||||
widget.onChangeCover(CoverType.none, null, null),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -704,7 +748,7 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
final imageFile = File(detail);
|
||||
if (!imageFile.existsSync()) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onChangeCover(CoverType.none, null);
|
||||
widget.onChangeCover(CoverType.none, null, null);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
|
@ -744,18 +788,9 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
),
|
||||
margin: EdgeInsets.zero,
|
||||
onClose: () => isPopoverOpen = false,
|
||||
child: IntrinsicWidth(
|
||||
child: RoundedTextButton(
|
||||
height: 28.0,
|
||||
onPressed: () => popoverController.show(),
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
textColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withValues(alpha: 0.5),
|
||||
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
),
|
||||
child: AFOverlayTextButton.primary(
|
||||
onTap: () => popoverController.show(),
|
||||
text: LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
isPopoverOpen = true;
|
||||
|
|
@ -795,6 +830,35 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
DeleteCoverButton(
|
||||
onTap: () => onCoverChanged(CoverType.none, null),
|
||||
),
|
||||
if (isCoverAlignSupport)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const HSpace(10),
|
||||
AlignCoverButton(
|
||||
onTap: switchAlignMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConverAlignOverlayButtons(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 20,
|
||||
right: 50,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AlignCoverCancelButton(
|
||||
onTap: cancelCoverAlign,
|
||||
),
|
||||
const HSpace(10),
|
||||
AlignCoverSaveButton(
|
||||
onTap: saveCoverAlign,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -818,7 +882,7 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
(details, _) = await saveImageToCloudStorage(details!, widget.view.id);
|
||||
}
|
||||
}
|
||||
widget.onChangeCover(type, details);
|
||||
widget.onChangeCover(type, details, null);
|
||||
|
||||
// After cover change,delete from localstorage if previous cover was image type
|
||||
if (isFileType(previousType, previousDetails) && _isLocalMode()) {
|
||||
|
|
@ -826,13 +890,41 @@ class DocumentCoverState extends State<DocumentCover> {
|
|||
}
|
||||
}
|
||||
|
||||
void setOverlayButtonsHidden(bool value) {
|
||||
if (isOverlayButtonsHidden == value) return;
|
||||
void setOverlayButtonsHidden(bool isHidden) {
|
||||
if (isHidden && isAlignOpen) {
|
||||
cancelCoverAlign();
|
||||
setState(() {
|
||||
isAlignOpen = false;
|
||||
});
|
||||
}
|
||||
if (isOverlayButtonsHidden == isHidden) return;
|
||||
setState(() {
|
||||
isOverlayButtonsHidden = value;
|
||||
isOverlayButtonsHidden = isHidden;
|
||||
});
|
||||
}
|
||||
|
||||
void switchAlignMode() {
|
||||
setState(() {
|
||||
isAlignOpen = !isAlignOpen;
|
||||
isOverlayButtonsHidden = isAlignOpen;
|
||||
});
|
||||
}
|
||||
|
||||
void cancelCoverAlign() {
|
||||
if (coverAlignController != null) {
|
||||
coverAlignController!.cancel();
|
||||
}
|
||||
saveCoverAlign();
|
||||
}
|
||||
|
||||
void saveCoverAlign() {
|
||||
if (coverAlignController != null && coverAlignController!.isModified) {
|
||||
final alignAttr = coverAlignController!.getAlignAttribute();
|
||||
widget.onChangeCover(widget.coverType, widget.coverDetails, alignAttr);
|
||||
}
|
||||
switchAlignMode();
|
||||
}
|
||||
|
||||
bool _isLocalMode() {
|
||||
return context.read<DocumentBloc>().isLocalMode;
|
||||
}
|
||||
|
|
@ -846,22 +938,12 @@ class DeleteCoverButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fillColor = UniversalPlatform.isDesktopOrWeb
|
||||
? Theme.of(context).colorScheme.surface.withValues(alpha: 0.5)
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5);
|
||||
final svgColor = UniversalPlatform.isDesktopOrWeb
|
||||
? Theme.of(context).colorScheme.tertiary
|
||||
: Theme.of(context).colorScheme.onPrimary;
|
||||
return FlowyIconButton(
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
fillColor: fillColor,
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
width: 28,
|
||||
icon: FlowySvg(
|
||||
return AFOverlayIconButton.primary(
|
||||
iconBuilder: (context, isHovering, __) => FlowySvg(
|
||||
FlowySvgs.delete_s,
|
||||
color: svgColor,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
onPressed: onTap,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -940,3 +1022,60 @@ class _DocumentIconState extends State<DocumentIcon> {
|
|||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class AlignCoverButton extends StatelessWidget {
|
||||
const AlignCoverButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOverlayIconButton.primary(
|
||||
iconBuilder: (context, isHovering, __) => FlowySvg(
|
||||
FlowySvgs.table_align_center_s,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class AlignCoverSaveButton extends StatelessWidget {
|
||||
const AlignCoverSaveButton({
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOverlayTextButton.primary(
|
||||
onTap: onTap,
|
||||
text: LocaleKeys.button_save.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class AlignCoverCancelButton extends StatelessWidget {
|
||||
const AlignCoverCancelButton({
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFOverlayTextButton.primary(
|
||||
onTap: onTap,
|
||||
text: LocaleKeys.button_cancel.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class FlowyNetworkImage extends StatefulWidget {
|
|||
this.fit = BoxFit.cover,
|
||||
this.progressIndicatorBuilder,
|
||||
this.errorWidgetBuilder,
|
||||
this.imageBuilder,
|
||||
required this.url,
|
||||
this.maxRetries = 5,
|
||||
this.retryDuration = const Duration(seconds: 6),
|
||||
|
|
@ -63,6 +64,9 @@ class FlowyNetworkImage extends StatefulWidget {
|
|||
/// Retry error codes.
|
||||
final Set<int> retryErrorCodes;
|
||||
|
||||
/// Optional builder to further customize the display of the image.
|
||||
final ImageWidgetBuilder? imageBuilder;
|
||||
|
||||
final void Function(bool isImageInCache)? onImageLoaded;
|
||||
|
||||
@override
|
||||
|
|
@ -140,6 +144,7 @@ class FlowyNetworkImageState extends State<FlowyNetworkImage> {
|
|||
width: widget.width,
|
||||
height: widget.height,
|
||||
progressIndicatorBuilder: widget.progressIndicatorBuilder,
|
||||
imageBuilder: widget.imageBuilder,
|
||||
errorWidget: _errorWidgetBuilder,
|
||||
errorListener: (value) async {
|
||||
Log.error(
|
||||
|
|
|
|||
|
|
@ -200,6 +200,59 @@ class ButtonsPage extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
"https://images.unsplash.com/photo-1493612276216-ee3925520721?q=80&w=1528&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: _buildSection(
|
||||
'Overlay Buttons',
|
||||
[
|
||||
AFOverlayButton.normal(
|
||||
onTap: () {},
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
"Overlay Button",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColorDark,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
AFOverlayTextButton.primary(
|
||||
text: 'Overlay Primary Text Button',
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
AFOverlayIconButton.primary(
|
||||
onTap: () {},
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return Icon(
|
||||
Icons.delete,
|
||||
size: 20,
|
||||
color: AppFlowyTheme.of(context).textColorScheme.primary,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
AFOverlayIconButton.disabled(
|
||||
iconBuilder: (context, isHovering, disabled) {
|
||||
return Icon(
|
||||
Icons.block,
|
||||
size: 20,
|
||||
color: AppFlowyTheme.of(context).textColorScheme.tertiary,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildSection(
|
||||
'Button with alignment',
|
||||
[
|
||||
|
|
|
|||
|
|
@ -14,3 +14,7 @@ export 'ghost_button/ghost_text_button.dart';
|
|||
export 'outlined_button/outlined_button.dart';
|
||||
export 'outlined_button/outlined_icon_text_button.dart';
|
||||
export 'outlined_button/outlined_text_button.dart';
|
||||
// Overlay buttons
|
||||
export 'overlay_button/overlay_button.dart';
|
||||
export 'overlay_button/overlay_text_button.dart';
|
||||
export 'overlay_button/overlay_icon_button.dart';
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:appflowy_ui/src/component/button/overlay_button/overlay_button_const.dart';
|
||||
import 'package:appflowy_ui/src/component/component.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef AFOverlayButtonWidgetBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
bool isHovering,
|
||||
bool disabled,
|
||||
);
|
||||
|
||||
class AFOverlayButton extends StatelessWidget {
|
||||
const AFOverlayButton._({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.backgroundColor,
|
||||
required this.builder,
|
||||
this.size = AFButtonSize.m,
|
||||
this.padding,
|
||||
this.borderRadius,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
/// Normal overlay button.
|
||||
factory AFOverlayButton.normal({
|
||||
Key? key,
|
||||
required VoidCallback onTap,
|
||||
required AFOverlayButtonWidgetBuilder builder,
|
||||
AFButtonSize size = AFButtonSize.m,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? borderRadius,
|
||||
bool disabled = false,
|
||||
}) {
|
||||
return AFOverlayButton._(
|
||||
key: key,
|
||||
builder: builder,
|
||||
onTap: onTap,
|
||||
size: size,
|
||||
padding: padding,
|
||||
borderRadius: borderRadius,
|
||||
disabled: disabled,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
final theme = Theme.of(context);
|
||||
if (disabled) {
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonDisableAlpha);
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonHoverAlpha);
|
||||
}
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonAlpha);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Disabled overlay button.
|
||||
factory AFOverlayButton.disabled({
|
||||
Key? key,
|
||||
required AFOverlayButtonWidgetBuilder builder,
|
||||
AFButtonSize size = AFButtonSize.m,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? borderRadius,
|
||||
}) {
|
||||
return AFOverlayButton._(
|
||||
key: key,
|
||||
builder: builder,
|
||||
onTap: () {},
|
||||
size: size,
|
||||
padding: padding,
|
||||
borderRadius: borderRadius,
|
||||
disabled: true,
|
||||
backgroundColor: (context, isHovering, disabled) => Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withAlpha(overlayButtonDisableAlpha),
|
||||
);
|
||||
}
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool disabled;
|
||||
final AFButtonSize size;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double? borderRadius;
|
||||
|
||||
final AFBaseButtonColorBuilder? backgroundColor;
|
||||
final AFGhostButtonWidgetBuilder builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AFBaseButton(
|
||||
disabled: disabled,
|
||||
backgroundColor: backgroundColor,
|
||||
borderColor: (_, __, ___, ____) => Colors.transparent,
|
||||
padding: padding ?? size.buildPadding(context),
|
||||
borderRadius: borderRadius ?? size.buildBorderRadius(context),
|
||||
onTap: onTap,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
const int overlayButtonAlpha = 0xCC;
|
||||
const int overlayButtonDisableAlpha = 0x66;
|
||||
const int overlayButtonHoverAlpha = 0xE5;
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:appflowy_ui/src/component/button/overlay_button/overlay_button_const.dart';
|
||||
import 'package:appflowy_ui/src/component/component.dart';
|
||||
import 'package:appflowy_ui/src/theme/appflowy_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef AFOverlayIconBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
bool isHovering,
|
||||
bool disabled,
|
||||
);
|
||||
|
||||
class AFOverlayIconButton extends StatelessWidget {
|
||||
const AFOverlayIconButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.iconBuilder,
|
||||
this.backgroundColor,
|
||||
this.size = AFButtonSize.m,
|
||||
this.padding,
|
||||
this.borderRadius,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
/// Primary overlay text button.
|
||||
factory AFOverlayIconButton.primary({
|
||||
Key? key,
|
||||
required VoidCallback onTap,
|
||||
required AFOverlayIconBuilder iconBuilder,
|
||||
AFButtonSize size = AFButtonSize.m,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? borderRadius,
|
||||
bool disabled = false,
|
||||
}) {
|
||||
return AFOverlayIconButton(
|
||||
key: key,
|
||||
onTap: onTap,
|
||||
iconBuilder: iconBuilder,
|
||||
size: size,
|
||||
padding: padding,
|
||||
borderRadius: borderRadius,
|
||||
disabled: disabled,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
final theme = Theme.of(context);
|
||||
if (disabled) {
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonDisableAlpha);
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonHoverAlpha);
|
||||
}
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonAlpha);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Disabled overlay text button.
|
||||
factory AFOverlayIconButton.disabled({
|
||||
Key? key,
|
||||
required AFOverlayIconBuilder iconBuilder,
|
||||
AFButtonSize size = AFButtonSize.m,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? borderRadius,
|
||||
}) {
|
||||
return AFOverlayIconButton(
|
||||
key: key,
|
||||
iconBuilder: iconBuilder,
|
||||
onTap: () {},
|
||||
size: size,
|
||||
padding: padding,
|
||||
borderRadius: borderRadius,
|
||||
disabled: true,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
return Theme.of(context).colorScheme.surface.withAlpha(overlayButtonDisableAlpha);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final bool disabled;
|
||||
final VoidCallback onTap;
|
||||
final AFButtonSize size;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double? borderRadius;
|
||||
|
||||
final AFOverlayIconBuilder iconBuilder;
|
||||
|
||||
final AFBaseButtonColorBuilder? backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return AFBaseButton(
|
||||
disabled: disabled,
|
||||
backgroundColor: backgroundColor,
|
||||
borderColor: (context, isHovering, disabled, isFocused) {
|
||||
return Colors.transparent;
|
||||
},
|
||||
padding: padding ?? EdgeInsets.all(theme.spacing.m),
|
||||
borderRadius: borderRadius ?? size.buildBorderRadius(context),
|
||||
onTap: onTap,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return iconBuilder(
|
||||
context,
|
||||
isHovering,
|
||||
disabled,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:appflowy_ui/src/component/button/overlay_button/overlay_button_const.dart';
|
||||
import 'package:appflowy_ui/src/component/component.dart';
|
||||
import 'package:appflowy_ui/src/theme/appflowy_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AFOverlayTextButton extends AFBaseTextButton {
|
||||
const AFOverlayTextButton({
|
||||
super.key,
|
||||
required super.text,
|
||||
required super.onTap,
|
||||
super.textColor,
|
||||
super.backgroundColor,
|
||||
super.size = AFButtonSize.m,
|
||||
super.padding,
|
||||
super.borderRadius,
|
||||
super.disabled = false,
|
||||
super.alignment,
|
||||
super.textStyle,
|
||||
});
|
||||
|
||||
/// Normal overlay text button.
|
||||
factory AFOverlayTextButton.primary({
|
||||
Key? key,
|
||||
required String text,
|
||||
required VoidCallback onTap,
|
||||
AFButtonSize size = AFButtonSize.m,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? borderRadius,
|
||||
bool disabled = false,
|
||||
Alignment? alignment,
|
||||
TextStyle? textStyle,
|
||||
}) {
|
||||
return AFOverlayTextButton(
|
||||
key: key,
|
||||
text: text,
|
||||
onTap: onTap,
|
||||
size: size,
|
||||
padding: padding,
|
||||
borderRadius: borderRadius,
|
||||
disabled: disabled,
|
||||
alignment: alignment,
|
||||
textStyle: textStyle,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
final theme = Theme.of(context);
|
||||
if (disabled) {
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonDisableAlpha);
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonHoverAlpha);
|
||||
}
|
||||
return theme.colorScheme.surface.withAlpha(overlayButtonAlpha);
|
||||
},
|
||||
textColor: (context, isHovering, disabled) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (disabled) {
|
||||
return theme.textColorScheme.tertiary;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.textColorScheme.primary;
|
||||
}
|
||||
return theme.textColorScheme.primary;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 76,
|
||||
),
|
||||
child: AFBaseButton(
|
||||
disabled: disabled,
|
||||
backgroundColor: backgroundColor,
|
||||
borderColor: (_, __, ___, ____) => Colors.transparent,
|
||||
padding: padding ?? size.buildPadding(context),
|
||||
borderRadius: borderRadius ?? size.buildBorderRadius(context),
|
||||
onTap: onTap,
|
||||
builder: (context, isHovering, disabled) {
|
||||
final textColor =
|
||||
this.textColor?.call(context, isHovering, disabled) ??
|
||||
theme.textColorScheme.primary;
|
||||
|
||||
Widget child = Text(
|
||||
text,
|
||||
style: textStyle ??
|
||||
size.buildTextStyle(context).copyWith(color: textColor),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
final alignment = this.alignment;
|
||||
if (alignment != null) {
|
||||
child = Align(
|
||||
alignment: alignment,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue