mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-26 14:46:19 +00:00
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:
parent
bb4f860f37
commit
9cac7e5a91
@ -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 |
@ -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 |
@ -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(
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)",
|
||||
|
||||
@ -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>
|
||||
@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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!,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -10,6 +10,7 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_animate: ^4.5.2
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user