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

@ -42,9 +42,6 @@ class _GalleryHomeState extends State<GalleryHome> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final m3e =
Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(cs);
return Scaffold(
appBar: AppBarM3E(
titleText: 'M3E Gallery',

View file

@ -28,6 +28,13 @@ class FabSection extends StatelessWidget {
kind: kind,
size: size,
onPressed: onPressed),
for (final size in FabM3ESize.values)
FabM3E(
icon: const Icon(Icons.add),
kind: FabM3EKind.primary,
size: size,
shapeFamily: FabM3EShapeFamily.square,
onPressed: onPressed),
],
),
const SizedBox(height: 12),

View file

@ -14,6 +14,38 @@ class _NavigationSectionState extends State<NavigationSection> {
int _barIndex = 0;
int _railIndex = 0;
// Controls for the rail demo
NavigationRailM3EType _railType = NavigationRailM3EType.expanded;
NavigationRailM3EModality _modality = NavigationRailM3EModality.standard;
bool _hideWhenCollapsed = false;
double _navigationBarWidth = 450;
List<NavigationRailM3ESection> get _railSections => const [
NavigationRailM3ESection(
header: Text('Main'),
destinations: [
NavigationRailM3EDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dash',
),
NavigationRailM3EDestination(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Reports',
smallBadge: true,
),
NavigationRailM3EDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
largeBadgeCount: 2,
),
],
),
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -31,9 +63,12 @@ class _NavigationSectionState extends State<NavigationSection> {
),
Wrap(
runSpacing: 12,
spacing: 12,
children: [
for (final style in NavBarM3EIndicatorStyle.values)
Column(
SizedBox(
width: _navigationBarWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
@ -70,6 +105,7 @@ class _NavigationSectionState extends State<NavigationSection> {
const SizedBox(height: 8),
],
),
),
],
),
const SizedBox(height: 12),
@ -77,37 +113,69 @@ class _NavigationSectionState extends State<NavigationSection> {
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Navigation Rail', style: theme.textTheme.titleMedium),
),
// Options for the rail demo (e.g., modality)
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Modality:', style: theme.textTheme.labelLarge),
const SizedBox(width: 8),
DropdownButton<NavigationRailM3EModality>(
value: _modality,
onChanged: (v) => setState(() => _modality = v!),
items: NavigationRailM3EModality.values
.map((m) => DropdownMenuItem(
value: m,
child: Text(m.name),
))
.toList(),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Hide when collapsed',
style: theme.textTheme.labelLarge),
Switch(
value: _hideWhenCollapsed,
onChanged: (v) => setState(() => _hideWhenCollapsed = v),
),
],
),
],
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: m3e.shapes.round.lg,
),
height: 220,
height: 600,
child: Row(
children: [
for (final style in RailIndicatorStyle.values) ...[
NavigationRailM3E(
type: _railType,
modality: _modality,
sections: _railSections,
selectedIndex: _railIndex,
onDestinationSelected: (i) =>
setState(() => _railIndex = i),
indicatorStyle: style,
destinations: const [
RailDestinationM3E(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dash'),
RailDestinationM3E(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Reports'),
RailDestinationM3E(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings'),
],
onDestinationSelected: (i) => setState(() => _railIndex = i),
onTypeChanged: (t) => setState(() => _railType = t),
fab: NavigationRailM3EFabSlot(
icon: const Icon(Icons.add),
label: 'New',
onPressed: () {},
),
hideWhenCollapsed: _hideWhenCollapsed,
onDismissModal: () => setState(
() => _modality = NavigationRailM3EModality.standard,
),
),
const VerticalDivider(width: 1),
],
Expanded(
child: Center(child: Text('Selected: $_railIndex')),
),

View file

@ -1,30 +0,0 @@
# melos_managed_dependency_overrides: icon_button_m3e,m3e_collection,m3e_design,split_button_m3e,app_bar_m3e,button_group_m3e,button_m3e,fab_m3e,loading_indicator_m3e,navigation_bar_m3e,navigation_rail_m3e,progress_indicator_m3e,slider_m3e,toolbar_m3e
dependency_overrides:
app_bar_m3e:
path: ..\\..\\packages\\app_bar_m3e
button_group_m3e:
path: ..\\..\\packages\\button_group_m3e
button_m3e:
path: ..\\..\\packages\\button_m3e
fab_m3e:
path: ..\\..\\packages\\fab_m3e
icon_button_m3e:
path: ..\\..\\packages\\icon_button_m3e
loading_indicator_m3e:
path: ..\\..\\packages\\loading_indicator_m3e
m3e_collection:
path: ..\\..\\packages\\m3e_collection
m3e_design:
path: ..\\..\\packages\\m3e_design
navigation_bar_m3e:
path: ..\\..\\packages\\navigation_bar_m3e
navigation_rail_m3e:
path: ..\\..\\packages\\navigation_rail_m3e
progress_indicator_m3e:
path: ..\\..\\packages\\progress_indicator_m3e
slider_m3e:
path: ..\\..\\packages\\slider_m3e
split_button_m3e:
path: ..\\..\\packages\\split_button_m3e
toolbar_m3e:
path: ..\\..\\packages\\toolbar_m3e

View file

@ -17,3 +17,17 @@ scripts:
create:
run: dart run tool/create_component.dart
description: Scaffold a new [component]_m3e package (melos run create -- name=badge)
pub-dry-run:
run: melos exec -c 1 --no-private -- "flutter pub publish --dry-run"
description: Run 'flutter pub publish --dry-run' in all publishable packages
packageFilters:
noPrivate: true
dirExists:
- lib
pub-publish:
run: melos exec -c 1 --no-private -- "flutter pub publish"
description: Run 'flutter pub publish' in all publishable packages
packageFilters:
noPrivate: true
dirExists:
- lib

View file

@ -1,5 +1,6 @@
library button_group_m3e;
export 'src/button_group_m3e_widget.dart';
export 'src/button_group_m3e_enums.dart';
export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope;
export 'src/button_group_m3e_scope.dart'
show ButtonGroupM3EScope, ButtonGroupM3EItemScope;
export 'src/button_group_m3e_widget.dart';

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'button_group_m3e_enums.dart';
import '_tokens_adapter.dart';
import 'button_group_m3e_enums.dart';
import 'button_group_m3e_scope.dart';
class ButtonGroupM3E extends StatelessWidget {
@ -57,7 +57,8 @@ class ButtonGroupM3E extends StatelessWidget {
final tokens = metricsFor(context, size, density);
final cs = Theme.of(context).colorScheme;
final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6);
final dividerThk = (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0);
final dividerThk =
(dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0);
final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing);
final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0;
@ -69,7 +70,8 @@ class ButtonGroupM3E extends StatelessWidget {
density: density,
direction: direction,
isConnected: _connected,
child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk),
child: _buildContent(
context, effSpacing, effRunSpacing, dividerClr, dividerThk),
);
final semantics = Semantics(
@ -123,8 +125,14 @@ class ButtonGroupM3E extends StatelessWidget {
}
return direction == Axis.horizontal
? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list)
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list);
? Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: list)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: list);
}
Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) {
@ -153,7 +161,11 @@ class ButtonGroupM3E extends StatelessWidget {
}
Widget _wrapItemScope(BuildContext context,
{required int index, required int count, required bool isFirst, required bool isLast, required Widget child}) {
{required int index,
required int count,
required bool isFirst,
required bool isLast,
required Widget child}) {
return ButtonGroupM3EItemScope(
index: index,
count: count,
@ -163,8 +175,9 @@ class ButtonGroupM3E extends StatelessWidget {
);
}
Widget _spacer(double spacing) =>
direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing);
Widget _spacer(double spacing) => direction == Axis.horizontal
? SizedBox(width: spacing)
: SizedBox(height: spacing);
Widget _buildDivider(Color color, double thickness) {
return direction == Axis.horizontal
@ -181,6 +194,7 @@ class ButtonGroupM3E extends StatelessWidget {
ButtonGroupM3ESize.lg => 96.0,
ButtonGroupM3ESize.xl => 120.0,
};
return ConstrainedBox(constraints: BoxConstraints(minWidth: minW), child: child);
return ConstrainedBox(
constraints: BoxConstraints(minWidth: minW), child: child);
}
}

View file

@ -1,6 +1,6 @@
library button_m3e;
export 'src/enums.dart';
export 'src/button_tokens_adapter.dart' show ButtonTokensAdapter, ButtonMeasurements;
export 'src/button_m3e.dart';
export 'src/button_tokens_adapter.dart'
show ButtonTokensAdapter, ButtonMeasurements;
export 'src/enums.dart';

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
@ -19,7 +19,8 @@ class ButtonMeasurements {
@immutable
class ButtonTokensAdapter {
const ButtonTokensAdapter(this.context, {this.smallPaddingDeprecated24 = false});
const ButtonTokensAdapter(this.context,
{this.smallPaddingDeprecated24 = false});
final BuildContext context;
final bool smallPaddingDeprecated24;
@ -59,17 +60,25 @@ class ButtonTokensAdapter {
Color outline() => _m3e.colors.outline;
double elevation(ButtonM3EStyle style, Set<MaterialState> states) {
final hovered = states.contains(MaterialState.hovered);
final pressed = states.contains(MaterialState.pressed);
final disabled = states.contains(MaterialState.disabled);
double elevation(ButtonM3EStyle style, Set<WidgetState> states) {
final hovered = states.contains(WidgetState.hovered);
final pressed = states.contains(WidgetState.pressed);
final disabled = states.contains(WidgetState.disabled);
if (disabled) return 0;
switch (style) {
case ButtonM3EStyle.elevated:
return pressed ? 0 : hovered ? 3 : 1;
return pressed
? 0
: hovered
? 3
: 1;
case ButtonM3EStyle.filled:
case ButtonM3EStyle.tonal:
return pressed ? 0 : hovered ? 1 : 0;
return pressed
? 0
: hovered
? 1
: 0;
case ButtonM3EStyle.outlined:
case ButtonM3EStyle.text:
return 0;
@ -91,12 +100,14 @@ class ButtonTokensAdapter {
}
}
double pressedRadius(ButtonM3ESize size) => (squareRadius(size) * 0.6).clamp(6, 18);
double pressedRadius(ButtonM3ESize size) =>
(squareRadius(size) * 0.6).clamp(6, 18);
ButtonMeasurements measurements(ButtonM3ESize size) {
switch (size) {
case ButtonM3ESize.xs:
return const ButtonMeasurements(height: 32, hPadding: 12, iconSize: 20, iconGap: 4);
return const ButtonMeasurements(
height: 32, hPadding: 12, iconSize: 20, iconGap: 4);
case ButtonM3ESize.sm:
return ButtonMeasurements(
height: 40,
@ -105,11 +116,14 @@ class ButtonTokensAdapter {
iconGap: 8,
);
case ButtonM3ESize.md:
return const ButtonMeasurements(height: 56, hPadding: 24, iconSize: 24, iconGap: 8);
return const ButtonMeasurements(
height: 56, hPadding: 24, iconSize: 24, iconGap: 8);
case ButtonM3ESize.lg:
return const ButtonMeasurements(height: 96, hPadding: 48, iconSize: 32, iconGap: 12);
return const ButtonMeasurements(
height: 96, hPadding: 48, iconSize: 32, iconGap: 12);
case ButtonM3ESize.xl:
return const ButtonMeasurements(height: 136, hPadding: 64, iconSize: 40, iconGap: 16);
return const ButtonMeasurements(
height: 136, hPadding: 64, iconSize: 40, iconGap: 16);
}
}
}

View file

@ -1,7 +1,7 @@
library fab_m3e;
export 'src/enums.dart';
export 'src/fab_theme_m3e.dart' show FabTokensAdapter;
export 'src/fab_m3e.dart';
export 'src/extended_fab_m3e.dart';
export 'src/fab_m3e.dart';
export 'src/fab_menu_m3e.dart';
export 'src/fab_theme_m3e.dart' show FabTokensAdapter;

View file

@ -39,7 +39,7 @@ class ExtendedFabM3E extends StatelessWidget {
final m = tokens.metrics(density);
final bg = tokens.bg(kind);
final fg = tokens.fg(kind);
final shape = tokens.shape(shapeFamily, size, extended: true);
final shape = tokens.shape(shapeFamily, size);
final minH = m.extendedHeight;
final child = DefaultTextStyle.merge(

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
@ -28,7 +29,8 @@ class _FabMetrics {
_FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double small = 40;
@ -38,7 +40,10 @@ _FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
double icon = 24;
if (density == FabM3EDensity.compact) {
small -= 4; regular -= 4; large -= 4; extH -= 4;
small -= 4;
regular -= 4;
large -= 4;
extH -= 4;
}
return _FabMetrics(
@ -93,11 +98,16 @@ class FabTokensAdapter {
}
// Shapes
ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size, {bool extended = false}) {
final set = family == FabM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
if (extended) return StadiumBorder(side: BorderSide.none);
ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size) {
final set = family == FabM3EShapeFamily.round
? _m3e.shapes.round
: _m3e.shapes.square;
// circular-ish fab: use large radius to approach circle; actual size enforced by constraints
final radius = switch (size) { FabM3ESize.small => set.lg, FabM3ESize.regular => set.xl, FabM3ESize.large => set.xl };
final radius = switch (size) {
FabM3ESize.small => set.lg,
FabM3ESize.regular => set.xl,
FabM3ESize.large => set.xl
};
return RoundedRectangleBorder(borderRadius: radius);
}

View file

@ -1,6 +1,6 @@
library loading_indicator_m3e;
export 'src/enums.dart';
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
export 'src/expressive_loading_indicator.dart';
export 'src/loading_indicator_m3e.dart';
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;

View file

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:material_new_shapes/material_new_shapes.dart';
import 'enums.dart';
import 'expressive_loading_indicator.dart';
import 'loading_tokens_adapter.dart';
import 'enums.dart';
/// Material 3 Expressive Loading Indicator
/// - Default: floating morphing shape on surface
@ -38,12 +39,15 @@ class LoadingIndicatorM3E extends StatelessWidget {
final activeColor = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(),
LoadingIndicatorM3EVariant.contained =>
color ?? tokens.containedActiveColor(),
};
final containerBg = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(),
LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(),
LoadingIndicatorM3EVariant.defaultStyle =>
containerColor ?? tokens.containerColorDefault(),
LoadingIndicatorM3EVariant.contained =>
containerColor ?? tokens.containedContainerColor(),
};
final indicator = ExpressiveLoadingIndicator(

View file

@ -0,0 +1,3 @@
## 0.1.0
- Initial release of NavigationRailM3E (collapsed & expanded) with modal/standard modes,
badges, sections, menu & FAB slots, and token integration.

View file

@ -1,13 +1,13 @@
MIT License
Copyright (c) ...
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
copies of the Software, and to permit persons to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

View file

@ -1,84 +1,51 @@
# navigation_rail_m3e
Material 3 **Expressive** Navigation Rail for Flutter with badges, pill/stripe indicators, and token-driven styling.
Material 3 **Expressive** Navigation Rail for Flutter — featuring **collapsed** & **expanded** variants,
**modal** and **standard** presentation, **sections**, **badges**, **menu** and **FAB** slots, and smooth
**expand/collapse transitions**. Built to match the M3 Expressive spec and integrate with the `m3e_design`
token package.
- `NavigationRailM3E` — wrapper around Flutter's `NavigationRail` with M3E tokens
- `RailDestinationM3E` — destination data (icon, selectedIcon, label, badge)
- `RailBadgeM3E` — small badge/dot utility for icons
<img src="https://raw.githubusercontent.com/EmilyMonestone/material_3_expressive/main/.github/images/nav_rail_m3e_cover.png" width="980"/>
All styling is driven by the `m3e_design` ThemeExtension (**M3ETheme**).
## Highlights
## Monorepo Layout
- Collapsed (96 dp) and Expanded (220360 dp) rails with animated transition
- Expanded **modal** presentation with scrim
- Optional menu and FAB/Extended FAB slots
- Item badges (large numeric & small dot)
- Sections with headers; full-width hit targets
- Token-driven colors, typography & shapes via `m3e_design` (with safe fallbacks)
```
packages/
m3e_design/
navigation_rail_m3e/
```
`pubspec.yaml` references `../m3e_design`.
## Usage
## Quick start
```dart
import 'package:navigation_rail_m3e/navigation_rail_m3e.dart';
final items = [
const RailDestinationM3E(
icon: Icon(Icons.inbox_outlined),
selectedIcon: Icon(Icons.inbox),
label: 'Inbox',
),
const RailDestinationM3E(
icon: Icon(Icons.chat_bubble_outline),
label: 'Chat',
badgeCount: 5,
),
const RailDestinationM3E(
icon: Icon(Icons.settings_outlined),
label: 'Settings',
badgeDot: true,
),
];
NavigationRailM3E(
destinations: items,
type: NavigationRailM3EType.expanded,
modality: NavigationRailM3EModality.standard,
selectedIndex: 0,
onDestinationSelected: (i) {},
labelBehavior: RailLabelBehavior.onlySelected, // none | onlySelected | alwaysShow
indicatorStyle: RailIndicatorStyle.pill, // pill | stripe | none
size: RailSize.regular, // compact | regular
density: RailDensity.regular, // regular | compact
shapeFamily: RailShapeFamily.round, // round | square
extended: false, // true to show labels permanently (wide rail)
groupAlignment: -1.0, // -1 top .. 1 bottom
leading: const Padding(
padding: EdgeInsets.all(8.0),
child: FlutterLogo(size: 24),
onDestinationSelected: (i) => setState(() => _index = i),
onTypeChanged: (t) => setState(() => type = t),
fab: NavigationRailM3EFabSlot(icon: const Icon(Icons.add), label: 'New', onPressed: () {}),
sections: [
NavigationRailM3ESection(
header: const Text('Main'),
destinations: [
NavigationRailM3EDestination(
icon: const Icon(Icons.edit_outlined),
selectedIcon: const Icon(Icons.edit),
label: 'Edit',
largeBadgeCount: 0,
),
NavigationRailM3EDestination(
icon: const Icon(Icons.star_outline),
selectedIcon: const Icon(Icons.star),
label: 'Starred',
smallBadge: true,
),
],
),
],
);
```
## Tokens mapping
- **Container**: `surfaceContainerHigh`
- **Indicator**: `secondaryContainer` (color). `pill` uses NavigationRail's indicator; `stripe` draws a left border on the selected icon.
- **Selected**: `onSecondaryContainer` (icon/label)
- **Unselected**: `onSurfaceVariant`
- **Label style**: `labelMedium`
- **Widths**: compact **≈64dp**, regular **≈80dp**, extended min **≈256dp**
- **Icon size**: **24dp**
- **Item padding**: from `spacing.sm/md`
## Badges
Use `badgeCount` for numeric badges or `badgeDot: true` for a small dot. Colors default to `errorContainer / onErrorContainer` and can be overridden via `RailBadgeM3E`.
## Accessibility
- Provide `semanticLabel` per destination (used as tooltip) or on the rail (`semanticLabel` on the widget).
- Choose the label behavior to balance density with readability.
## License
MIT
See the `/example` app for a runnable demo.

View file

@ -0,0 +1,17 @@
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-raw-types: true
strong-mode:
implicit-casts: false
implicit-dynamic: false
linter:
rules:
- public_member_api_docs
- always_declare_return_types
- prefer_final_locals
- prefer_const_constructors
- prefer_const_literals_to_create_immutables
- avoid_print
- directives_ordering

View file

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:navigation_rail_m3e/navigation_rail_m3e.dart';
void main() {
runApp(const DemoApp());
}
class DemoApp extends StatefulWidget {
const DemoApp({super.key});
@override
State<DemoApp> createState() => _DemoAppState();
}
class _DemoAppState extends State<DemoApp> {
var type = NavigationRailM3EType.expanded;
var modality = NavigationRailM3EModality.standard;
int index = 0;
List<NavigationRailM3ESection> get sections => [
const NavigationRailM3ESection(
header: Text('Main'),
destinations: [
NavigationRailM3EDestination(
icon: Icon(Icons.edit_outlined),
selectedIcon: Icon(Icons.edit),
label: 'Edit',
),
NavigationRailM3EDestination(
icon: Icon(Icons.star_outline),
selectedIcon: Icon(Icons.star),
label: 'Starred',
smallBadge: true,
),
NavigationRailM3EDestination(
icon: Icon(Icons.inbox_outlined),
selectedIcon: Icon(Icons.inbox),
label: 'Inbox',
largeBadgeCount: 3,
),
],
),
];
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('NavigationRailM3E Demo'),
actions: [
IconButton(
onPressed: () => setState(() {
type = type == NavigationRailM3EType.expanded
? NavigationRailM3EType.collapsed
: NavigationRailM3EType.expanded;
}),
icon: const Icon(Icons.swap_horiz),
tooltip: 'Toggle type',
),
IconButton(
onPressed: () => setState(() {
modality = modality == NavigationRailM3EModality.standard
? NavigationRailM3EModality.modal
: NavigationRailM3EModality.standard;
}),
icon: const Icon(Icons.layers),
tooltip: 'Toggle modality',
),
],
),
body: Row(
children: [
NavigationRailM3E(
type: type,
modality: modality,
sections: sections,
selectedIndex: index,
onDestinationSelected: (i) => setState(() => index = i),
onTypeChanged: (t) => setState(() => type = t),
fab: NavigationRailM3EFabSlot(
icon: const Icon(Icons.add),
label: 'New',
onPressed: () {},
),
hideWhenCollapsed: false,
expandedWidth: 280,
onDismissModal: () =>
setState(() => modality = NavigationRailM3EModality.standard),
),
Expanded(
child: Center(
child: Text('Selected index: $index'),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,15 @@
name: navigation_rail_m3e_example
description: Example for navigation_rail_m3e
publish_to: "none"
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
navigation_rail_m3e:
path: ../
flutter:
uses-material-design: true

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

View file

@ -1,18 +1,28 @@
name: navigation_rail_m3e
description: Material 3 Expressive Navigation Rail for Flutter with token-driven colors, shapes, indicators, and badges.
description: Material 3 Expressive navigation rail (collapsed & expanded) with modal/standard modes, badges, sections, and m3e_design token integration.
version: 0.1.0
publish_to: none
homepage: https://github.com/EmilyMonestone/material_3_expressive
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0"
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
# Integrates with your design system tokens.
m3e_design:
path: ../m3e_design
# FAB components used by the rail (FabM3E and ExtendedFabM3E).
fab_m3e:
path: ../fab_m3e
# Icon button used by the rail's menu control.
icon_button_m3e:
path: ../icon_button_m3e
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

View file

@ -1,4 +0,0 @@
# melos_managed_dependency_overrides: m3e_design
dependency_overrides:
m3e_design:
path: ..\\m3e_design

View file

@ -1,7 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () {
expect(3 + 4, 7);
});
}

View file

@ -1,7 +1,6 @@
library progress_indicator_m3e;
export 'src/circular_progress_m3e.dart';
export 'src/enums.dart';
export 'src/linear_progress_m3e.dart';
export 'src/circular_progress_m3e.dart';
export 'src/progress_with_label_m3e.dart';

View file

@ -9,7 +9,7 @@ class Palette {
// Use theme roles; callers can override colors if needed.
Color get active => cs.primary;
Color get track => cs.onSurfaceVariant.withOpacity(0.24);
Color get track => cs.onSurfaceVariant.withValues(alpha: 0.24);
Color get bg => cs.surface;
}
@ -39,7 +39,8 @@ class LinearSpec {
LinearSpec specForLinear({
required LinearProgressM3ESize size,
required ProgressM3EShape shape,
}) => switch ((shape, size)) {
}) =>
switch ((shape, size)) {
(ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec(
trackHeight: 4,
gap: 4,

View file

@ -73,7 +73,8 @@ class _CircularProgressIndicatorM3EState
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final active = widget.activeColor ?? cs.primary;
final track = widget.trackColor ?? cs.onSurfaceVariant.withOpacity(0.24);
final track =
widget.trackColor ?? cs.onSurfaceVariant.withValues(alpha: 0.24);
final wantsWavy = widget.shape == ProgressM3EShape.wavy;
final diameter =
wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat;

View file

@ -1,7 +1,7 @@
library slider_m3e;
export 'src/enums.dart';
export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter;
export 'src/slider_theme_m3e.dart';
export 'src/slider_m3e.dart';
export 'src/range_slider_m3e.dart';
export 'src/slider_m3e.dart';
export 'src/slider_theme_m3e.dart';
export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter;

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'slider_theme_m3e.dart';
import 'enums.dart';
import 'slider_theme_m3e.dart';
class RangeSliderM3E extends StatelessWidget {
const RangeSliderM3E({
@ -63,7 +64,8 @@ class RangeSliderM3E extends StatelessWidget {
divisions: divisions,
labels: labels,
semanticFormatterCallback: semanticLabel != null
? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
? (v) =>
'$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
: null,
),
);

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'slider_theme_m3e.dart';
import 'enums.dart';
import 'slider_theme_m3e.dart';
class SliderM3E extends StatelessWidget {
const SliderM3E({
@ -63,7 +64,8 @@ class SliderM3E extends StatelessWidget {
divisions: divisions,
label: label,
semanticFormatterCallback: semanticLabel != null
? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
? (v) =>
'$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
: null,
);

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'slider_tokens_adapter.dart';
import 'enums.dart';
import 'slider_tokens_adapter.dart';
SliderThemeData sliderThemeM3E(
BuildContext context, {
@ -42,7 +43,9 @@ SliderThemeData sliderThemeM3E(
overlayColor: t.overlayColor(emphasis),
valueIndicatorColor: t.valueIndicatorColor(),
valueIndicatorTextStyle: t.valueIndicatorTextStyle(),
showValueIndicator: showValueIndicator ? ShowValueIndicator.onDrag : ShowValueIndicator.onlyForDiscrete,
showValueIndicator: showValueIndicator
? ShowValueIndicator.onDrag
: ShowValueIndicator.onlyForDiscrete,
thumbShape: thumbShape,
overlayShape: RoundSliderOverlayShape(overlayRadius: m.overlayRadius),
rangeThumbShape: shapeFamily == SliderM3EShapeFamily.round

View file

@ -100,6 +100,11 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
final pressedRadius = widget.size.pressedRadius;
final innerRadius = widget.size.innerCornerRadius;
const innerGap = SplitButtonM3ETokens.innerGap;
// Elevated style needs larger perceived separation between segments.
final double effectiveInnerGap =
widget.emphasis == SplitButtonM3EEmphasis.elevated
? innerGap * 2
: innerGap;
final chevronTurns = _menuOpen
? SplitButtonM3ETokens.chevronOpenTurns
: 0.0;
@ -220,7 +225,9 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
if (!widget.enabled) return;
setState(() => _trailingPressed = v);
},
onTap: widget.enabled ? () => _openMenu(context) : null,
onTap: widget.enabled
? () => _openMenu(_trailingKey.currentContext ?? context)
: null,
child: Padding(
padding: EdgeInsetsDirectional.only(
start: trailingLeftPad,
@ -241,7 +248,27 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
),
);
return FocusTraversalGroup(
// Menu theme to match SplitButton design (colors, font, shape)
final theme = Theme.of(context);
final m3e = context.m3e;
final bool contIsTransparent = cont.a == 0.0;
final Color menuColor = contIsTransparent
? theme.colorScheme.surfaceContainerHigh
: cont;
final TextStyle? menuTextStyle = m3e.typography.base.labelLarge?.copyWith(
color: contIsTransparent ? theme.colorScheme.onSurface : onCont,
);
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.size.pressedRadius),
);
return PopupMenuTheme(
data: theme.popupMenuTheme.copyWith(
color: menuColor,
textStyle: menuTextStyle,
shape: shape,
),
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: minTap),
@ -250,11 +277,12 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
textDirection: dir,
children: [
leading,
const SizedBox(width: innerGap),
SizedBox(width: effectiveInnerGap),
trailing,
],
),
),
),
);
}
@ -311,9 +339,21 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
Future<void> _openMenu(BuildContext context) async {
if (widget.menuBuilder != null) {
setState(() => _menuOpen = true);
// Enforce menu min width to trailing button width
Size _tSize = Size.zero;
final tCtx = _trailingKey.currentContext;
if (tCtx != null) {
final tb = tCtx.findRenderObject() as RenderBox?;
if (tb != null) _tSize = tb.size;
}
final double _minMenuWidth = _tSize.width > 0
? _tSize.width
: widget.size.trailingWidthCentered;
final res = await showMenu<T>(
context: context,
position: _menuPosition(context),
constraints: BoxConstraints(minWidth: _minMenuWidth),
items: widget.menuBuilder!(context),
);
if (mounted) {
@ -326,18 +366,54 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
// Convert simple items to PopupMenuEntries
final items = widget.items!;
setState(() => _menuOpen = true);
// Ensure menu item text/icon colors match the button's foreground (onCont)
final theme = Theme.of(context);
final m3e = context.m3e;
final (
Color _cont,
Color onCont,
BorderSide? _outlineSide,
double? _elevation,
) = _resolveColorsAndShapes(
context,
);
// Enforce menu min width to trailing button width
Size _tSize = Size.zero;
final tCtx2 = _trailingKey.currentContext;
if (tCtx2 != null) {
final tb2 = tCtx2.findRenderObject() as RenderBox?;
if (tb2 != null) _tSize = tb2.size;
}
final double _minMenuWidth2 = _tSize.width > 0
? _tSize.width
: widget.size.trailingWidthCentered;
final res = await showMenu<T>(
context: context,
position: _menuPosition(context),
items: items
.map(
(e) => PopupMenuItem<T>(
constraints: BoxConstraints(minWidth: _minMenuWidth2),
items: items.map((e) {
final Color effective = e.enabled
? onCont
: onCont.withValues(alpha: 0.38);
final Widget baseChild = e.child is Widget
? e.child as Widget
: Text('${e.child}');
final Widget styledChild = IconTheme.merge(
data: IconThemeData(color: effective, size: widget.size.iconPx),
child: DefaultTextStyle.merge(
style: TextStyle(color: effective),
child: baseChild,
),
);
return PopupMenuItem<T>(
value: e.value,
enabled: e.enabled,
child: e.child is Widget ? e.child as Widget : Text('${e.child}'),
),
)
.toList(),
child: styledChild,
);
}).toList(),
);
if (!mounted) return;
setState(() => _menuOpen = false);
@ -345,41 +421,46 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
}
RelativeRect _menuPosition(BuildContext context) {
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final textDir = Directionality.of(context);
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
// Default to whole control if trailing key is missing
RenderBox? tb;
Offset tTopLeft = Offset.zero;
Size tSize = Size.zero;
final tCtx = _trailingKey.currentContext;
if (tCtx != null) {
tb = tCtx.findRenderObject() as RenderBox?;
}
if (tb != null) {
tTopLeft = tb.localToGlobal(Offset.zero);
tSize = tb.size;
} else {
final box = context.findRenderObject() as RenderBox?;
if (box != null) {
tTopLeft = box.localToGlobal(Offset.zero);
tSize = box.size;
}
// Prefer the trailing segment as the anchor, fallback to the whole control.
final BuildContext? tCtx = _trailingKey.currentContext;
RenderBox? targetBox = tCtx?.findRenderObject() as RenderBox?;
targetBox ??= context.findRenderObject() as RenderBox?;
if (targetBox == null) {
// If we can't resolve a box, fill as a safe (rare) fallback.
return RelativeRect.fill;
}
final top = tTopLeft.dy + tSize.height;
final Offset targetTopLeft = targetBox.localToGlobal(
Offset.zero,
ancestor: overlay,
);
final Rect targetRect = Rect.fromLTWH(
targetTopLeft.dx,
targetTopLeft.dy,
targetBox.size.width,
targetBox.size.height,
);
// Place the menu just below the trailing segment with a small vertical gap,
// keeping horizontal alignment anchored to the trailing edge.
const double _kMenuVerticalOffset = 4.0;
final double top = targetRect.bottom + _kMenuVerticalOffset;
final TextDirection textDir = Directionality.of(context);
late double left;
late double right;
if (textDir == TextDirection.ltr) {
final endX = tTopLeft.dx + tSize.width; // right edge
left = endX;
right = overlay.size.width - endX;
final double endX = targetRect.right; // trailing edge in LTR
left = 0.0;
right = overlay.size.width - endX; // align menu's right edge to endX
} else {
final startX = tTopLeft.dx; // left edge is trailing in RTL
left = startX;
right = overlay.size.width - startX;
final double startX = targetRect.left; // trailing edge in RTL
left = startX; // align menu's left edge to startX
right = 0.0;
}
return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top);

View file

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'toolbar_tokens_adapter.dart';
import 'toolbar_action_m3e.dart';
import 'toolbar_tokens_adapter.dart';
class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
const ToolbarM3E({
@ -59,9 +60,12 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
Size get preferredSize {
// A rough default; actual height is resolved at build based on size/density.
switch (size) {
case ToolbarM3ESize.small: return const Size.fromHeight(40);
case ToolbarM3ESize.medium: return const Size.fromHeight(48);
case ToolbarM3ESize.large: return const Size.fromHeight(56);
case ToolbarM3ESize.small:
return const Size.fromHeight(40);
case ToolbarM3ESize.medium:
return const Size.fromHeight(48);
case ToolbarM3ESize.large:
return const Size.fromHeight(56);
}
}
@ -69,7 +73,8 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) {
final tokens = ToolbarTokensAdapter(context);
final metrics = tokens.metrics(density);
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final m3e = Theme.of(context).extension<M3ETheme>() ??
M3ETheme.defaults(Theme.of(context).colorScheme);
final height = switch (size) {
ToolbarM3ESize.small => metrics.heightSmall,
@ -84,12 +89,18 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
final resolvedTitle = title ??
(titleText != null
? Text(titleText!, style: tokens.titleStyle().copyWith(color: fg), overflow: TextOverflow.ellipsis)
? Text(titleText!,
style: tokens.titleStyle().copyWith(color: fg),
overflow: TextOverflow.ellipsis)
: null);
final resolvedSubtitle = subtitle ??
(subtitleText != null
? Text(subtitleText!, style: tokens.subtitleStyle().copyWith(color: fg.withValues(alpha: 0.8)), overflow: TextOverflow.ellipsis)
? Text(subtitleText!,
style: tokens
.subtitleStyle()
.copyWith(color: fg.withValues(alpha: 0.8)),
overflow: TextOverflow.ellipsis)
: null);
final toolbarRow = Row(
@ -118,7 +129,10 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
final bar = Material(
color: bg,
elevation: elevation ?? (variant == ToolbarM3EVariant.surface ? metrics.elevationSurface : metrics.elevationProminent),
elevation: elevation ??
(variant == ToolbarM3EVariant.surface
? metrics.elevationSurface
: metrics.elevationProminent),
shape: shape,
clipBehavior: clipBehavior,
child: SizedBox(
@ -127,13 +141,17 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
padding: pad,
child: IconTheme.merge(
data: IconThemeData(color: fg, size: metrics.iconSize),
child: DefaultTextStyle.merge(style: TextStyle(color: fg), child: toolbarRow),
child: DefaultTextStyle.merge(
style: TextStyle(color: fg), child: toolbarRow),
),
),
),
);
final content = safeArea ? SafeArea(top: false, left: false, right: false, bottom: false, child: bar) : bar;
final content = safeArea
? SafeArea(
top: false, left: false, right: false, bottom: false, child: bar)
: bar;
if (semanticLabel == null) return content;
return Semantics(container: true, label: semanticLabel!, child: content);
@ -141,7 +159,8 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
}
class _TitleBlock extends StatelessWidget {
const _TitleBlock({required this.title, required this.subtitle, required this.center});
const _TitleBlock(
{required this.title, required this.subtitle, required this.center});
final Widget? title;
final Widget? subtitle;
final bool center;
@ -152,10 +171,15 @@ class _TitleBlock extends StatelessWidget {
final col = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: center ? CrossAxisAlignment.center : CrossAxisAlignment.start,
crossAxisAlignment:
center ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
if (title != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.titleSmall!, child: title!),
if (subtitle != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.bodySmall!, child: subtitle!),
if (title != null)
DefaultTextStyle.merge(
style: Theme.of(context).textTheme.titleSmall!, child: title!),
if (subtitle != null)
DefaultTextStyle.merge(
style: Theme.of(context).textTheme.bodySmall!, child: subtitle!),
],
);
@ -187,17 +211,23 @@ class _ActionsRow extends StatelessWidget {
Widget build(BuildContext context) {
if (actions.isEmpty) return const SizedBox.shrink();
final inline = actions.take(maxInline).toList(growable: false);
final overflow = actions.length > maxInline ? actions.sublist(maxInline) : const <ToolbarActionM3E>[];
final overflow = actions.length > maxInline
? actions.sublist(maxInline)
: const <ToolbarActionM3E>[];
final row = Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final a in inline) ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize),
for (final a in inline)
ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize),
if (overflow.isNotEmpty)
_OverflowMenu(
actions: overflow,
icon: overflowIcon,
textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: m3e.colors.onSurface),
textStyle: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: m3e.colors.onSurface),
destructiveColor: m3e.colors.error,
),
],
@ -230,10 +260,14 @@ class _OverflowMenu extends StatelessWidget {
enabled: actions[i].enabled,
child: DefaultTextStyle.merge(
style: (actions[i].isDestructive
? (textStyle?.copyWith(color: destructiveColor) ?? TextStyle(color: destructiveColor))
? (textStyle?.copyWith(color: destructiveColor) ??
TextStyle(color: destructiveColor))
: textStyle) ??
const TextStyle(),
child: Text(actions[i].label ?? actions[i].tooltip ?? actions[i].semanticLabel ?? 'Action ${i + 1}'),
child: Text(actions[i].label ??
actions[i].tooltip ??
actions[i].semanticLabel ??
'Action ${i + 1}'),
),
),
],

View file

@ -1,6 +1,6 @@
library toolbar_m3e;
export 'src/enums.dart';
export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter;
export 'src/toolbar_action_m3e.dart';
export 'src/toolbar_m3e.dart';
export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter;