feat: add menu / avatar component (#7933)

* feat: support avatar in appflowy_ui package

* feat: add menu component

* feat: optimize section component

* feat: add menu example

* feat: add menu item selected status

* feat: support popover

* fix: analyze issue
This commit is contained in:
Lucas 2025-05-14 19:19:43 +08:00 committed by GitHub
parent bb4f860f37
commit 9cac7e5a91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1861 additions and 5 deletions

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon / Alt Arrow Right / S">
<path id="Vector" d="M7.5 5L12.5 10L7.5 15" stroke="#B5BBD3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M1 8.35008L2.22608 7.27725C2.86398 6.71915 3.8254 6.75114 4.42472 7.35046L7.42756 10.3533C7.90856 10.8344 8.66586 10.9 9.2225 10.5087L9.43123 10.3621C10.2322 9.79916 11.3159 9.86436 12.0436 10.5193L14.3 12.5501M1 8C1 4.70012 1 3.05025 2.02509 2.02509C3.05025 1 4.70012 1 8 1C11.2998 1 12.9498 1 13.9748 2.02509C15 3.05025 15 4.70012 15 8C15 11.2998 15 12.9498 13.9748 13.9748C12.9498 15 11.2998 15 8 15C4.70012 15 3.05025 15 2.02509 13.9748C1 12.9498 1 11.2998 1 8ZM9.40235 5.20059C9.40004 6.27828 10.5653 6.95438 11.4998 6.41752C11.9353 6.16726 12.2034 5.70289 12.2023 5.20059C12.2047 4.12283 11.0394 3.44679 10.1049 3.98366C9.66937 4.23385 9.40126 4.69828 9.40235 5.20059Z" stroke="#21232A" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 842 B

View File

@ -1,4 +1,6 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:appflowy_ui_example/src/avatar/avatar_page.dart';
import 'package:appflowy_ui_example/src/menu/menu_page.dart';
import 'package:flutter/material.dart';
import 'src/buttons/buttons_page.dart';
@ -67,6 +69,8 @@ class _MyHomePageState extends State<MyHomePage> {
Tab(text: 'Button'),
Tab(text: 'TextField'),
Tab(text: 'Modal'),
Tab(text: 'Avatar'),
Tab(text: 'Menu'),
];
@override
@ -100,6 +104,8 @@ class _MyHomePageState extends State<MyHomePage> {
ButtonsPage(),
TextFieldPage(),
ModalPage(),
AvatarPage(),
MenuPage(),
],
),
bottomNavigationBar: TabBar(

View File

@ -0,0 +1,89 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class AvatarPage extends StatelessWidget {
const AvatarPage({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Avatar with Name (Initials)'),
Wrap(
spacing: 16,
children: [
AFAvatar(name: 'Nina', size: AFAvatarSize.xs),
AFAvatar(name: 'Nina', size: AFAvatarSize.s),
AFAvatar(name: 'Nina', size: AFAvatarSize.m),
AFAvatar(name: 'Nina', size: AFAvatarSize.l),
AFAvatar(name: 'Nina', size: AFAvatarSize.xl),
],
),
const SizedBox(height: 32),
_sectionTitle('Avatar with Image URL'),
Wrap(
spacing: 16,
children: [
AFAvatar(
url: 'https://avatar.iran.liara.run/public/35',
size: AFAvatarSize.xs,
),
AFAvatar(
url: 'https://avatar.iran.liara.run/public/36',
size: AFAvatarSize.s,
),
AFAvatar(
url: 'https://avatar.iran.liara.run/public/37',
size: AFAvatarSize.m,
),
AFAvatar(
url: 'https://avatar.iran.liara.run/public/38',
size: AFAvatarSize.l,
),
AFAvatar(
url: 'https://avatar.iran.liara.run/public/39',
size: AFAvatarSize.xl,
),
],
),
const SizedBox(height: 32),
_sectionTitle('Custom Colors'),
Wrap(
spacing: 16,
children: [
AFAvatar(
name: 'Nina',
size: AFAvatarSize.l,
backgroundColor: Colors.deepPurple,
textColor: Colors.white,
),
AFAvatar(
name: 'Lucas Xu',
size: AFAvatarSize.l,
backgroundColor: Colors.amber,
textColor: Colors.black,
),
AFAvatar(
name: 'A',
size: AFAvatarSize.l,
backgroundColor: Colors.green,
textColor: Colors.white,
),
],
),
],
),
);
}
Widget _sectionTitle(String text) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
);
}

View File

@ -0,0 +1,199 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_svg/svg.dart';
/// A showcase page for the AFMenu, AFMenuSection, and AFMenuItem components.
class MenuPage extends StatefulWidget {
const MenuPage({super.key});
@override
State<MenuPage> createState() => _MenuPageState();
}
class _MenuPageState extends State<MenuPage> {
final popoverController = AFPopoverController();
@override
void dispose() {
popoverController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final leading = SvgPicture.asset(
'assets/images/vector.svg',
colorFilter: ColorFilter.mode(
theme.textColorScheme.primary,
BlendMode.srcIn,
),
);
final logo = Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
border: Border.all(
color: theme.borderColorScheme.primary,
),
),
padding: EdgeInsets.all(theme.spacing.xs),
child: const FlutterLogo(size: 18),
);
final arrowRight = SvgPicture.asset(
'assets/images/arrow_right.svg',
width: 20,
height: 20,
);
final animationDuration = const Duration(milliseconds: 120);
return Center(
child: SingleChildScrollView(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.start,
runAlignment: WrapAlignment.start,
runSpacing: 16,
spacing: 16,
children: [
AFMenu(
children: [
AFMenuSection(
title: 'Section 1',
children: [
AFMenuItem(
leading: leading,
title: 'Menu Item 1',
selected: true,
onTap: () {},
),
AFPopover(
controller: popoverController,
shadows: theme.shadow.medium,
anchor: const AFAnchor(
offset: Offset(0, -20),
overlayAlignment: Alignment.centerRight,
),
effects: [
FadeEffect(duration: animationDuration),
ScaleEffect(
duration: animationDuration,
begin: Offset(.95, .95),
end: Offset(1, 1),
),
MoveEffect(
duration: animationDuration,
begin: Offset(-10, 0),
end: Offset(0, 0),
),
],
popover: (context) {
return AFMenu(
children: [
AFMenuItem(
leading: leading,
title: 'Menu Item 2-1',
onTap: () {},
),
AFMenuItem(
leading: leading,
title: 'Menu Item 2-2',
onTap: () {},
),
AFMenuItem(
leading: leading,
title: 'Menu Item 2-3',
onTap: () {},
),
],
);
},
child: AFMenuItem(
leading: leading,
title: 'Menu Item 2',
onTap: () {
popoverController.toggle();
},
),
),
AFMenuItem(
leading: leading,
title: 'Menu Item 3',
onTap: () {},
),
],
),
AFMenuSection(
title: 'Section 2',
children: [
AFMenuItem(
leading: logo,
title: 'Menu Item 4',
subtitle: 'Menu Item',
trailing: const Icon(
Icons.check,
size: 18,
color: Colors.blueAccent,
),
onTap: () {},
),
AFMenuItem(
leading: logo,
title: 'Menu Item 5',
subtitle: 'Menu Item',
onTap: () {},
),
AFMenuItem(
leading: logo,
title: 'Menu Item 6',
subtitle: 'Menu Item',
onTap: () {},
),
],
),
AFMenuSection(
title: 'Section 3',
children: [
AFMenuItem(
leading: leading,
title: 'Menu Item 7',
trailing: arrowRight,
onTap: () {},
),
AFMenuItem(
leading: leading,
title: 'Menu Item 8',
trailing: arrowRight,
onTap: () {},
),
],
),
],
),
const SizedBox(height: 32),
// Example: Menu with search bar
AFMenu(
children: [
AFMenuItem(
leading: leading,
title: 'Menu Item 1',
onTap: () {},
),
AFMenuItem(
leading: leading,
title: 'Menu Item 2',
onTap: () {},
),
AFMenuItem(
leading: leading,
title: 'Menu Item 3',
onTap: () {},
),
],
),
],
),
),
);
}
}

View File

@ -64,7 +64,7 @@
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "appflowy_ui_example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* appflowy_ui_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = appflowy_ui_example.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@ -476,8 +476,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = VHB67HRSZG;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -608,8 +610,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = VHB67HRSZG;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -628,8 +632,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = VHB67HRSZG;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -14,6 +14,8 @@ dependencies:
appflowy_ui:
path: ../
cupertino_icons: ^1.0.6
flutter_svg: ^2.1.0
flutter_animate: ^4.5.2
dev_dependencies:
flutter_test:
@ -22,3 +24,7 @@ dev_dependencies:
flutter:
uses-material-design: true
assets:
- assets/images/vector.svg
- assets/images/arrow_right.svg

View File

@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:appflowy_ui/src/theme/definition/theme_data.dart';
import 'package:appflowy_ui/src/theme/appflowy_theme.dart';
/// Avatar sizes in pixels
enum AFAvatarSize {
xs,
s,
m,
l,
xl;
double get size {
switch (this) {
case AFAvatarSize.xs:
return 16.0;
case AFAvatarSize.s:
return 24.0;
case AFAvatarSize.m:
return 32.0;
case AFAvatarSize.l:
return 48.0;
case AFAvatarSize.xl:
return 64.0;
}
}
TextStyle buildTextStyle(AppFlowyThemeData theme, Color color) {
switch (this) {
case AFAvatarSize.xs:
return theme.textStyle.caption.standard(color: color);
case AFAvatarSize.s:
return theme.textStyle.body.standard(color: color);
case AFAvatarSize.m:
return theme.textStyle.heading4.standard(color: color);
case AFAvatarSize.l:
return theme.textStyle.heading3.standard(color: color);
case AFAvatarSize.xl:
return theme.textStyle.heading2.standard(color: color);
}
}
}
/// Avatar widget
class AFAvatar extends StatelessWidget {
/// Displays an avatar. Precedence: [child] > [url] > [name].
///
/// If [child] is provided, it is shown. Otherwise, if [url] is provided and non-empty, the image is shown. Otherwise, initials from [name] are shown.
const AFAvatar({
super.key,
this.name,
this.url,
this.size = AFAvatarSize.m,
this.textColor,
this.backgroundColor,
this.child,
});
/// The name of the avatar. Used for initials if [child] and [url] are not provided.
final String? name;
/// The URL of the avatar image. Used if [child] is not provided.
final String? url;
/// Custom widget to display as the avatar. Takes highest precedence.
final Widget? child;
/// The size of the avatar.
final AFAvatarSize size;
/// The text color for initials. Only applies when showing initials.
final Color? textColor;
/// The background color for initials. Only applies when showing initials.
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final double avatarSize = size.size;
final Color bgColor =
backgroundColor ?? theme.backgroundColorScheme.primary;
final Color txtColor = textColor ?? theme.textColorScheme.primary;
final TextStyle textStyle = size.buildTextStyle(theme, txtColor);
final Widget avatarContent = _buildAvatarContent(
avatarSize: avatarSize,
bgColor: bgColor,
textStyle: textStyle,
);
return SizedBox(
width: avatarSize,
height: avatarSize,
child: avatarContent,
);
}
Widget _buildAvatarContent({
required double avatarSize,
required Color bgColor,
required TextStyle textStyle,
}) {
if (child != null) {
return ClipOval(
child: SizedBox(
width: avatarSize,
height: avatarSize,
child: child,
),
);
} else if (url != null && url!.isNotEmpty) {
return ClipOval(
child: Image.network(
url!,
width: avatarSize,
height: avatarSize,
fit: BoxFit.cover,
// fallback to initials if the image is not found
errorBuilder: (context, error, stackTrace) => _buildInitialsCircle(
avatarSize,
bgColor,
textStyle,
),
),
);
} else {
return _buildInitialsCircle(
avatarSize,
bgColor,
textStyle,
);
}
}
Widget _buildInitialsCircle(double size, Color bgColor, TextStyle textStyle) {
final initials = _getInitials(name);
return Container(
decoration: BoxDecoration(
color: bgColor,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
initials,
style: textStyle,
textAlign: TextAlign.center,
),
);
}
String _getInitials(String? name) {
if (name == null || name.trim().isEmpty) return '';
final parts = name.trim().split(RegExp(r'\s+'));
if (parts.length == 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
}

View File

@ -2,3 +2,6 @@ export 'button/button.dart';
export 'separator/divider.dart';
export 'modal/modal.dart';
export 'textfield/textfield.dart';
export 'avatar/avatar.dart';
export 'menu/menu.dart';
export 'popover/popover.dart';

View File

@ -0,0 +1,41 @@
export 'menu_item.dart';
export 'section.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
/// The main menu container widget, supporting sections, menu items.
class AFMenu extends StatelessWidget {
const AFMenu({
super.key,
required this.children,
this.width = 240,
});
/// The list of widgets to display in the menu (sections or menu items).
final List<Widget> children;
/// The width of the menu.
final double width;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.surfaceColorScheme.primary,
borderRadius: BorderRadius.circular(theme.borderRadius.l),
border: Border.all(
color: theme.borderColorScheme.primary,
),
boxShadow: theme.shadow.medium,
),
width: width,
padding: EdgeInsets.all(theme.spacing.m),
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
);
}
}

View File

@ -0,0 +1,102 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
/// Menu item widget
class AFMenuItem extends StatelessWidget {
/// Creates a menu item.
///
/// [title] and [onTap] are required. Optionally provide [leading], [subtitle], [selected], and [trailing].
const AFMenuItem({
super.key,
required this.title,
required this.onTap,
this.leading,
this.subtitle,
this.selected = false,
this.trailing,
});
/// Widget to display before the title (e.g., an icon or avatar).
final Widget? leading;
/// The main text of the menu item.
final String title;
/// Optional secondary text displayed below the title.
final String? subtitle;
/// Whether the menu item is selected.
final bool selected;
/// Called when the menu item is tapped.
final VoidCallback onTap;
/// Widget to display after the title (e.g., a trailing icon).
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFBaseButton(
onTap: onTap,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.s,
),
borderRadius: theme.borderRadius.m,
borderColor: (context, isHovering, disabled, isFocused) {
return theme.borderColorScheme.transparent;
},
backgroundColor: (context, isHovering, disabled) {
final theme = AppFlowyTheme.of(context);
if (disabled) {
return theme.fillColorScheme.transparent;
}
if (selected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return theme.fillColorScheme.transparent;
},
builder: (context, isHovering, disabled) {
return Row(
children: [
// Leading widget (icon/avatar), if provided
if (leading != null) ...[
leading!,
SizedBox(width: theme.spacing.m),
],
// Main content: title and optional subtitle
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title text
Text(
title,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
// Subtitle text, if provided
if (subtitle != null)
Text(
subtitle!,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
),
],
),
),
// Trailing widget (e.g., icon), if provided
if (trailing != null) trailing!,
],
);
},
);
}
}

View File

@ -0,0 +1,42 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
/// A section in the menu, optionally with a title and a list of children.
class AFMenuSection extends StatelessWidget {
const AFMenuSection({
super.key,
this.title,
required this.children,
});
/// The title of the section (e.g., 'Section 1').
final String? title;
/// The widgets to display in this section (typically AFMenuItem widgets).
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.s,
),
child: Text(
title!,
style: theme.textStyle.caption.enhanced(
color: theme.textColorScheme.tertiary,
),
),
),
],
...children,
],
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:appflowy_ui/src/component/popover/shadcn/_portal.dart';
import 'package:flutter/material.dart';
/// Automatically infers the position of the [ShadPortal] in the global
/// coordinate system adjusting according to the [offset],
/// [followerAnchor] and [targetAnchor] properties.
@immutable
class AFAnchorAuto extends ShadAnchorAuto {
const AFAnchorAuto({
super.offset,
super.followTargetOnResize,
super.followerAnchor,
super.targetAnchor,
});
}
/// Manually specifies the position of the [ShadPortal] in the global
/// coordinate system.
@immutable
class AFAnchor extends ShadAnchor {
const AFAnchor({
super.childAlignment,
super.overlayAlignment,
super.offset,
});
}
/// Manually specifies the position of the [ShadPortal] in the global
/// coordinate system.
@immutable
class AFGlobalAnchor extends ShadGlobalAnchor {
const AFGlobalAnchor(super.offset);
}

View File

@ -0,0 +1,276 @@
export 'anchor.dart';
import 'dart:ui';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:appflowy_ui/src/component/popover/shadcn/_mouse_area.dart';
import 'package:appflowy_ui/src/component/popover/shadcn/_portal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// Notes: The implementation of this page is copied from [flutter_shadcn_ui](https://github.com/nank1ro/flutter-shadcn-ui).
///
/// Renaming is for the consistency of the AppFlowy UI.
/// Controls the visibility of a [AFPopover].
class AFPopoverController extends ChangeNotifier {
AFPopoverController({bool isOpen = false}) : _isOpen = isOpen;
bool _isOpen = false;
/// Indicates if the popover is visible.
bool get isOpen => _isOpen;
/// Displays the popover.
void show() {
if (_isOpen) return;
_isOpen = true;
notifyListeners();
}
/// Hides the popover.
void hide() {
if (!_isOpen) return;
_isOpen = false;
notifyListeners();
}
void setOpen(bool open) {
if (_isOpen == open) return;
_isOpen = open;
notifyListeners();
}
/// Toggles the visibility of the popover.
void toggle() => _isOpen ? hide() : show();
}
class AFPopover extends StatefulWidget {
const AFPopover({
super.key,
required this.child,
required this.popover,
this.controller,
this.visible,
this.closeOnTapOutside = true,
this.focusNode,
this.anchor,
this.effects,
this.shadows,
this.padding,
this.decoration,
this.filter,
this.groupId,
this.areaGroupId,
this.useSameGroupIdForChild = true,
}) : assert(
(controller != null) ^ (visible != null),
'Either controller or visible must be provided',
);
/// {@template ShadPopover.popover}
/// The widget displayed as a popover.
/// {@endtemplate}
final WidgetBuilder popover;
/// {@template ShadPopover.child}
/// The child widget.
/// {@endtemplate}
final Widget child;
/// {@template ShadPopover.controller}
/// The controller that controls the visibility of the [popover].
/// {@endtemplate}
final AFPopoverController? controller;
/// {@template ShadPopover.visible}
/// Indicates if the popover should be visible.
/// {@endtemplate}
final bool? visible;
/// {@template ShadPopover.closeOnTapOutside}
/// Closes the popover when the user taps outside, defaults to true.
/// {@endtemplate}
final bool closeOnTapOutside;
/// {@template ShadPopover.focusNode}
/// The focus node of the child, the [popover] will be shown when
/// focused.
/// {@endtemplate}
final FocusNode? focusNode;
///{@template ShadPopover.anchor}
/// The position of the [popover] in the global coordinate system.
///
/// Defaults to `ShadAnchorAuto()`.
/// {@endtemplate}
final ShadAnchorBase? anchor;
/// {@template ShadPopover.effects}
/// The animation effects applied to the [popover]. Defaults to
/// [FadeEffect(), ScaleEffect(begin: Offset(.95, .95), end: Offset(1, 1)),
/// MoveEffect(begin: Offset(0, 2), end: Offset(0, 0))].
/// {@endtemplate}
final List<Effect<dynamic>>? effects;
/// {@template ShadPopover.shadows}
/// The shadows applied to the [popover], defaults to
/// [ShadShadows.md].
/// {@endtemplate}
final List<BoxShadow>? shadows;
/// {@template ShadPopover.padding}
/// The padding of the [popover], defaults to
/// `EdgeInsets.symmetric(horizontal: 12, vertical: 6)`.
/// {@endtemplate}
final EdgeInsetsGeometry? padding;
/// {@template ShadPopover.decoration}
/// The decoration of the [popover].
/// {@endtemplate}
final BoxDecoration? decoration;
/// {@template ShadPopover.filter}
/// The filter of the [popover], defaults to `null`.
/// {@endtemplate}
final ImageFilter? filter;
/// {@template ShadPopover.groupId}
/// The group id of the [popover], defaults to `UniqueKey()`.
///
/// Used to determine it the tap is inside the [popover] or not.
/// {@endtemplate}
final Object? groupId;
/// {@macro ShadMouseArea.groupId}
final Object? areaGroupId;
/// {@template ShadPopover.useSameGroupIdForChild}
/// Whether the [groupId] should be used for the child widget, defaults to
/// `true`. This teams that taps on the child widget will be handled as inside
/// the popover.
/// {@endtemplate}
final bool useSameGroupIdForChild;
@override
State<AFPopover> createState() => _AFPopoverState();
}
class _AFPopoverState extends State<AFPopover> {
AFPopoverController? _controller;
AFPopoverController get controller => widget.controller ?? _controller!;
bool animating = false;
late final _popoverKey = UniqueKey();
Object get groupId => widget.groupId ?? _popoverKey;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_controller = AFPopoverController();
}
}
@override
void didUpdateWidget(covariant AFPopover oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != null) {
if (widget.visible! && !controller.isOpen) {
controller.show();
} else if (!widget.visible! && controller.isOpen) {
controller.hide();
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final effectiveEffects = widget.effects ?? [];
final effectivePadding = widget.padding ?? EdgeInsets.zero;
final effectiveAnchor = widget.anchor ?? const ShadAnchorAuto();
final effectiveFilter = widget.filter;
Widget popover = ShadMouseArea(
groupId: widget.areaGroupId,
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.surfaceColorScheme.primary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: Padding(
padding: effectivePadding,
child: DefaultTextStyle(
style: TextStyle(
color: theme.textColorScheme.primary,
),
textAlign: TextAlign.center,
child: Builder(
builder: widget.popover,
),
),
),
),
);
if (effectiveFilter != null) {
popover = BackdropFilter(
filter: widget.filter!,
child: popover,
);
}
if (effectiveEffects.isNotEmpty) {
popover = Animate(
effects: effectiveEffects,
child: popover,
);
}
if (widget.closeOnTapOutside) {
popover = TapRegion(
groupId: groupId,
behavior: HitTestBehavior.opaque,
onTapOutside: (_) => controller.hide(),
child: popover,
);
}
Widget child = ListenableBuilder(
listenable: controller,
builder: (context, _) {
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () {
controller.hide();
},
},
child: ShadPortal(
portalBuilder: (_) => popover,
visible: controller.isOpen,
anchor: effectiveAnchor,
child: widget.child,
),
);
},
);
if (widget.useSameGroupIdForChild) {
child = TapRegion(
groupId: groupId,
child: child,
);
}
return child;
}
}

View File

@ -0,0 +1,515 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Notes: The implementation of this page is copied from [flutter_shadcn_ui](https://github.com/nank1ro/flutter-shadcn-ui).
abstract class MouseAreaRegistry {
/// Register the given [ShadMouseAreaRenderBox] with the registry.
void registerMouseArea(ShadMouseAreaRenderBox region);
/// Unregister the given [ShadMouseAreaRenderBox] with the registry.
void unregisterMouseArea(ShadMouseAreaRenderBox region);
/// Allows finding of the nearest [MouseAreaRegistry], such as a
/// [MouseAreaSurfaceRenderBox].
static MouseAreaRegistry? maybeOf(BuildContext context) {
return context.findAncestorRenderObjectOfType<MouseAreaSurfaceRenderBox>();
}
/// Allows finding of the nearest [MouseAreaRegistry], such as a
/// [MouseAreaSurfaceRenderBox].
///
/// Will throw if a [MouseAreaRegistry] isn't found.
static MouseAreaRegistry of(BuildContext context) {
final registry = maybeOf(context);
assert(() {
if (registry == null) {
throw FlutterError(
'''
MouseRegionRegistry.of() was called with a context that does not contain a MouseRegionSurface widget.\n
No MouseRegionSurface widget ancestor could be found starting from the context that was passed to
MouseRegionRegistry.of().\n
The context used was:\n
$context
''',
);
}
return true;
}());
return registry!;
}
}
class MouseAreaSurfaceRenderBox extends RenderProxyBoxWithHitTestBehavior
implements MouseAreaRegistry {
final Expando<BoxHitTestResult> _cachedResults = Expando<BoxHitTestResult>();
final Set<ShadMouseAreaRenderBox> _registeredRegions =
<ShadMouseAreaRenderBox>{};
final Map<Object?, Set<ShadMouseAreaRenderBox>> _groupIdToRegions =
<Object?, Set<ShadMouseAreaRenderBox>>{};
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
assert(
() {
for (final region in _registeredRegions) {
if (!region.enabled) {
return false;
}
}
return true;
}(),
'A MouseAreaRegion was registered when it was disabled.',
);
if (_registeredRegions.isEmpty) {
return;
}
final result = _cachedResults[entry];
if (result == null) {
return;
}
// A child was hit, so we need to call onExit for those regions or
// groups of regions that were not hit.
final hitRegions = _getRegionsHit(_registeredRegions, result.path)
.cast<ShadMouseAreaRenderBox>()
.toSet();
final insideRegions = <ShadMouseAreaRenderBox>{
for (final ShadMouseAreaRenderBox region in hitRegions)
if (region.groupId == null)
region
// Adding all grouped regions, so they act as a single region.
else
..._groupIdToRegions[region.groupId]!,
};
// If they're not inside, then they're outside.
final outsideRegions = _registeredRegions.difference(insideRegions);
for (final region in outsideRegions) {
region.onExit?.call(
PointerExitEvent(
viewId: event.viewId,
timeStamp: event.timeStamp,
pointer: event.pointer,
device: event.device,
position: event.position,
delta: event.delta,
buttons: event.buttons,
obscured: event.obscured,
pressureMin: event.pressureMin,
pressureMax: event.pressureMax,
distance: event.distance,
distanceMax: event.distanceMax,
size: event.size,
radiusMajor: event.radiusMajor,
radiusMinor: event.radiusMinor,
radiusMin: event.radiusMin,
radiusMax: event.radiusMax,
orientation: event.orientation,
tilt: event.tilt,
down: event.down,
synthesized: event.synthesized,
embedderId: event.embedderId,
),
);
}
for (final region in insideRegions) {
region.onEnter?.call(
PointerEnterEvent(
viewId: event.viewId,
timeStamp: event.timeStamp,
pointer: event.pointer,
device: event.device,
position: event.position,
delta: event.delta,
buttons: event.buttons,
obscured: event.obscured,
pressureMin: event.pressureMin,
pressureMax: event.pressureMax,
distance: event.distance,
distanceMax: event.distanceMax,
size: event.size,
radiusMajor: event.radiusMajor,
radiusMinor: event.radiusMinor,
radiusMin: event.radiusMin,
radiusMax: event.radiusMax,
orientation: event.orientation,
tilt: event.tilt,
down: event.down,
synthesized: event.synthesized,
embedderId: event.embedderId,
),
);
}
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) {
return false;
}
final hitTarget =
hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget) {
final entry = BoxHitTestEntry(this, position);
_cachedResults[entry] = result;
result.add(entry);
}
return hitTarget;
}
@override
void registerMouseArea(ShadMouseAreaRenderBox region) {
assert(!_registeredRegions.contains(region));
_registeredRegions.add(region);
if (region.groupId != null) {
_groupIdToRegions[region.groupId] ??= <ShadMouseAreaRenderBox>{};
_groupIdToRegions[region.groupId]!.add(region);
}
}
@override
void unregisterMouseArea(ShadMouseAreaRenderBox region) {
assert(_registeredRegions.contains(region));
_registeredRegions.remove(region);
if (region.groupId != null) {
assert(_groupIdToRegions.containsKey(region.groupId));
_groupIdToRegions[region.groupId]!.remove(region);
if (_groupIdToRegions[region.groupId]!.isEmpty) {
_groupIdToRegions.remove(region.groupId);
}
}
}
// Returns the registered regions that are in the hit path.
Set<HitTestTarget> _getRegionsHit(
Set<ShadMouseAreaRenderBox> detectors,
Iterable<HitTestEntry> hitTestPath,
) {
return <HitTestTarget>{
for (final HitTestEntry<HitTestTarget> entry in hitTestPath)
if (entry.target case final HitTestTarget target)
if (_registeredRegions.contains(target)) target,
};
}
}
class ShadMouseArea extends SingleChildRenderObjectWidget {
/// Creates a const [ShadMouseArea].
///
/// The [child] argument is required.
const ShadMouseArea({
super.key,
super.child,
this.enabled = true,
this.behavior = HitTestBehavior.deferToChild,
this.groupId,
this.onEnter,
this.onExit,
this.cursor = MouseCursor.defer,
String? debugLabel,
}) : debugLabel = kReleaseMode ? null : debugLabel;
/// Whether or not this [ShadMouseArea] is enabled as part of the composite
/// region.
final bool enabled;
/// How to behave during hit testing when deciding how the hit test propagates
/// to children and whether to consider targets behind this [ShadMouseArea].
///
/// Defaults to [HitTestBehavior.deferToChild].
///
/// See [HitTestBehavior] for the allowed values and their meanings.
final HitTestBehavior behavior;
/// {@template ShadMouseArea.groupId}
/// An optional group ID that groups [ShadMouseArea]s together so that they
/// operate as one region. If any member of a group is hit by a particular
/// hover, then all members will have their [onEnter] or [onExit] called.
///
/// If the group id is null, then only this region is hit tested.
/// {@endtemplate}
final Object? groupId;
/// Triggered when a pointer enters the region.
final PointerEnterEventListener? onEnter;
/// Triggered when a pointer exits the region.
final PointerExitEventListener? onExit;
/// The mouse cursor for mouse pointers that are hovering over the region.
///
/// When a mouse enters the region, its cursor will be changed to the [cursor]
/// When the mouse leaves the region, the cursor will be decided by the region
/// found at the new location.
///
/// The [cursor] defaults to [MouseCursor.defer], deferring the choice of
/// cursor to the next region behind it in hit-test order.
final MouseCursor cursor;
/// An optional debug label to help with debugging in debug mode.
///
/// Will be null in release mode.
final String? debugLabel;
@override
RenderObject createRenderObject(BuildContext context) {
return ShadMouseAreaRenderBox(
registry: MouseAreaRegistry.maybeOf(context),
enabled: enabled,
behavior: behavior,
groupId: groupId,
debugLabel: debugLabel,
onEnter: onEnter,
onExit: onExit,
cursor: cursor,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(
FlagProperty(
'enabled',
value: enabled,
ifFalse: 'DISABLED',
defaultValue: true,
),
)
..add(
DiagnosticsProperty<HitTestBehavior>(
'behavior',
behavior,
defaultValue: HitTestBehavior.deferToChild,
),
)
..add(
DiagnosticsProperty<Object?>(
'debugLabel',
debugLabel,
defaultValue: null,
),
)
..add(
DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null),
);
}
@override
void updateRenderObject(
BuildContext context,
covariant ShadMouseAreaRenderBox renderObject,
) {
renderObject
..registry = MouseAreaRegistry.maybeOf(context)
..enabled = enabled
..behavior = behavior
..groupId = groupId
..onEnter = onEnter
..onExit = onExit;
if (!kReleaseMode) {
renderObject.debugLabel = debugLabel;
}
}
}
class ShadMouseAreaRenderBox extends RenderProxyBoxWithHitTestBehavior {
/// Creates a [ShadMouseAreaRenderBox].
ShadMouseAreaRenderBox({
this.onEnter,
this.onExit,
MouseAreaRegistry? registry,
bool enabled = true,
super.behavior = HitTestBehavior.deferToChild,
bool validForMouseTracker = true,
Object? groupId,
String? debugLabel,
MouseCursor cursor = MouseCursor.defer,
}) : _registry = registry,
_cursor = cursor,
_validForMouseTracker = validForMouseTracker,
_enabled = enabled,
_groupId = groupId,
debugLabel = kReleaseMode ? null : debugLabel;
bool _isRegistered = false;
/// A label used in debug builds. Will be null in release builds.
String? debugLabel;
bool _enabled;
Object? _groupId;
MouseAreaRegistry? _registry;
bool _validForMouseTracker;
MouseCursor _cursor;
PointerEnterEventListener? onEnter;
PointerExitEventListener? onExit;
MouseCursor get cursor => _cursor;
set cursor(MouseCursor value) {
if (_cursor != value) {
_cursor = value;
// A repaint is needed in order to trigger a device update of
// [MouseTracker] so that this new value can be found.
markNeedsPaint();
}
}
/// Whether or not this region should participate in the composite region.
bool get enabled => _enabled;
set enabled(bool value) {
if (_enabled != value) {
_enabled = value;
markNeedsLayout();
}
}
/// An optional group ID that groups [ShadMouseAreaRenderBox]s together so
/// that they operate as one region. If any member of a group is hit by a
/// particular hover, then all members will have their
/// [onEnter] or [onExit] called.
///
/// If the group id is null, then only this region is hit tested.
Object? get groupId => _groupId;
set groupId(Object? value) {
if (_groupId != value) {
// If the group changes, we need to unregister and re-register under the
// new group. The re-registration happens automatically in layout().
if (_isRegistered) {
_registry!.unregisterMouseArea(this);
_isRegistered = false;
}
_groupId = value;
markNeedsLayout();
}
}
/// The registry that this [ShadMouseAreaRenderBox] should register with.
///
/// If the [registry] is null, then this region will not be registered
/// anywhere, and will not do any tap detection.
///
/// A [MouseAreaSurfaceRenderBox] is a [MouseAreaRegistry].
MouseAreaRegistry? get registry => _registry;
set registry(MouseAreaRegistry? value) {
if (_registry != value) {
if (_isRegistered) {
_registry!.unregisterMouseArea(this);
_isRegistered = false;
}
_registry = value;
markNeedsLayout();
}
}
bool get validForMouseTracker => _validForMouseTracker;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_validForMouseTracker = true;
}
@override
Size computeSizeForNoChild(BoxConstraints constraints) {
return constraints.biggest;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(
DiagnosticsProperty<String?>(
'debugLabel',
debugLabel,
defaultValue: null,
),
)
..add(
DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null),
)
..add(
FlagProperty(
'enabled',
value: enabled,
ifFalse: 'DISABLED',
defaultValue: true,
),
);
}
@override
void detach() {
// It's possible that the renderObject be detached during mouse events
// dispatching, set the [MouseTrackerAnnotation.validForMouseTracker] false
// to prevent the callbacks from being called.
_validForMouseTracker = false;
super.detach();
}
@override
void dispose() {
if (_isRegistered) {
_registry!.unregisterMouseArea(this);
}
super.dispose();
}
@override
void layout(Constraints constraints, {bool parentUsesSize = false}) {
super.layout(constraints, parentUsesSize: parentUsesSize);
if (_registry == null) {
return;
}
if (_isRegistered) {
_registry!.unregisterMouseArea(this);
}
final shouldBeRegistered = _enabled && _registry != null;
if (shouldBeRegistered) {
_registry!.registerMouseArea(this);
}
_isRegistered = shouldBeRegistered;
}
}
/// A widget that provides notification of a hover inside or outside of a set of
/// registered regions, grouped by [ShadMouseArea.groupId], without
/// participating in the [gesture disambiguation](https://flutter.dev/to/gesture-disambiguation) system.
class ShadMouseAreaSurface extends SingleChildRenderObjectWidget {
/// Creates a const [RenderTapRegionSurface].
///
/// The [child] attribute is required.
const ShadMouseAreaSurface({
super.key,
required Widget super.child,
});
@override
RenderObject createRenderObject(BuildContext context) {
return MouseAreaSurfaceRenderBox();
}
@override
void updateRenderObject(
BuildContext context,
RenderProxyBoxWithHitTestBehavior renderObject,
) {}
}

View File

@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
/// Notes: The implementation of this page is copied from [flutter_shadcn_ui](https://github.com/nank1ro/flutter-shadcn-ui).
/// The position of the [ShadPortal] in the global coordinate system.
sealed class ShadAnchorBase {
const ShadAnchorBase();
}
/// Automatically infers the position of the [ShadPortal] in the global
/// coordinate system adjusting according to the [offset],
/// [followerAnchor] and [targetAnchor] properties.
@immutable
class ShadAnchorAuto extends ShadAnchorBase {
const ShadAnchorAuto({
this.offset = Offset.zero,
this.followTargetOnResize = true,
this.followerAnchor = Alignment.bottomCenter,
this.targetAnchor = Alignment.bottomCenter,
});
/// The offset of the overlay from the target widget.
final Offset offset;
/// Whether the overlay is automatically adjusted to follow the target
/// widget when the target widget moves dues to a window resize.
final bool followTargetOnResize;
/// The coordinates of the overlay from which the overlay starts, which
/// is calculated from the initial [targetAnchor].
final Alignment followerAnchor;
/// The coordinates of the target from which the overlay starts.
final Alignment targetAnchor;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ShadAnchorAuto &&
other.offset == offset &&
other.followTargetOnResize == followTargetOnResize &&
other.followerAnchor == followerAnchor &&
other.targetAnchor == targetAnchor;
}
@override
int get hashCode =>
offset.hashCode ^
followTargetOnResize.hashCode ^
followerAnchor.hashCode ^
targetAnchor.hashCode;
}
/// Manually specifies the position of the [ShadPortal] in the global
/// coordinate system.
@immutable
class ShadAnchor extends ShadAnchorBase {
const ShadAnchor({
this.childAlignment = Alignment.topLeft,
this.overlayAlignment = Alignment.bottomLeft,
this.offset = Offset.zero,
});
final Alignment childAlignment;
final Alignment overlayAlignment;
final Offset offset;
static const center = ShadAnchor(
childAlignment: Alignment.topCenter,
overlayAlignment: Alignment.bottomCenter,
);
ShadAnchor copyWith({
Alignment? childAlignment,
Alignment? overlayAlignment,
Offset? offset,
}) {
return ShadAnchor(
childAlignment: childAlignment ?? this.childAlignment,
overlayAlignment: overlayAlignment ?? this.overlayAlignment,
offset: offset ?? this.offset,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ShadAnchor &&
other.childAlignment == childAlignment &&
other.overlayAlignment == overlayAlignment &&
other.offset == offset;
}
@override
int get hashCode {
return childAlignment.hashCode ^
overlayAlignment.hashCode ^
offset.hashCode;
}
}
@immutable
class ShadGlobalAnchor extends ShadAnchorBase {
const ShadGlobalAnchor(this.offset);
/// The global offset where the overlay is positioned.
final Offset offset;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ShadGlobalAnchor && other.offset == offset;
}
@override
int get hashCode => offset.hashCode;
}
class ShadPortal extends StatefulWidget {
const ShadPortal({
super.key,
required this.child,
required this.portalBuilder,
required this.visible,
required this.anchor,
});
final Widget child;
final WidgetBuilder portalBuilder;
final bool visible;
final ShadAnchorBase anchor;
@override
State<ShadPortal> createState() => _ShadPortalState();
}
class _ShadPortalState extends State<ShadPortal> {
final layerLink = LayerLink();
final overlayPortalController = OverlayPortalController();
final overlayKey = GlobalKey();
@override
void initState() {
super.initState();
updateVisibility();
}
@override
void didUpdateWidget(covariant ShadPortal oldWidget) {
super.didUpdateWidget(oldWidget);
updateVisibility();
}
@override
void dispose() {
hide();
super.dispose();
}
void updateVisibility() {
final shouldShow = widget.visible;
WidgetsBinding.instance.addPostFrameCallback((timer) {
shouldShow ? show() : hide();
});
}
void hide() {
if (overlayPortalController.isShowing) {
overlayPortalController.hide();
}
}
void show() {
if (!overlayPortalController.isShowing) {
overlayPortalController.show();
}
}
Widget buildAutoPosition(
BuildContext context,
ShadAnchorAuto anchor,
) {
if (anchor.followTargetOnResize) {
MediaQuery.sizeOf(context);
}
final overlayState = Overlay.of(context, debugRequiredFor: widget);
final box = this.context.findRenderObject()! as RenderBox;
final overlayAncestor =
overlayState.context.findRenderObject()! as RenderBox;
final overlay = overlayKey.currentContext?.findRenderObject() as RenderBox?;
final overlaySize = overlay?.size ?? Size.zero;
final targetOffset = switch (anchor.targetAnchor) {
Alignment.topLeft => box.size.topLeft(Offset.zero),
Alignment.topCenter => box.size.topCenter(Offset.zero),
Alignment.topRight => box.size.topRight(Offset.zero),
Alignment.centerLeft => box.size.centerLeft(Offset.zero),
Alignment.center => box.size.center(Offset.zero),
Alignment.centerRight => box.size.centerRight(Offset.zero),
Alignment.bottomLeft => box.size.bottomLeft(Offset.zero),
Alignment.bottomCenter => box.size.bottomCenter(Offset.zero),
Alignment.bottomRight => box.size.bottomRight(Offset.zero),
final alignment => throw Exception(
"""ShadAnchorAuto doesn't support the alignment $alignment you provided""",
),
};
var followerOffset = switch (anchor.followerAnchor) {
Alignment.topLeft => Offset(-overlaySize.width / 2, -overlaySize.height),
Alignment.topCenter => Offset(0, -overlaySize.height),
Alignment.topRight => Offset(overlaySize.width / 2, -overlaySize.height),
Alignment.centerLeft =>
Offset(-overlaySize.width / 2, -overlaySize.height / 2),
Alignment.center => Offset(0, -overlaySize.height / 2),
Alignment.centerRight =>
Offset(overlaySize.width / 2, -overlaySize.height / 2),
Alignment.bottomLeft => Offset(-overlaySize.width / 2, 0),
Alignment.bottomCenter => Offset.zero,
Alignment.bottomRight => Offset(overlaySize.width / 2, 0),
final alignment => throw Exception(
"""ShadAnchorAuto doesn't support the alignment $alignment you provided""",
),
};
followerOffset += targetOffset + anchor.offset;
final target = box.localToGlobal(
followerOffset,
ancestor: overlayAncestor,
);
if (overlay == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {});
});
}
return CustomSingleChildLayout(
delegate: ShadPositionDelegate(
target: target,
verticalOffset: 0,
preferBelow: true,
),
child: KeyedSubtree(
key: overlayKey,
child: Visibility.maintain(
// The overlay layout details are available only after the view is
// rendered, in this way we can avoid the flickering effect.
visible: overlay != null,
child: IgnorePointer(
ignoring: overlay == null,
child: widget.portalBuilder(context),
),
),
),
);
}
Widget buildManualPosition(
BuildContext context,
ShadAnchor anchor,
) {
return CompositedTransformFollower(
link: layerLink,
offset: anchor.offset,
followerAnchor: anchor.childAlignment,
targetAnchor: anchor.overlayAlignment,
child: widget.portalBuilder(context),
);
}
Widget buildGlobalPosition(
BuildContext context,
ShadGlobalAnchor anchor,
) {
return CustomSingleChildLayout(
delegate: ShadPositionDelegate(
target: anchor.offset,
verticalOffset: 0,
preferBelow: true,
),
child: widget.portalBuilder(context),
);
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: layerLink,
child: OverlayPortal(
controller: overlayPortalController,
overlayChildBuilder: (context) {
return Material(
type: MaterialType.transparency,
child: Center(
widthFactor: 1,
heightFactor: 1,
child: switch (widget.anchor) {
final ShadAnchorAuto anchor =>
buildAutoPosition(context, anchor),
final ShadAnchor anchor => buildManualPosition(context, anchor),
final ShadGlobalAnchor anchor =>
buildGlobalPosition(context, anchor),
},
),
);
},
child: widget.child,
),
);
}
}
/// A delegate for computing the layout of an overlay to be displayed above or
/// below a target specified in the global coordinate system.
class ShadPositionDelegate extends SingleChildLayoutDelegate {
/// Creates a delegate for computing the layout of an overlay.
ShadPositionDelegate({
required this.target,
required this.verticalOffset,
required this.preferBelow,
});
/// The offset of the target the overlay is positioned near in the global
/// coordinate system.
final Offset target;
/// The amount of vertical distance between the target and the displayed
/// overlay.
final double verticalOffset;
/// Whether the overlay is displayed below its widget by default.
///
/// If there is insufficient space to display the tooltip in the preferred
/// direction, the tooltip will be displayed in the opposite direction.
final bool preferBelow;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
constraints.loosen();
@override
Offset getPositionForChild(Size size, Size childSize) {
return positionDependentBox(
size: size,
childSize: childSize,
target: target,
verticalOffset: verticalOffset,
preferBelow: preferBelow,
margin: 0,
);
}
@override
bool shouldRelayout(ShadPositionDelegate oldDelegate) {
return target != oldDelegate.target ||
verticalOffset != oldDelegate.verticalOffset ||
preferBelow != oldDelegate.preferBelow;
}
}

View File

@ -314,7 +314,7 @@ class TextThemeHeadline extends TextThemeType {
_defaultTextStyle(
family: family ?? super.fontFamily,
color: color,
weight: weight ?? FontWeight.w600,
weight: weight ?? FontWeight.w500,
);
@override
@ -374,7 +374,7 @@ class TextThemeTitle extends TextThemeType {
_defaultTextStyle(
family: family ?? super.fontFamily,
color: color,
weight: weight ?? FontWeight.w600,
weight: weight ?? FontWeight.w500,
);
@override
@ -434,7 +434,7 @@ class TextThemeBody extends TextThemeType {
_defaultTextStyle(
family: family ?? super.fontFamily,
color: color,
weight: weight ?? FontWeight.w600,
weight: weight ?? FontWeight.w500,
);
@override
@ -494,7 +494,7 @@ class TextThemeCaption extends TextThemeType {
_defaultTextStyle(
family: family ?? super.fontFamily,
color: color,
weight: weight ?? FontWeight.w600,
weight: weight ?? FontWeight.w500,
);
@override

View File

@ -10,6 +10,7 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_animate: ^4.5.2
flutter_lints: ^5.0.0
dev_dependencies: