Update NavigationRailM3E implementation; update FAB and navigation sections to adapt to changes.
This commit is contained in:
parent
1cb404b4df
commit
83f5a02943
49 changed files with 1651 additions and 661 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
14
melos.yaml
14
melos.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
3
packages/navigation_rail_m3e/CHANGELOG.md
Normal file
3
packages/navigation_rail_m3e/CHANGELOG.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (220–360 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.
|
||||
17
packages/navigation_rail_m3e/analysis_options.yaml
Normal file
17
packages/navigation_rail_m3e/analysis_options.yaml
Normal 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
|
||||
101
packages/navigation_rail_m3e/example/lib/main.dart
Normal file
101
packages/navigation_rail_m3e/example/lib/main.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
packages/navigation_rail_m3e/example/pubspec.yaml
Normal file
15
packages/navigation_rail_m3e/example/pubspec.yaml
Normal 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
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 }
|
||||
8
packages/navigation_rail_m3e/lib/src/modality.dart
Normal file
8
packages/navigation_rail_m3e/lib/src/modality.dart
Normal 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (220–360). 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();
|
||||
}
|
||||
|
||||
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
packages/navigation_rail_m3e/lib/src/rail_collapsed.dart
Normal file
110
packages/navigation_rail_m3e/lib/src/rail_collapsed.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
171
packages/navigation_rail_m3e/lib/src/rail_expanded.dart
Normal file
171
packages/navigation_rail_m3e/lib/src/rail_expanded.dart
Normal 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 (220–360dp). 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;
|
||||
}
|
||||
}
|
||||
60
packages/navigation_rail_m3e/lib/src/rail_fab_slot.dart
Normal file
60
packages/navigation_rail_m3e/lib/src/rail_fab_slot.dart
Normal 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;
|
||||
}
|
||||
126
packages/navigation_rail_m3e/lib/src/rail_item.dart
Normal file
126
packages/navigation_rail_m3e/lib/src/rail_item.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
packages/navigation_rail_m3e/lib/src/rail_menu_slot.dart
Normal file
10
packages/navigation_rail_m3e/lib/src/rail_menu_slot.dart
Normal 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;
|
||||
}
|
||||
13
packages/navigation_rail_m3e/lib/src/rail_section_m3e.dart
Normal file
13
packages/navigation_rail_m3e/lib/src/rail_section_m3e.dart
Normal 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;
|
||||
}
|
||||
98
packages/navigation_rail_m3e/lib/src/rail_theme.dart
Normal file
98
packages/navigation_rail_m3e/lib/src/rail_theme.dart
Normal 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)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
packages/navigation_rail_m3e/lib/src/type.dart
Normal file
15
packages/navigation_rail_m3e/lib/src/type.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// M3 Expressive types for the rail.
|
||||
enum NavigationRailM3EType {
|
||||
/// Slim 96dp rail.
|
||||
collapsed,
|
||||
|
||||
/// Wide 220–360dp rail that replaces the drawer.
|
||||
expanded,
|
||||
}
|
||||
|
||||
extension NavigationRailM3ETypeX on NavigationRailM3EType {
|
||||
bool get isCollapsed => this == NavigationRailM3EType.collapsed;
|
||||
bool get isExpanded => this == NavigationRailM3EType.expanded;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# melos_managed_dependency_overrides: m3e_design
|
||||
dependency_overrides:
|
||||
m3e_design:
|
||||
path: ..\\m3e_design
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('placeholder', () {
|
||||
expect(3 + 4, 7);
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue