This commit is contained in:
PirateBrook 2026-03-24 00:23:32 +01:00 committed by GitHub
commit dbf427360c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 856 additions and 59 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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;
},
);
}
}

View file

@ -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(),
);
}
}

View file

@ -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(

View file

@ -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',
[

View file

@ -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';

View file

@ -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,
);
}
}

View file

@ -0,0 +1,3 @@
const int overlayButtonAlpha = 0xCC;
const int overlayButtonDisableAlpha = 0x66;
const int overlayButtonHoverAlpha = 0xE5;

View file

@ -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,
);
},
);
}
}

View file

@ -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;
},
),
);
}
}