diff --git a/apps/gallery/.metadata b/apps/gallery/.metadata index 84f56b1..e2cd2e9 100644 --- a/apps/gallery/.metadata +++ b/apps/gallery/.metadata @@ -15,24 +15,9 @@ migration: - platform: root create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - - platform: android - create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - - platform: ios - create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - - platform: linux - create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - - platform: macos - create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - platform: web create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - - platform: windows - create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 # User provided section diff --git a/apps/gallery/lib/sections/button_section.dart b/apps/gallery/lib/sections/button_section.dart index 897c2e5..bf670c6 100644 --- a/apps/gallery/lib/sections/button_section.dart +++ b/apps/gallery/lib/sections/button_section.dart @@ -41,6 +41,26 @@ class ButtonSection extends StatelessWidget { ), ], ], + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Text('with Icon', + style: Theme.of(context).textTheme.labelLarge), + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final variant in ButtonM3EStyle.values) + ButtonM3E( + icon: Icon(Icons.add), + label: Text(variant.name), + style: variant, + size: ButtonM3ESize.sm, + shape: ButtonM3EShape.round, + onPressed: () {}, + ), + ], + ), ], ), ); diff --git a/apps/gallery/lib/sections/icon_button_section.dart b/apps/gallery/lib/sections/icon_button_section.dart index 59b37c6..ff685db 100644 --- a/apps/gallery/lib/sections/icon_button_section.dart +++ b/apps/gallery/lib/sections/icon_button_section.dart @@ -33,6 +33,24 @@ class IconButtonSection extends StatelessWidget { ], ), ], + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('with badgeValue', + style: Theme.of(context).textTheme.titleMedium), + ), + Row( + spacing: 12, + children: [ + for (final size in IconButtonM3ESize.values) + IconButtonM3E( + icon: const Icon(Icons.favorite), + variant: IconButtonM3EVariant.filled, + size: size, + onPressed: () {}, + badgeValue: size.index + 1, + ), + ], + ) ], ), ); diff --git a/apps/gallery/lib/sections/navigation_section.dart b/apps/gallery/lib/sections/navigation_section.dart index 32a077b..9d548bb 100644 --- a/apps/gallery/lib/sections/navigation_section.dart +++ b/apps/gallery/lib/sections/navigation_section.dart @@ -34,13 +34,13 @@ class _NavigationSectionState extends State { icon: Icon(Icons.analytics_outlined), selectedIcon: Icon(Icons.analytics), label: 'Reports', - smallBadge: true, + badgeCount: 0, ), NavigationRailM3EDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings', - largeBadgeCount: 2, + badgeCount: 2, ), ], ), diff --git a/melos.yaml b/melos.yaml index 6cdd403..13d812b 100644 --- a/melos.yaml +++ b/melos.yaml @@ -16,7 +16,7 @@ scripts: test: melos exec -- flutter test --coverage create: run: dart run tool/create_component.dart - description: Scaffold a new [component]_m3e package (melos run create -- name=badge) + description: Scaffold a new [component]_m3e package (melos run create -- name=badgeValue) 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 diff --git a/packages/icon_button_m3e/lib/src/icon_button_m3e.dart b/packages/icon_button_m3e/lib/src/icon_button_m3e.dart index cf909a4..f783bcb 100644 --- a/packages/icon_button_m3e/lib/src/icon_button_m3e.dart +++ b/packages/icon_button_m3e/lib/src/icon_button_m3e.dart @@ -10,6 +10,7 @@ import 'enums.dart'; /// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected. /// - Widths: default, narrow, wide /// - Toggle: [isSelected] + [selectedIcon] +/// - Badge: [string] or [number] class IconButtonM3E extends StatelessWidget { const IconButtonM3E({ super.key, @@ -24,6 +25,7 @@ class IconButtonM3E extends StatelessWidget { this.isSelected, this.selectedIcon, this.enableFeedback, + this.badgeValue, }); final Widget icon; @@ -37,6 +39,7 @@ class IconButtonM3E extends StatelessWidget { final bool? isSelected; final Widget? selectedIcon; final bool? enableFeedback; + final Object? badgeValue; @override Widget build(BuildContext context) { @@ -123,7 +126,46 @@ class IconButtonM3E extends StatelessWidget { child: SizedBox( width: visual.width, height: visual.height, - child: button, + child: () { + final Object? v = badgeValue; + Widget? badge; + if (v == null) { + badge = null; + } else if (v is num) { + final int c = v.round().clamp(0, 999999); + badge = Badge.count( + count: c, + backgroundColor: scheme.error, + textColor: scheme.onError, + ); + } else if (v is String) { + if (v.isEmpty) { + badge = null; + } else { + badge = Badge( + label: Text(v), + backgroundColor: scheme.error, + textColor: scheme.onError, + ); + } + } else { + assert(() { + throw FlutterError( + 'IconButtonM3E.badgeValue must be a String or num, but got \'${v.runtimeType}\'.', + ); + }()); + badge = null; + } + return badge == null + ? button + : Stack( + clipBehavior: Clip.none, + children: [ + button, + PositionedDirectional(top: 0, end: 0, child: badge), + ], + ); + }(), ), ), ); diff --git a/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart b/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart index 93f28e1..d3fa639 100644 --- a/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart +++ b/packages/navigation_bar_m3e/lib/src/nav_destination_m3e.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; + + import 'nav_badge_m3e.dart'; class NavigationDestinationM3E { @@ -15,7 +17,7 @@ class NavigationDestinationM3E { final Widget? selectedIcon; final String label; - /// Optional badge counter + /// Optional badgeValue counter final int? badgeCount; /// If true, show a small dot instead of a counter. diff --git a/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart index 0a5021e..ebbb6dc 100644 --- a/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart +++ b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:m3e_design/m3e_design.dart'; + import 'enums.dart'; -import 'nav_tokens_adapter.dart'; import 'nav_destination_m3e.dart'; +import 'nav_tokens_adapter.dart'; class NavigationBarM3E extends StatelessWidget { const NavigationBarM3E({ @@ -10,7 +11,7 @@ class NavigationBarM3E extends StatelessWidget { required this.destinations, this.selectedIndex = 0, this.onDestinationSelected, - this.labelBehavior = NavBarM3ELabelBehavior.onlySelected, + this.labelBehavior = NavBarM3ELabelBehavior.alwaysShow, this.size = NavBarM3ESize.medium, this.shapeFamily = NavBarM3EShapeFamily.round, this.density = NavBarM3EDensity.regular, @@ -49,9 +50,12 @@ class NavigationBarM3E extends StatelessWidget { final tokens = NavTokensAdapter(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 = size == NavBarM3ESize.small ? metrics.heightSmall : metrics.heightMedium; + final height = size == NavBarM3ESize.small + ? metrics.heightSmall + : metrics.heightMedium; final bg = backgroundColor ?? tokens.containerColor(); final shape = tokens.containerShape(shapeFamily); @@ -69,21 +73,27 @@ class NavigationBarM3E extends StatelessWidget { : (indicatorColor ?? tokens.indicatorColor()), indicatorShape: switch (indicatorStyle) { NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(), - NavBarM3EIndicatorStyle.underline => const StadiumBorder(), // we'll fake underline via decoration below + NavBarM3EIndicatorStyle.underline => + const StadiumBorder(), // we'll fake underline via decoration below NavBarM3EIndicatorStyle.none => const StadiumBorder(), }, - backgroundColor: Colors.transparent, // outer Material supplies bg + shape + backgroundColor: + Colors.transparent, // outer Material supplies bg + shape labelBehavior: switch (labelBehavior) { - NavBarM3ELabelBehavior.alwaysShow => NavigationDestinationLabelBehavior.alwaysShow, - NavBarM3ELabelBehavior.onlySelected => NavigationDestinationLabelBehavior.onlyShowSelected, - NavBarM3ELabelBehavior.alwaysHide => NavigationDestinationLabelBehavior.alwaysHide, + NavBarM3ELabelBehavior.alwaysShow => + NavigationDestinationLabelBehavior.alwaysShow, + NavBarM3ELabelBehavior.onlySelected => + NavigationDestinationLabelBehavior.onlyShowSelected, + NavBarM3ELabelBehavior.alwaysHide => + NavigationDestinationLabelBehavior.alwaysHide, }, selectedIndex: selectedIndex, destinations: List.generate(destinations.length, (i) { final d = destinations[i]; return NavigationDestination( icon: _icon(context, false, d, metrics.iconSize), - selectedIcon: _selectedIcon(context, true, d, metrics.iconSize, tokens, indicatorStyle), + selectedIcon: _selectedIcon( + context, true, d, metrics.iconSize, tokens, indicatorStyle), label: d.label, tooltip: d.semanticLabel, ); @@ -100,22 +110,29 @@ class NavigationBarM3E extends StatelessWidget { final content = DefaultTextStyle.merge( style: tokens.labelStyle().copyWith( - color: m3e.colors.onSurfaceVariant, - ), + color: m3e.colors.onSurfaceVariant, + ), child: IconTheme.merge( - data: IconThemeData(size: metrics.iconSize, color: m3e.colors.onSurfaceVariant), + data: IconThemeData( + size: metrics.iconSize, color: m3e.colors.onSurfaceVariant), child: padded, ), ); if (!safeArea && semanticLabel == null) return content; - final wrapped = SafeArea(top: false, left: false, right: false, bottom: safeArea, child: content); + final wrapped = SafeArea( + top: false, + left: false, + right: false, + bottom: safeArea, + child: content); if (semanticLabel == null) return wrapped; return Semantics(container: true, label: semanticLabel!, child: wrapped); } - Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d, double iconSize) { + Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d, + double iconSize) { return SizedBox( width: iconSize + 8, // give a little space for underline height: iconSize + 8, @@ -135,7 +152,8 @@ class NavigationBarM3E extends StatelessWidget { if (style != NavBarM3EIndicatorStyle.underline) return w; final metrics = tokens.metrics(density); - final deco = tokens.underlineDecoration(tokens.indicatorColor(), metrics.indicatorThickness); + final deco = tokens.underlineDecoration( + tokens.indicatorColor(), metrics.indicatorThickness); return DecoratedBox( decoration: deco, child: w, diff --git a/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart b/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart deleted file mode 100644 index bb11146..0000000 --- a/packages/navigation_bar_m3e/lib/src/navigation_bar_m3e_widget.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:m3e_design/m3e_design.dart'; - -class NavigationBarM3EWidget extends StatelessWidget { - const NavigationBarM3EWidget({super.key}); - - @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('NavigationBar placeholder', style: m3e.typography.base.titleMedium), - ); - } -} - -String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join(); diff --git a/packages/navigation_rail_m3e/example/lib/main.dart b/packages/navigation_rail_m3e/example/lib/main.dart index 68dcf5b..9477cdf 100644 --- a/packages/navigation_rail_m3e/example/lib/main.dart +++ b/packages/navigation_rail_m3e/example/lib/main.dart @@ -29,13 +29,13 @@ class _DemoAppState extends State { icon: Icon(Icons.star_outline), selectedIcon: Icon(Icons.star), label: 'Starred', - smallBadge: true, + badgeCount: 0, ), NavigationRailM3EDestination( icon: Icon(Icons.inbox_outlined), selectedIcon: Icon(Icons.inbox), label: 'Inbox', - largeBadgeCount: 3, + badgeCount: 3, ), ], ), diff --git a/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart b/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart index 0a58fcf..250492a 100644 --- a/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart +++ b/packages/navigation_rail_m3e/lib/navigation_rail_m3e.dart @@ -2,12 +2,13 @@ library navigation_rail_m3e; -export 'src/type.dart'; export 'src/modality.dart'; -export 'src/rail_theme.dart'; -export 'src/rail_tokens_adapter.dart'; +export 'src/navigation_rail_m3e_widget.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 +export 'src/rail_item_button_m3e.dart'; +export 'src/rail_section_m3e.dart'; +export 'src/rail_theme.dart'; +export 'src/rail_tokens_adapter.dart'; +export 'src/type.dart'; 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 c6df1ef..b8fa7ef 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 @@ -26,6 +26,7 @@ class NavigationRailM3E extends StatefulWidget { this.expandedWidth, this.onDismissModal, this.onTypeChanged, + this.labelBehavior = NavigationRailM3ELabelBehavior.alwaysShow, }); /// Presentation type for the rail (collapsed or expanded). @@ -58,6 +59,9 @@ class NavigationRailM3E extends StatefulWidget { /// Called when the built-in menu button toggles the rail type. final ValueChanged? onTypeChanged; + /// Controls how labels are shown when the rail is expanded. + final NavigationRailM3ELabelBehavior labelBehavior; + @override State createState() => _NavigationRailM3EState(); } @@ -65,10 +69,15 @@ class NavigationRailM3E extends StatefulWidget { class _NavigationRailM3EState extends State with TickerProviderStateMixin { OverlayEntry? _modalEntry; + OverlayEntry? _collapsedPeekEntry; + final LayerLink _anchor = LayerLink(); + bool _suppressInk = false; bool get _isExpanded => widget.type == NavigationRailM3EType.expanded; bool get _isModal => widget.modality == NavigationRailM3EModality.modal; - bool get _needsOverlay => _isModal; + bool get _needsOverlay => _isModal && _isExpanded; + bool get _needsCollapsedPeek => + !_isExpanded && !_isModal && widget.hideWhenCollapsed; @override void initState() { @@ -79,17 +88,29 @@ class _NavigationRailM3EState extends State @override void didUpdateWidget(covariant NavigationRailM3E oldWidget) { super.didUpdateWidget(oldWidget); + + // Suppress ink effects briefly during type transitions to avoid flicker. + if (oldWidget.type != widget.type) { + setState(() => _suppressInk = true); + Future.delayed(const Duration(milliseconds: 320), () { + if (mounted) setState(() => _suppressInk = false); + }); + } + WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay()); } @override void dispose() { _removeOverlay(); + _removeCollapsedPeekOverlay(); super.dispose(); } void _syncOverlay() { if (!mounted) return; + + // Expanded modal overlay management if (_needsOverlay) { if (_modalEntry == null) { _insertOverlay(); @@ -99,6 +120,17 @@ class _NavigationRailM3EState extends State } else { _removeOverlay(); } + + // Collapsed peek overlay management (standard modality with hideWhenCollapsed) + if (_needsCollapsedPeek) { + if (_collapsedPeekEntry == null) { + _insertCollapsedPeekOverlay(); + } else { + _collapsedPeekEntry!.markNeedsBuild(); + } + } else { + _removeCollapsedPeekOverlay(); + } } void _insertOverlay() { @@ -113,6 +145,19 @@ class _NavigationRailM3EState extends State _modalEntry = null; } + void _insertCollapsedPeekOverlay() { + final overlay = Overlay.of(context, rootOverlay: true); + if (overlay == null) return; + _collapsedPeekEntry = + OverlayEntry(builder: (ctx) => _buildCollapsedPeekOverlay(ctx)); + overlay.insert(_collapsedPeekEntry!); + } + + void _removeCollapsedPeekOverlay() { + _collapsedPeekEntry?.remove(); + _collapsedPeekEntry = null; + } + Widget _buildModalOverlay(BuildContext context) { return Stack( children: [ @@ -143,6 +188,39 @@ class _NavigationRailM3EState extends State ); } + Widget _buildCollapsedPeekOverlay(BuildContext context) { + // A small floating menu button anchored to the rail's target, visible when + // the rail is fully hidden (hideWhenCollapsed == true). + Widget btn = IconButtonM3E( + icon: const Icon(Icons.menu), + tooltip: 'Expand', + onPressed: widget.onTypeChanged == null + ? null + : () => widget.onTypeChanged!(NavigationRailM3EType.expanded), + ); + if (_suppressInk) { + final t = Theme.of(context); + btn = Theme( + data: t.copyWith( + splashFactory: NoSplash.splashFactory, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: btn, + ); + } + + return CompositedTransformFollower( + link: _anchor, + showWhenUnlinked: false, + offset: const Offset(8, 36), // slight inset and same top spacing as rail + child: Material( + type: MaterialType.transparency, + child: btn, + ), + ); + } + double _targetWidth(BuildContext context) { final theme = Theme.of(context).extension() ?? const NavigationRailM3ETheme(); @@ -157,21 +235,35 @@ class _NavigationRailM3EState extends State Widget _buildMenuButton(BuildContext context, {required Alignment alignment}) { final isExpanded = _isExpanded; + Widget button = 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, + ), + ); + + if (_suppressInk) { + final t = Theme.of(context); + button = Theme( + data: t.copyWith( + splashFactory: NoSplash.splashFactory, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: button, + ); + } + 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, - ), - ), + child: button, ), ); } @@ -193,7 +285,7 @@ class _NavigationRailM3EState extends State size: fab.size, shapeFamily: FabM3EShapeFamily.square, density: fab.density, - elevation: fab.elevation, + elevation: 0, semanticLabel: fab.semanticLabel, ) : FabM3E( @@ -205,13 +297,14 @@ class _NavigationRailM3EState extends State size: fab.size, shapeFamily: FabM3EShapeFamily.square, density: fab.density, - elevation: fab.elevation, + elevation: 0, semanticLabel: fab.semanticLabel, ), ); } - List _buildChildren(BuildContext context) { + List _buildChildren(BuildContext context, + {required bool showLabels}) { final theme = Theme.of(context).extension() ?? const NavigationRailM3ETheme(); final isExpanded = _isExpanded; @@ -250,6 +343,8 @@ class _NavigationRailM3EState extends State selected: index == widget.selectedIndex, onTap: () => widget.onDestinationSelected(index), expanded: true, + labelBehavior: widget.labelBehavior, + suppressInk: _suppressInk, ), )); } @@ -265,6 +360,8 @@ class _NavigationRailM3EState extends State selected: i == widget.selectedIndex, onTap: () => widget.onDestinationSelected(i), expanded: false, + labelBehavior: widget.labelBehavior, + suppressInk: _suppressInk, ), )); } @@ -280,9 +377,14 @@ class _NavigationRailM3EState extends State curve: Curves.easeOutCubic, width: width, decoration: BoxDecoration(color: tokens.containerColor), - child: ListView( - padding: EdgeInsets.zero, - children: _buildChildren(context), + child: LayoutBuilder( + builder: (ctx, constraints) { + final showLabels = _isExpanded && constraints.maxWidth >= 180; + return ListView( + padding: EdgeInsets.zero, + children: _buildChildren(ctx, showLabels: showLabels), + ); + }, ), ); } @@ -292,13 +394,11 @@ class _NavigationRailM3EState extends State // 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(); - } + final Widget child = + _needsOverlay ? const SizedBox.shrink() : _buildRailCore(context); - return _buildRailCore(context); + // Always provide an anchor target for positioning collapsed peek overlay. + return CompositedTransformTarget(link: _anchor, child: child); } static int _destinationIndex(List sections, 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 b71cf3c..0ae3685 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart @@ -1,20 +1,19 @@ -import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'rail_tokens_adapter.dart'; -/// Large numeric badge for rail items (0..999+). One class per file. +/// Large numeric badgeValue for rail items (0..999+). One class per file. class RailBadgeM3E extends StatelessWidget { - /// Creates a large numeric badge. + /// Creates a large numeric badgeValue. const RailBadgeM3E({ super.key, - required this.count, + this.count, this.maxDigits = 3, this.dense = false, }); - /// The numeric value to display in the badge. - final int count; + /// The numeric value to display in the badgeValue. + final int? count; /// Maximum digits before showing a trailing '+' (e.g. 999+). final int maxDigits; @@ -24,25 +23,37 @@ class RailBadgeM3E extends StatelessWidget { @override Widget build(BuildContext context) { + if (count == null) { + return const SizedBox.shrink(); + } final tokens = NavigationRailTokensAdapter(context); - final String text = count > (10 * (pow10(maxDigits) - 1)) + final String text = count! > (10 * (pow10(maxDigits) - 1)) ? '${pow10(maxDigits) - 1}+' : '$count'; final double pad = dense ? 2 : 4; - return Container( - padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad), - decoration: BoxDecoration( - color: tokens.badgeLargeBackground, - borderRadius: BorderRadius.circular(999), - ), - child: DefaultTextStyle( - style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: tokens.badgeLargeLabel, - fontFeatures: const [FontFeature.tabularFigures()], - ), - child: Text(text, maxLines: 1), - ), - ); + return count == 0 + ? Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tokens.badgeBackground, + shape: BoxShape.circle, + ), + ) + : Container( + padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad), + decoration: BoxDecoration( + color: tokens.badgeBackground, + borderRadius: BorderRadius.circular(999), + ), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.labelSmall!.copyWith( + color: tokens.badgeLargeLabel, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Text(text, maxLines: 1), + ), + ); } /// Returns 10 to the power of [n]. @@ -54,3 +65,19 @@ class RailBadgeM3E extends StatelessWidget { return v; } } + +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_collapsed.dart b/packages/navigation_rail_m3e/lib/src/rail_collapsed.dart deleted file mode 100644 index 0e53668..0000000 --- a/packages/navigation_rail_m3e/lib/src/rail_collapsed.dart +++ /dev/null @@ -1,110 +0,0 @@ -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 edf609e..430c540 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_destination_m3e.dart @@ -6,8 +6,7 @@ class NavigationRailM3EDestination { required this.icon, this.selectedIcon, required this.label, - this.largeBadgeCount, - this.smallBadge = false, + this.badgeCount, this.semanticLabel, this.short = false, }); @@ -15,8 +14,7 @@ class NavigationRailM3EDestination { final Widget icon; final Widget? selectedIcon; final String label; - final int? largeBadgeCount; - final bool smallBadge; + final int? badgeCount; final String? semanticLabel; /// If true, uses short item height (56dp) instead of 64dp. diff --git a/packages/navigation_rail_m3e/lib/src/rail_expanded.dart b/packages/navigation_rail_m3e/lib/src/rail_expanded.dart deleted file mode 100644 index 3d0e2de..0000000 --- a/packages/navigation_rail_m3e/lib/src/rail_expanded.dart +++ /dev/null @@ -1,171 +0,0 @@ -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_item.dart b/packages/navigation_rail_m3e/lib/src/rail_item.dart index db655d5..c6cd600 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_item.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_item.dart @@ -1,9 +1,5 @@ import 'package:flutter/material.dart'; - -import 'rail_badge_m3e.dart'; -import 'rail_destination_m3e.dart'; -import 'rail_theme.dart'; -import 'rail_tokens_adapter.dart'; +import 'package:navigation_rail_m3e/navigation_rail_m3e.dart'; /// Single rail item (private to package). One class per file. class RailItem extends StatelessWidget { @@ -14,17 +10,28 @@ class RailItem extends StatelessWidget { required this.selected, required this.onTap, required this.expanded, + required this.labelBehavior, + this.suppressInk = false, }); /// 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; + /// Whether this item's label should be visible. + final NavigationRailM3ELabelBehavior labelBehavior; + + /// When true, disables splash/hover/highlight effects to prevent flicker during transitions. + final bool suppressInk; + @override Widget build(BuildContext context) { final theme = Theme.of(context).extension() ?? @@ -32,95 +39,41 @@ class RailItem extends StatelessWidget { 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 Widget button = RailItemButtonM3E( + icon: destination.icon, + selectedIcon: destination.selectedIcon, + isSelected: selected, + onPressed: onTap, + expanded: expanded, + labelBehavior: labelBehavior, + label: destination.label, + semanticLabel: destination.semanticLabel, + suppressInk: suppressInk, + badgeCount: destination.badgeCount, ); - 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, - ), - ); + Widget core; + if (!expanded) { + // Collapsed: left-aligned icon-only button with 48x48 tap target. + core = SizedBox( + height: height, + child: Align(alignment: Alignment.centerLeft, child: button), + ); + } else { + core = ConstrainedBox( + constraints: BoxConstraints(minHeight: height), + child: Row( + children: [ + Expanded(child: button), + ], + ), + ); + } - 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, - ), + child: core, ); } } 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 752c29c..1bfec11 100644 --- a/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart +++ b/packages/navigation_rail_m3e/lib/src/rail_tokens_adapter.dart @@ -35,7 +35,7 @@ class NavigationRailTokensAdapter { _cs.onSecondaryContainer; } - Color get badgeLargeBackground => + Color get badgeBackground => _maybe(() => context.m3e.colors.error) ?? _cs.error; Color get badgeLargeLabel => _maybe(() => context.m3e.colors.onError) ?? _cs.onError; diff --git a/packages/navigation_rail_m3e/lib/src/type.dart b/packages/navigation_rail_m3e/lib/src/type.dart index a47bcca..58e7202 100644 --- a/packages/navigation_rail_m3e/lib/src/type.dart +++ b/packages/navigation_rail_m3e/lib/src/type.dart @@ -1,5 +1,3 @@ -import 'package:flutter/foundation.dart'; - /// M3 Expressive types for the rail. enum NavigationRailM3EType { /// Slim 96dp rail. @@ -12,4 +10,15 @@ enum NavigationRailM3EType { extension NavigationRailM3ETypeX on NavigationRailM3EType { bool get isCollapsed => this == NavigationRailM3EType.collapsed; bool get isExpanded => this == NavigationRailM3EType.expanded; -} \ No newline at end of file +} + +/// Controls how labels are shown for rail destinations when the rail is expanded. +/// +/// - alwaysShow (default): show all labels (subject to width constraints). +/// - onlySelected: show the label only for the selected destination. +/// - alwaysHide: never show labels even when expanded. +enum NavigationRailM3ELabelBehavior { + alwaysShow, + onlySelected, + alwaysHide, +} diff --git a/packages/navigation_rail_m3e/pubspec.yaml b/packages/navigation_rail_m3e/pubspec.yaml index b14489c..0c96435 100644 --- a/packages/navigation_rail_m3e/pubspec.yaml +++ b/packages/navigation_rail_m3e/pubspec.yaml @@ -18,6 +18,9 @@ dependencies: # Icon button used by the rail's menu control. icon_button_m3e: path: ../icon_button_m3e + # Buttons used by rail items in expanded state. + button_m3e: + path: ../button_m3e dev_dependencies: flutter_test: