Refactor NavigationRailM3E to simplify rail types; update badge handling and expand functionality of navigation components.

This commit is contained in:
Emily Pauli 2025-10-23 14:32:23 +02:00
commit 5b27a91894
20 changed files with 360 additions and 486 deletions

View file

@ -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

View file

@ -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: () {},
),
],
),
],
),
);

View file

@ -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,
),
],
)
],
),
);

View file

@ -34,13 +34,13 @@ class _NavigationSectionState extends State<NavigationSection> {
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,
),
],
),

View file

@ -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

View file

@ -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),
],
);
}(),
),
),
);

View file

@ -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.

View file

@ -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>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final m3e = Theme.of(context).extension<M3ETheme>() ??
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,

View file

@ -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();

View file

@ -29,13 +29,13 @@ class _DemoAppState extends State<DemoApp> {
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,
),
],
),

View file

@ -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';
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';

View file

@ -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<NavigationRailM3EType>? onTypeChanged;
/// Controls how labels are shown when the rail is expanded.
final NavigationRailM3ELabelBehavior labelBehavior;
@override
State<NavigationRailM3E> createState() => _NavigationRailM3EState();
}
@ -65,10 +69,15 @@ class NavigationRailM3E extends StatefulWidget {
class _NavigationRailM3EState extends State<NavigationRailM3E>
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<NavigationRailM3E>
@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<NavigationRailM3E>
} 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<NavigationRailM3E>
_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<NavigationRailM3E>
);
}
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<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme();
@ -157,21 +235,35 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
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<NavigationRailM3E>
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<NavigationRailM3E>
size: fab.size,
shapeFamily: FabM3EShapeFamily.square,
density: fab.density,
elevation: fab.elevation,
elevation: 0,
semanticLabel: fab.semanticLabel,
),
);
}
List<Widget> _buildChildren(BuildContext context) {
List<Widget> _buildChildren(BuildContext context,
{required bool showLabels}) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme();
final isExpanded = _isExpanded;
@ -250,6 +343,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
selected: index == widget.selectedIndex,
onTap: () => widget.onDestinationSelected(index),
expanded: true,
labelBehavior: widget.labelBehavior,
suppressInk: _suppressInk,
),
));
}
@ -265,6 +360,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
selected: i == widget.selectedIndex,
onTap: () => widget.onDestinationSelected(i),
expanded: false,
labelBehavior: widget.labelBehavior,
suppressInk: _suppressInk,
),
));
}
@ -280,9 +377,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
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<NavigationRailM3E>
// 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<NavigationRailM3ESection> sections,

View file

@ -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,
),
);
}
}

View file

@ -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<NavigationRailM3ESection> sections;
/// Currently selected destination index.
final int selectedIndex;
/// Callback when a destination is tapped.
final ValueChanged<int> 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<NavigationRailM3ETheme>() ??
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,
);
}
}

View file

@ -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.

View file

@ -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 (220360dp). 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<NavigationRailM3ESection> sections;
/// Currently selected destination index.
final int selectedIndex;
/// Callback when a destination is tapped.
final ValueChanged<int> 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<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme();
final tokens = NavigationRailTokensAdapter(context);
final w = (width ?? theme.expandedMinWidth)
.clamp(theme.expandedMinWidth, theme.expandedMaxWidth);
final children = <Widget>[
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<NavigationRailM3ESection> 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;
}
}

View file

@ -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<NavigationRailM3ETheme>() ??
@ -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,
);
}
}

View file

@ -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;

View file

@ -1,5 +1,3 @@
import 'package:flutter/foundation.dart';
/// M3 Expressive types for the rail.
enum NavigationRailM3EType {
/// Slim 96dp rail.
@ -13,3 +11,14 @@ extension NavigationRailM3ETypeX on NavigationRailM3EType {
bool get isCollapsed => this == NavigationRailM3EType.collapsed;
bool get isExpanded => this == NavigationRailM3EType.expanded;
}
/// 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,
}

View file

@ -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: