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 - platform: root
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_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 - platform: web
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: windows
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
# User provided section # 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), icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics), selectedIcon: Icon(Icons.analytics),
label: 'Reports', label: 'Reports',
smallBadge: true, badgeCount: 0,
), ),
NavigationRailM3EDestination( NavigationRailM3EDestination(
icon: Icon(Icons.settings_outlined), icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings), selectedIcon: Icon(Icons.settings),
label: 'Settings', label: 'Settings',
largeBadgeCount: 2, badgeCount: 2,
), ),
], ],
), ),

View file

@ -16,7 +16,7 @@ scripts:
test: melos exec -- flutter test --coverage test: melos exec -- flutter test --coverage
create: create:
run: dart run tool/create_component.dart 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: pub-dry-run:
run: melos exec -c 1 --no-private -- "flutter pub publish --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 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. /// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected.
/// - Widths: default, narrow, wide /// - Widths: default, narrow, wide
/// - Toggle: [isSelected] + [selectedIcon] /// - Toggle: [isSelected] + [selectedIcon]
/// - Badge: [string] or [number]
class IconButtonM3E extends StatelessWidget { class IconButtonM3E extends StatelessWidget {
const IconButtonM3E({ const IconButtonM3E({
super.key, super.key,
@ -24,6 +25,7 @@ class IconButtonM3E extends StatelessWidget {
this.isSelected, this.isSelected,
this.selectedIcon, this.selectedIcon,
this.enableFeedback, this.enableFeedback,
this.badgeValue,
}); });
final Widget icon; final Widget icon;
@ -37,6 +39,7 @@ class IconButtonM3E extends StatelessWidget {
final bool? isSelected; final bool? isSelected;
final Widget? selectedIcon; final Widget? selectedIcon;
final bool? enableFeedback; final bool? enableFeedback;
final Object? badgeValue;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -123,7 +126,46 @@ class IconButtonM3E extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: visual.width, width: visual.width,
height: visual.height, 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 'package:flutter/material.dart';
import 'nav_badge_m3e.dart'; import 'nav_badge_m3e.dart';
class NavigationDestinationM3E { class NavigationDestinationM3E {
@ -15,7 +17,7 @@ class NavigationDestinationM3E {
final Widget? selectedIcon; final Widget? selectedIcon;
final String label; final String label;
/// Optional badge counter /// Optional badgeValue counter
final int? badgeCount; final int? badgeCount;
/// If true, show a small dot instead of a counter. /// If true, show a small dot instead of a counter.

View file

@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart'; import 'package:m3e_design/m3e_design.dart';
import 'enums.dart'; import 'enums.dart';
import 'nav_tokens_adapter.dart';
import 'nav_destination_m3e.dart'; import 'nav_destination_m3e.dart';
import 'nav_tokens_adapter.dart';
class NavigationBarM3E extends StatelessWidget { class NavigationBarM3E extends StatelessWidget {
const NavigationBarM3E({ const NavigationBarM3E({
@ -10,7 +11,7 @@ class NavigationBarM3E extends StatelessWidget {
required this.destinations, required this.destinations,
this.selectedIndex = 0, this.selectedIndex = 0,
this.onDestinationSelected, this.onDestinationSelected,
this.labelBehavior = NavBarM3ELabelBehavior.onlySelected, this.labelBehavior = NavBarM3ELabelBehavior.alwaysShow,
this.size = NavBarM3ESize.medium, this.size = NavBarM3ESize.medium,
this.shapeFamily = NavBarM3EShapeFamily.round, this.shapeFamily = NavBarM3EShapeFamily.round,
this.density = NavBarM3EDensity.regular, this.density = NavBarM3EDensity.regular,
@ -49,9 +50,12 @@ class NavigationBarM3E extends StatelessWidget {
final tokens = NavTokensAdapter(context); final tokens = NavTokensAdapter(context);
final metrics = tokens.metrics(density); 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 bg = backgroundColor ?? tokens.containerColor();
final shape = tokens.containerShape(shapeFamily); final shape = tokens.containerShape(shapeFamily);
@ -69,21 +73,27 @@ class NavigationBarM3E extends StatelessWidget {
: (indicatorColor ?? tokens.indicatorColor()), : (indicatorColor ?? tokens.indicatorColor()),
indicatorShape: switch (indicatorStyle) { indicatorShape: switch (indicatorStyle) {
NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(), 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(), NavBarM3EIndicatorStyle.none => const StadiumBorder(),
}, },
backgroundColor: Colors.transparent, // outer Material supplies bg + shape backgroundColor:
Colors.transparent, // outer Material supplies bg + shape
labelBehavior: switch (labelBehavior) { labelBehavior: switch (labelBehavior) {
NavBarM3ELabelBehavior.alwaysShow => NavigationDestinationLabelBehavior.alwaysShow, NavBarM3ELabelBehavior.alwaysShow =>
NavBarM3ELabelBehavior.onlySelected => NavigationDestinationLabelBehavior.onlyShowSelected, NavigationDestinationLabelBehavior.alwaysShow,
NavBarM3ELabelBehavior.alwaysHide => NavigationDestinationLabelBehavior.alwaysHide, NavBarM3ELabelBehavior.onlySelected =>
NavigationDestinationLabelBehavior.onlyShowSelected,
NavBarM3ELabelBehavior.alwaysHide =>
NavigationDestinationLabelBehavior.alwaysHide,
}, },
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
destinations: List.generate(destinations.length, (i) { destinations: List.generate(destinations.length, (i) {
final d = destinations[i]; final d = destinations[i];
return NavigationDestination( return NavigationDestination(
icon: _icon(context, false, d, metrics.iconSize), 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, label: d.label,
tooltip: d.semanticLabel, tooltip: d.semanticLabel,
); );
@ -100,22 +110,29 @@ class NavigationBarM3E extends StatelessWidget {
final content = DefaultTextStyle.merge( final content = DefaultTextStyle.merge(
style: tokens.labelStyle().copyWith( style: tokens.labelStyle().copyWith(
color: m3e.colors.onSurfaceVariant, color: m3e.colors.onSurfaceVariant,
), ),
child: IconTheme.merge( child: IconTheme.merge(
data: IconThemeData(size: metrics.iconSize, color: m3e.colors.onSurfaceVariant), data: IconThemeData(
size: metrics.iconSize, color: m3e.colors.onSurfaceVariant),
child: padded, child: padded,
), ),
); );
if (!safeArea && semanticLabel == null) return content; 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; if (semanticLabel == null) return wrapped;
return Semantics(container: true, label: semanticLabel!, child: 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( return SizedBox(
width: iconSize + 8, // give a little space for underline width: iconSize + 8, // give a little space for underline
height: iconSize + 8, height: iconSize + 8,
@ -135,7 +152,8 @@ class NavigationBarM3E extends StatelessWidget {
if (style != NavBarM3EIndicatorStyle.underline) return w; if (style != NavBarM3EIndicatorStyle.underline) return w;
final metrics = tokens.metrics(density); final metrics = tokens.metrics(density);
final deco = tokens.underlineDecoration(tokens.indicatorColor(), metrics.indicatorThickness); final deco = tokens.underlineDecoration(
tokens.indicatorColor(), metrics.indicatorThickness);
return DecoratedBox( return DecoratedBox(
decoration: deco, decoration: deco,
child: w, 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), icon: Icon(Icons.star_outline),
selectedIcon: Icon(Icons.star), selectedIcon: Icon(Icons.star),
label: 'Starred', label: 'Starred',
smallBadge: true, badgeCount: 0,
), ),
NavigationRailM3EDestination( NavigationRailM3EDestination(
icon: Icon(Icons.inbox_outlined), icon: Icon(Icons.inbox_outlined),
selectedIcon: Icon(Icons.inbox), selectedIcon: Icon(Icons.inbox),
label: 'Inbox', label: 'Inbox',
largeBadgeCount: 3, badgeCount: 3,
), ),
], ],
), ),

View file

@ -2,12 +2,13 @@
library navigation_rail_m3e; library navigation_rail_m3e;
export 'src/type.dart';
export 'src/modality.dart'; export 'src/modality.dart';
export 'src/rail_theme.dart'; export 'src/navigation_rail_m3e_widget.dart';
export 'src/rail_tokens_adapter.dart';
export 'src/rail_badge_m3e.dart'; export 'src/rail_badge_m3e.dart';
export 'src/rail_destination_m3e.dart'; export 'src/rail_destination_m3e.dart';
export 'src/rail_section_m3e.dart';
export 'src/rail_fab_slot.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.expandedWidth,
this.onDismissModal, this.onDismissModal,
this.onTypeChanged, this.onTypeChanged,
this.labelBehavior = NavigationRailM3ELabelBehavior.alwaysShow,
}); });
/// Presentation type for the rail (collapsed or expanded). /// 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. /// Called when the built-in menu button toggles the rail type.
final ValueChanged<NavigationRailM3EType>? onTypeChanged; final ValueChanged<NavigationRailM3EType>? onTypeChanged;
/// Controls how labels are shown when the rail is expanded.
final NavigationRailM3ELabelBehavior labelBehavior;
@override @override
State<NavigationRailM3E> createState() => _NavigationRailM3EState(); State<NavigationRailM3E> createState() => _NavigationRailM3EState();
} }
@ -65,10 +69,15 @@ class NavigationRailM3E extends StatefulWidget {
class _NavigationRailM3EState extends State<NavigationRailM3E> class _NavigationRailM3EState extends State<NavigationRailM3E>
with TickerProviderStateMixin { with TickerProviderStateMixin {
OverlayEntry? _modalEntry; OverlayEntry? _modalEntry;
OverlayEntry? _collapsedPeekEntry;
final LayerLink _anchor = LayerLink();
bool _suppressInk = false;
bool get _isExpanded => widget.type == NavigationRailM3EType.expanded; bool get _isExpanded => widget.type == NavigationRailM3EType.expanded;
bool get _isModal => widget.modality == NavigationRailM3EModality.modal; bool get _isModal => widget.modality == NavigationRailM3EModality.modal;
bool get _needsOverlay => _isModal; bool get _needsOverlay => _isModal && _isExpanded;
bool get _needsCollapsedPeek =>
!_isExpanded && !_isModal && widget.hideWhenCollapsed;
@override @override
void initState() { void initState() {
@ -79,17 +88,29 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
@override @override
void didUpdateWidget(covariant NavigationRailM3E oldWidget) { void didUpdateWidget(covariant NavigationRailM3E oldWidget) {
super.didUpdateWidget(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()); WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
} }
@override @override
void dispose() { void dispose() {
_removeOverlay(); _removeOverlay();
_removeCollapsedPeekOverlay();
super.dispose(); super.dispose();
} }
void _syncOverlay() { void _syncOverlay() {
if (!mounted) return; if (!mounted) return;
// Expanded modal overlay management
if (_needsOverlay) { if (_needsOverlay) {
if (_modalEntry == null) { if (_modalEntry == null) {
_insertOverlay(); _insertOverlay();
@ -99,6 +120,17 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
} else { } else {
_removeOverlay(); _removeOverlay();
} }
// Collapsed peek overlay management (standard modality with hideWhenCollapsed)
if (_needsCollapsedPeek) {
if (_collapsedPeekEntry == null) {
_insertCollapsedPeekOverlay();
} else {
_collapsedPeekEntry!.markNeedsBuild();
}
} else {
_removeCollapsedPeekOverlay();
}
} }
void _insertOverlay() { void _insertOverlay() {
@ -113,6 +145,19 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
_modalEntry = null; _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) { Widget _buildModalOverlay(BuildContext context) {
return Stack( return Stack(
children: [ 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) { double _targetWidth(BuildContext context) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ?? final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme(); const NavigationRailM3ETheme();
@ -157,21 +235,35 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
Widget _buildMenuButton(BuildContext context, Widget _buildMenuButton(BuildContext context,
{required Alignment alignment}) { {required Alignment alignment}) {
final isExpanded = _isExpanded; 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( return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12), padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
child: Align( child: Align(
alignment: alignment, alignment: alignment,
child: IconButtonM3E( child: button,
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
tooltip: isExpanded ? 'Collapse' : 'Expand',
onPressed: widget.onTypeChanged == null
? null
: () => widget.onTypeChanged!(
isExpanded
? NavigationRailM3EType.collapsed
: NavigationRailM3EType.expanded,
),
),
), ),
); );
} }
@ -193,7 +285,7 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
size: fab.size, size: fab.size,
shapeFamily: FabM3EShapeFamily.square, shapeFamily: FabM3EShapeFamily.square,
density: fab.density, density: fab.density,
elevation: fab.elevation, elevation: 0,
semanticLabel: fab.semanticLabel, semanticLabel: fab.semanticLabel,
) )
: FabM3E( : FabM3E(
@ -205,13 +297,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
size: fab.size, size: fab.size,
shapeFamily: FabM3EShapeFamily.square, shapeFamily: FabM3EShapeFamily.square,
density: fab.density, density: fab.density,
elevation: fab.elevation, elevation: 0,
semanticLabel: fab.semanticLabel, semanticLabel: fab.semanticLabel,
), ),
); );
} }
List<Widget> _buildChildren(BuildContext context) { List<Widget> _buildChildren(BuildContext context,
{required bool showLabels}) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ?? final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme(); const NavigationRailM3ETheme();
final isExpanded = _isExpanded; final isExpanded = _isExpanded;
@ -250,6 +343,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
selected: index == widget.selectedIndex, selected: index == widget.selectedIndex,
onTap: () => widget.onDestinationSelected(index), onTap: () => widget.onDestinationSelected(index),
expanded: true, expanded: true,
labelBehavior: widget.labelBehavior,
suppressInk: _suppressInk,
), ),
)); ));
} }
@ -265,6 +360,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
selected: i == widget.selectedIndex, selected: i == widget.selectedIndex,
onTap: () => widget.onDestinationSelected(i), onTap: () => widget.onDestinationSelected(i),
expanded: false, expanded: false,
labelBehavior: widget.labelBehavior,
suppressInk: _suppressInk,
), ),
)); ));
} }
@ -280,9 +377,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
width: width, width: width,
decoration: BoxDecoration(color: tokens.containerColor), decoration: BoxDecoration(color: tokens.containerColor),
child: ListView( child: LayoutBuilder(
padding: EdgeInsets.zero, builder: (ctx, constraints) {
children: _buildChildren(context), 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. // Keep overlay in sync after build completes to avoid layout side-effects.
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay()); WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
if (_needsOverlay) { final Widget child =
// When showing modal via overlay, render nothing in the layout slot so _needsOverlay ? const SizedBox.shrink() : _buildRailCore(context);
// content underneath can occupy the width. The overlay covers it.
return const SizedBox.shrink();
}
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, static int _destinationIndex(List<NavigationRailM3ESection> sections,

View file

@ -1,20 +1,19 @@
import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'rail_tokens_adapter.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 { class RailBadgeM3E extends StatelessWidget {
/// Creates a large numeric badge. /// Creates a large numeric badgeValue.
const RailBadgeM3E({ const RailBadgeM3E({
super.key, super.key,
required this.count, this.count,
this.maxDigits = 3, this.maxDigits = 3,
this.dense = false, this.dense = false,
}); });
/// The numeric value to display in the badge. /// The numeric value to display in the badgeValue.
final int count; final int? count;
/// Maximum digits before showing a trailing '+' (e.g. 999+). /// Maximum digits before showing a trailing '+' (e.g. 999+).
final int maxDigits; final int maxDigits;
@ -24,25 +23,37 @@ class RailBadgeM3E extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (count == null) {
return const SizedBox.shrink();
}
final tokens = NavigationRailTokensAdapter(context); final tokens = NavigationRailTokensAdapter(context);
final String text = count > (10 * (pow10(maxDigits) - 1)) final String text = count! > (10 * (pow10(maxDigits) - 1))
? '${pow10(maxDigits) - 1}+' ? '${pow10(maxDigits) - 1}+'
: '$count'; : '$count';
final double pad = dense ? 2 : 4; final double pad = dense ? 2 : 4;
return Container( return count == 0
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad), ? Container(
decoration: BoxDecoration( width: 8,
color: tokens.badgeLargeBackground, height: 8,
borderRadius: BorderRadius.circular(999), decoration: BoxDecoration(
), color: tokens.badgeBackground,
child: DefaultTextStyle( shape: BoxShape.circle,
style: Theme.of(context).textTheme.labelSmall!.copyWith( ),
color: tokens.badgeLargeLabel, )
fontFeatures: const [FontFeature.tabularFigures()], : Container(
), padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
child: Text(text, maxLines: 1), 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]. /// Returns 10 to the power of [n].
@ -54,3 +65,19 @@ class RailBadgeM3E extends StatelessWidget {
return v; 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, required this.icon,
this.selectedIcon, this.selectedIcon,
required this.label, required this.label,
this.largeBadgeCount, this.badgeCount,
this.smallBadge = false,
this.semanticLabel, this.semanticLabel,
this.short = false, this.short = false,
}); });
@ -15,8 +14,7 @@ class NavigationRailM3EDestination {
final Widget icon; final Widget icon;
final Widget? selectedIcon; final Widget? selectedIcon;
final String label; final String label;
final int? largeBadgeCount; final int? badgeCount;
final bool smallBadge;
final String? semanticLabel; final String? semanticLabel;
/// If true, uses short item height (56dp) instead of 64dp. /// 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 'package:flutter/material.dart';
import 'package:navigation_rail_m3e/navigation_rail_m3e.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. /// Single rail item (private to package). One class per file.
class RailItem extends StatelessWidget { class RailItem extends StatelessWidget {
@ -14,17 +10,28 @@ class RailItem extends StatelessWidget {
required this.selected, required this.selected,
required this.onTap, required this.onTap,
required this.expanded, required this.expanded,
required this.labelBehavior,
this.suppressInk = false,
}); });
/// Destination data driving this item. /// Destination data driving this item.
final NavigationRailM3EDestination destination; final NavigationRailM3EDestination destination;
/// Whether this item is currently selected. /// Whether this item is currently selected.
final bool selected; final bool selected;
/// Called when the item is tapped. /// Called when the item is tapped.
final VoidCallback onTap; final VoidCallback onTap;
/// Whether the rail is expanded (shows label and badges inline). /// Whether the rail is expanded (shows label and badges inline).
final bool expanded; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ?? final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
@ -32,95 +39,41 @@ class RailItem extends StatelessWidget {
final tokens = NavigationRailTokensAdapter(context); final tokens = NavigationRailTokensAdapter(context);
final height = destination.short ? theme.itemShortHeight : theme.itemHeight; final height = destination.short ? theme.itemShortHeight : theme.itemHeight;
final icon = IconTheme.merge( final Widget button = RailItemButtonM3E(
data: IconThemeData( icon: destination.icon,
size: theme.iconSize, selectedIcon: destination.selectedIcon,
color: isSelected: selected,
selected ? tokens.activeIconAndLabel : tokens.inactiveIconAndLabel, onPressed: onTap,
), expanded: expanded,
child: selected labelBehavior: labelBehavior,
? (destination.selectedIcon ?? destination.icon) label: destination.label,
: destination.icon, semanticLabel: destination.semanticLabel,
suppressInk: suppressInk,
badgeCount: destination.badgeCount,
); );
final label = DefaultTextStyle( Widget core;
style: Theme.of(context).textTheme.labelLarge!.copyWith( if (!expanded) {
color: selected // Collapsed: left-aligned icon-only button with 48x48 tap target.
? tokens.activeIconAndLabel core = SizedBox(
: tokens.inactiveIconAndLabel, height: height,
), child: Align(alignment: Alignment.centerLeft, child: button),
child: Text( );
destination.label, } else {
overflow: TextOverflow.ellipsis, core = ConstrainedBox(
maxLines: 1, constraints: BoxConstraints(minHeight: height),
semanticsLabel: destination.semanticLabel ?? destination.label, 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( return Semantics(
selected: selected, selected: selected,
button: true, button: true,
child: InkWell( child: core,
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,
),
); );
} }
} }

View file

@ -35,7 +35,7 @@ class NavigationRailTokensAdapter {
_cs.onSecondaryContainer; _cs.onSecondaryContainer;
} }
Color get badgeLargeBackground => Color get badgeBackground =>
_maybe(() => context.m3e.colors.error) ?? _cs.error; _maybe(() => context.m3e.colors.error) ?? _cs.error;
Color get badgeLargeLabel => Color get badgeLargeLabel =>
_maybe(() => context.m3e.colors.onError) ?? _cs.onError; _maybe(() => context.m3e.colors.onError) ?? _cs.onError;

View file

@ -1,5 +1,3 @@
import 'package:flutter/foundation.dart';
/// M3 Expressive types for the rail. /// M3 Expressive types for the rail.
enum NavigationRailM3EType { enum NavigationRailM3EType {
/// Slim 96dp rail. /// Slim 96dp rail.
@ -12,4 +10,15 @@ enum NavigationRailM3EType {
extension NavigationRailM3ETypeX on NavigationRailM3EType { extension NavigationRailM3ETypeX on NavigationRailM3EType {
bool get isCollapsed => this == NavigationRailM3EType.collapsed; bool get isCollapsed => this == NavigationRailM3EType.collapsed;
bool get isExpanded => this == NavigationRailM3EType.expanded; 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 used by the rail's menu control.
icon_button_m3e: icon_button_m3e:
path: ../icon_button_m3e path: ../icon_button_m3e
# Buttons used by rail items in expanded state.
button_m3e:
path: ../button_m3e
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: