Skip to content

Commit

Permalink
feat: menu (#712)
Browse files Browse the repository at this point in the history
* 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
digitalheartxs and qbait authored Aug 27, 2024
1 parent 0e16ebd commit 00dd389
Show file tree
Hide file tree
Showing 5 changed files with 381 additions and 0 deletions.
169 changes: 169 additions & 0 deletions catalyst_voices/lib/widgets/menu/voices_menu.dart
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,
});
}
1 change: 1 addition & 0 deletions catalyst_voices/lib/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export 'indicators/process_progress_indicator.dart';
export 'indicators/voices_circular_progress_indicator.dart';
export 'indicators/voices_linear_progress_indicator.dart';
export 'indicators/voices_status_indicator.dart';
export 'menu/voices_menu.dart';
export 'seed_phrase/seed_phrases_completer.dart';
export 'seed_phrase/seed_phrases_picker.dart';
export 'seed_phrase/seed_phrases_sequencer.dart';
Expand Down
98 changes: 98 additions & 0 deletions catalyst_voices/test/widgets/menu/voices_menu_test.dart
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 catalyst_voices/uikit_example/lib/examples/voices_menu_example.dart
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(),
),
],
),
);
}
}
Loading

0 comments on commit 00dd389

Please sign in to comment.