diff --git a/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart index 650fbf1d855e1..da9f3cc05d039 100644 --- a/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart +++ b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart @@ -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; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart index 7265ef6f82be5..118ef7040e9e3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart @@ -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 createState() => _DesktopCoverState(); } @@ -42,6 +47,27 @@ class _DesktopCoverState extends State { ); 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 { 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 { 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 { 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 { if (detail == null) { return const SizedBox.shrink(); } + switch (widget.coverType) { case CoverType.file: if (isURL(detail)) { @@ -144,20 +184,33 @@ class _DesktopCoverState extends State { 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; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover_align.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover_align.dart new file mode 100644 index 0000000000000..f58823b0aee35 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover_align.dart @@ -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 createState() => _DesktopCoverAlignState(); +} + +class _DesktopCoverAlignState extends State { + 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; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart index 7c8287e55010f..f69e9440b29e6 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart @@ -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 { 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 { 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 { } void _saveIconOrCover({ - (CoverType, String?)? cover, + (CoverType, String?, String?)? cover, EmojiIconData? icon, }) async { if (!widget.editorState.editable) { @@ -322,6 +331,7 @@ class _DocumentCoverWidgetState extends State { 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 isCoverTitleHovered; @@ -443,8 +455,8 @@ class _DocumentHeaderToolbarState extends State { 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 createState() => DocumentCoverState(); @@ -555,6 +568,22 @@ class DocumentCoverState extends State { 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 { 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 { widget.onChangeCover( CoverType.file, files.first.path, + null, ); }, onSelectedAIImage: (_) { @@ -649,11 +684,19 @@ class DocumentCoverState extends State { }, 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 { 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 { 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 { ), 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 { 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 { (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 { } } - 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 = isHidden; + }); + } + + void switchAlignMode() { setState(() { - isOverlayButtonsHidden = value; + 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().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 { 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(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart index 090db27ddcad0..7645ebf0979bc 100644 --- a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -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 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 { width: widget.width, height: widget.height, progressIndicatorBuilder: widget.progressIndicatorBuilder, + imageBuilder: widget.imageBuilder, errorWidget: _errorWidgetBuilder, errorListener: (value) async { Log.error( diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart index 0d0c018222ed2..29a7f48f57a34 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/example/lib/src/buttons/buttons_page.dart @@ -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', [ diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart index 31a3a20b5f7fd..3a7989d9f3508 100644 --- a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/button.dart @@ -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'; \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_button.dart new file mode 100644 index 0000000000000..c9cf2c48af68c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_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, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_button_const.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_button_const.dart new file mode 100644 index 0000000000000..ac5a3c64e6c5d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_button_const.dart @@ -0,0 +1,3 @@ +const int overlayButtonAlpha = 0xCC; +const int overlayButtonDisableAlpha = 0x66; +const int overlayButtonHoverAlpha = 0xE5; \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_icon_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_icon_button.dart new file mode 100644 index 0000000000000..3d8726528edd5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_icon_button.dart @@ -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, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_text_button.dart b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_text_button.dart new file mode 100644 index 0000000000000..a3f761ef91f64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_ui/lib/src/component/button/overlay_button/overlay_text_button.dart @@ -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; + }, + ), + ); + } +}