-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: menu - initial commit * feat: menu - more scalable syntax * feat: menu - more scalable syntax * feat: menu - add enabled and showDivider properties * feat: menu - workaround to show correct chevron icons * feat: menu - remove padding from divider * feat: menu - correct theming for disabled menu item * feat: menu - fix lint issues * feat: menu - better spacing * feat: menu - add comments * feat: menu - fix focus * feat: menu - add tests * feat: menu - remove unnecessary test group * feat: menu - test group name as class, not string --------- Co-authored-by: Jakub Szwiec <[email protected]>
- Loading branch information
1 parent
0e16ebd
commit 00dd389
Showing
5 changed files
with
381 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; | ||
import 'package:flutter/material.dart'; | ||
|
||
/// A menu of the app that | ||
/// can be also us as a cascade. | ||
class VoicesMenu extends StatelessWidget { | ||
/// Menu items passed as models, can be nested. | ||
final List<MenuItem> menuItems; | ||
|
||
/// The widget that is clicked to open menu. | ||
final Widget child; | ||
|
||
/// The callback called when the menu item is tapped. | ||
final ValueChanged<MenuItem>? onTap; | ||
|
||
/// The default constructor for the [VoicesMenu]. | ||
const VoicesMenu({ | ||
super.key, | ||
required this.menuItems, | ||
required this.child, | ||
this.onTap, | ||
}); | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
return Center( | ||
child: MenuBar( | ||
style: const MenuStyle( | ||
elevation: WidgetStatePropertyAll<double>(0), | ||
), | ||
children: [ | ||
SubmenuButton( | ||
menuChildren: [...menuItems.map(_mapItemToButton)], | ||
menuStyle: const MenuStyle(alignment: Alignment.centerRight), | ||
style: MenuItemButton.styleFrom(shadowColor: Colors.transparent), | ||
child: child, | ||
), | ||
], | ||
), | ||
); | ||
} | ||
|
||
_MenuButton _mapItemToButton(MenuItem item) { | ||
return _MenuButton( | ||
menuItem: item, | ||
menuChildren: (item is SubMenuItem) | ||
? item.children.map(_mapItemToButton).toList() | ||
: null, | ||
onSelected: (item is SubMenuItem) ? null : onTap, | ||
); | ||
} | ||
} | ||
|
||
class _MenuButton extends StatelessWidget { | ||
final MenuItem menuItem; | ||
final List<Widget>? menuChildren; | ||
final ValueChanged<MenuItem>? onSelected; | ||
|
||
const _MenuButton({ | ||
required this.menuItem, | ||
this.menuChildren, | ||
this.onSelected, | ||
}); | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final theme = Theme.of(context); | ||
final textTheme = theme.textTheme; | ||
final icon = (menuItem.icon != null) ? Icon(menuItem.icon, size: 24) : null; | ||
|
||
final textStyle = textTheme.bodyMedium?.copyWith( | ||
color: | ||
menuItem.enabled ? textTheme.bodySmall?.color : theme.disabledColor, | ||
); | ||
|
||
final children = menuChildren; | ||
return Wrap( | ||
children: [ | ||
Stack( | ||
children: [ | ||
if (icon != null) | ||
Positioned.fill( | ||
child: Align( | ||
alignment: Alignment.centerLeft, | ||
child: Padding( | ||
padding: const EdgeInsets.only(left: 10), | ||
child: Icon( | ||
icon.icon, | ||
size: icon.size, | ||
color: menuItem.enabled | ||
? textTheme.bodySmall?.color | ||
: theme.disabledColor, | ||
), | ||
), | ||
), | ||
), | ||
if (children != null) | ||
const Positioned.fill( | ||
child: Align( | ||
alignment: Alignment.centerRight, | ||
child: Padding( | ||
padding: EdgeInsets.only(right: 6), | ||
child: Icon(CatalystVoicesIcons.chevron_right, size: 20), | ||
), | ||
), | ||
), | ||
if (children == null) | ||
MenuItemButton( | ||
leadingIcon: icon, | ||
onPressed: () => onSelected?.call(menuItem), | ||
style: MenuItemButton.styleFrom(iconColor: Colors.transparent), | ||
child: Text( | ||
menuItem.label, | ||
style: textStyle, | ||
), | ||
) | ||
else | ||
SubmenuButton( | ||
leadingIcon: icon, | ||
menuChildren: children, | ||
style: MenuItemButton.styleFrom(iconColor: Colors.transparent), | ||
child: Text( | ||
menuItem.label, | ||
style: textStyle, | ||
), | ||
), | ||
], | ||
), | ||
if (menuItem.showDivider) | ||
const Divider( | ||
height: 0, | ||
indent: 0, | ||
thickness: 1, | ||
), | ||
], | ||
); | ||
} | ||
} | ||
|
||
/// Model representing Menu Item | ||
class MenuItem { | ||
final int id; | ||
final String label; | ||
final IconData? icon; | ||
final bool showDivider; | ||
final bool enabled; | ||
|
||
MenuItem({ | ||
required this.id, | ||
required this.label, | ||
this.icon, | ||
this.showDivider = false, | ||
this.enabled = true, | ||
}); | ||
} | ||
|
||
/// Model representing Submenu Item | ||
/// and extending from MenuItem | ||
class SubMenuItem extends MenuItem { | ||
List<MenuItem> children; | ||
|
||
SubMenuItem({ | ||
required super.id, | ||
required super.label, | ||
required this.children, | ||
super.icon, | ||
super.showDivider, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import 'package:catalyst_voices/widgets/menu/voices_menu.dart'; | ||
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; | ||
import 'package:flutter/gestures.dart'; | ||
import 'package:flutter/material.dart'; | ||
import 'package:flutter_test/flutter_test.dart'; | ||
import '../../helpers/helpers.dart'; | ||
|
||
void main() { | ||
final menu = [ | ||
MenuItem( | ||
id: 1, | ||
label: 'Rename', | ||
icon: CatalystVoicesIcons.pencil, | ||
), | ||
SubMenuItem( | ||
id: 2, | ||
label: 'Move Private Team', | ||
icon: CatalystVoicesIcons.switch_horizontal, | ||
children: [ | ||
MenuItem( | ||
id: 3, | ||
label: 'Team 1: The Vikings', | ||
), | ||
MenuItem( | ||
id: 4, | ||
label: 'Team 2: Pure Hearts', | ||
), | ||
], | ||
), | ||
MenuItem( | ||
id: 5, | ||
label: 'Move to public', | ||
icon: CatalystVoicesIcons.switch_horizontal, | ||
showDivider: true, | ||
enabled: false, | ||
), | ||
MenuItem( | ||
id: 6, | ||
label: 'Delete', | ||
icon: CatalystVoicesIcons.trash, | ||
), | ||
]; | ||
|
||
group(VoicesMenu, () { | ||
testWidgets('displays first level menu correctly', (tester) async { | ||
// Given | ||
final widget = VoicesMenu( | ||
menuItems: menu, | ||
child: const Text('sample menu'), | ||
); | ||
|
||
// When | ||
await tester.pumpApp(widget); | ||
await tester.pumpAndSettle(); | ||
|
||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); | ||
await gesture.addPointer(location: Offset.zero); | ||
addTearDown(gesture.removePointer); | ||
await tester.pump(); | ||
await gesture.moveTo(tester.getCenter(find.byType(Text))); | ||
await tester.tap(find.text('sample menu')); | ||
await tester.pumpAndSettle(); | ||
|
||
// Then | ||
expect(find.byType(SubmenuButton), findsExactly(2)); | ||
expect(find.byType(MenuItemButton), findsExactly(3)); | ||
expect(find.text('Rename'), findsOne); | ||
}); | ||
|
||
testWidgets('displays nested menu correctly', (tester) async { | ||
// Given | ||
final widget = VoicesMenu( | ||
menuItems: menu, | ||
child: const Text('sample menu'), | ||
); | ||
|
||
// When | ||
await tester.pumpApp(widget); | ||
await tester.pumpAndSettle(); | ||
|
||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); | ||
await gesture.addPointer(location: Offset.zero); | ||
addTearDown(gesture.removePointer); | ||
await tester.pump(); | ||
await gesture.moveTo(tester.getCenter(find.byType(Text))); | ||
await tester.tap(find.text('sample menu')); | ||
await tester.pumpAndSettle(); | ||
|
||
await tester.tap(find.text('Move Private Team')); | ||
await tester.pumpAndSettle(); | ||
|
||
// Then | ||
expect(find.byType(SubmenuButton), findsExactly(2)); | ||
expect(find.byType(MenuItemButton), findsExactly(5)); | ||
expect(find.text('Team 1: The Vikings'), findsOne); | ||
}); | ||
}); | ||
} |
107 changes: 107 additions & 0 deletions
107
catalyst_voices/uikit_example/lib/examples/voices_menu_example.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
import 'package:catalyst_voices/widgets/menu/voices_menu.dart'; | ||
import 'package:catalyst_voices/widgets/widgets.dart'; | ||
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; | ||
import 'package:flutter/material.dart'; | ||
|
||
class VoicesMenuExample extends StatelessWidget { | ||
static const String route = '/menu-example'; | ||
|
||
const VoicesMenuExample({super.key}); | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
return Scaffold( | ||
appBar: AppBar(title: const Text('Voices Menu')), | ||
body: Row( | ||
children: [ | ||
Column( | ||
children: [ | ||
VoicesMenu( | ||
onTap: (menuItem) => | ||
debugPrint('Selected label: ${menuItem.label}'), | ||
menuItems: [ | ||
MenuItem( | ||
id: 1, | ||
label: 'Rename', | ||
icon: CatalystVoicesIcons.pencil, | ||
), | ||
SubMenuItem( | ||
id: 4, | ||
label: 'Move Private Team', | ||
icon: CatalystVoicesIcons.switch_horizontal, | ||
children: [ | ||
MenuItem(id: 5, label: 'Team 1: The Vikings'), | ||
MenuItem(id: 6, label: 'Team 2: Pure Hearts'), | ||
], | ||
), | ||
MenuItem( | ||
id: 2, | ||
label: 'Move to public', | ||
icon: CatalystVoicesIcons.switch_horizontal, | ||
showDivider: true, | ||
enabled: false, | ||
), | ||
MenuItem( | ||
id: 3, | ||
label: 'Delete', | ||
icon: CatalystVoicesIcons.trash, | ||
), | ||
], | ||
child: const SizedBox( | ||
height: 56, | ||
width: 200, | ||
child: Align( | ||
alignment: Alignment.centerLeft, | ||
child: Text('My first proposal'), | ||
), | ||
), | ||
), | ||
VoicesMenu( | ||
onTap: (menuItem) => | ||
debugPrint('Selected label: ${menuItem.label}'), | ||
menuItems: [ | ||
MenuItem( | ||
id: 1, | ||
label: 'Rename', | ||
icon: CatalystVoicesIcons.pencil, | ||
), | ||
SubMenuItem( | ||
id: 4, | ||
label: 'Move Private Team', | ||
icon: CatalystVoicesIcons.switch_horizontal, | ||
children: [ | ||
MenuItem(id: 5, label: 'Team 1: The Vikings'), | ||
MenuItem(id: 6, label: 'Team 2: Pure Hearts'), | ||
], | ||
), | ||
MenuItem( | ||
id: 2, | ||
label: 'Move to public', | ||
icon: CatalystVoicesIcons.switch_horizontal, | ||
showDivider: true, | ||
), | ||
MenuItem( | ||
id: 3, | ||
label: 'Delete', | ||
icon: CatalystVoicesIcons.trash, | ||
), | ||
], | ||
child: const SizedBox( | ||
height: 56, | ||
width: 200, | ||
child: Align( | ||
alignment: Alignment.centerLeft, | ||
child: Text('My second proposal'), | ||
), | ||
), | ||
), | ||
], | ||
), | ||
Expanded( | ||
child: Container(), | ||
), | ||
], | ||
), | ||
); | ||
} | ||
} |
Oops, something went wrong.