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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final m3e =
Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(cs);
return Scaffold( return Scaffold(
appBar: AppBarM3E( appBar: AppBarM3E(
titleText: 'M3E Gallery', titleText: 'M3E Gallery',

View file

@ -28,6 +28,13 @@ class FabSection extends StatelessWidget {
kind: kind, kind: kind,
size: size, size: size,
onPressed: onPressed), 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), const SizedBox(height: 12),

View file

@ -14,6 +14,38 @@ class _NavigationSectionState extends State<NavigationSection> {
int _barIndex = 0; int _barIndex = 0;
int _railIndex = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -31,44 +63,48 @@ class _NavigationSectionState extends State<NavigationSection> {
), ),
Wrap( Wrap(
runSpacing: 12, runSpacing: 12,
spacing: 12,
children: [ children: [
for (final style in NavBarM3EIndicatorStyle.values) for (final style in NavBarM3EIndicatorStyle.values)
Column( SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, width: _navigationBarWidth,
children: [ child: Column(
Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.only(bottom: 4), children: [
child: Text('indicator: ${style.name}', Padding(
style: theme.textTheme.labelLarge), padding: const EdgeInsets.only(bottom: 4),
), child: Text('indicator: ${style.name}',
NavigationBarM3E( style: theme.textTheme.labelLarge),
selectedIndex: _barIndex, ),
onDestinationSelected: (i) => NavigationBarM3E(
setState(() => _barIndex = i), selectedIndex: _barIndex,
indicatorStyle: style, onDestinationSelected: (i) =>
destinations: const [ setState(() => _barIndex = i),
NavigationDestinationM3E( indicatorStyle: style,
icon: Icon(Icons.home_outlined), destinations: const [
selectedIcon: Icon(Icons.home), NavigationDestinationM3E(
label: 'Home'), icon: Icon(Icons.home_outlined),
NavigationDestinationM3E( selectedIcon: Icon(Icons.home),
icon: Icon(Icons.search_outlined), label: 'Home'),
selectedIcon: Icon(Icons.search), NavigationDestinationM3E(
label: 'Search', icon: Icon(Icons.search_outlined),
badgeDot: true), selectedIcon: Icon(Icons.search),
NavigationDestinationM3E( label: 'Search',
icon: Icon(Icons.favorite_outline), badgeDot: true),
selectedIcon: Icon(Icons.favorite), NavigationDestinationM3E(
label: 'Favorites', icon: Icon(Icons.favorite_outline),
badgeCount: 2), selectedIcon: Icon(Icons.favorite),
NavigationDestinationM3E( label: 'Favorites',
icon: Icon(Icons.person_outline), badgeCount: 2),
selectedIcon: Icon(Icons.person), NavigationDestinationM3E(
label: 'Profile'), icon: Icon(Icons.person_outline),
], selectedIcon: Icon(Icons.person),
), label: 'Profile'),
const SizedBox(height: 8), ],
], ),
const SizedBox(height: 8),
],
),
), ),
], ],
), ),
@ -77,37 +113,69 @@ class _NavigationSectionState extends State<NavigationSection> {
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Navigation Rail', style: theme.textTheme.titleMedium), 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( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
borderRadius: m3e.shapes.round.lg, borderRadius: m3e.shapes.round.lg,
), ),
height: 220, height: 600,
child: Row( child: Row(
children: [ children: [
for (final style in RailIndicatorStyle.values) ...[ NavigationRailM3E(
NavigationRailM3E( type: _railType,
selectedIndex: _railIndex, modality: _modality,
onDestinationSelected: (i) => sections: _railSections,
setState(() => _railIndex = i), selectedIndex: _railIndex,
indicatorStyle: style, onDestinationSelected: (i) => setState(() => _railIndex = i),
destinations: const [ onTypeChanged: (t) => setState(() => _railType = t),
RailDestinationM3E( fab: NavigationRailM3EFabSlot(
icon: Icon(Icons.dashboard_outlined), icon: const Icon(Icons.add),
selectedIcon: Icon(Icons.dashboard), label: 'New',
label: 'Dash'), onPressed: () {},
RailDestinationM3E(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Reports'),
RailDestinationM3E(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings'),
],
), ),
const VerticalDivider(width: 1), hideWhenCollapsed: _hideWhenCollapsed,
], onDismissModal: () => setState(
() => _modality = NavigationRailM3EModality.standard,
),
),
const VerticalDivider(width: 1),
Expanded( Expanded(
child: Center(child: Text('Selected: $_railIndex')), 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: create:
run: dart run tool/create_component.dart run: dart run tool/create_component.dart
description: Scaffold a new [component]_m3e package (melos run create -- name=badge) description: Scaffold a new [component]_m3e package (melos run create -- name=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; library button_group_m3e;
export 'src/button_group_m3e_widget.dart';
export 'src/button_group_m3e_enums.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:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'button_group_m3e_enums.dart';
import '_tokens_adapter.dart'; import '_tokens_adapter.dart';
import 'button_group_m3e_enums.dart';
import 'button_group_m3e_scope.dart'; import 'button_group_m3e_scope.dart';
class ButtonGroupM3E extends StatelessWidget { class ButtonGroupM3E extends StatelessWidget {
@ -57,7 +57,8 @@ class ButtonGroupM3E extends StatelessWidget {
final tokens = metricsFor(context, size, density); final tokens = metricsFor(context, size, density);
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6); 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 effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing);
final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0; final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0;
@ -69,7 +70,8 @@ class ButtonGroupM3E extends StatelessWidget {
density: density, density: density,
direction: direction, direction: direction,
isConnected: _connected, isConnected: _connected,
child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk), child: _buildContent(
context, effSpacing, effRunSpacing, dividerClr, dividerThk),
); );
final semantics = Semantics( final semantics = Semantics(
@ -123,8 +125,14 @@ class ButtonGroupM3E extends StatelessWidget {
} }
return direction == Axis.horizontal return direction == Axis.horizontal
? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list) ? Row(
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list); 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) { Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) {
@ -153,7 +161,11 @@ class ButtonGroupM3E extends StatelessWidget {
} }
Widget _wrapItemScope(BuildContext context, 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( return ButtonGroupM3EItemScope(
index: index, index: index,
count: count, count: count,
@ -163,8 +175,9 @@ class ButtonGroupM3E extends StatelessWidget {
); );
} }
Widget _spacer(double spacing) => Widget _spacer(double spacing) => direction == Axis.horizontal
direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing); ? SizedBox(width: spacing)
: SizedBox(height: spacing);
Widget _buildDivider(Color color, double thickness) { Widget _buildDivider(Color color, double thickness) {
return direction == Axis.horizontal return direction == Axis.horizontal
@ -181,6 +194,7 @@ class ButtonGroupM3E extends StatelessWidget {
ButtonGroupM3ESize.lg => 96.0, ButtonGroupM3ESize.lg => 96.0,
ButtonGroupM3ESize.xl => 120.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; library button_m3e;
export 'src/enums.dart';
export 'src/button_tokens_adapter.dart' show ButtonTokensAdapter, ButtonMeasurements;
export 'src/button_m3e.dart'; 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:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart'; import 'package:m3e_design/m3e_design.dart';
import 'enums.dart'; import 'enums.dart';
@immutable @immutable
@ -19,14 +19,15 @@ class ButtonMeasurements {
@immutable @immutable
class ButtonTokensAdapter { class ButtonTokensAdapter {
const ButtonTokensAdapter(this.context, {this.smallPaddingDeprecated24 = false}); const ButtonTokensAdapter(this.context,
{this.smallPaddingDeprecated24 = false});
final BuildContext context; final BuildContext context;
final bool smallPaddingDeprecated24; final bool smallPaddingDeprecated24;
M3ETheme get _m3e { M3ETheme get _m3e {
final t = Theme.of(context); final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme); return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
} }
Color container(ButtonM3EStyle style) { Color container(ButtonM3EStyle style) {
final c = _m3e.colors; final c = _m3e.colors;
@ -59,17 +60,25 @@ class ButtonTokensAdapter {
Color outline() => _m3e.colors.outline; Color outline() => _m3e.colors.outline;
double elevation(ButtonM3EStyle style, Set<MaterialState> states) { double elevation(ButtonM3EStyle style, Set<WidgetState> states) {
final hovered = states.contains(MaterialState.hovered); final hovered = states.contains(WidgetState.hovered);
final pressed = states.contains(MaterialState.pressed); final pressed = states.contains(WidgetState.pressed);
final disabled = states.contains(MaterialState.disabled); final disabled = states.contains(WidgetState.disabled);
if (disabled) return 0; if (disabled) return 0;
switch (style) { switch (style) {
case ButtonM3EStyle.elevated: case ButtonM3EStyle.elevated:
return pressed ? 0 : hovered ? 3 : 1; return pressed
? 0
: hovered
? 3
: 1;
case ButtonM3EStyle.filled: case ButtonM3EStyle.filled:
case ButtonM3EStyle.tonal: case ButtonM3EStyle.tonal:
return pressed ? 0 : hovered ? 1 : 0; return pressed
? 0
: hovered
? 1
: 0;
case ButtonM3EStyle.outlined: case ButtonM3EStyle.outlined:
case ButtonM3EStyle.text: case ButtonM3EStyle.text:
return 0; 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) { ButtonMeasurements measurements(ButtonM3ESize size) {
switch (size) { switch (size) {
case ButtonM3ESize.xs: 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: case ButtonM3ESize.sm:
return ButtonMeasurements( return ButtonMeasurements(
height: 40, height: 40,
@ -105,11 +116,14 @@ class ButtonTokensAdapter {
iconGap: 8, iconGap: 8,
); );
case ButtonM3ESize.md: 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: 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: 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; library fab_m3e;
export 'src/enums.dart'; 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/extended_fab_m3e.dart';
export 'src/fab_m3e.dart';
export 'src/fab_menu_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 m = tokens.metrics(density);
final bg = tokens.bg(kind); final bg = tokens.bg(kind);
final fg = tokens.fg(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 minH = m.extendedHeight;
final child = DefaultTextStyle.merge( final child = DefaultTextStyle.merge(

View file

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

View file

@ -1,6 +1,6 @@
library loading_indicator_m3e; library loading_indicator_m3e;
export 'src/enums.dart'; export 'src/enums.dart';
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
export 'src/expressive_loading_indicator.dart'; export 'src/expressive_loading_indicator.dart';
export 'src/loading_indicator_m3e.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:flutter/material.dart';
import 'package:material_new_shapes/material_new_shapes.dart'; import 'package:material_new_shapes/material_new_shapes.dart';
import 'enums.dart';
import 'expressive_loading_indicator.dart'; import 'expressive_loading_indicator.dart';
import 'loading_tokens_adapter.dart'; import 'loading_tokens_adapter.dart';
import 'enums.dart';
/// Material 3 Expressive Loading Indicator /// Material 3 Expressive Loading Indicator
/// - Default: floating morphing shape on surface /// - Default: floating morphing shape on surface
@ -38,12 +39,15 @@ class LoadingIndicatorM3E extends StatelessWidget {
final activeColor = switch (variant) { final activeColor = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(), LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(), LoadingIndicatorM3EVariant.contained =>
color ?? tokens.containedActiveColor(),
}; };
final containerBg = switch (variant) { final containerBg = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(), LoadingIndicatorM3EVariant.defaultStyle =>
LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(), containerColor ?? tokens.containerColorDefault(),
LoadingIndicatorM3EVariant.contained =>
containerColor ?? tokens.containedContainerColor(),
}; };
final indicator = ExpressiveLoadingIndicator( 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 MIT License
Copyright (c) ... Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to do so, subject to the
furnished to do so, subject to the following conditions: following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View file

@ -1,84 +1,51 @@
# navigation_rail_m3e # 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 <img src="https://raw.githubusercontent.com/EmilyMonestone/material_3_expressive/main/.github/images/nav_rail_m3e_cover.png" width="980"/>
- `RailDestinationM3E` — destination data (icon, selectedIcon, label, badge)
- `RailBadgeM3E` — small badge/dot utility for icons
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)
``` ## Quick start
packages/
m3e_design/
navigation_rail_m3e/
```
`pubspec.yaml` references `../m3e_design`.
## Usage
```dart ```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( NavigationRailM3E(
destinations: items, type: NavigationRailM3EType.expanded,
modality: NavigationRailM3EModality.standard,
selectedIndex: 0, selectedIndex: 0,
onDestinationSelected: (i) {}, onDestinationSelected: (i) => setState(() => _index = i),
labelBehavior: RailLabelBehavior.onlySelected, // none | onlySelected | alwaysShow onTypeChanged: (t) => setState(() => type = t),
indicatorStyle: RailIndicatorStyle.pill, // pill | stripe | none fab: NavigationRailM3EFabSlot(icon: const Icon(Icons.add), label: 'New', onPressed: () {}),
size: RailSize.regular, // compact | regular sections: [
density: RailDensity.regular, // regular | compact NavigationRailM3ESection(
shapeFamily: RailShapeFamily.round, // round | square header: const Text('Main'),
extended: false, // true to show labels permanently (wide rail) destinations: [
groupAlignment: -1.0, // -1 top .. 1 bottom NavigationRailM3EDestination(
leading: const Padding( icon: const Icon(Icons.edit_outlined),
padding: EdgeInsets.all(8.0), selectedIcon: const Icon(Icons.edit),
child: FlutterLogo(size: 24), label: 'Edit',
), largeBadgeCount: 0,
),
NavigationRailM3EDestination(
icon: const Icon(Icons.star_outline),
selectedIcon: const Icon(Icons.star),
label: 'Starred',
smallBadge: true,
),
],
),
],
); );
``` ```
## Tokens mapping See the `/example` app for a runnable demo.
- **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

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; library navigation_rail_m3e;
export 'src/enums.dart'; export 'src/type.dart';
export 'src/rail_tokens_adapter.dart' show RailTokensAdapter; export 'src/modality.dart';
export 'src/navigation_rail_m3e.dart'; export 'src/rail_theme.dart';
export 'src/rail_tokens_adapter.dart';
export 'src/rail_badge_m3e.dart'; export 'src/rail_badge_m3e.dart';
export 'src/rail_destination_m3e.dart'; export 'src/rail_destination_m3e.dart';
export 'src/rail_section_m3e.dart';
export 'src/rail_fab_slot.dart';
export 'src/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:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart'; import 'package:icon_button_m3e/icon_button_m3e.dart';
class NavigationRailM3EWidget extends StatelessWidget { import 'modality.dart';
const NavigationRailM3EWidget({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final m3e = context.m3e; // Keep overlay in sync after build completes to avoid layout side-effects.
return Container( WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
padding: EdgeInsets.all(m3e.spacing.md),
decoration: BoxDecoration( if (_needsOverlay) {
color: m3e.colors.surfaceStrong, // When showing modal via overlay, render nothing in the layout slot so
borderRadius: m3e.shapes.square.md, // content underneath can occupy the width. The overlay covers it.
), return const SizedBox.shrink();
child: Text('NavigationRail placeholder', style: m3e.typography.base.titleMedium), }
);
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: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 { class RailBadgeM3E extends StatelessWidget {
/// Creates a large numeric badge.
const RailBadgeM3E({ const RailBadgeM3E({
super.key, super.key,
required this.child, required this.count,
this.count, this.maxDigits = 3,
this.showDot = false, this.dense = false,
this.maxCount = 99, });
this.backgroundColor,
this.foregroundColor,
this.semanticLabel,
this.offset = const Offset(8, -6),
}) : assert(count == null || count >= 0);
final Widget child; /// The numeric value to display in the badge.
final int? count; final int count;
final bool showDot;
final int maxCount; /// Maximum digits before showing a trailing '+' (e.g. 999+).
final Color? backgroundColor; final int maxDigits;
final Color? foregroundColor;
final String? semanticLabel; /// Whether to use a denser (smaller padding) variant.
final Offset offset; final bool dense;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context); final tokens = NavigationRailTokensAdapter(context);
final m3e = t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme); final String text = count > (10 * (pow10(maxDigits) - 1))
final bg = backgroundColor ?? m3e.colors.errorContainer; ? '${pow10(maxDigits) - 1}+'
final fg = foregroundColor ?? m3e.colors.onErrorContainer; : '$count';
final double pad = dense ? 2 : 4;
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) {
return Container( return Container(
width: 8, height: 8, padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
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),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: tokens.badgeLargeBackground,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(999),
), ),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: DefaultTextStyle( child: DefaultTextStyle(
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), style: Theme.of(context).textTheme.labelSmall!.copyWith(
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)), 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 'package:flutter/material.dart';
import 'rail_badge_m3e.dart';
class RailDestinationM3E { /// Model for a navigation destination. One class per file.
const RailDestinationM3E({ class NavigationRailM3EDestination {
const NavigationRailM3EDestination({
required this.icon, required this.icon,
required this.label,
this.selectedIcon, this.selectedIcon,
this.badgeCount, required this.label,
this.badgeDot = false, this.largeBadgeCount,
this.smallBadge = false,
this.semanticLabel, this.semanticLabel,
this.short = false,
}); });
final Widget icon; final Widget icon;
final Widget? selectedIcon; final Widget? selectedIcon;
final String label; final String label;
final int? largeBadgeCount;
/// Optional badge counter final bool smallBadge;
final int? badgeCount;
/// If true, show a small dot instead of a counter.
final bool badgeDot;
final String? semanticLabel; final String? semanticLabel;
Widget buildIcon([bool selected = false]) { /// If true, uses short item height (56dp) instead of 64dp.
final base = selected && selectedIcon != null ? selectedIcon! : icon; final bool short;
if (badgeCount != null || badgeDot) {
return RailBadgeM3E(
child: base,
count: badgeCount,
showDot: badgeDot,
semanticLabel: semanticLabel,
);
}
return base;
}
} }

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:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart'; import 'package:m3e_design/m3e_design.dart' as m3e;
import 'enums.dart';
@immutable /// Provides colors & shapes from `m3e_design` with safe fallbacks to Theme.of(context).
class _RailMetrics { class NavigationRailTokensAdapter {
final double widthCompact; const NavigationRailTokensAdapter(this.context);
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,
});
}
_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; final BuildContext context;
M3ETheme get _m3e { ColorScheme get _cs => Theme.of(context).colorScheme;
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.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); Color get activeIndicatorColor {
return _maybe(() => context.m3e.colors.secondaryContainer) ??
// Container/background _cs.secondaryContainer;
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);
} }
ShapeBorder indicatorShapePill() => const StadiumBorder(); Color get activeIconAndLabel {
return _maybe(() => context.m3e.colors.secondary) ?? _cs.secondary;
}
// Stripe decoration for selected destination Color get inactiveIconAndLabel {
BoxDecoration stripeDecoration(Color color, double thickness) { return _maybe(() => context.m3e.colors.onSurfaceVariant) ??
return BoxDecoration( _cs.onSurfaceVariant;
border: Border( }
left: BorderSide(color: color, width: thickness),
), 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 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 version: 0.1.0
publish_to: none homepage: https://github.com/EmilyMonestone/material_3_expressive
environment: environment:
sdk: ">=3.5.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.22.0"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# Integrates with your design system tokens.
m3e_design: m3e_design:
path: ../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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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; library progress_indicator_m3e;
export 'src/circular_progress_m3e.dart';
export 'src/enums.dart'; export 'src/enums.dart';
export 'src/linear_progress_m3e.dart'; export 'src/linear_progress_m3e.dart';
export 'src/circular_progress_m3e.dart';
export 'src/progress_with_label_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. // Use theme roles; callers can override colors if needed.
Color get active => cs.primary; 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; Color get bg => cs.surface;
} }
@ -39,7 +39,8 @@ class LinearSpec {
LinearSpec specForLinear({ LinearSpec specForLinear({
required LinearProgressM3ESize size, required LinearProgressM3ESize size,
required ProgressM3EShape shape, required ProgressM3EShape shape,
}) => switch ((shape, size)) { }) =>
switch ((shape, size)) {
(ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec( (ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec(
trackHeight: 4, trackHeight: 4,
gap: 4, gap: 4,

View file

@ -73,7 +73,8 @@ class _CircularProgressIndicatorM3EState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final active = widget.activeColor ?? cs.primary; 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 wantsWavy = widget.shape == ProgressM3EShape.wavy;
final diameter = final diameter =
wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat; wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat;

View file

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

View file

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

View file

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

View file

@ -100,6 +100,11 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
final pressedRadius = widget.size.pressedRadius; final pressedRadius = widget.size.pressedRadius;
final innerRadius = widget.size.innerCornerRadius; final innerRadius = widget.size.innerCornerRadius;
const innerGap = SplitButtonM3ETokens.innerGap; 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 final chevronTurns = _menuOpen
? SplitButtonM3ETokens.chevronOpenTurns ? SplitButtonM3ETokens.chevronOpenTurns
: 0.0; : 0.0;
@ -220,7 +225,9 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
if (!widget.enabled) return; if (!widget.enabled) return;
setState(() => _trailingPressed = v); setState(() => _trailingPressed = v);
}, },
onTap: widget.enabled ? () => _openMenu(context) : null, onTap: widget.enabled
? () => _openMenu(_trailingKey.currentContext ?? context)
: null,
child: Padding( child: Padding(
padding: EdgeInsetsDirectional.only( padding: EdgeInsetsDirectional.only(
start: trailingLeftPad, start: trailingLeftPad,
@ -241,18 +248,39 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
), ),
); );
return FocusTraversalGroup( // Menu theme to match SplitButton design (colors, font, shape)
policy: ReadingOrderTraversalPolicy(), final theme = Theme.of(context);
child: ConstrainedBox( final m3e = context.m3e;
constraints: const BoxConstraints(minHeight: minTap), final bool contIsTransparent = cont.a == 0.0;
child: Row( final Color menuColor = contIsTransparent
mainAxisSize: MainAxisSize.min, ? theme.colorScheme.surfaceContainerHigh
textDirection: dir, : cont;
children: [ final TextStyle? menuTextStyle = m3e.typography.base.labelLarge?.copyWith(
leading, color: contIsTransparent ? theme.colorScheme.onSurface : onCont,
const SizedBox(width: innerGap), );
trailing, 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),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: dir,
children: [
leading,
SizedBox(width: effectiveInnerGap),
trailing,
],
),
), ),
), ),
); );
@ -311,9 +339,21 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
Future<void> _openMenu(BuildContext context) async { Future<void> _openMenu(BuildContext context) async {
if (widget.menuBuilder != null) { if (widget.menuBuilder != null) {
setState(() => _menuOpen = true); 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>( final res = await showMenu<T>(
context: context, context: context,
position: _menuPosition(context), position: _menuPosition(context),
constraints: BoxConstraints(minWidth: _minMenuWidth),
items: widget.menuBuilder!(context), items: widget.menuBuilder!(context),
); );
if (mounted) { if (mounted) {
@ -326,18 +366,54 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
// Convert simple items to PopupMenuEntries // Convert simple items to PopupMenuEntries
final items = widget.items!; final items = widget.items!;
setState(() => _menuOpen = true); 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>( final res = await showMenu<T>(
context: context, context: context,
position: _menuPosition(context), position: _menuPosition(context),
items: items constraints: BoxConstraints(minWidth: _minMenuWidth2),
.map( items: items.map((e) {
(e) => PopupMenuItem<T>( final Color effective = e.enabled
value: e.value, ? onCont
enabled: e.enabled, : onCont.withValues(alpha: 0.38);
child: e.child is Widget ? e.child as Widget : Text('${e.child}'), final Widget baseChild = e.child is Widget
), ? e.child as Widget
) : Text('${e.child}');
.toList(), 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: styledChild,
);
}).toList(),
); );
if (!mounted) return; if (!mounted) return;
setState(() => _menuOpen = false); setState(() => _menuOpen = false);
@ -345,41 +421,46 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
} }
RelativeRect _menuPosition(BuildContext context) { RelativeRect _menuPosition(BuildContext context) {
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final RenderBox overlay =
final textDir = Directionality.of(context); Overlay.of(context).context.findRenderObject() as RenderBox;
// Default to whole control if trailing key is missing // Prefer the trailing segment as the anchor, fallback to the whole control.
RenderBox? tb; final BuildContext? tCtx = _trailingKey.currentContext;
Offset tTopLeft = Offset.zero; RenderBox? targetBox = tCtx?.findRenderObject() as RenderBox?;
Size tSize = Size.zero; targetBox ??= context.findRenderObject() as RenderBox?;
final tCtx = _trailingKey.currentContext; if (targetBox == null) {
if (tCtx != null) { // If we can't resolve a box, fill as a safe (rare) fallback.
tb = tCtx.findRenderObject() as RenderBox?; return RelativeRect.fill;
}
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;
}
} }
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 left;
late double right; late double right;
if (textDir == TextDirection.ltr) { if (textDir == TextDirection.ltr) {
final endX = tTopLeft.dx + tSize.width; // right edge final double endX = targetRect.right; // trailing edge in LTR
left = endX; left = 0.0;
right = overlay.size.width - endX; right = overlay.size.width - endX; // align menu's right edge to endX
} else { } else {
final startX = tTopLeft.dx; // left edge is trailing in RTL final double startX = targetRect.left; // trailing edge in RTL
left = startX; left = startX; // align menu's left edge to startX
right = overlay.size.width - startX; right = 0.0;
} }
return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top); return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top);

View file

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