From 83f5a029431392dedd3001aa92885121e760d7e2 Mon Sep 17 00:00:00 2001 From: Emily Pauli Date: Thu, 23 Oct 2025 12:31:46 +0200 Subject: [PATCH] Update `NavigationRailM3E` implementation; update FAB and navigation sections to adapt to changes. --- apps/gallery/lib/main.dart | 3 - apps/gallery/lib/sections/fab_section.dart | 7 + .../lib/sections/navigation_section.dart | 186 ++++++---- apps/gallery/pubspec_overrides.yaml | 30 -- melos.yaml | 14 + .../lib/button_group_m3e.dart | 5 +- .../lib/src/button_group_m3e_widget.dart | 34 +- packages/button_m3e/lib/button_m3e.dart | 6 +- .../lib/src/button_tokens_adapter.dart | 42 ++- packages/fab_m3e/lib/fab_m3e.dart | 4 +- .../fab_m3e/lib/src/extended_fab_m3e.dart | 2 +- packages/fab_m3e/lib/src/fab_theme_m3e.dart | 22 +- .../lib/loading_indicator_m3e.dart | 2 +- .../lib/src/loading_indicator_m3e.dart | 12 +- packages/navigation_rail_m3e/CHANGELOG.md | 3 + packages/navigation_rail_m3e/LICENSE | 8 +- packages/navigation_rail_m3e/README.md | 109 +++--- .../navigation_rail_m3e/analysis_options.yaml | 17 + .../navigation_rail_m3e/example/lib/main.dart | 101 ++++++ .../navigation_rail_m3e/example/pubspec.yaml | 15 + .../lib/navigation_rail_m3e.dart | 12 +- .../navigation_rail_m3e/lib/src/enums.dart | 5 - .../navigation_rail_m3e/lib/src/modality.dart | 8 + .../lib/src/navigation_rail_m3e.dart | 177 ---------- .../lib/src/navigation_rail_m3e_widget.dart | 322 +++++++++++++++++- .../lib/src/rail_badge_m3e.dart | 96 +++--- .../lib/src/rail_collapsed.dart | 110 ++++++ .../lib/src/rail_destination_m3e.dart | 36 +- .../lib/src/rail_expanded.dart | 171 ++++++++++ .../lib/src/rail_fab_slot.dart | 60 ++++ .../lib/src/rail_item.dart | 126 +++++++ .../lib/src/rail_menu_slot.dart | 10 + .../lib/src/rail_section_m3e.dart | 13 + .../lib/src/rail_theme.dart | 98 ++++++ .../lib/src/rail_tokens_adapter.dart | 123 +++---- .../navigation_rail_m3e/lib/src/type.dart | 15 + packages/navigation_rail_m3e/pubspec.yaml | 18 +- .../pubspec_overrides.yaml | 4 - .../test/navigation_rail_m3e_test.dart | 7 - .../lib/progress_indicator_m3e.dart | 3 +- .../lib/src/_tokens.dart | 5 +- .../lib/src/circular_progress_m3e.dart | 3 +- packages/slider_m3e/lib/slider_m3e.dart | 6 +- .../slider_m3e/lib/src/range_slider_m3e.dart | 6 +- packages/slider_m3e/lib/src/slider_m3e.dart | 6 +- .../slider_m3e/lib/src/slider_theme_m3e.dart | 7 +- .../lib/src/split_button.dart | 177 +++++++--- packages/toolbar_m3e/lib/src/toolbar_m3e.dart | 72 ++-- packages/toolbar_m3e/lib/toolbar_m3e.dart | 2 +- 49 files changed, 1655 insertions(+), 665 deletions(-) delete mode 100644 apps/gallery/pubspec_overrides.yaml create mode 100644 packages/navigation_rail_m3e/CHANGELOG.md create mode 100644 packages/navigation_rail_m3e/analysis_options.yaml create mode 100644 packages/navigation_rail_m3e/example/lib/main.dart create mode 100644 packages/navigation_rail_m3e/example/pubspec.yaml delete mode 100644 packages/navigation_rail_m3e/lib/src/enums.dart create mode 100644 packages/navigation_rail_m3e/lib/src/modality.dart delete mode 100644 packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_collapsed.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_expanded.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_fab_slot.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_item.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_menu_slot.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_section_m3e.dart create mode 100644 packages/navigation_rail_m3e/lib/src/rail_theme.dart create mode 100644 packages/navigation_rail_m3e/lib/src/type.dart delete mode 100644 packages/navigation_rail_m3e/pubspec_overrides.yaml delete mode 100644 packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart index 5c91221..18be406 100644 --- a/apps/gallery/lib/main.dart +++ b/apps/gallery/lib/main.dart @@ -42,9 +42,6 @@ class _GalleryHomeState extends State { @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final m3e = - Theme.of(context).extension() ?? M3ETheme.defaults(cs); return Scaffold( appBar: AppBarM3E( titleText: 'M3E Gallery', diff --git a/apps/gallery/lib/sections/fab_section.dart b/apps/gallery/lib/sections/fab_section.dart index 554a579..47b837e 100644 --- a/apps/gallery/lib/sections/fab_section.dart +++ b/apps/gallery/lib/sections/fab_section.dart @@ -28,6 +28,13 @@ class FabSection extends StatelessWidget { kind: kind, size: size, onPressed: onPressed), + for (final size in FabM3ESize.values) + FabM3E( + icon: const Icon(Icons.add), + kind: FabM3EKind.primary, + size: size, + shapeFamily: FabM3EShapeFamily.square, + onPressed: onPressed), ], ), const SizedBox(height: 12), diff --git a/apps/gallery/lib/sections/navigation_section.dart b/apps/gallery/lib/sections/navigation_section.dart index 043c806..32a077b 100644 --- a/apps/gallery/lib/sections/navigation_section.dart +++ b/apps/gallery/lib/sections/navigation_section.dart @@ -14,6 +14,38 @@ class _NavigationSectionState extends State { int _barIndex = 0; int _railIndex = 0; + // Controls for the rail demo + NavigationRailM3EType _railType = NavigationRailM3EType.expanded; + NavigationRailM3EModality _modality = NavigationRailM3EModality.standard; + bool _hideWhenCollapsed = false; + + double _navigationBarWidth = 450; + + List get _railSections => const [ + NavigationRailM3ESection( + header: Text('Main'), + destinations: [ + NavigationRailM3EDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: 'Dash', + ), + NavigationRailM3EDestination( + icon: Icon(Icons.analytics_outlined), + selectedIcon: Icon(Icons.analytics), + label: 'Reports', + smallBadge: true, + ), + NavigationRailM3EDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + largeBadgeCount: 2, + ), + ], + ), + ]; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -31,44 +63,48 @@ class _NavigationSectionState extends State { ), Wrap( runSpacing: 12, + spacing: 12, children: [ for (final style in NavBarM3EIndicatorStyle.values) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text('indicator: ${style.name}', - style: theme.textTheme.labelLarge), - ), - NavigationBarM3E( - selectedIndex: _barIndex, - onDestinationSelected: (i) => - setState(() => _barIndex = i), - indicatorStyle: style, - destinations: const [ - NavigationDestinationM3E( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home'), - NavigationDestinationM3E( - icon: Icon(Icons.search_outlined), - selectedIcon: Icon(Icons.search), - label: 'Search', - badgeDot: true), - NavigationDestinationM3E( - icon: Icon(Icons.favorite_outline), - selectedIcon: Icon(Icons.favorite), - label: 'Favorites', - badgeCount: 2), - NavigationDestinationM3E( - icon: Icon(Icons.person_outline), - selectedIcon: Icon(Icons.person), - label: 'Profile'), - ], - ), - const SizedBox(height: 8), - ], + SizedBox( + width: _navigationBarWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('indicator: ${style.name}', + style: theme.textTheme.labelLarge), + ), + NavigationBarM3E( + selectedIndex: _barIndex, + onDestinationSelected: (i) => + setState(() => _barIndex = i), + indicatorStyle: style, + destinations: const [ + NavigationDestinationM3E( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home'), + NavigationDestinationM3E( + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', + badgeDot: true), + NavigationDestinationM3E( + icon: Icon(Icons.favorite_outline), + selectedIcon: Icon(Icons.favorite), + label: 'Favorites', + badgeCount: 2), + NavigationDestinationM3E( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profile'), + ], + ), + const SizedBox(height: 8), + ], + ), ), ], ), @@ -77,37 +113,69 @@ class _NavigationSectionState extends State { padding: const EdgeInsets.symmetric(vertical: 8), child: Text('Navigation Rail', style: theme.textTheme.titleMedium), ), + // Options for the rail demo (e.g., modality) + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Modality:', style: theme.textTheme.labelLarge), + const SizedBox(width: 8), + DropdownButton( + value: _modality, + onChanged: (v) => setState(() => _modality = v!), + items: NavigationRailM3EModality.values + .map((m) => DropdownMenuItem( + value: m, + child: Text(m.name), + )) + .toList(), + ), + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Hide when collapsed', + style: theme.textTheme.labelLarge), + Switch( + value: _hideWhenCollapsed, + onChanged: (v) => setState(() => _hideWhenCollapsed = v), + ), + ], + ), + ], + ), + const SizedBox(height: 8), Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: m3e.shapes.round.lg, ), - height: 220, + height: 600, child: Row( children: [ - for (final style in RailIndicatorStyle.values) ...[ - NavigationRailM3E( - selectedIndex: _railIndex, - onDestinationSelected: (i) => - setState(() => _railIndex = i), - indicatorStyle: style, - destinations: const [ - RailDestinationM3E( - icon: Icon(Icons.dashboard_outlined), - selectedIcon: Icon(Icons.dashboard), - label: 'Dash'), - RailDestinationM3E( - icon: Icon(Icons.analytics_outlined), - selectedIcon: Icon(Icons.analytics), - label: 'Reports'), - RailDestinationM3E( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings'), - ], + NavigationRailM3E( + type: _railType, + modality: _modality, + sections: _railSections, + selectedIndex: _railIndex, + onDestinationSelected: (i) => setState(() => _railIndex = i), + onTypeChanged: (t) => setState(() => _railType = t), + fab: NavigationRailM3EFabSlot( + icon: const Icon(Icons.add), + label: 'New', + onPressed: () {}, ), - const VerticalDivider(width: 1), - ], + hideWhenCollapsed: _hideWhenCollapsed, + onDismissModal: () => setState( + () => _modality = NavigationRailM3EModality.standard, + ), + ), + const VerticalDivider(width: 1), Expanded( child: Center(child: Text('Selected: $_railIndex')), ), diff --git a/apps/gallery/pubspec_overrides.yaml b/apps/gallery/pubspec_overrides.yaml deleted file mode 100644 index a5b82c3..0000000 --- a/apps/gallery/pubspec_overrides.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# melos_managed_dependency_overrides: icon_button_m3e,m3e_collection,m3e_design,split_button_m3e,app_bar_m3e,button_group_m3e,button_m3e,fab_m3e,loading_indicator_m3e,navigation_bar_m3e,navigation_rail_m3e,progress_indicator_m3e,slider_m3e,toolbar_m3e -dependency_overrides: - app_bar_m3e: - path: ..\\..\\packages\\app_bar_m3e - button_group_m3e: - path: ..\\..\\packages\\button_group_m3e - button_m3e: - path: ..\\..\\packages\\button_m3e - fab_m3e: - path: ..\\..\\packages\\fab_m3e - icon_button_m3e: - path: ..\\..\\packages\\icon_button_m3e - loading_indicator_m3e: - path: ..\\..\\packages\\loading_indicator_m3e - m3e_collection: - path: ..\\..\\packages\\m3e_collection - m3e_design: - path: ..\\..\\packages\\m3e_design - navigation_bar_m3e: - path: ..\\..\\packages\\navigation_bar_m3e - navigation_rail_m3e: - path: ..\\..\\packages\\navigation_rail_m3e - progress_indicator_m3e: - path: ..\\..\\packages\\progress_indicator_m3e - slider_m3e: - path: ..\\..\\packages\\slider_m3e - split_button_m3e: - path: ..\\..\\packages\\split_button_m3e - toolbar_m3e: - path: ..\\..\\packages\\toolbar_m3e diff --git a/melos.yaml b/melos.yaml index df9f216..6cdd403 100644 --- a/melos.yaml +++ b/melos.yaml @@ -17,3 +17,17 @@ scripts: create: run: dart run tool/create_component.dart description: Scaffold a new [component]_m3e package (melos run create -- name=badge) + pub-dry-run: + run: melos exec -c 1 --no-private -- "flutter pub publish --dry-run" + description: Run 'flutter pub publish --dry-run' in all publishable packages + packageFilters: + noPrivate: true + dirExists: + - lib + pub-publish: + run: melos exec -c 1 --no-private -- "flutter pub publish" + description: Run 'flutter pub publish' in all publishable packages + packageFilters: + noPrivate: true + dirExists: + - lib diff --git a/packages/button_group_m3e/lib/button_group_m3e.dart b/packages/button_group_m3e/lib/button_group_m3e.dart index 30eb731..0a98f41 100644 --- a/packages/button_group_m3e/lib/button_group_m3e.dart +++ b/packages/button_group_m3e/lib/button_group_m3e.dart @@ -1,5 +1,6 @@ library button_group_m3e; -export 'src/button_group_m3e_widget.dart'; export 'src/button_group_m3e_enums.dart'; -export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope; +export 'src/button_group_m3e_scope.dart' + show ButtonGroupM3EScope, ButtonGroupM3EItemScope; +export 'src/button_group_m3e_widget.dart'; diff --git a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart index 131f56a..afb9322 100644 --- a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart +++ b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:m3e_design/m3e_design.dart'; -import 'button_group_m3e_enums.dart'; + import '_tokens_adapter.dart'; +import 'button_group_m3e_enums.dart'; import 'button_group_m3e_scope.dart'; class ButtonGroupM3E extends StatelessWidget { @@ -57,7 +57,8 @@ class ButtonGroupM3E extends StatelessWidget { final tokens = metricsFor(context, size, density); final cs = Theme.of(context).colorScheme; final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6); - final dividerThk = (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0); + final dividerThk = + (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0); final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing); final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0; @@ -69,7 +70,8 @@ class ButtonGroupM3E extends StatelessWidget { density: density, direction: direction, isConnected: _connected, - child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk), + child: _buildContent( + context, effSpacing, effRunSpacing, dividerClr, dividerThk), ); final semantics = Semantics( @@ -123,8 +125,14 @@ class ButtonGroupM3E extends StatelessWidget { } return direction == Axis.horizontal - ? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list) - : Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list); + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: list) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: list); } Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) { @@ -153,7 +161,11 @@ class ButtonGroupM3E extends StatelessWidget { } Widget _wrapItemScope(BuildContext context, - {required int index, required int count, required bool isFirst, required bool isLast, required Widget child}) { + {required int index, + required int count, + required bool isFirst, + required bool isLast, + required Widget child}) { return ButtonGroupM3EItemScope( index: index, count: count, @@ -163,8 +175,9 @@ class ButtonGroupM3E extends StatelessWidget { ); } - Widget _spacer(double spacing) => - direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing); + Widget _spacer(double spacing) => direction == Axis.horizontal + ? SizedBox(width: spacing) + : SizedBox(height: spacing); Widget _buildDivider(Color color, double thickness) { return direction == Axis.horizontal @@ -181,6 +194,7 @@ class ButtonGroupM3E extends StatelessWidget { ButtonGroupM3ESize.lg => 96.0, ButtonGroupM3ESize.xl => 120.0, }; - return ConstrainedBox(constraints: BoxConstraints(minWidth: minW), child: child); + return ConstrainedBox( + constraints: BoxConstraints(minWidth: minW), child: child); } } diff --git a/packages/button_m3e/lib/button_m3e.dart b/packages/button_m3e/lib/button_m3e.dart index a1d7724..f58b59c 100644 --- a/packages/button_m3e/lib/button_m3e.dart +++ b/packages/button_m3e/lib/button_m3e.dart @@ -1,6 +1,6 @@ - library button_m3e; -export 'src/enums.dart'; -export 'src/button_tokens_adapter.dart' show ButtonTokensAdapter, ButtonMeasurements; export 'src/button_m3e.dart'; +export 'src/button_tokens_adapter.dart' + show ButtonTokensAdapter, ButtonMeasurements; +export 'src/enums.dart'; diff --git a/packages/button_m3e/lib/src/button_tokens_adapter.dart b/packages/button_m3e/lib/src/button_tokens_adapter.dart index 299fb13..c73b78b 100644 --- a/packages/button_m3e/lib/src/button_tokens_adapter.dart +++ b/packages/button_m3e/lib/src/button_tokens_adapter.dart @@ -1,6 +1,6 @@ - import 'package:flutter/material.dart'; import 'package:m3e_design/m3e_design.dart'; + import 'enums.dart'; @immutable @@ -19,14 +19,15 @@ class ButtonMeasurements { @immutable class ButtonTokensAdapter { - const ButtonTokensAdapter(this.context, {this.smallPaddingDeprecated24 = false}); + const ButtonTokensAdapter(this.context, + {this.smallPaddingDeprecated24 = false}); final BuildContext context; final bool smallPaddingDeprecated24; M3ETheme get _m3e { final t = Theme.of(context); return t.extension() ?? M3ETheme.defaults(t.colorScheme); - } + } Color container(ButtonM3EStyle style) { final c = _m3e.colors; @@ -59,17 +60,25 @@ class ButtonTokensAdapter { Color outline() => _m3e.colors.outline; - double elevation(ButtonM3EStyle style, Set states) { - final hovered = states.contains(MaterialState.hovered); - final pressed = states.contains(MaterialState.pressed); - final disabled = states.contains(MaterialState.disabled); + double elevation(ButtonM3EStyle style, Set states) { + final hovered = states.contains(WidgetState.hovered); + final pressed = states.contains(WidgetState.pressed); + final disabled = states.contains(WidgetState.disabled); if (disabled) return 0; switch (style) { case ButtonM3EStyle.elevated: - return pressed ? 0 : hovered ? 3 : 1; + return pressed + ? 0 + : hovered + ? 3 + : 1; case ButtonM3EStyle.filled: case ButtonM3EStyle.tonal: - return pressed ? 0 : hovered ? 1 : 0; + return pressed + ? 0 + : hovered + ? 1 + : 0; case ButtonM3EStyle.outlined: case ButtonM3EStyle.text: return 0; @@ -91,12 +100,14 @@ class ButtonTokensAdapter { } } - double pressedRadius(ButtonM3ESize size) => (squareRadius(size) * 0.6).clamp(6, 18); + double pressedRadius(ButtonM3ESize size) => + (squareRadius(size) * 0.6).clamp(6, 18); ButtonMeasurements measurements(ButtonM3ESize size) { switch (size) { case ButtonM3ESize.xs: - return const ButtonMeasurements(height: 32, hPadding: 12, iconSize: 20, iconGap: 4); + return const ButtonMeasurements( + height: 32, hPadding: 12, iconSize: 20, iconGap: 4); case ButtonM3ESize.sm: return ButtonMeasurements( height: 40, @@ -105,11 +116,14 @@ class ButtonTokensAdapter { iconGap: 8, ); case ButtonM3ESize.md: - return const ButtonMeasurements(height: 56, hPadding: 24, iconSize: 24, iconGap: 8); + return const ButtonMeasurements( + height: 56, hPadding: 24, iconSize: 24, iconGap: 8); case ButtonM3ESize.lg: - return const ButtonMeasurements(height: 96, hPadding: 48, iconSize: 32, iconGap: 12); + return const ButtonMeasurements( + height: 96, hPadding: 48, iconSize: 32, iconGap: 12); case ButtonM3ESize.xl: - return const ButtonMeasurements(height: 136, hPadding: 64, iconSize: 40, iconGap: 16); + return const ButtonMeasurements( + height: 136, hPadding: 64, iconSize: 40, iconGap: 16); } } } diff --git a/packages/fab_m3e/lib/fab_m3e.dart b/packages/fab_m3e/lib/fab_m3e.dart index 57c6f2c..307d159 100644 --- a/packages/fab_m3e/lib/fab_m3e.dart +++ b/packages/fab_m3e/lib/fab_m3e.dart @@ -1,7 +1,7 @@ library fab_m3e; export 'src/enums.dart'; -export 'src/fab_theme_m3e.dart' show FabTokensAdapter; -export 'src/fab_m3e.dart'; export 'src/extended_fab_m3e.dart'; +export 'src/fab_m3e.dart'; export 'src/fab_menu_m3e.dart'; +export 'src/fab_theme_m3e.dart' show FabTokensAdapter; diff --git a/packages/fab_m3e/lib/src/extended_fab_m3e.dart b/packages/fab_m3e/lib/src/extended_fab_m3e.dart index 0d645e1..73bc3ac 100644 --- a/packages/fab_m3e/lib/src/extended_fab_m3e.dart +++ b/packages/fab_m3e/lib/src/extended_fab_m3e.dart @@ -39,7 +39,7 @@ class ExtendedFabM3E extends StatelessWidget { final m = tokens.metrics(density); final bg = tokens.bg(kind); final fg = tokens.fg(kind); - final shape = tokens.shape(shapeFamily, size, extended: true); + final shape = tokens.shape(shapeFamily, size); final minH = m.extendedHeight; final child = DefaultTextStyle.merge( diff --git a/packages/fab_m3e/lib/src/fab_theme_m3e.dart b/packages/fab_m3e/lib/src/fab_theme_m3e.dart index 70353f0..2e667a0 100644 --- a/packages/fab_m3e/lib/src/fab_theme_m3e.dart +++ b/packages/fab_m3e/lib/src/fab_theme_m3e.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:m3e_design/m3e_design.dart'; + import 'enums.dart'; @immutable @@ -28,7 +29,8 @@ class _FabMetrics { _FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) { final theme = Theme.of(context); - final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final m3e = + theme.extension() ?? M3ETheme.defaults(theme.colorScheme); final sp = m3e.spacing; double small = 40; @@ -38,7 +40,10 @@ _FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) { double icon = 24; if (density == FabM3EDensity.compact) { - small -= 4; regular -= 4; large -= 4; extH -= 4; + small -= 4; + regular -= 4; + large -= 4; + extH -= 4; } return _FabMetrics( @@ -93,11 +98,16 @@ class FabTokensAdapter { } // Shapes - ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size, {bool extended = false}) { - final set = family == FabM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; - if (extended) return StadiumBorder(side: BorderSide.none); + ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size) { + final set = family == FabM3EShapeFamily.round + ? _m3e.shapes.round + : _m3e.shapes.square; // circular-ish fab: use large radius to approach circle; actual size enforced by constraints - final radius = switch (size) { FabM3ESize.small => set.lg, FabM3ESize.regular => set.xl, FabM3ESize.large => set.xl }; + final radius = switch (size) { + FabM3ESize.small => set.lg, + FabM3ESize.regular => set.xl, + FabM3ESize.large => set.xl + }; return RoundedRectangleBorder(borderRadius: radius); } diff --git a/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart b/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart index b0b53c5..11e61d8 100644 --- a/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart +++ b/packages/loading_indicator_m3e/lib/loading_indicator_m3e.dart @@ -1,6 +1,6 @@ library loading_indicator_m3e; export 'src/enums.dart'; -export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter; export 'src/expressive_loading_indicator.dart'; export 'src/loading_indicator_m3e.dart'; +export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter; diff --git a/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart b/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart index d83a100..75eecca 100644 --- a/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart +++ b/packages/loading_indicator_m3e/lib/src/loading_indicator_m3e.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:material_new_shapes/material_new_shapes.dart'; + +import 'enums.dart'; import 'expressive_loading_indicator.dart'; import 'loading_tokens_adapter.dart'; -import 'enums.dart'; /// Material 3 Expressive Loading Indicator /// - Default: floating morphing shape on surface @@ -38,12 +39,15 @@ class LoadingIndicatorM3E extends StatelessWidget { final activeColor = switch (variant) { LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(), - LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(), + LoadingIndicatorM3EVariant.contained => + color ?? tokens.containedActiveColor(), }; final containerBg = switch (variant) { - LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(), - LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(), + LoadingIndicatorM3EVariant.defaultStyle => + containerColor ?? tokens.containerColorDefault(), + LoadingIndicatorM3EVariant.contained => + containerColor ?? tokens.containedContainerColor(), }; final indicator = ExpressiveLoadingIndicator( diff --git a/packages/navigation_rail_m3e/CHANGELOG.md b/packages/navigation_rail_m3e/CHANGELOG.md new file mode 100644 index 0000000..55801a7 --- /dev/null +++ b/packages/navigation_rail_m3e/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 +- Initial release of NavigationRailM3E (collapsed & expanded) with modal/standard modes, + badges, sections, menu & FAB slots, and token integration. \ No newline at end of file diff --git a/packages/navigation_rail_m3e/LICENSE b/packages/navigation_rail_m3e/LICENSE index 12ca7c2..abea576 100644 --- a/packages/navigation_rail_m3e/LICENSE +++ b/packages/navigation_rail_m3e/LICENSE @@ -1,13 +1,13 @@ MIT License -Copyright (c) ... +Copyright (c) 2025 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +copies of the Software, and to permit persons to do so, subject to the +following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/packages/navigation_rail_m3e/README.md b/packages/navigation_rail_m3e/README.md index f69fc2f..ec915b0 100644 --- a/packages/navigation_rail_m3e/README.md +++ b/packages/navigation_rail_m3e/README.md @@ -1,84 +1,51 @@ # navigation_rail_m3e -Material 3 **Expressive** Navigation Rail for Flutter with badges, pill/stripe indicators, and token-driven styling. +Material 3 **Expressive** Navigation Rail for Flutter — featuring **collapsed** & **expanded** variants, +**modal** and **standard** presentation, **sections**, **badges**, **menu** and **FAB** slots, and smooth +**expand/collapse transitions**. Built to match the M3 Expressive spec and integrate with the `m3e_design` +token package. -- `NavigationRailM3E` — wrapper around Flutter's `NavigationRail` with M3E tokens -- `RailDestinationM3E` — destination data (icon, selectedIcon, label, badge) -- `RailBadgeM3E` — small badge/dot utility for icons + -All styling is driven by the `m3e_design` ThemeExtension (**M3ETheme**). +## Highlights -## Monorepo Layout +- Collapsed (96 dp) and Expanded (220–360 dp) rails with animated transition +- Expanded **modal** presentation with scrim +- Optional menu and FAB/Extended FAB slots +- Item badges (large numeric & small dot) +- Sections with headers; full-width hit targets +- Token-driven colors, typography & shapes via `m3e_design` (with safe fallbacks) -``` -packages/ - m3e_design/ - navigation_rail_m3e/ -``` - -`pubspec.yaml` references `../m3e_design`. - -## Usage +## Quick start ```dart -import 'package:navigation_rail_m3e/navigation_rail_m3e.dart'; - -final items = [ - const RailDestinationM3E( - icon: Icon(Icons.inbox_outlined), - selectedIcon: Icon(Icons.inbox), - label: 'Inbox', - ), - const RailDestinationM3E( - icon: Icon(Icons.chat_bubble_outline), - label: 'Chat', - badgeCount: 5, - ), - const RailDestinationM3E( - icon: Icon(Icons.settings_outlined), - label: 'Settings', - badgeDot: true, - ), -]; - NavigationRailM3E( - destinations: items, + type: NavigationRailM3EType.expanded, + modality: NavigationRailM3EModality.standard, selectedIndex: 0, - onDestinationSelected: (i) {}, - labelBehavior: RailLabelBehavior.onlySelected, // none | onlySelected | alwaysShow - indicatorStyle: RailIndicatorStyle.pill, // pill | stripe | none - size: RailSize.regular, // compact | regular - density: RailDensity.regular, // regular | compact - shapeFamily: RailShapeFamily.round, // round | square - extended: false, // true to show labels permanently (wide rail) - groupAlignment: -1.0, // -1 top .. 1 bottom - leading: const Padding( - padding: EdgeInsets.all(8.0), - child: FlutterLogo(size: 24), - ), + onDestinationSelected: (i) => setState(() => _index = i), + onTypeChanged: (t) => setState(() => type = t), + fab: NavigationRailM3EFabSlot(icon: const Icon(Icons.add), label: 'New', onPressed: () {}), + sections: [ + NavigationRailM3ESection( + header: const Text('Main'), + destinations: [ + NavigationRailM3EDestination( + icon: const Icon(Icons.edit_outlined), + selectedIcon: const Icon(Icons.edit), + label: 'Edit', + largeBadgeCount: 0, + ), + NavigationRailM3EDestination( + icon: const Icon(Icons.star_outline), + selectedIcon: const Icon(Icons.star), + label: 'Starred', + smallBadge: true, + ), + ], + ), + ], ); ``` -## Tokens mapping - -- **Container**: `surfaceContainerHigh` -- **Indicator**: `secondaryContainer` (color). `pill` uses NavigationRail's indicator; `stripe` draws a left border on the selected icon. -- **Selected**: `onSecondaryContainer` (icon/label) -- **Unselected**: `onSurfaceVariant` -- **Label style**: `labelMedium` -- **Widths**: compact **≈64dp**, regular **≈80dp**, extended min **≈256dp** -- **Icon size**: **24dp** -- **Item padding**: from `spacing.sm/md` - -## Badges - -Use `badgeCount` for numeric badges or `badgeDot: true` for a small dot. Colors default to `errorContainer / onErrorContainer` and can be overridden via `RailBadgeM3E`. - -## Accessibility - -- Provide `semanticLabel` per destination (used as tooltip) or on the rail (`semanticLabel` on the widget). -- Choose the label behavior to balance density with readability. - -## License - -MIT +See the `/example` app for a runnable demo. \ No newline at end of file diff --git a/packages/navigation_rail_m3e/analysis_options.yaml b/packages/navigation_rail_m3e/analysis_options.yaml new file mode 100644 index 0000000..87c94e7 --- /dev/null +++ b/packages/navigation_rail_m3e/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-raw-types: true + strong-mode: + implicit-casts: false + implicit-dynamic: false +linter: + rules: + - public_member_api_docs + - always_declare_return_types + - prefer_final_locals + - prefer_const_constructors + - prefer_const_literals_to_create_immutables + - avoid_print + - directives_ordering \ No newline at end of file diff --git a/packages/navigation_rail_m3e/example/lib/main.dart b/packages/navigation_rail_m3e/example/lib/main.dart new file mode 100644 index 0000000..68dcf5b --- /dev/null +++ b/packages/navigation_rail_m3e/example/lib/main.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:navigation_rail_m3e/navigation_rail_m3e.dart'; + +void main() { + runApp(const DemoApp()); +} + +class DemoApp extends StatefulWidget { + const DemoApp({super.key}); + @override + State createState() => _DemoAppState(); +} + +class _DemoAppState extends State { + var type = NavigationRailM3EType.expanded; + var modality = NavigationRailM3EModality.standard; + int index = 0; + + List get sections => [ + const NavigationRailM3ESection( + header: Text('Main'), + destinations: [ + NavigationRailM3EDestination( + icon: Icon(Icons.edit_outlined), + selectedIcon: Icon(Icons.edit), + label: 'Edit', + ), + NavigationRailM3EDestination( + icon: Icon(Icons.star_outline), + selectedIcon: Icon(Icons.star), + label: 'Starred', + smallBadge: true, + ), + NavigationRailM3EDestination( + icon: Icon(Icons.inbox_outlined), + selectedIcon: Icon(Icons.inbox), + label: 'Inbox', + largeBadgeCount: 3, + ), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + appBar: AppBar( + title: const Text('NavigationRailM3E Demo'), + actions: [ + IconButton( + onPressed: () => setState(() { + type = type == NavigationRailM3EType.expanded + ? NavigationRailM3EType.collapsed + : NavigationRailM3EType.expanded; + }), + icon: const Icon(Icons.swap_horiz), + tooltip: 'Toggle type', + ), + IconButton( + onPressed: () => setState(() { + modality = modality == NavigationRailM3EModality.standard + ? NavigationRailM3EModality.modal + : NavigationRailM3EModality.standard; + }), + icon: const Icon(Icons.layers), + tooltip: 'Toggle modality', + ), + ], + ), + body: Row( + children: [ + NavigationRailM3E( + type: type, + modality: modality, + sections: sections, + selectedIndex: index, + onDestinationSelected: (i) => setState(() => index = i), + onTypeChanged: (t) => setState(() => type = t), + fab: NavigationRailM3EFabSlot( + icon: const Icon(Icons.add), + label: 'New', + onPressed: () {}, + ), + hideWhenCollapsed: false, + expandedWidth: 280, + onDismissModal: () => + setState(() => modality = NavigationRailM3EModality.standard), + ), + Expanded( + child: Center( + child: Text('Selected index: $index'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/navigation_rail_m3e/example/pubspec.yaml b/packages/navigation_rail_m3e/example/pubspec.yaml new file mode 100644 index 0000000..6870f2e --- /dev/null +++ b/packages/navigation_rail_m3e/example/pubspec.yaml @@ -0,0 +1,15 @@ +name: navigation_rail_m3e_example +description: Example for navigation_rail_m3e +publish_to: "none" + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + navigation_rail_m3e: + path: ../ + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart b/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart index db59373..0a58fcf 100644 --- a/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart +++ b/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart @@ -1,7 +1,13 @@ +// ignore_for_file: public_member_api_docs + library navigation_rail_m3e; -export 'src/enums.dart'; -export 'src/rail_tokens_adapter.dart' show RailTokensAdapter; -export 'src/navigation_rail_m3e.dart'; +export 'src/type.dart'; +export 'src/modality.dart'; +export 'src/rail_theme.dart'; +export 'src/rail_tokens_adapter.dart'; export 'src/rail_badge_m3e.dart'; export 'src/rail_destination_m3e.dart'; +export 'src/rail_section_m3e.dart'; +export 'src/rail_fab_slot.dart'; +export 'src/navigation_rail_m3e_widget.dart'; \ No newline at end of file diff --git a/packages/navigation_rail_m3e/lib/src/enums.dart b/packages/navigation_rail_m3e/lib/src/enums.dart deleted file mode 100644 index 8585379..0000000 --- a/packages/navigation_rail_m3e/lib/src/enums.dart +++ /dev/null @@ -1,5 +0,0 @@ -enum RailLabelBehavior { alwaysShow, onlySelected, alwaysHide } -enum RailSize { compact, regular } -enum RailShapeFamily { round, square } -enum RailDensity { regular, compact } -enum RailIndicatorStyle { pill, stripe, none } diff --git a/packages/navigation_rail_m3e/lib/src/modality.dart b/packages/navigation_rail_m3e/lib/src/modality.dart new file mode 100644 index 0000000..7c1647d --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/modality.dart @@ -0,0 +1,8 @@ +/// Modality for the expanded rail. +enum NavigationRailM3EModality { + /// Occupies layout space. + standard, + + /// Overlays content with a scrim and dismisses on tap/esc. + modal, +} diff --git a/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart deleted file mode 100644 index 153be15..0000000 --- a/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'enums.dart'; -import 'rail_destination_m3e.dart'; -import 'rail_tokens_adapter.dart'; - -class NavigationRailM3E extends StatelessWidget { - const NavigationRailM3E({ - super.key, - required this.destinations, - this.selectedIndex = 0, - this.onDestinationSelected, - this.labelBehavior = RailLabelBehavior.onlySelected, - this.size = RailSize.regular, - this.shapeFamily = RailShapeFamily.round, - this.density = RailDensity.regular, - this.backgroundColor, - this.elevation, - this.indicatorStyle = RailIndicatorStyle.pill, - this.indicatorColor, - this.padding, - this.groupAlignment, - this.leading, - this.trailing, - this.extended = false, - this.minExtendedWidth, - this.useSafeArea = true, - this.semanticLabel, - }); - - final List destinations; - final int selectedIndex; - final ValueChanged? onDestinationSelected; - - final RailLabelBehavior labelBehavior; - final RailSize size; - final RailShapeFamily shapeFamily; - final RailDensity density; - - final Color? backgroundColor; - final double? elevation; - - final RailIndicatorStyle indicatorStyle; - final Color? indicatorColor; - - final EdgeInsetsGeometry? padding; - - /// Aligns the group of destinations (-1 top .. 1 bottom). - final double? groupAlignment; - - /// Optional leading and trailing widgets (e.g., FAB or menu). - final Widget? leading; - final Widget? trailing; - - /// Whether to show the rail in extended mode (icons + labels). - final bool extended; - - /// Minimum width when extended. - final double? minExtendedWidth; - - final bool useSafeArea; - - final String? semanticLabel; - - @override - Widget build(BuildContext context) { - assert(destinations.isNotEmpty, 'Provide at least one destination'); - - final tokens = RailTokensAdapter(context); - final metrics = tokens.metrics(density); - - final width = - size == RailSize.compact ? metrics.widthCompact : metrics.widthRegular; - final bg = backgroundColor ?? tokens.containerColor(); - final shape = tokens.containerShape(shapeFamily); - - final rail = Material( - color: bg, - elevation: elevation ?? - 0, // null means use theme default; avoids invalid zero assertion - shape: shape, - child: SizedBox( - width: - extended ? (minExtendedWidth ?? metrics.extendedMinWidth) : width, - child: NavigationRail( - backgroundColor: Colors.transparent, - elevation: elevation, // pass through or null - extended: extended, - minExtendedWidth: minExtendedWidth ?? metrics.extendedMinWidth, - selectedIndex: selectedIndex, - groupAlignment: groupAlignment, - leading: leading, - trailing: trailing, - labelType: switch (labelBehavior) { - RailLabelBehavior.alwaysShow => NavigationRailLabelType.all, - RailLabelBehavior.onlySelected => NavigationRailLabelType.selected, - RailLabelBehavior.alwaysHide => NavigationRailLabelType.none, - }, - useIndicator: indicatorStyle != RailIndicatorStyle.none, - indicatorColor: indicatorColor ?? tokens.indicatorColor(), - indicatorShape: switch (indicatorStyle) { - RailIndicatorStyle.pill => tokens.indicatorShapePill(), - RailIndicatorStyle.stripe => - const StadiumBorder(), // we'll fake stripe using decoration on selected icon - RailIndicatorStyle.none => const StadiumBorder(), - }, - selectedIconTheme: IconThemeData( - color: tokens.selectedColor(), size: metrics.iconSize), - unselectedIconTheme: IconThemeData( - color: tokens.unselectedColor(), size: metrics.iconSize), - selectedLabelTextStyle: - tokens.labelStyle().copyWith(color: tokens.selectedColor()), - unselectedLabelTextStyle: - tokens.labelStyle().copyWith(color: tokens.unselectedColor()), - destinations: List.generate(destinations.length, (i) { - final d = destinations[i]; - return NavigationRailDestination( - icon: _icon(context, false, d, metrics.iconSize), - selectedIcon: _selectedIcon( - context, true, d, metrics.iconSize, tokens, indicatorStyle), - label: Text(d.label), - padding: metrics.itemPadding as EdgeInsets?, - ); - }), - onDestinationSelected: onDestinationSelected, - ), - ), - ); - - final padded = Padding( - padding: padding ?? EdgeInsets.zero, - child: rail, - ); - - if (!useSafeArea && semanticLabel == null) return padded; - - final wrapped = SafeArea( - top: true, - bottom: true, - left: true, - right: false, - child: padded, - ); - - if (semanticLabel == null) return wrapped; - return Semantics(container: true, label: semanticLabel!, child: wrapped); - } - - Widget _icon(BuildContext context, bool selected, RailDestinationM3E d, - double iconSize) { - return SizedBox( - width: iconSize + 8, - height: iconSize + 8, - child: Center(child: d.buildIcon(selected)), - ); - } - - Widget _selectedIcon( - BuildContext context, - bool selected, - RailDestinationM3E d, - double iconSize, - RailTokensAdapter tokens, - RailIndicatorStyle style, - ) { - final w = _icon(context, selected, d, iconSize); - if (style != RailIndicatorStyle.stripe) return w; - - final metrics = tokens.metrics(density); - final deco = tokens.stripeDecoration( - tokens.indicatorColor(), metrics.stripeThickness); - return DecoratedBox( - decoration: deco, - child: w, - ); - } -} diff --git a/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart index ac21ebb..c6df1ef 100644 --- a/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart +++ b/packages/navigation_rail_m3e/lib/src/navigation_rail_m3e_widget.dart @@ -1,21 +1,315 @@ +import 'package:fab_m3e/fab_m3e.dart'; import 'package:flutter/material.dart'; -import 'package:m3e_design/m3e_design.dart'; +import 'package:icon_button_m3e/icon_button_m3e.dart'; -class NavigationRailM3EWidget extends StatelessWidget { - const NavigationRailM3EWidget({super.key}); +import 'modality.dart'; +import 'rail_destination_m3e.dart'; +import 'rail_fab_slot.dart'; +import 'rail_item.dart'; +import 'rail_section_m3e.dart'; +import 'rail_theme.dart'; +import 'rail_tokens_adapter.dart'; +import 'type.dart'; + +/// Material 3 Expressive Navigation Rail — single widget that animates between states. +class NavigationRailM3E extends StatefulWidget { + /// Creates a Material 3 Expressive navigation rail. + const NavigationRailM3E({ + super.key, + required this.type, + this.modality = NavigationRailM3EModality.standard, + required this.sections, + required this.selectedIndex, + required this.onDestinationSelected, + this.fab, + this.hideWhenCollapsed = false, + this.expandedWidth, + this.onDismissModal, + this.onTypeChanged, + }); + + /// Presentation type for the rail (collapsed or expanded). + final NavigationRailM3EType type; + + /// How the rail is shown (standard or modal overlay). + final NavigationRailM3EModality modality; + + /// Sections and destinations to display. + final List sections; + + /// Index of the currently selected destination. + final int selectedIndex; + + /// Called when a destination is selected. + final ValueChanged onDestinationSelected; + + /// Optional FAB/extended FAB shown near the top cluster. + final NavigationRailM3EFabSlot? fab; + + /// When [type] is collapsed and this is true, rail animates to width 0. + final bool hideWhenCollapsed; + + /// Custom expanded width (220–360). Clamped to theme bounds. + final double? expandedWidth; + + /// Called to dismiss when in modal mode. + final VoidCallback? onDismissModal; + + /// Called when the built-in menu button toggles the rail type. + final ValueChanged? onTypeChanged; + + @override + State createState() => _NavigationRailM3EState(); +} + +class _NavigationRailM3EState extends State + with TickerProviderStateMixin { + OverlayEntry? _modalEntry; + + bool get _isExpanded => widget.type == NavigationRailM3EType.expanded; + bool get _isModal => widget.modality == NavigationRailM3EModality.modal; + bool get _needsOverlay => _isModal; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay()); + } + + @override + void didUpdateWidget(covariant NavigationRailM3E oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay()); + } + + @override + void dispose() { + _removeOverlay(); + super.dispose(); + } + + void _syncOverlay() { + if (!mounted) return; + if (_needsOverlay) { + if (_modalEntry == null) { + _insertOverlay(); + } else { + _modalEntry!.markNeedsBuild(); + } + } else { + _removeOverlay(); + } + } + + void _insertOverlay() { + final overlay = Overlay.of(context, rootOverlay: true); + if (overlay == null) return; + _modalEntry = OverlayEntry(builder: (ctx) => _buildModalOverlay(ctx)); + overlay.insert(_modalEntry!); + } + + void _removeOverlay() { + _modalEntry?.remove(); + _modalEntry = null; + } + + Widget _buildModalOverlay(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: !_isExpanded, + child: GestureDetector( + onTap: widget.onDismissModal, + child: AnimatedContainer( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + color: Theme.of(context) + .colorScheme + .scrim + .withValues(alpha: _isExpanded ? 0.32 : 0.0), + ), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Material( + type: MaterialType.transparency, + child: _buildRailCore(context), + ), + ), + ], + ); + } + + double _targetWidth(BuildContext context) { + final theme = Theme.of(context).extension() ?? + const NavigationRailM3ETheme(); + final isExpanded = _isExpanded; + return isExpanded + ? (widget.expandedWidth ?? theme.expandedMinWidth) + .clamp(theme.expandedMinWidth, theme.expandedMaxWidth) + .toDouble() + : (widget.hideWhenCollapsed ? 0.0 : theme.collapsedWidth); + } + + Widget _buildMenuButton(BuildContext context, + {required Alignment alignment}) { + final isExpanded = _isExpanded; + return Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12), + child: Align( + alignment: alignment, + child: IconButtonM3E( + icon: Icon(isExpanded ? Icons.menu_open : Icons.menu), + tooltip: isExpanded ? 'Collapse' : 'Expand', + onPressed: widget.onTypeChanged == null + ? null + : () => widget.onTypeChanged!( + isExpanded + ? NavigationRailM3EType.collapsed + : NavigationRailM3EType.expanded, + ), + ), + ), + ); + } + + Widget? _buildFab(BuildContext context) { + final fab = widget.fab; + if (fab == null) return null; + final isExpanded = _isExpanded; + return Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12), + child: isExpanded + ? ExtendedFabM3E( + label: Text(fab.label), + icon: fab.icon, + onPressed: fab.onPressed, + tooltip: fab.tooltip, + heroTag: fab.heroTag, + kind: fab.kind, + size: fab.size, + shapeFamily: FabM3EShapeFamily.square, + density: fab.density, + elevation: fab.elevation, + semanticLabel: fab.semanticLabel, + ) + : FabM3E( + icon: fab.icon, + onPressed: fab.onPressed, + tooltip: fab.tooltip, + heroTag: fab.heroTag, + kind: fab.kind, + size: fab.size, + shapeFamily: FabM3EShapeFamily.square, + density: fab.density, + elevation: fab.elevation, + semanticLabel: fab.semanticLabel, + ), + ); + } + + List _buildChildren(BuildContext context) { + final theme = Theme.of(context).extension() ?? + const NavigationRailM3ETheme(); + final isExpanded = _isExpanded; + + final children = []; + children.add(const SizedBox(height: 36)); + children.add(_buildMenuButton(context, + alignment: isExpanded ? Alignment.centerLeft : Alignment.center)); + final fabWidget = _buildFab(context); + if (fabWidget != null) children.add(fabWidget); + + if (isExpanded) { + for (final section in widget.sections) { + if (section.header != null) { + children.add(Padding( + padding: EdgeInsetsDirectional.only( + start: 16, + end: 16, + top: theme.sectionHeaderSpacingTop, + bottom: theme.sectionHeaderSpacingBottom, + ), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.titleSmall!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant), + child: section.header!, + ), + )); + } + for (final dest in section.destinations) { + final index = _destinationIndex(widget.sections, dest); + children.add(Padding( + padding: const EdgeInsetsDirectional.only( + start: 16, end: 16, top: 8.0, bottom: 8.0), + child: RailItem( + destination: dest, + selected: index == widget.selectedIndex, + onTap: () => widget.onDestinationSelected(index), + expanded: true, + ), + )); + } + } + } else { + final all = widget.sections.expand((s) => s.destinations).toList(); + for (int i = 0; i < all.length; i++) { + children.add(Padding( + padding: const EdgeInsetsDirectional.only( + start: 16.0, end: 16.0, top: 8.0, bottom: 8.0), + child: RailItem( + destination: all[i], + selected: i == widget.selectedIndex, + onTap: () => widget.onDestinationSelected(i), + expanded: false, + ), + )); + } + } + return children; + } + + Widget _buildRailCore(BuildContext context) { + final tokens = NavigationRailTokensAdapter(context); + final width = _targetWidth(context); + return AnimatedContainer( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + width: width, + decoration: BoxDecoration(color: tokens.containerColor), + child: ListView( + padding: EdgeInsets.zero, + children: _buildChildren(context), + ), + ); + } @override Widget build(BuildContext context) { - final m3e = context.m3e; - return Container( - padding: EdgeInsets.all(m3e.spacing.md), - decoration: BoxDecoration( - color: m3e.colors.surfaceStrong, - borderRadius: m3e.shapes.square.md, - ), - child: Text('NavigationRail placeholder', style: m3e.typography.base.titleMedium), - ); + // Keep overlay in sync after build completes to avoid layout side-effects. + WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay()); + + if (_needsOverlay) { + // When showing modal via overlay, render nothing in the layout slot so + // content underneath can occupy the width. The overlay covers it. + return const SizedBox.shrink(); + } + + return _buildRailCore(context); + } + + static int _destinationIndex(List sections, + NavigationRailM3EDestination dest) { + var i = 0; + for (final s in sections) { + for (final d in s.destinations) { + if (identical(d, dest)) return i; + i++; + } + } + return 0; } } - -String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart b/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart index 1cae400..b71cf3c 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart @@ -1,76 +1,56 @@ +import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; -import 'package:m3e_design/m3e_design.dart'; +import 'rail_tokens_adapter.dart'; + +/// Large numeric badge for rail items (0..999+). One class per file. class RailBadgeM3E extends StatelessWidget { + /// Creates a large numeric badge. const RailBadgeM3E({ super.key, - required this.child, - this.count, - this.showDot = false, - this.maxCount = 99, - this.backgroundColor, - this.foregroundColor, - this.semanticLabel, - this.offset = const Offset(8, -6), - }) : assert(count == null || count >= 0); + required this.count, + this.maxDigits = 3, + this.dense = false, + }); - final Widget child; - final int? count; - final bool showDot; - final int maxCount; - final Color? backgroundColor; - final Color? foregroundColor; - final String? semanticLabel; - final Offset offset; + /// The numeric value to display in the badge. + final int count; + + /// Maximum digits before showing a trailing '+' (e.g. 999+). + final int maxDigits; + + /// Whether to use a denser (smaller padding) variant. + final bool dense; @override Widget build(BuildContext context) { - final t = Theme.of(context); - final m3e = t.extension() ?? M3ETheme.defaults(t.colorScheme); - final bg = backgroundColor ?? m3e.colors.errorContainer; - final fg = foregroundColor ?? m3e.colors.onErrorContainer; - - final badge = showDot - ? _dot(bg) - : _label(bg, fg, count == null ? '' : _format(count!, maxCount)); - - return Stack( - clipBehavior: Clip.none, - children: [ - child, - Positioned( - right: offset.dx, - top: offset.dy, - child: Semantics( - label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'), - child: badge, - ), - ), - ], - ); - } - - Widget _dot(Color bg) { + final tokens = NavigationRailTokensAdapter(context); + final String text = count > (10 * (pow10(maxDigits) - 1)) + ? '${pow10(maxDigits) - 1}+' + : '$count'; + final double pad = dense ? 2 : 4; return Container( - width: 8, height: 8, - decoration: BoxDecoration(color: bg, shape: BoxShape.circle), - ); - } - - Widget _label(Color bg, Color fg, String text) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad), decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(10), + color: tokens.badgeLargeBackground, + borderRadius: BorderRadius.circular(999), ), - constraints: const BoxConstraints(minWidth: 18, minHeight: 18), child: DefaultTextStyle( - style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), - child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)), + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: tokens.badgeLargeLabel, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Text(text, maxLines: 1), ), ); } - String _format(int c, int max) => (c > max) ? '$max+' : '$c'; + /// Returns 10 to the power of [n]. + static int pow10(int n) { + var v = 1; + for (var i = 0; i < n; i++) { + v *= 10; + } + return v; + } } diff --git a/packages/navigation_rail_m3e/lib/src/rail_collapsed.dart b/packages/navigation_rail_m3e/lib/src/rail_collapsed.dart new file mode 100644 index 0000000..0e53668 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_collapsed.dart @@ -0,0 +1,110 @@ +import 'package:fab_m3e/fab_m3e.dart'; +import 'package:flutter/material.dart'; +import 'package:icon_button_m3e/icon_button_m3e.dart'; + +import 'rail_fab_slot.dart'; +import 'rail_item.dart'; +import 'rail_section_m3e.dart'; +import 'rail_theme.dart'; +import 'rail_tokens_adapter.dart'; + +/// Collapsed (96dp) rail. One class per file. +class CollapsedRail extends StatelessWidget { + const CollapsedRail({ + super.key, + required this.sections, + required this.selectedIndex, + required this.onDestinationSelected, + this.fab, + this.hideWhenCollapsed = false, + required this.isExpanded, + this.onToggleType, + }); + + /// Sections rendered in the rail. + final List sections; + + /// Currently selected destination index. + final int selectedIndex; + + /// Callback when a destination is tapped. + final ValueChanged onDestinationSelected; + + /// Whether the current rail type is expanded. + final bool isExpanded; + + /// Called when the user taps the built-in menu button to toggle type. + final VoidCallback? onToggleType; + + /// Optional FAB/extended FAB slot. + final NavigationRailM3EFabSlot? fab; + + /// When true and rail is collapsed, animate width to zero. + final bool hideWhenCollapsed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension() ?? + const NavigationRailM3ETheme(); + final tokens = NavigationRailTokensAdapter(context); + + final width = hideWhenCollapsed ? 0.0 : theme.collapsedWidth; + final allDestinations = sections.expand((s) => s.destinations).toList(); + + final Widget content = ListView( + padding: EdgeInsets.zero, + children: [ + const SizedBox(height: 36), + Padding( + padding: const EdgeInsetsDirectional.only( + start: 16.0, end: 16.0, bottom: 12.0), + child: Align( + alignment: Alignment.center, + child: IconButtonM3E( + icon: Icon(isExpanded ? Icons.menu_open : Icons.menu), + tooltip: isExpanded ? 'Collapse' : 'Expand', + onPressed: onToggleType, + ), + ), + ), + if (fab != null) + Padding( + padding: const EdgeInsetsDirectional.only( + start: 16.0, end: 16.0, bottom: 12.0), + child: FabM3E( + icon: fab!.icon, + onPressed: fab!.onPressed, + tooltip: fab!.tooltip, + heroTag: fab!.heroTag, + kind: fab!.kind, + size: fab!.size, + shapeFamily: FabM3EShapeFamily.square, + density: fab!.density, + elevation: fab!.elevation, + semanticLabel: fab!.semanticLabel, + ), + ), + for (int i = 0; i < allDestinations.length; i++) ...[ + Padding( + padding: const EdgeInsetsDirectional.only( + start: 16.0, end: 16.0, top: 8.0, bottom: 8.0), + child: RailItem( + destination: allDestinations[i], + selected: i == selectedIndex, + onTap: () => onDestinationSelected(i), + expanded: false, + ), + ), + ], + ], + ); + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + width: width, + decoration: BoxDecoration(color: tokens.containerColor), + child: content, + ); + } +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart b/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart index 8ee12ea..edf609e 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart @@ -1,38 +1,24 @@ import 'package:flutter/material.dart'; -import 'rail_badge_m3e.dart'; -class RailDestinationM3E { - const RailDestinationM3E({ +/// Model for a navigation destination. One class per file. +class NavigationRailM3EDestination { + const NavigationRailM3EDestination({ required this.icon, - required this.label, this.selectedIcon, - this.badgeCount, - this.badgeDot = false, + required this.label, + this.largeBadgeCount, + this.smallBadge = false, this.semanticLabel, + this.short = false, }); final Widget icon; final Widget? selectedIcon; final String label; - - /// Optional badge counter - final int? badgeCount; - - /// If true, show a small dot instead of a counter. - final bool badgeDot; - + final int? largeBadgeCount; + final bool smallBadge; final String? semanticLabel; - Widget buildIcon([bool selected = false]) { - final base = selected && selectedIcon != null ? selectedIcon! : icon; - if (badgeCount != null || badgeDot) { - return RailBadgeM3E( - child: base, - count: badgeCount, - showDot: badgeDot, - semanticLabel: semanticLabel, - ); - } - return base; - } + /// If true, uses short item height (56dp) instead of 64dp. + final bool short; } diff --git a/packages/navigation_rail_m3e/lib/src/rail_expanded.dart b/packages/navigation_rail_m3e/lib/src/rail_expanded.dart new file mode 100644 index 0000000..3d0e2de --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_expanded.dart @@ -0,0 +1,171 @@ +import 'package:fab_m3e/fab_m3e.dart'; +import 'package:flutter/material.dart'; +import 'package:icon_button_m3e/icon_button_m3e.dart'; + +import 'rail_destination_m3e.dart'; +import 'rail_fab_slot.dart'; +import 'rail_item.dart'; +import 'rail_section_m3e.dart'; +import 'rail_theme.dart'; +import 'rail_tokens_adapter.dart'; + +/// Expanded rail (220–360dp). One class per file. +class ExpandedRail extends StatelessWidget { + /// Creates the expanded rail variant. + const ExpandedRail({ + super.key, + required this.sections, + required this.selectedIndex, + required this.onDestinationSelected, + required this.isExpanded, + this.onToggleType, + this.fab, + this.width, + this.modal = false, + this.onDismissModal, + }); + + /// Sections rendered in the rail. + final List sections; + + /// Currently selected destination index. + final int selectedIndex; + + /// Callback when a destination is tapped. + final ValueChanged onDestinationSelected; + + /// Whether the current rail type is expanded. + final bool isExpanded; + + /// Called when the user taps the built-in menu button to toggle type. + final VoidCallback? onToggleType; + + /// Optional FAB/extended FAB slot. + final NavigationRailM3EFabSlot? fab; + + /// Desired rail width (clamped to theme min/max) when expanded. + final double? width; + + /// Whether the expanded rail is displayed as a modal overlay. + final bool modal; + + /// Invoked to dismiss when [modal] is true. + final VoidCallback? onDismissModal; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension() ?? + const NavigationRailM3ETheme(); + final tokens = NavigationRailTokensAdapter(context); + + final w = (width ?? theme.expandedMinWidth) + .clamp(theme.expandedMinWidth, theme.expandedMaxWidth); + + final children = [ + const SizedBox(height: 36), + Padding( + padding: + const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12), + child: Align( + alignment: Alignment.centerLeft, + child: IconButtonM3E( + icon: Icon(isExpanded ? Icons.menu_open : Icons.menu), + tooltip: isExpanded ? 'Collapse' : 'Expand', + onPressed: onToggleType, + ), + ), + ), + if (fab != null) + Padding( + padding: + const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12), + child: ExtendedFabM3E( + label: Text(fab!.label), + icon: fab!.icon, + onPressed: fab!.onPressed, + tooltip: fab!.tooltip, + heroTag: fab!.heroTag, + kind: fab!.kind, + size: fab!.size, + shapeFamily: FabM3EShapeFamily.square, + density: fab!.density, + elevation: fab!.elevation, + semanticLabel: fab!.semanticLabel, + ), + ), + ]; + + for (final section in sections) { + if (section.header != null) { + children.add(Padding( + padding: EdgeInsetsDirectional.only( + start: 16, + end: 16, + top: theme.sectionHeaderSpacingTop, + bottom: theme.sectionHeaderSpacingBottom, + ), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.titleSmall!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant), + child: section.header!, + ), + )); + } + for (final dest in section.destinations) { + final index = _destinationIndex(sections, dest); + children.add(Padding( + padding: const EdgeInsetsDirectional.only( + start: 16, end: 16, top: 8.0, bottom: 8.0), + child: RailItem( + destination: dest, + selected: index == selectedIndex, + onTap: () => onDestinationSelected(index), + expanded: true, + ), + )); + } + } + + final rail = AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + width: w.toDouble(), + decoration: BoxDecoration(color: tokens.containerColor), + child: ListView( + padding: EdgeInsets.zero, + children: children, + ), + ); + + if (!modal) return rail; + + // Modal overlay with scrim + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: onDismissModal, + child: ColoredBox( + color: Theme.of(context) + .colorScheme + .scrim + .withValues(alpha: 0.32)), + ), + ), + Align(alignment: Alignment.centerLeft, child: rail), + ], + ); + } + + static int _destinationIndex(List sections, + NavigationRailM3EDestination dest) { + var i = 0; + for (final s in sections) { + for (final d in s.destinations) { + if (identical(d, dest)) return i; + i++; + } + } + return 0; + } +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_fab_slot.dart b/packages/navigation_rail_m3e/lib/src/rail_fab_slot.dart new file mode 100644 index 0000000..85ad851 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_fab_slot.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:fab_m3e/fab_m3e.dart' + show FabM3EKind, FabM3ESize, FabM3EShapeFamily, FabM3EDensity; + +/// Configuration for the rail's built-in FAB. +/// +/// The rail renders: +/// - a FabM3E when collapsed +/// - an ExtendedFabM3E when expanded +/// +/// Consumers provide values (icon, label, onPressed, etc.) instead of a widget. +@immutable +class NavigationRailM3EFabSlot { + const NavigationRailM3EFabSlot({ + required this.icon, + required this.label, + this.onPressed, + this.tooltip, + this.heroTag, + this.kind = FabM3EKind.primary, + this.size = FabM3ESize.regular, + this.shapeFamily = FabM3EShapeFamily.round, + this.density = FabM3EDensity.regular, + this.elevation, + this.semanticLabel, + }); + + /// Icon widget shown inside the FAB (collapsed) and leading icon (expanded). + final Widget icon; + + /// Text label for the extended FAB (expanded rail variant). + final String label; + + /// Tap callback for the FAB. + final VoidCallback? onPressed; + + /// Tooltip text for hover/long-press. + final String? tooltip; + + /// Optional Hero tag for FAB transitions. + final Object? heroTag; + + /// Visual kind (primary, surface, tertiary, etc.). + final FabM3EKind kind; + + /// Size of the FAB button. + final FabM3ESize size; + + /// Shape family (round/square). + final FabM3EShapeFamily shapeFamily; + + /// Density (affects metrics like size and padding). + final FabM3EDensity density; + + /// Elevation override. + final double? elevation; + + /// Optional semantic label for accessibility. + final String? semanticLabel; +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_item.dart b/packages/navigation_rail_m3e/lib/src/rail_item.dart new file mode 100644 index 0000000..db655d5 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_item.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +import 'rail_badge_m3e.dart'; +import 'rail_destination_m3e.dart'; +import 'rail_theme.dart'; +import 'rail_tokens_adapter.dart'; + +/// Single rail item (private to package). One class per file. +class RailItem extends StatelessWidget { + /// Creates a single navigation rail item. + const RailItem({ + super.key, + required this.destination, + required this.selected, + required this.onTap, + required this.expanded, + }); + + /// Destination data driving this item. + final NavigationRailM3EDestination destination; + /// Whether this item is currently selected. + final bool selected; + /// Called when the item is tapped. + final VoidCallback onTap; + /// Whether the rail is expanded (shows label and badges inline). + final bool expanded; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension() ?? + const NavigationRailM3ETheme(); + final tokens = NavigationRailTokensAdapter(context); + final height = destination.short ? theme.itemShortHeight : theme.itemHeight; + + final icon = IconTheme.merge( + data: IconThemeData( + size: theme.iconSize, + color: + selected ? tokens.activeIconAndLabel : tokens.inactiveIconAndLabel, + ), + child: selected + ? (destination.selectedIcon ?? destination.icon) + : destination.icon, + ); + + final label = DefaultTextStyle( + style: Theme.of(context).textTheme.labelLarge!.copyWith( + color: selected + ? tokens.activeIconAndLabel + : tokens.inactiveIconAndLabel, + ), + child: Text( + destination.label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + semanticsLabel: destination.semanticLabel ?? destination.label, + ), + ); + + final badges = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (destination.largeBadgeCount != null && + destination.largeBadgeCount! > 0) + Padding( + padding: EdgeInsets.only(left: theme.iconLabelGap), + child: RailBadgeM3E(count: destination.largeBadgeCount!), + ), + if (destination.smallBadge) + Padding( + padding: const EdgeInsetsDirectional.only(start: 6.0), + child: _SmallDot(color: tokens.badgeSmallDot), + ), + ], + ); + + final content = AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + height: height, + decoration: ShapeDecoration( + color: selected ? tokens.activeIndicatorColor : Colors.transparent, + shape: const StadiumBorder(), // Full corner per spec + ), + padding: EdgeInsetsDirectional.only( + start: theme.indicatorLeading, + end: theme.indicatorTrailing, + ), + child: Row( + children: [ + icon, + SizedBox(width: theme.iconLabelGap), + if (expanded) Expanded(child: label) else const SizedBox.shrink(), + if (expanded) badges, + ], + ), + ); + + // Full-width hit target + return Semantics( + selected: selected, + button: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: content, + ), + ); + } +} + +class _SmallDot extends StatelessWidget { + const _SmallDot({required this.color}); + final Color color; + @override + Widget build(BuildContext context) { + return Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_menu_slot.dart b/packages/navigation_rail_m3e/lib/src/rail_menu_slot.dart new file mode 100644 index 0000000..8194635 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_menu_slot.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +/// Menu slot at the top of the rail (non-selectable). One class per file. +class NavigationRailM3EMenu extends StatelessWidget { + const NavigationRailM3EMenu({super.key, required this.child}); + final Widget child; + + @override + Widget build(BuildContext context) => child; +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_section_m3e.dart b/packages/navigation_rail_m3e/lib/src/rail_section_m3e.dart new file mode 100644 index 0000000..4dff379 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_section_m3e.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'rail_destination_m3e.dart'; + +/// Section groups a header and a list of destinations. One class per file. +class NavigationRailM3ESection { + const NavigationRailM3ESection({ + required this.destinations, + this.header, + }); + + final List destinations; + final Widget? header; +} \ No newline at end of file diff --git a/packages/navigation_rail_m3e/lib/src/rail_theme.dart b/packages/navigation_rail_m3e/lib/src/rail_theme.dart new file mode 100644 index 0000000..d82b5a0 --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/rail_theme.dart @@ -0,0 +1,98 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; + +/// Theme extension for NavigationRailM3E token values. +class NavigationRailM3ETheme extends ThemeExtension { + const NavigationRailM3ETheme({ + this.collapsedWidth = 96.0, + this.expandedMinWidth = 220.0, + this.expandedMaxWidth = 360.0, + this.itemHeight = 64.0, + this.itemShortHeight = 56.0, + this.iconSize = 24.0, + this.indicatorLeading = 16.0, + this.indicatorTrailing = 16.0, + this.iconLabelGap = 8.0, + this.itemVerticalGap = 6.0, + this.headerMinSpace = 40.0, + this.sectionHeaderSpacingTop = 12.0, + this.sectionHeaderSpacingBottom = 8.0, + }); + + final double collapsedWidth; + final double expandedMinWidth; + final double expandedMaxWidth; + final double itemHeight; + final double itemShortHeight; + final double iconSize; + final double indicatorLeading; + final double indicatorTrailing; + final double iconLabelGap; + final double itemVerticalGap; + final double headerMinSpace; + final double sectionHeaderSpacingTop; + final double sectionHeaderSpacingBottom; + + @override + NavigationRailM3ETheme copyWith({ + double? collapsedWidth, + double? expandedMinWidth, + double? expandedMaxWidth, + double? itemHeight, + double? itemShortHeight, + double? iconSize, + double? indicatorLeading, + double? indicatorTrailing, + double? iconLabelGap, + double? itemVerticalGap, + double? headerMinSpace, + double? sectionHeaderSpacingTop, + double? sectionHeaderSpacingBottom, + }) { + return NavigationRailM3ETheme( + collapsedWidth: collapsedWidth ?? this.collapsedWidth, + expandedMinWidth: expandedMinWidth ?? this.expandedMinWidth, + expandedMaxWidth: expandedMaxWidth ?? this.expandedMaxWidth, + itemHeight: itemHeight ?? this.itemHeight, + itemShortHeight: itemShortHeight ?? this.itemShortHeight, + iconSize: iconSize ?? this.iconSize, + indicatorLeading: indicatorLeading ?? this.indicatorLeading, + indicatorTrailing: indicatorTrailing ?? this.indicatorTrailing, + iconLabelGap: iconLabelGap ?? this.iconLabelGap, + itemVerticalGap: itemVerticalGap ?? this.itemVerticalGap, + headerMinSpace: headerMinSpace ?? this.headerMinSpace, + sectionHeaderSpacingTop: + sectionHeaderSpacingTop ?? this.sectionHeaderSpacingTop, + sectionHeaderSpacingBottom: + sectionHeaderSpacingBottom ?? this.sectionHeaderSpacingBottom, + ); + } + + @override + ThemeExtension lerp( + ThemeExtension? other, double t) { + if (other is! NavigationRailM3ETheme) return this; + return NavigationRailM3ETheme( + collapsedWidth: lerpDouble(collapsedWidth, other.collapsedWidth, t)!, + expandedMinWidth: + lerpDouble(expandedMinWidth, other.expandedMinWidth, t)!, + expandedMaxWidth: + lerpDouble(expandedMaxWidth, other.expandedMaxWidth, t)!, + itemHeight: lerpDouble(itemHeight, other.itemHeight, t)!, + itemShortHeight: lerpDouble(itemShortHeight, other.itemShortHeight, t)!, + iconSize: lerpDouble(iconSize, other.iconSize, t)!, + indicatorLeading: + lerpDouble(indicatorLeading, other.indicatorLeading, t)!, + indicatorTrailing: + lerpDouble(indicatorTrailing, other.indicatorTrailing, t)!, + iconLabelGap: lerpDouble(iconLabelGap, other.iconLabelGap, t)!, + itemVerticalGap: lerpDouble(itemVerticalGap, other.itemVerticalGap, t)!, + headerMinSpace: lerpDouble(headerMinSpace, other.headerMinSpace, t)!, + sectionHeaderSpacingTop: lerpDouble( + sectionHeaderSpacingTop, other.sectionHeaderSpacingTop, t)!, + sectionHeaderSpacingBottom: lerpDouble( + sectionHeaderSpacingBottom, other.sectionHeaderSpacingBottom, t)!, + ); + } +} diff --git a/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart b/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart index 1f71d02..752c29c 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart @@ -1,88 +1,59 @@ import 'package:flutter/material.dart'; -import 'package:m3e_design/m3e_design.dart'; -import 'enums.dart'; +import 'package:m3e_design/m3e_design.dart' as m3e; -@immutable -class _RailMetrics { - final double widthCompact; - final double widthRegular; - final double extendedMinWidth; - final double iconSize; - final EdgeInsetsGeometry itemPadding; - final double stripeThickness; - const _RailMetrics({ - required this.widthCompact, - required this.widthRegular, - required this.extendedMinWidth, - required this.iconSize, - required this.itemPadding, - required this.stripeThickness, - }); -} +/// Provides colors & shapes from `m3e_design` with safe fallbacks to Theme.of(context). +class NavigationRailTokensAdapter { + const NavigationRailTokensAdapter(this.context); -_RailMetrics _metricsFor(BuildContext context, RailDensity density) { - final theme = Theme.of(context); - final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); - final sp = m3e.spacing; - - double wC = 64; // compact width - double wR = 80; // regular width - double ext = 256; // extended min width - double icon = 24; - double stripe = 3; - - if (density == RailDensity.compact) { - wC -= 4; wR -= 4; stripe -= 1; - } - - return _RailMetrics( - widthCompact: wC, - widthRegular: wR, - extendedMinWidth: ext, - iconSize: icon, - itemPadding: EdgeInsets.symmetric(horizontal: sp.md, vertical: sp.sm), - stripeThickness: stripe, - ); -} - -class RailTokensAdapter { - RailTokensAdapter(this.context); final BuildContext context; - M3ETheme get _m3e { - final t = Theme.of(context); - return t.extension() ?? M3ETheme.defaults(t.colorScheme); + ColorScheme get _cs => Theme.of(context).colorScheme; + + // Colors per spec + Color get containerColor { + // Use surface container token if present, else fallback. + return _maybe(() => context.m3e.colors.surfaceContainer) ?? + _cs.surfaceContainer; } - _RailMetrics metrics(RailDensity density) => _metricsFor(context, density); - - // Container/background - Color containerColor() => _m3e.colors.surfaceContainerHigh; - - // Indicator - Color indicatorColor() => _m3e.colors.secondaryContainer; - - // Icon/label colors - Color selectedColor() => _m3e.colors.onSecondaryContainer; - Color unselectedColor() => _m3e.colors.onSurfaceVariant; - - // Typography - TextStyle labelStyle() => _m3e.type.labelMedium; - - // Shapes - ShapeBorder containerShape(RailShapeFamily family) { - final set = family == RailShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square; - return RoundedRectangleBorder(borderRadius: set.lg); + Color get activeIndicatorColor { + return _maybe(() => context.m3e.colors.secondaryContainer) ?? + _cs.secondaryContainer; } - ShapeBorder indicatorShapePill() => const StadiumBorder(); + Color get activeIconAndLabel { + return _maybe(() => context.m3e.colors.secondary) ?? _cs.secondary; + } - // Stripe decoration for selected destination - BoxDecoration stripeDecoration(Color color, double thickness) { - return BoxDecoration( - border: Border( - left: BorderSide(color: color, width: thickness), - ), - ); + Color get inactiveIconAndLabel { + return _maybe(() => context.m3e.colors.onSurfaceVariant) ?? + _cs.onSurfaceVariant; + } + + Color get menuColor { + return _maybe(() => context.m3e.colors.onSecondaryContainer) ?? + _cs.onSecondaryContainer; + } + + Color get badgeLargeBackground => + _maybe(() => context.m3e.colors.error) ?? _cs.error; + Color get badgeLargeLabel => + _maybe(() => context.m3e.colors.onError) ?? _cs.onError; + Color get badgeSmallDot => + _maybe(() => context.m3e.colors.error) ?? _cs.error; + + ShapeBorder get indicatorShapeFull { + // Full corner per M3E: use the most rounded token, fallback to StadiumBorder. + final br = _maybe(() => context.m3e.shapes.round.xs); + if (br != null) return RoundedRectangleBorder(borderRadius: br); + return const StadiumBorder(); + } + + T? _maybe(T Function() pick) { + try { + return pick(); + } catch (_) { + return null; + } } } diff --git a/packages/navigation_rail_m3e/lib/src/type.dart b/packages/navigation_rail_m3e/lib/src/type.dart new file mode 100644 index 0000000..a47bcca --- /dev/null +++ b/packages/navigation_rail_m3e/lib/src/type.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// M3 Expressive types for the rail. +enum NavigationRailM3EType { + /// Slim 96dp rail. + collapsed, + + /// Wide 220–360dp rail that replaces the drawer. + expanded, +} + +extension NavigationRailM3ETypeX on NavigationRailM3EType { + bool get isCollapsed => this == NavigationRailM3EType.collapsed; + bool get isExpanded => this == NavigationRailM3EType.expanded; +} \ No newline at end of file diff --git a/packages/navigation_rail_m3e/pubspec.yaml b/packages/navigation_rail_m3e/pubspec.yaml index 5493c4f..b14489c 100644 --- a/packages/navigation_rail_m3e/pubspec.yaml +++ b/packages/navigation_rail_m3e/pubspec.yaml @@ -1,18 +1,28 @@ name: navigation_rail_m3e -description: Material 3 Expressive Navigation Rail for Flutter with token-driven colors, shapes, indicators, and badges. +description: Material 3 Expressive navigation rail (collapsed & expanded) with modal/standard modes, badges, sections, and m3e_design token integration. version: 0.1.0 -publish_to: none +homepage: https://github.com/EmilyMonestone/material_3_expressive environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.22.0" + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: sdk: flutter + # Integrates with your design system tokens. m3e_design: path: ../m3e_design + # FAB components used by the rail (FabM3E and ExtendedFabM3E). + fab_m3e: + path: ../fab_m3e + # Icon button used by the rail's menu control. + icon_button_m3e: + path: ../icon_button_m3e dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/navigation_rail_m3e/pubspec_overrides.yaml b/packages/navigation_rail_m3e/pubspec_overrides.yaml deleted file mode 100644 index 69acba6..0000000 --- a/packages/navigation_rail_m3e/pubspec_overrides.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# melos_managed_dependency_overrides: m3e_design -dependency_overrides: - m3e_design: - path: ..\\m3e_design diff --git a/packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart b/packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart deleted file mode 100644 index 56e7cc8..0000000 --- a/packages/navigation_rail_m3e/test/navigation_rail_m3e_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('placeholder', () { - expect(3 + 4, 7); - }); -} diff --git a/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart b/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart index c67addc..1eb8647 100644 --- a/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart +++ b/packages/progress_indicator_m3e/lib/progress_indicator_m3e.dart @@ -1,7 +1,6 @@ - library progress_indicator_m3e; +export 'src/circular_progress_m3e.dart'; export 'src/enums.dart'; export 'src/linear_progress_m3e.dart'; -export 'src/circular_progress_m3e.dart'; export 'src/progress_with_label_m3e.dart'; diff --git a/packages/progress_indicator_m3e/lib/src/_tokens.dart b/packages/progress_indicator_m3e/lib/src/_tokens.dart index 94fdddc..a599b03 100644 --- a/packages/progress_indicator_m3e/lib/src/_tokens.dart +++ b/packages/progress_indicator_m3e/lib/src/_tokens.dart @@ -9,7 +9,7 @@ class Palette { // Use theme roles; callers can override colors if needed. Color get active => cs.primary; - Color get track => cs.onSurfaceVariant.withOpacity(0.24); + Color get track => cs.onSurfaceVariant.withValues(alpha: 0.24); Color get bg => cs.surface; } @@ -39,7 +39,8 @@ class LinearSpec { LinearSpec specForLinear({ required LinearProgressM3ESize size, required ProgressM3EShape shape, -}) => switch ((shape, size)) { +}) => + switch ((shape, size)) { (ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec( trackHeight: 4, gap: 4, diff --git a/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart b/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart index e67fa17..79a19bd 100644 --- a/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart +++ b/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart @@ -73,7 +73,8 @@ class _CircularProgressIndicatorM3EState Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final active = widget.activeColor ?? cs.primary; - final track = widget.trackColor ?? cs.onSurfaceVariant.withOpacity(0.24); + final track = + widget.trackColor ?? cs.onSurfaceVariant.withValues(alpha: 0.24); final wantsWavy = widget.shape == ProgressM3EShape.wavy; final diameter = wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat; diff --git a/packages/slider_m3e/lib/slider_m3e.dart b/packages/slider_m3e/lib/slider_m3e.dart index 1a96e03..e6089cd 100644 --- a/packages/slider_m3e/lib/slider_m3e.dart +++ b/packages/slider_m3e/lib/slider_m3e.dart @@ -1,7 +1,7 @@ library slider_m3e; export 'src/enums.dart'; -export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter; -export 'src/slider_theme_m3e.dart'; -export 'src/slider_m3e.dart'; export 'src/range_slider_m3e.dart'; +export 'src/slider_m3e.dart'; +export 'src/slider_theme_m3e.dart'; +export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter; diff --git a/packages/slider_m3e/lib/src/range_slider_m3e.dart b/packages/slider_m3e/lib/src/range_slider_m3e.dart index df57640..6c09fe9 100644 --- a/packages/slider_m3e/lib/src/range_slider_m3e.dart +++ b/packages/slider_m3e/lib/src/range_slider_m3e.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'slider_theme_m3e.dart'; + import 'enums.dart'; +import 'slider_theme_m3e.dart'; class RangeSliderM3E extends StatelessWidget { const RangeSliderM3E({ @@ -63,7 +64,8 @@ class RangeSliderM3E extends StatelessWidget { divisions: divisions, labels: labels, semanticFormatterCallback: semanticLabel != null - ? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%' + ? (v) => + '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%' : null, ), ); diff --git a/packages/slider_m3e/lib/src/slider_m3e.dart b/packages/slider_m3e/lib/src/slider_m3e.dart index cf6537f..1a0028e 100644 --- a/packages/slider_m3e/lib/src/slider_m3e.dart +++ b/packages/slider_m3e/lib/src/slider_m3e.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'slider_theme_m3e.dart'; + import 'enums.dart'; +import 'slider_theme_m3e.dart'; class SliderM3E extends StatelessWidget { const SliderM3E({ @@ -63,7 +64,8 @@ class SliderM3E extends StatelessWidget { divisions: divisions, label: label, semanticFormatterCallback: semanticLabel != null - ? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%' + ? (v) => + '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%' : null, ); diff --git a/packages/slider_m3e/lib/src/slider_theme_m3e.dart b/packages/slider_m3e/lib/src/slider_theme_m3e.dart index c6d70e8..eae9b64 100644 --- a/packages/slider_m3e/lib/src/slider_theme_m3e.dart +++ b/packages/slider_m3e/lib/src/slider_theme_m3e.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'slider_tokens_adapter.dart'; + import 'enums.dart'; +import 'slider_tokens_adapter.dart'; SliderThemeData sliderThemeM3E( BuildContext context, { @@ -42,7 +43,9 @@ SliderThemeData sliderThemeM3E( overlayColor: t.overlayColor(emphasis), valueIndicatorColor: t.valueIndicatorColor(), valueIndicatorTextStyle: t.valueIndicatorTextStyle(), - showValueIndicator: showValueIndicator ? ShowValueIndicator.onDrag : ShowValueIndicator.onlyForDiscrete, + showValueIndicator: showValueIndicator + ? ShowValueIndicator.onDrag + : ShowValueIndicator.onlyForDiscrete, thumbShape: thumbShape, overlayShape: RoundSliderOverlayShape(overlayRadius: m.overlayRadius), rangeThumbShape: shapeFamily == SliderM3EShapeFamily.round diff --git a/packages/split_button_m3e/lib/src/split_button.dart b/packages/split_button_m3e/lib/src/split_button.dart index 5f58505..23ce7be 100644 --- a/packages/split_button_m3e/lib/src/split_button.dart +++ b/packages/split_button_m3e/lib/src/split_button.dart @@ -100,6 +100,11 @@ class _SplitButtonM3EState extends State> { final pressedRadius = widget.size.pressedRadius; final innerRadius = widget.size.innerCornerRadius; const innerGap = SplitButtonM3ETokens.innerGap; + // Elevated style needs larger perceived separation between segments. + final double effectiveInnerGap = + widget.emphasis == SplitButtonM3EEmphasis.elevated + ? innerGap * 2 + : innerGap; final chevronTurns = _menuOpen ? SplitButtonM3ETokens.chevronOpenTurns : 0.0; @@ -220,7 +225,9 @@ class _SplitButtonM3EState extends State> { if (!widget.enabled) return; setState(() => _trailingPressed = v); }, - onTap: widget.enabled ? () => _openMenu(context) : null, + onTap: widget.enabled + ? () => _openMenu(_trailingKey.currentContext ?? context) + : null, child: Padding( padding: EdgeInsetsDirectional.only( start: trailingLeftPad, @@ -241,18 +248,39 @@ class _SplitButtonM3EState extends State> { ), ); - return FocusTraversalGroup( - policy: ReadingOrderTraversalPolicy(), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: minTap), - child: Row( - mainAxisSize: MainAxisSize.min, - textDirection: dir, - children: [ - leading, - const SizedBox(width: innerGap), - trailing, - ], + // Menu theme to match SplitButton design (colors, font, shape) + final theme = Theme.of(context); + final m3e = context.m3e; + final bool contIsTransparent = cont.a == 0.0; + final Color menuColor = contIsTransparent + ? theme.colorScheme.surfaceContainerHigh + : cont; + final TextStyle? menuTextStyle = m3e.typography.base.labelLarge?.copyWith( + color: contIsTransparent ? theme.colorScheme.onSurface : onCont, + ); + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.size.pressedRadius), + ); + + return PopupMenuTheme( + data: theme.popupMenuTheme.copyWith( + color: menuColor, + textStyle: menuTextStyle, + shape: shape, + ), + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: minTap), + child: Row( + mainAxisSize: MainAxisSize.min, + textDirection: dir, + children: [ + leading, + SizedBox(width: effectiveInnerGap), + trailing, + ], + ), ), ), ); @@ -311,9 +339,21 @@ class _SplitButtonM3EState extends State> { Future _openMenu(BuildContext context) async { if (widget.menuBuilder != null) { setState(() => _menuOpen = true); + // Enforce menu min width to trailing button width + Size _tSize = Size.zero; + final tCtx = _trailingKey.currentContext; + if (tCtx != null) { + final tb = tCtx.findRenderObject() as RenderBox?; + if (tb != null) _tSize = tb.size; + } + final double _minMenuWidth = _tSize.width > 0 + ? _tSize.width + : widget.size.trailingWidthCentered; + final res = await showMenu( context: context, position: _menuPosition(context), + constraints: BoxConstraints(minWidth: _minMenuWidth), items: widget.menuBuilder!(context), ); if (mounted) { @@ -326,18 +366,54 @@ class _SplitButtonM3EState extends State> { // Convert simple items to PopupMenuEntries final items = widget.items!; setState(() => _menuOpen = true); + + // Ensure menu item text/icon colors match the button's foreground (onCont) + final theme = Theme.of(context); + final m3e = context.m3e; + final ( + Color _cont, + Color onCont, + BorderSide? _outlineSide, + double? _elevation, + ) = _resolveColorsAndShapes( + context, + ); + + // Enforce menu min width to trailing button width + Size _tSize = Size.zero; + final tCtx2 = _trailingKey.currentContext; + if (tCtx2 != null) { + final tb2 = tCtx2.findRenderObject() as RenderBox?; + if (tb2 != null) _tSize = tb2.size; + } + final double _minMenuWidth2 = _tSize.width > 0 + ? _tSize.width + : widget.size.trailingWidthCentered; + final res = await showMenu( context: context, position: _menuPosition(context), - items: items - .map( - (e) => PopupMenuItem( - value: e.value, - enabled: e.enabled, - child: e.child is Widget ? e.child as Widget : Text('${e.child}'), - ), - ) - .toList(), + constraints: BoxConstraints(minWidth: _minMenuWidth2), + items: items.map((e) { + final Color effective = e.enabled + ? onCont + : onCont.withValues(alpha: 0.38); + final Widget baseChild = e.child is Widget + ? e.child as Widget + : Text('${e.child}'); + final Widget styledChild = IconTheme.merge( + data: IconThemeData(color: effective, size: widget.size.iconPx), + child: DefaultTextStyle.merge( + style: TextStyle(color: effective), + child: baseChild, + ), + ); + return PopupMenuItem( + value: e.value, + enabled: e.enabled, + child: styledChild, + ); + }).toList(), ); if (!mounted) return; setState(() => _menuOpen = false); @@ -345,41 +421,46 @@ class _SplitButtonM3EState extends State> { } RelativeRect _menuPosition(BuildContext context) { - final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; - final textDir = Directionality.of(context); + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; - // Default to whole control if trailing key is missing - RenderBox? tb; - Offset tTopLeft = Offset.zero; - Size tSize = Size.zero; - final tCtx = _trailingKey.currentContext; - if (tCtx != null) { - tb = tCtx.findRenderObject() as RenderBox?; - } - if (tb != null) { - tTopLeft = tb.localToGlobal(Offset.zero); - tSize = tb.size; - } else { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - tTopLeft = box.localToGlobal(Offset.zero); - tSize = box.size; - } + // Prefer the trailing segment as the anchor, fallback to the whole control. + final BuildContext? tCtx = _trailingKey.currentContext; + RenderBox? targetBox = tCtx?.findRenderObject() as RenderBox?; + targetBox ??= context.findRenderObject() as RenderBox?; + if (targetBox == null) { + // If we can't resolve a box, fill as a safe (rare) fallback. + return RelativeRect.fill; } - final top = tTopLeft.dy + tSize.height; + final Offset targetTopLeft = targetBox.localToGlobal( + Offset.zero, + ancestor: overlay, + ); + final Rect targetRect = Rect.fromLTWH( + targetTopLeft.dx, + targetTopLeft.dy, + targetBox.size.width, + targetBox.size.height, + ); + // Place the menu just below the trailing segment with a small vertical gap, + // keeping horizontal alignment anchored to the trailing edge. + const double _kMenuVerticalOffset = 4.0; + final double top = targetRect.bottom + _kMenuVerticalOffset; + + final TextDirection textDir = Directionality.of(context); late double left; late double right; if (textDir == TextDirection.ltr) { - final endX = tTopLeft.dx + tSize.width; // right edge - left = endX; - right = overlay.size.width - endX; + final double endX = targetRect.right; // trailing edge in LTR + left = 0.0; + right = overlay.size.width - endX; // align menu's right edge to endX } else { - final startX = tTopLeft.dx; // left edge is trailing in RTL - left = startX; - right = overlay.size.width - startX; + final double startX = targetRect.left; // trailing edge in RTL + left = startX; // align menu's left edge to startX + right = 0.0; } return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top); diff --git a/packages/toolbar_m3e/lib/src/toolbar_m3e.dart b/packages/toolbar_m3e/lib/src/toolbar_m3e.dart index 5c390d3..e90fb8a 100644 --- a/packages/toolbar_m3e/lib/src/toolbar_m3e.dart +++ b/packages/toolbar_m3e/lib/src/toolbar_m3e.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:m3e_design/m3e_design.dart'; + import 'enums.dart'; -import 'toolbar_tokens_adapter.dart'; import 'toolbar_action_m3e.dart'; +import 'toolbar_tokens_adapter.dart'; class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { const ToolbarM3E({ @@ -59,9 +60,12 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize { // A rough default; actual height is resolved at build based on size/density. switch (size) { - case ToolbarM3ESize.small: return const Size.fromHeight(40); - case ToolbarM3ESize.medium: return const Size.fromHeight(48); - case ToolbarM3ESize.large: return const Size.fromHeight(56); + case ToolbarM3ESize.small: + return const Size.fromHeight(40); + case ToolbarM3ESize.medium: + return const Size.fromHeight(48); + case ToolbarM3ESize.large: + return const Size.fromHeight(56); } } @@ -69,7 +73,8 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { final tokens = ToolbarTokensAdapter(context); final metrics = tokens.metrics(density); - final m3e = Theme.of(context).extension() ?? M3ETheme.defaults(Theme.of(context).colorScheme); + final m3e = Theme.of(context).extension() ?? + M3ETheme.defaults(Theme.of(context).colorScheme); final height = switch (size) { ToolbarM3ESize.small => metrics.heightSmall, @@ -84,12 +89,18 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { final resolvedTitle = title ?? (titleText != null - ? Text(titleText!, style: tokens.titleStyle().copyWith(color: fg), overflow: TextOverflow.ellipsis) + ? Text(titleText!, + style: tokens.titleStyle().copyWith(color: fg), + overflow: TextOverflow.ellipsis) : null); final resolvedSubtitle = subtitle ?? (subtitleText != null - ? Text(subtitleText!, style: tokens.subtitleStyle().copyWith(color: fg.withValues(alpha: 0.8)), overflow: TextOverflow.ellipsis) + ? Text(subtitleText!, + style: tokens + .subtitleStyle() + .copyWith(color: fg.withValues(alpha: 0.8)), + overflow: TextOverflow.ellipsis) : null); final toolbarRow = Row( @@ -118,7 +129,10 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { final bar = Material( color: bg, - elevation: elevation ?? (variant == ToolbarM3EVariant.surface ? metrics.elevationSurface : metrics.elevationProminent), + elevation: elevation ?? + (variant == ToolbarM3EVariant.surface + ? metrics.elevationSurface + : metrics.elevationProminent), shape: shape, clipBehavior: clipBehavior, child: SizedBox( @@ -127,13 +141,17 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { padding: pad, child: IconTheme.merge( data: IconThemeData(color: fg, size: metrics.iconSize), - child: DefaultTextStyle.merge(style: TextStyle(color: fg), child: toolbarRow), + child: DefaultTextStyle.merge( + style: TextStyle(color: fg), child: toolbarRow), ), ), ), ); - final content = safeArea ? SafeArea(top: false, left: false, right: false, bottom: false, child: bar) : bar; + final content = safeArea + ? SafeArea( + top: false, left: false, right: false, bottom: false, child: bar) + : bar; if (semanticLabel == null) return content; return Semantics(container: true, label: semanticLabel!, child: content); @@ -141,7 +159,8 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget { } class _TitleBlock extends StatelessWidget { - const _TitleBlock({required this.title, required this.subtitle, required this.center}); + const _TitleBlock( + {required this.title, required this.subtitle, required this.center}); final Widget? title; final Widget? subtitle; final bool center; @@ -152,10 +171,15 @@ class _TitleBlock extends StatelessWidget { final col = Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: center ? CrossAxisAlignment.center : CrossAxisAlignment.start, + crossAxisAlignment: + center ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ - if (title != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.titleSmall!, child: title!), - if (subtitle != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.bodySmall!, child: subtitle!), + if (title != null) + DefaultTextStyle.merge( + style: Theme.of(context).textTheme.titleSmall!, child: title!), + if (subtitle != null) + DefaultTextStyle.merge( + style: Theme.of(context).textTheme.bodySmall!, child: subtitle!), ], ); @@ -187,17 +211,23 @@ class _ActionsRow extends StatelessWidget { Widget build(BuildContext context) { if (actions.isEmpty) return const SizedBox.shrink(); final inline = actions.take(maxInline).toList(growable: false); - final overflow = actions.length > maxInline ? actions.sublist(maxInline) : const []; + final overflow = actions.length > maxInline + ? actions.sublist(maxInline) + : const []; final row = Row( mainAxisSize: MainAxisSize.min, children: [ - for (final a in inline) ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize), + for (final a in inline) + ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize), if (overflow.isNotEmpty) _OverflowMenu( actions: overflow, icon: overflowIcon, - textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: m3e.colors.onSurface), + textStyle: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: m3e.colors.onSurface), destructiveColor: m3e.colors.error, ), ], @@ -230,10 +260,14 @@ class _OverflowMenu extends StatelessWidget { enabled: actions[i].enabled, child: DefaultTextStyle.merge( style: (actions[i].isDestructive - ? (textStyle?.copyWith(color: destructiveColor) ?? TextStyle(color: destructiveColor)) + ? (textStyle?.copyWith(color: destructiveColor) ?? + TextStyle(color: destructiveColor)) : textStyle) ?? const TextStyle(), - child: Text(actions[i].label ?? actions[i].tooltip ?? actions[i].semanticLabel ?? 'Action ${i + 1}'), + child: Text(actions[i].label ?? + actions[i].tooltip ?? + actions[i].semanticLabel ?? + 'Action ${i + 1}'), ), ), ], diff --git a/packages/toolbar_m3e/lib/toolbar_m3e.dart b/packages/toolbar_m3e/lib/toolbar_m3e.dart index cb7234a..ab70526 100644 --- a/packages/toolbar_m3e/lib/toolbar_m3e.dart +++ b/packages/toolbar_m3e/lib/toolbar_m3e.dart @@ -1,6 +1,6 @@ library toolbar_m3e; export 'src/enums.dart'; -export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter; export 'src/toolbar_action_m3e.dart'; export 'src/toolbar_m3e.dart'; +export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter;