Update NavigationRailM3E implementation; update FAB and navigation sections to adapt to changes.

This commit is contained in:
Emily Pauli 2025-10-23 12:31:46 +02:00
commit 83f5a02943
49 changed files with 1651 additions and 661 deletions

View file

@ -1,7 +1,13 @@
// ignore_for_file: public_member_api_docs
library navigation_rail_m3e;
export 'src/enums.dart';
export 'src/rail_tokens_adapter.dart' show RailTokensAdapter;
export 'src/navigation_rail_m3e.dart';
export 'src/type.dart';
export 'src/modality.dart';
export 'src/rail_theme.dart';
export 'src/rail_tokens_adapter.dart';
export 'src/rail_badge_m3e.dart';
export 'src/rail_destination_m3e.dart';
export 'src/rail_section_m3e.dart';
export 'src/rail_fab_slot.dart';
export 'src/navigation_rail_m3e_widget.dart';

View file

@ -1,5 +0,0 @@
enum RailLabelBehavior { alwaysShow, onlySelected, alwaysHide }
enum RailSize { compact, regular }
enum RailShapeFamily { round, square }
enum RailDensity { regular, compact }
enum RailIndicatorStyle { pill, stripe, none }

View file

@ -0,0 +1,8 @@
/// Modality for the expanded rail.
enum NavigationRailM3EModality {
/// Occupies layout space.
standard,
/// Overlays content with a scrim and dismisses on tap/esc.
modal,
}

View file

@ -1,177 +0,0 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'rail_destination_m3e.dart';
import 'rail_tokens_adapter.dart';
class NavigationRailM3E extends StatelessWidget {
const NavigationRailM3E({
super.key,
required this.destinations,
this.selectedIndex = 0,
this.onDestinationSelected,
this.labelBehavior = RailLabelBehavior.onlySelected,
this.size = RailSize.regular,
this.shapeFamily = RailShapeFamily.round,
this.density = RailDensity.regular,
this.backgroundColor,
this.elevation,
this.indicatorStyle = RailIndicatorStyle.pill,
this.indicatorColor,
this.padding,
this.groupAlignment,
this.leading,
this.trailing,
this.extended = false,
this.minExtendedWidth,
this.useSafeArea = true,
this.semanticLabel,
});
final List<RailDestinationM3E> destinations;
final int selectedIndex;
final ValueChanged<int>? onDestinationSelected;
final RailLabelBehavior labelBehavior;
final RailSize size;
final RailShapeFamily shapeFamily;
final RailDensity density;
final Color? backgroundColor;
final double? elevation;
final RailIndicatorStyle indicatorStyle;
final Color? indicatorColor;
final EdgeInsetsGeometry? padding;
/// Aligns the group of destinations (-1 top .. 1 bottom).
final double? groupAlignment;
/// Optional leading and trailing widgets (e.g., FAB or menu).
final Widget? leading;
final Widget? trailing;
/// Whether to show the rail in extended mode (icons + labels).
final bool extended;
/// Minimum width when extended.
final double? minExtendedWidth;
final bool useSafeArea;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
assert(destinations.isNotEmpty, 'Provide at least one destination');
final tokens = RailTokensAdapter(context);
final metrics = tokens.metrics(density);
final width =
size == RailSize.compact ? metrics.widthCompact : metrics.widthRegular;
final bg = backgroundColor ?? tokens.containerColor();
final shape = tokens.containerShape(shapeFamily);
final rail = Material(
color: bg,
elevation: elevation ??
0, // null means use theme default; avoids invalid zero assertion
shape: shape,
child: SizedBox(
width:
extended ? (minExtendedWidth ?? metrics.extendedMinWidth) : width,
child: NavigationRail(
backgroundColor: Colors.transparent,
elevation: elevation, // pass through or null
extended: extended,
minExtendedWidth: minExtendedWidth ?? metrics.extendedMinWidth,
selectedIndex: selectedIndex,
groupAlignment: groupAlignment,
leading: leading,
trailing: trailing,
labelType: switch (labelBehavior) {
RailLabelBehavior.alwaysShow => NavigationRailLabelType.all,
RailLabelBehavior.onlySelected => NavigationRailLabelType.selected,
RailLabelBehavior.alwaysHide => NavigationRailLabelType.none,
},
useIndicator: indicatorStyle != RailIndicatorStyle.none,
indicatorColor: indicatorColor ?? tokens.indicatorColor(),
indicatorShape: switch (indicatorStyle) {
RailIndicatorStyle.pill => tokens.indicatorShapePill(),
RailIndicatorStyle.stripe =>
const StadiumBorder(), // we'll fake stripe using decoration on selected icon
RailIndicatorStyle.none => const StadiumBorder(),
},
selectedIconTheme: IconThemeData(
color: tokens.selectedColor(), size: metrics.iconSize),
unselectedIconTheme: IconThemeData(
color: tokens.unselectedColor(), size: metrics.iconSize),
selectedLabelTextStyle:
tokens.labelStyle().copyWith(color: tokens.selectedColor()),
unselectedLabelTextStyle:
tokens.labelStyle().copyWith(color: tokens.unselectedColor()),
destinations: List.generate(destinations.length, (i) {
final d = destinations[i];
return NavigationRailDestination(
icon: _icon(context, false, d, metrics.iconSize),
selectedIcon: _selectedIcon(
context, true, d, metrics.iconSize, tokens, indicatorStyle),
label: Text(d.label),
padding: metrics.itemPadding as EdgeInsets?,
);
}),
onDestinationSelected: onDestinationSelected,
),
),
);
final padded = Padding(
padding: padding ?? EdgeInsets.zero,
child: rail,
);
if (!useSafeArea && semanticLabel == null) return padded;
final wrapped = SafeArea(
top: true,
bottom: true,
left: true,
right: false,
child: padded,
);
if (semanticLabel == null) return wrapped;
return Semantics(container: true, label: semanticLabel!, child: wrapped);
}
Widget _icon(BuildContext context, bool selected, RailDestinationM3E d,
double iconSize) {
return SizedBox(
width: iconSize + 8,
height: iconSize + 8,
child: Center(child: d.buildIcon(selected)),
);
}
Widget _selectedIcon(
BuildContext context,
bool selected,
RailDestinationM3E d,
double iconSize,
RailTokensAdapter tokens,
RailIndicatorStyle style,
) {
final w = _icon(context, selected, d, iconSize);
if (style != RailIndicatorStyle.stripe) return w;
final metrics = tokens.metrics(density);
final deco = tokens.stripeDecoration(
tokens.indicatorColor(), metrics.stripeThickness);
return DecoratedBox(
decoration: deco,
child: w,
);
}
}

View file

@ -1,21 +1,315 @@
import 'package:fab_m3e/fab_m3e.dart';
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'package:icon_button_m3e/icon_button_m3e.dart';
class NavigationRailM3EWidget extends StatelessWidget {
const NavigationRailM3EWidget({super.key});
import 'modality.dart';
import 'rail_destination_m3e.dart';
import 'rail_fab_slot.dart';
import 'rail_item.dart';
import 'rail_section_m3e.dart';
import 'rail_theme.dart';
import 'rail_tokens_adapter.dart';
import 'type.dart';
/// Material 3 Expressive Navigation Rail single widget that animates between states.
class NavigationRailM3E extends StatefulWidget {
/// Creates a Material 3 Expressive navigation rail.
const NavigationRailM3E({
super.key,
required this.type,
this.modality = NavigationRailM3EModality.standard,
required this.sections,
required this.selectedIndex,
required this.onDestinationSelected,
this.fab,
this.hideWhenCollapsed = false,
this.expandedWidth,
this.onDismissModal,
this.onTypeChanged,
});
/// Presentation type for the rail (collapsed or expanded).
final NavigationRailM3EType type;
/// How the rail is shown (standard or modal overlay).
final NavigationRailM3EModality modality;
/// Sections and destinations to display.
final List<NavigationRailM3ESection> sections;
/// Index of the currently selected destination.
final int selectedIndex;
/// Called when a destination is selected.
final ValueChanged<int> onDestinationSelected;
/// Optional FAB/extended FAB shown near the top cluster.
final NavigationRailM3EFabSlot? fab;
/// When [type] is collapsed and this is true, rail animates to width 0.
final bool hideWhenCollapsed;
/// Custom expanded width (220360). Clamped to theme bounds.
final double? expandedWidth;
/// Called to dismiss when in modal mode.
final VoidCallback? onDismissModal;
/// Called when the built-in menu button toggles the rail type.
final ValueChanged<NavigationRailM3EType>? onTypeChanged;
@override
State<NavigationRailM3E> createState() => _NavigationRailM3EState();
}
class _NavigationRailM3EState extends State<NavigationRailM3E>
with TickerProviderStateMixin {
OverlayEntry? _modalEntry;
bool get _isExpanded => widget.type == NavigationRailM3EType.expanded;
bool get _isModal => widget.modality == NavigationRailM3EModality.modal;
bool get _needsOverlay => _isModal;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
}
@override
void didUpdateWidget(covariant NavigationRailM3E oldWidget) {
super.didUpdateWidget(oldWidget);
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
}
@override
void dispose() {
_removeOverlay();
super.dispose();
}
void _syncOverlay() {
if (!mounted) return;
if (_needsOverlay) {
if (_modalEntry == null) {
_insertOverlay();
} else {
_modalEntry!.markNeedsBuild();
}
} else {
_removeOverlay();
}
}
void _insertOverlay() {
final overlay = Overlay.of(context, rootOverlay: true);
if (overlay == null) return;
_modalEntry = OverlayEntry(builder: (ctx) => _buildModalOverlay(ctx));
overlay.insert(_modalEntry!);
}
void _removeOverlay() {
_modalEntry?.remove();
_modalEntry = null;
}
Widget _buildModalOverlay(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: IgnorePointer(
ignoring: !_isExpanded,
child: GestureDetector(
onTap: widget.onDismissModal,
child: AnimatedContainer(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
color: Theme.of(context)
.colorScheme
.scrim
.withValues(alpha: _isExpanded ? 0.32 : 0.0),
),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: Material(
type: MaterialType.transparency,
child: _buildRailCore(context),
),
),
],
);
}
double _targetWidth(BuildContext context) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme();
final isExpanded = _isExpanded;
return isExpanded
? (widget.expandedWidth ?? theme.expandedMinWidth)
.clamp(theme.expandedMinWidth, theme.expandedMaxWidth)
.toDouble()
: (widget.hideWhenCollapsed ? 0.0 : theme.collapsedWidth);
}
Widget _buildMenuButton(BuildContext context,
{required Alignment alignment}) {
final isExpanded = _isExpanded;
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
child: Align(
alignment: alignment,
child: IconButtonM3E(
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
tooltip: isExpanded ? 'Collapse' : 'Expand',
onPressed: widget.onTypeChanged == null
? null
: () => widget.onTypeChanged!(
isExpanded
? NavigationRailM3EType.collapsed
: NavigationRailM3EType.expanded,
),
),
),
);
}
Widget? _buildFab(BuildContext context) {
final fab = widget.fab;
if (fab == null) return null;
final isExpanded = _isExpanded;
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
child: isExpanded
? ExtendedFabM3E(
label: Text(fab.label),
icon: fab.icon,
onPressed: fab.onPressed,
tooltip: fab.tooltip,
heroTag: fab.heroTag,
kind: fab.kind,
size: fab.size,
shapeFamily: FabM3EShapeFamily.square,
density: fab.density,
elevation: fab.elevation,
semanticLabel: fab.semanticLabel,
)
: FabM3E(
icon: fab.icon,
onPressed: fab.onPressed,
tooltip: fab.tooltip,
heroTag: fab.heroTag,
kind: fab.kind,
size: fab.size,
shapeFamily: FabM3EShapeFamily.square,
density: fab.density,
elevation: fab.elevation,
semanticLabel: fab.semanticLabel,
),
);
}
List<Widget> _buildChildren(BuildContext context) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme();
final isExpanded = _isExpanded;
final children = <Widget>[];
children.add(const SizedBox(height: 36));
children.add(_buildMenuButton(context,
alignment: isExpanded ? Alignment.centerLeft : Alignment.center));
final fabWidget = _buildFab(context);
if (fabWidget != null) children.add(fabWidget);
if (isExpanded) {
for (final section in widget.sections) {
if (section.header != null) {
children.add(Padding(
padding: EdgeInsetsDirectional.only(
start: 16,
end: 16,
top: theme.sectionHeaderSpacingTop,
bottom: theme.sectionHeaderSpacingBottom,
),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleSmall!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
child: section.header!,
),
));
}
for (final dest in section.destinations) {
final index = _destinationIndex(widget.sections, dest);
children.add(Padding(
padding: const EdgeInsetsDirectional.only(
start: 16, end: 16, top: 8.0, bottom: 8.0),
child: RailItem(
destination: dest,
selected: index == widget.selectedIndex,
onTap: () => widget.onDestinationSelected(index),
expanded: true,
),
));
}
}
} else {
final all = widget.sections.expand((s) => s.destinations).toList();
for (int i = 0; i < all.length; i++) {
children.add(Padding(
padding: const EdgeInsetsDirectional.only(
start: 16.0, end: 16.0, top: 8.0, bottom: 8.0),
child: RailItem(
destination: all[i],
selected: i == widget.selectedIndex,
onTap: () => widget.onDestinationSelected(i),
expanded: false,
),
));
}
}
return children;
}
Widget _buildRailCore(BuildContext context) {
final tokens = NavigationRailTokensAdapter(context);
final width = _targetWidth(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
width: width,
decoration: BoxDecoration(color: tokens.containerColor),
child: ListView(
padding: EdgeInsets.zero,
children: _buildChildren(context),
),
);
}
@override
Widget build(BuildContext context) {
final m3e = context.m3e;
return Container(
padding: EdgeInsets.all(m3e.spacing.md),
decoration: BoxDecoration(
color: m3e.colors.surfaceStrong,
borderRadius: m3e.shapes.square.md,
),
child: Text('NavigationRail placeholder', style: m3e.typography.base.titleMedium),
);
// Keep overlay in sync after build completes to avoid layout side-effects.
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
if (_needsOverlay) {
// When showing modal via overlay, render nothing in the layout slot so
// content underneath can occupy the width. The overlay covers it.
return const SizedBox.shrink();
}
return _buildRailCore(context);
}
static int _destinationIndex(List<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;
}
}
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();

View file

@ -1,76 +1,56 @@
import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'rail_tokens_adapter.dart';
/// Large numeric badge for rail items (0..999+). One class per file.
class RailBadgeM3E extends StatelessWidget {
/// Creates a large numeric badge.
const RailBadgeM3E({
super.key,
required this.child,
this.count,
this.showDot = false,
this.maxCount = 99,
this.backgroundColor,
this.foregroundColor,
this.semanticLabel,
this.offset = const Offset(8, -6),
}) : assert(count == null || count >= 0);
required this.count,
this.maxDigits = 3,
this.dense = false,
});
final Widget child;
final int? count;
final bool showDot;
final int maxCount;
final Color? backgroundColor;
final Color? foregroundColor;
final String? semanticLabel;
final Offset offset;
/// The numeric value to display in the badge.
final int count;
/// Maximum digits before showing a trailing '+' (e.g. 999+).
final int maxDigits;
/// Whether to use a denser (smaller padding) variant.
final bool dense;
@override
Widget build(BuildContext context) {
final t = Theme.of(context);
final m3e = t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
final bg = backgroundColor ?? m3e.colors.errorContainer;
final fg = foregroundColor ?? m3e.colors.onErrorContainer;
final badge = showDot
? _dot(bg)
: _label(bg, fg, count == null ? '' : _format(count!, maxCount));
return Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
right: offset.dx,
top: offset.dy,
child: Semantics(
label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'),
child: badge,
),
),
],
);
}
Widget _dot(Color bg) {
final tokens = NavigationRailTokensAdapter(context);
final String text = count > (10 * (pow10(maxDigits) - 1))
? '${pow10(maxDigits) - 1}+'
: '$count';
final double pad = dense ? 2 : 4;
return Container(
width: 8, height: 8,
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
);
}
Widget _label(Color bg, Color fg, String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
color: tokens.badgeLargeBackground,
borderRadius: BorderRadius.circular(999),
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: DefaultTextStyle(
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600),
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)),
style: Theme.of(context).textTheme.labelSmall!.copyWith(
color: tokens.badgeLargeLabel,
fontFeatures: const [FontFeature.tabularFigures()],
),
child: Text(text, maxLines: 1),
),
);
}
String _format(int c, int max) => (c > max) ? '$max+' : '$c';
/// Returns 10 to the power of [n].
static int pow10(int n) {
var v = 1;
for (var i = 0; i < n; i++) {
v *= 10;
}
return v;
}
}

View file

@ -0,0 +1,110 @@
import 'package:fab_m3e/fab_m3e.dart';
import 'package:flutter/material.dart';
import 'package:icon_button_m3e/icon_button_m3e.dart';
import 'rail_fab_slot.dart';
import 'rail_item.dart';
import 'rail_section_m3e.dart';
import 'rail_theme.dart';
import 'rail_tokens_adapter.dart';
/// Collapsed (96dp) rail. One class per file.
class CollapsedRail extends StatelessWidget {
const CollapsedRail({
super.key,
required this.sections,
required this.selectedIndex,
required this.onDestinationSelected,
this.fab,
this.hideWhenCollapsed = false,
required this.isExpanded,
this.onToggleType,
});
/// Sections rendered in the rail.
final List<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

@ -1,38 +1,24 @@
import 'package:flutter/material.dart';
import 'rail_badge_m3e.dart';
class RailDestinationM3E {
const RailDestinationM3E({
/// Model for a navigation destination. One class per file.
class NavigationRailM3EDestination {
const NavigationRailM3EDestination({
required this.icon,
required this.label,
this.selectedIcon,
this.badgeCount,
this.badgeDot = false,
required this.label,
this.largeBadgeCount,
this.smallBadge = false,
this.semanticLabel,
this.short = false,
});
final Widget icon;
final Widget? selectedIcon;
final String label;
/// Optional badge counter
final int? badgeCount;
/// If true, show a small dot instead of a counter.
final bool badgeDot;
final int? largeBadgeCount;
final bool smallBadge;
final String? semanticLabel;
Widget buildIcon([bool selected = false]) {
final base = selected && selectedIcon != null ? selectedIcon! : icon;
if (badgeCount != null || badgeDot) {
return RailBadgeM3E(
child: base,
count: badgeCount,
showDot: badgeDot,
semanticLabel: semanticLabel,
);
}
return base;
}
/// If true, uses short item height (56dp) instead of 64dp.
final bool short;
}

View file

@ -0,0 +1,171 @@
import 'package:fab_m3e/fab_m3e.dart';
import 'package:flutter/material.dart';
import 'package:icon_button_m3e/icon_button_m3e.dart';
import 'rail_destination_m3e.dart';
import 'rail_fab_slot.dart';
import 'rail_item.dart';
import 'rail_section_m3e.dart';
import 'rail_theme.dart';
import 'rail_tokens_adapter.dart';
/// Expanded rail (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

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:fab_m3e/fab_m3e.dart'
show FabM3EKind, FabM3ESize, FabM3EShapeFamily, FabM3EDensity;
/// Configuration for the rail's built-in FAB.
///
/// The rail renders:
/// - a FabM3E when collapsed
/// - an ExtendedFabM3E when expanded
///
/// Consumers provide values (icon, label, onPressed, etc.) instead of a widget.
@immutable
class NavigationRailM3EFabSlot {
const NavigationRailM3EFabSlot({
required this.icon,
required this.label,
this.onPressed,
this.tooltip,
this.heroTag,
this.kind = FabM3EKind.primary,
this.size = FabM3ESize.regular,
this.shapeFamily = FabM3EShapeFamily.round,
this.density = FabM3EDensity.regular,
this.elevation,
this.semanticLabel,
});
/// Icon widget shown inside the FAB (collapsed) and leading icon (expanded).
final Widget icon;
/// Text label for the extended FAB (expanded rail variant).
final String label;
/// Tap callback for the FAB.
final VoidCallback? onPressed;
/// Tooltip text for hover/long-press.
final String? tooltip;
/// Optional Hero tag for FAB transitions.
final Object? heroTag;
/// Visual kind (primary, surface, tertiary, etc.).
final FabM3EKind kind;
/// Size of the FAB button.
final FabM3ESize size;
/// Shape family (round/square).
final FabM3EShapeFamily shapeFamily;
/// Density (affects metrics like size and padding).
final FabM3EDensity density;
/// Elevation override.
final double? elevation;
/// Optional semantic label for accessibility.
final String? semanticLabel;
}

View file

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'rail_badge_m3e.dart';
import 'rail_destination_m3e.dart';
import 'rail_theme.dart';
import 'rail_tokens_adapter.dart';
/// Single rail item (private to package). One class per file.
class RailItem extends StatelessWidget {
/// Creates a single navigation rail item.
const RailItem({
super.key,
required this.destination,
required this.selected,
required this.onTap,
required this.expanded,
});
/// Destination data driving this item.
final NavigationRailM3EDestination destination;
/// Whether this item is currently selected.
final bool selected;
/// Called when the item is tapped.
final VoidCallback onTap;
/// Whether the rail is expanded (shows label and badges inline).
final bool expanded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
const NavigationRailM3ETheme();
final tokens = NavigationRailTokensAdapter(context);
final height = destination.short ? theme.itemShortHeight : theme.itemHeight;
final icon = IconTheme.merge(
data: IconThemeData(
size: theme.iconSize,
color:
selected ? tokens.activeIconAndLabel : tokens.inactiveIconAndLabel,
),
child: selected
? (destination.selectedIcon ?? destination.icon)
: destination.icon,
);
final label = DefaultTextStyle(
style: Theme.of(context).textTheme.labelLarge!.copyWith(
color: selected
? tokens.activeIconAndLabel
: tokens.inactiveIconAndLabel,
),
child: Text(
destination.label,
overflow: TextOverflow.ellipsis,
maxLines: 1,
semanticsLabel: destination.semanticLabel ?? destination.label,
),
);
final badges = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (destination.largeBadgeCount != null &&
destination.largeBadgeCount! > 0)
Padding(
padding: EdgeInsets.only(left: theme.iconLabelGap),
child: RailBadgeM3E(count: destination.largeBadgeCount!),
),
if (destination.smallBadge)
Padding(
padding: const EdgeInsetsDirectional.only(start: 6.0),
child: _SmallDot(color: tokens.badgeSmallDot),
),
],
);
final content = AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
height: height,
decoration: ShapeDecoration(
color: selected ? tokens.activeIndicatorColor : Colors.transparent,
shape: const StadiumBorder(), // Full corner per spec
),
padding: EdgeInsetsDirectional.only(
start: theme.indicatorLeading,
end: theme.indicatorTrailing,
),
child: Row(
children: [
icon,
SizedBox(width: theme.iconLabelGap),
if (expanded) Expanded(child: label) else const SizedBox.shrink(),
if (expanded) badges,
],
),
);
// Full-width hit target
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: content,
),
);
}
}
class _SmallDot extends StatelessWidget {
const _SmallDot({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
);
}
}

View file

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
/// Menu slot at the top of the rail (non-selectable). One class per file.
class NavigationRailM3EMenu extends StatelessWidget {
const NavigationRailM3EMenu({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) => child;
}

View file

@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'rail_destination_m3e.dart';
/// Section groups a header and a list of destinations. One class per file.
class NavigationRailM3ESection {
const NavigationRailM3ESection({
required this.destinations,
this.header,
});
final List<NavigationRailM3EDestination> destinations;
final Widget? header;
}

View file

@ -0,0 +1,98 @@
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
/// Theme extension for NavigationRailM3E token values.
class NavigationRailM3ETheme extends ThemeExtension<NavigationRailM3ETheme> {
const NavigationRailM3ETheme({
this.collapsedWidth = 96.0,
this.expandedMinWidth = 220.0,
this.expandedMaxWidth = 360.0,
this.itemHeight = 64.0,
this.itemShortHeight = 56.0,
this.iconSize = 24.0,
this.indicatorLeading = 16.0,
this.indicatorTrailing = 16.0,
this.iconLabelGap = 8.0,
this.itemVerticalGap = 6.0,
this.headerMinSpace = 40.0,
this.sectionHeaderSpacingTop = 12.0,
this.sectionHeaderSpacingBottom = 8.0,
});
final double collapsedWidth;
final double expandedMinWidth;
final double expandedMaxWidth;
final double itemHeight;
final double itemShortHeight;
final double iconSize;
final double indicatorLeading;
final double indicatorTrailing;
final double iconLabelGap;
final double itemVerticalGap;
final double headerMinSpace;
final double sectionHeaderSpacingTop;
final double sectionHeaderSpacingBottom;
@override
NavigationRailM3ETheme copyWith({
double? collapsedWidth,
double? expandedMinWidth,
double? expandedMaxWidth,
double? itemHeight,
double? itemShortHeight,
double? iconSize,
double? indicatorLeading,
double? indicatorTrailing,
double? iconLabelGap,
double? itemVerticalGap,
double? headerMinSpace,
double? sectionHeaderSpacingTop,
double? sectionHeaderSpacingBottom,
}) {
return NavigationRailM3ETheme(
collapsedWidth: collapsedWidth ?? this.collapsedWidth,
expandedMinWidth: expandedMinWidth ?? this.expandedMinWidth,
expandedMaxWidth: expandedMaxWidth ?? this.expandedMaxWidth,
itemHeight: itemHeight ?? this.itemHeight,
itemShortHeight: itemShortHeight ?? this.itemShortHeight,
iconSize: iconSize ?? this.iconSize,
indicatorLeading: indicatorLeading ?? this.indicatorLeading,
indicatorTrailing: indicatorTrailing ?? this.indicatorTrailing,
iconLabelGap: iconLabelGap ?? this.iconLabelGap,
itemVerticalGap: itemVerticalGap ?? this.itemVerticalGap,
headerMinSpace: headerMinSpace ?? this.headerMinSpace,
sectionHeaderSpacingTop:
sectionHeaderSpacingTop ?? this.sectionHeaderSpacingTop,
sectionHeaderSpacingBottom:
sectionHeaderSpacingBottom ?? this.sectionHeaderSpacingBottom,
);
}
@override
ThemeExtension<NavigationRailM3ETheme> lerp(
ThemeExtension<NavigationRailM3ETheme>? other, double t) {
if (other is! NavigationRailM3ETheme) return this;
return NavigationRailM3ETheme(
collapsedWidth: lerpDouble(collapsedWidth, other.collapsedWidth, t)!,
expandedMinWidth:
lerpDouble(expandedMinWidth, other.expandedMinWidth, t)!,
expandedMaxWidth:
lerpDouble(expandedMaxWidth, other.expandedMaxWidth, t)!,
itemHeight: lerpDouble(itemHeight, other.itemHeight, t)!,
itemShortHeight: lerpDouble(itemShortHeight, other.itemShortHeight, t)!,
iconSize: lerpDouble(iconSize, other.iconSize, t)!,
indicatorLeading:
lerpDouble(indicatorLeading, other.indicatorLeading, t)!,
indicatorTrailing:
lerpDouble(indicatorTrailing, other.indicatorTrailing, t)!,
iconLabelGap: lerpDouble(iconLabelGap, other.iconLabelGap, t)!,
itemVerticalGap: lerpDouble(itemVerticalGap, other.itemVerticalGap, t)!,
headerMinSpace: lerpDouble(headerMinSpace, other.headerMinSpace, t)!,
sectionHeaderSpacingTop: lerpDouble(
sectionHeaderSpacingTop, other.sectionHeaderSpacingTop, t)!,
sectionHeaderSpacingBottom: lerpDouble(
sectionHeaderSpacingBottom, other.sectionHeaderSpacingBottom, t)!,
);
}
}

View file

@ -1,88 +1,59 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'package:m3e_design/m3e_design.dart' as m3e;
@immutable
class _RailMetrics {
final double widthCompact;
final double widthRegular;
final double extendedMinWidth;
final double iconSize;
final EdgeInsetsGeometry itemPadding;
final double stripeThickness;
const _RailMetrics({
required this.widthCompact,
required this.widthRegular,
required this.extendedMinWidth,
required this.iconSize,
required this.itemPadding,
required this.stripeThickness,
});
}
/// Provides colors & shapes from `m3e_design` with safe fallbacks to Theme.of(context).
class NavigationRailTokensAdapter {
const NavigationRailTokensAdapter(this.context);
_RailMetrics _metricsFor(BuildContext context, RailDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double wC = 64; // compact width
double wR = 80; // regular width
double ext = 256; // extended min width
double icon = 24;
double stripe = 3;
if (density == RailDensity.compact) {
wC -= 4; wR -= 4; stripe -= 1;
}
return _RailMetrics(
widthCompact: wC,
widthRegular: wR,
extendedMinWidth: ext,
iconSize: icon,
itemPadding: EdgeInsets.symmetric(horizontal: sp.md, vertical: sp.sm),
stripeThickness: stripe,
);
}
class RailTokensAdapter {
RailTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
ColorScheme get _cs => Theme.of(context).colorScheme;
// Colors per spec
Color get containerColor {
// Use surface container token if present, else fallback.
return _maybe(() => context.m3e.colors.surfaceContainer) ??
_cs.surfaceContainer;
}
_RailMetrics metrics(RailDensity density) => _metricsFor(context, density);
// Container/background
Color containerColor() => _m3e.colors.surfaceContainerHigh;
// Indicator
Color indicatorColor() => _m3e.colors.secondaryContainer;
// Icon/label colors
Color selectedColor() => _m3e.colors.onSecondaryContainer;
Color unselectedColor() => _m3e.colors.onSurfaceVariant;
// Typography
TextStyle labelStyle() => _m3e.type.labelMedium;
// Shapes
ShapeBorder containerShape(RailShapeFamily family) {
final set = family == RailShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
return RoundedRectangleBorder(borderRadius: set.lg);
Color get activeIndicatorColor {
return _maybe(() => context.m3e.colors.secondaryContainer) ??
_cs.secondaryContainer;
}
ShapeBorder indicatorShapePill() => const StadiumBorder();
Color get activeIconAndLabel {
return _maybe(() => context.m3e.colors.secondary) ?? _cs.secondary;
}
// Stripe decoration for selected destination
BoxDecoration stripeDecoration(Color color, double thickness) {
return BoxDecoration(
border: Border(
left: BorderSide(color: color, width: thickness),
),
);
Color get inactiveIconAndLabel {
return _maybe(() => context.m3e.colors.onSurfaceVariant) ??
_cs.onSurfaceVariant;
}
Color get menuColor {
return _maybe(() => context.m3e.colors.onSecondaryContainer) ??
_cs.onSecondaryContainer;
}
Color get badgeLargeBackground =>
_maybe(() => context.m3e.colors.error) ?? _cs.error;
Color get badgeLargeLabel =>
_maybe(() => context.m3e.colors.onError) ?? _cs.onError;
Color get badgeSmallDot =>
_maybe(() => context.m3e.colors.error) ?? _cs.error;
ShapeBorder get indicatorShapeFull {
// Full corner per M3E: use the most rounded token, fallback to StadiumBorder.
final br = _maybe(() => context.m3e.shapes.round.xs);
if (br != null) return RoundedRectangleBorder(borderRadius: br);
return const StadiumBorder();
}
T? _maybe<T>(T Function() pick) {
try {
return pick();
} catch (_) {
return null;
}
}
}

View file

@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
/// M3 Expressive types for the rail.
enum NavigationRailM3EType {
/// Slim 96dp rail.
collapsed,
/// Wide 220360dp rail that replaces the drawer.
expanded,
}
extension NavigationRailM3ETypeX on NavigationRailM3EType {
bool get isCollapsed => this == NavigationRailM3EType.collapsed;
bool get isExpanded => this == NavigationRailM3EType.expanded;
}