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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
final m3e =
|
|
||||||
Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(cs);
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBarM3E(
|
appBar: AppBarM3E(
|
||||||
titleText: 'M3E Gallery',
|
titleText: 'M3E Gallery',
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ class FabSection extends StatelessWidget {
|
||||||
kind: kind,
|
kind: kind,
|
||||||
size: size,
|
size: size,
|
||||||
onPressed: onPressed),
|
onPressed: onPressed),
|
||||||
|
for (final size in FabM3ESize.values)
|
||||||
|
FabM3E(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
kind: FabM3EKind.primary,
|
||||||
|
size: size,
|
||||||
|
shapeFamily: FabM3EShapeFamily.square,
|
||||||
|
onPressed: onPressed),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,38 @@ class _NavigationSectionState extends State<NavigationSection> {
|
||||||
int _barIndex = 0;
|
int _barIndex = 0;
|
||||||
int _railIndex = 0;
|
int _railIndex = 0;
|
||||||
|
|
||||||
|
// Controls for the rail demo
|
||||||
|
NavigationRailM3EType _railType = NavigationRailM3EType.expanded;
|
||||||
|
NavigationRailM3EModality _modality = NavigationRailM3EModality.standard;
|
||||||
|
bool _hideWhenCollapsed = false;
|
||||||
|
|
||||||
|
double _navigationBarWidth = 450;
|
||||||
|
|
||||||
|
List<NavigationRailM3ESection> get _railSections => const [
|
||||||
|
NavigationRailM3ESection(
|
||||||
|
header: Text('Main'),
|
||||||
|
destinations: [
|
||||||
|
NavigationRailM3EDestination(
|
||||||
|
icon: Icon(Icons.dashboard_outlined),
|
||||||
|
selectedIcon: Icon(Icons.dashboard),
|
||||||
|
label: 'Dash',
|
||||||
|
),
|
||||||
|
NavigationRailM3EDestination(
|
||||||
|
icon: Icon(Icons.analytics_outlined),
|
||||||
|
selectedIcon: Icon(Icons.analytics),
|
||||||
|
label: 'Reports',
|
||||||
|
smallBadge: true,
|
||||||
|
),
|
||||||
|
NavigationRailM3EDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: 'Settings',
|
||||||
|
largeBadgeCount: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -31,44 +63,48 @@ class _NavigationSectionState extends State<NavigationSection> {
|
||||||
),
|
),
|
||||||
Wrap(
|
Wrap(
|
||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
for (final style in NavBarM3EIndicatorStyle.values)
|
for (final style in NavBarM3EIndicatorStyle.values)
|
||||||
Column(
|
SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
width: _navigationBarWidth,
|
||||||
children: [
|
child: Column(
|
||||||
Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
children: [
|
||||||
child: Text('indicator: ${style.name}',
|
Padding(
|
||||||
style: theme.textTheme.labelLarge),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
),
|
child: Text('indicator: ${style.name}',
|
||||||
NavigationBarM3E(
|
style: theme.textTheme.labelLarge),
|
||||||
selectedIndex: _barIndex,
|
),
|
||||||
onDestinationSelected: (i) =>
|
NavigationBarM3E(
|
||||||
setState(() => _barIndex = i),
|
selectedIndex: _barIndex,
|
||||||
indicatorStyle: style,
|
onDestinationSelected: (i) =>
|
||||||
destinations: const [
|
setState(() => _barIndex = i),
|
||||||
NavigationDestinationM3E(
|
indicatorStyle: style,
|
||||||
icon: Icon(Icons.home_outlined),
|
destinations: const [
|
||||||
selectedIcon: Icon(Icons.home),
|
NavigationDestinationM3E(
|
||||||
label: 'Home'),
|
icon: Icon(Icons.home_outlined),
|
||||||
NavigationDestinationM3E(
|
selectedIcon: Icon(Icons.home),
|
||||||
icon: Icon(Icons.search_outlined),
|
label: 'Home'),
|
||||||
selectedIcon: Icon(Icons.search),
|
NavigationDestinationM3E(
|
||||||
label: 'Search',
|
icon: Icon(Icons.search_outlined),
|
||||||
badgeDot: true),
|
selectedIcon: Icon(Icons.search),
|
||||||
NavigationDestinationM3E(
|
label: 'Search',
|
||||||
icon: Icon(Icons.favorite_outline),
|
badgeDot: true),
|
||||||
selectedIcon: Icon(Icons.favorite),
|
NavigationDestinationM3E(
|
||||||
label: 'Favorites',
|
icon: Icon(Icons.favorite_outline),
|
||||||
badgeCount: 2),
|
selectedIcon: Icon(Icons.favorite),
|
||||||
NavigationDestinationM3E(
|
label: 'Favorites',
|
||||||
icon: Icon(Icons.person_outline),
|
badgeCount: 2),
|
||||||
selectedIcon: Icon(Icons.person),
|
NavigationDestinationM3E(
|
||||||
label: 'Profile'),
|
icon: Icon(Icons.person_outline),
|
||||||
],
|
selectedIcon: Icon(Icons.person),
|
||||||
),
|
label: 'Profile'),
|
||||||
const SizedBox(height: 8),
|
],
|
||||||
],
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -77,37 +113,69 @@ class _NavigationSectionState extends State<NavigationSection> {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Text('Navigation Rail', style: theme.textTheme.titleMedium),
|
child: Text('Navigation Rail', style: theme.textTheme.titleMedium),
|
||||||
),
|
),
|
||||||
|
// Options for the rail demo (e.g., modality)
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Modality:', style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
DropdownButton<NavigationRailM3EModality>(
|
||||||
|
value: _modality,
|
||||||
|
onChanged: (v) => setState(() => _modality = v!),
|
||||||
|
items: NavigationRailM3EModality.values
|
||||||
|
.map((m) => DropdownMenuItem(
|
||||||
|
value: m,
|
||||||
|
child: Text(m.name),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Hide when collapsed',
|
||||||
|
style: theme.textTheme.labelLarge),
|
||||||
|
Switch(
|
||||||
|
value: _hideWhenCollapsed,
|
||||||
|
onChanged: (v) => setState(() => _hideWhenCollapsed = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: m3e.shapes.round.lg,
|
borderRadius: m3e.shapes.round.lg,
|
||||||
),
|
),
|
||||||
height: 220,
|
height: 600,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
for (final style in RailIndicatorStyle.values) ...[
|
NavigationRailM3E(
|
||||||
NavigationRailM3E(
|
type: _railType,
|
||||||
selectedIndex: _railIndex,
|
modality: _modality,
|
||||||
onDestinationSelected: (i) =>
|
sections: _railSections,
|
||||||
setState(() => _railIndex = i),
|
selectedIndex: _railIndex,
|
||||||
indicatorStyle: style,
|
onDestinationSelected: (i) => setState(() => _railIndex = i),
|
||||||
destinations: const [
|
onTypeChanged: (t) => setState(() => _railType = t),
|
||||||
RailDestinationM3E(
|
fab: NavigationRailM3EFabSlot(
|
||||||
icon: Icon(Icons.dashboard_outlined),
|
icon: const Icon(Icons.add),
|
||||||
selectedIcon: Icon(Icons.dashboard),
|
label: 'New',
|
||||||
label: 'Dash'),
|
onPressed: () {},
|
||||||
RailDestinationM3E(
|
|
||||||
icon: Icon(Icons.analytics_outlined),
|
|
||||||
selectedIcon: Icon(Icons.analytics),
|
|
||||||
label: 'Reports'),
|
|
||||||
RailDestinationM3E(
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
selectedIcon: Icon(Icons.settings),
|
|
||||||
label: 'Settings'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const VerticalDivider(width: 1),
|
hideWhenCollapsed: _hideWhenCollapsed,
|
||||||
],
|
onDismissModal: () => setState(
|
||||||
|
() => _modality = NavigationRailM3EModality.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(child: Text('Selected: $_railIndex')),
|
child: Center(child: Text('Selected: $_railIndex')),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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:
|
create:
|
||||||
run: dart run tool/create_component.dart
|
run: dart run tool/create_component.dart
|
||||||
description: Scaffold a new [component]_m3e package (melos run create -- name=badge)
|
description: Scaffold a new [component]_m3e package (melos run create -- name=badge)
|
||||||
|
pub-dry-run:
|
||||||
|
run: melos exec -c 1 --no-private -- "flutter pub publish --dry-run"
|
||||||
|
description: Run 'flutter pub publish --dry-run' in all publishable packages
|
||||||
|
packageFilters:
|
||||||
|
noPrivate: true
|
||||||
|
dirExists:
|
||||||
|
- lib
|
||||||
|
pub-publish:
|
||||||
|
run: melos exec -c 1 --no-private -- "flutter pub publish"
|
||||||
|
description: Run 'flutter pub publish' in all publishable packages
|
||||||
|
packageFilters:
|
||||||
|
noPrivate: true
|
||||||
|
dirExists:
|
||||||
|
- lib
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
library button_group_m3e;
|
library button_group_m3e;
|
||||||
|
|
||||||
export 'src/button_group_m3e_widget.dart';
|
|
||||||
export 'src/button_group_m3e_enums.dart';
|
export 'src/button_group_m3e_enums.dart';
|
||||||
export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope;
|
export 'src/button_group_m3e_scope.dart'
|
||||||
|
show ButtonGroupM3EScope, ButtonGroupM3EItemScope;
|
||||||
|
export 'src/button_group_m3e_widget.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
|
||||||
import 'button_group_m3e_enums.dart';
|
|
||||||
import '_tokens_adapter.dart';
|
import '_tokens_adapter.dart';
|
||||||
|
import 'button_group_m3e_enums.dart';
|
||||||
import 'button_group_m3e_scope.dart';
|
import 'button_group_m3e_scope.dart';
|
||||||
|
|
||||||
class ButtonGroupM3E extends StatelessWidget {
|
class ButtonGroupM3E extends StatelessWidget {
|
||||||
|
|
@ -57,7 +57,8 @@ class ButtonGroupM3E extends StatelessWidget {
|
||||||
final tokens = metricsFor(context, size, density);
|
final tokens = metricsFor(context, size, density);
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6);
|
final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6);
|
||||||
final dividerThk = (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0);
|
final dividerThk =
|
||||||
|
(dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0);
|
||||||
|
|
||||||
final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing);
|
final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing);
|
||||||
final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0;
|
final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0;
|
||||||
|
|
@ -69,7 +70,8 @@ class ButtonGroupM3E extends StatelessWidget {
|
||||||
density: density,
|
density: density,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
isConnected: _connected,
|
isConnected: _connected,
|
||||||
child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk),
|
child: _buildContent(
|
||||||
|
context, effSpacing, effRunSpacing, dividerClr, dividerThk),
|
||||||
);
|
);
|
||||||
|
|
||||||
final semantics = Semantics(
|
final semantics = Semantics(
|
||||||
|
|
@ -123,8 +125,14 @@ class ButtonGroupM3E extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return direction == Axis.horizontal
|
return direction == Axis.horizontal
|
||||||
? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list)
|
? Row(
|
||||||
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list);
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: list)
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: list);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) {
|
Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) {
|
||||||
|
|
@ -153,7 +161,11 @@ class ButtonGroupM3E extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _wrapItemScope(BuildContext context,
|
Widget _wrapItemScope(BuildContext context,
|
||||||
{required int index, required int count, required bool isFirst, required bool isLast, required Widget child}) {
|
{required int index,
|
||||||
|
required int count,
|
||||||
|
required bool isFirst,
|
||||||
|
required bool isLast,
|
||||||
|
required Widget child}) {
|
||||||
return ButtonGroupM3EItemScope(
|
return ButtonGroupM3EItemScope(
|
||||||
index: index,
|
index: index,
|
||||||
count: count,
|
count: count,
|
||||||
|
|
@ -163,8 +175,9 @@ class ButtonGroupM3E extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _spacer(double spacing) =>
|
Widget _spacer(double spacing) => direction == Axis.horizontal
|
||||||
direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing);
|
? SizedBox(width: spacing)
|
||||||
|
: SizedBox(height: spacing);
|
||||||
|
|
||||||
Widget _buildDivider(Color color, double thickness) {
|
Widget _buildDivider(Color color, double thickness) {
|
||||||
return direction == Axis.horizontal
|
return direction == Axis.horizontal
|
||||||
|
|
@ -181,6 +194,7 @@ class ButtonGroupM3E extends StatelessWidget {
|
||||||
ButtonGroupM3ESize.lg => 96.0,
|
ButtonGroupM3ESize.lg => 96.0,
|
||||||
ButtonGroupM3ESize.xl => 120.0,
|
ButtonGroupM3ESize.xl => 120.0,
|
||||||
};
|
};
|
||||||
return ConstrainedBox(constraints: BoxConstraints(minWidth: minW), child: child);
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minWidth: minW), child: child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
library button_m3e;
|
library button_m3e;
|
||||||
|
|
||||||
export 'src/enums.dart';
|
|
||||||
export 'src/button_tokens_adapter.dart' show ButtonTokensAdapter, ButtonMeasurements;
|
|
||||||
export 'src/button_m3e.dart';
|
export 'src/button_m3e.dart';
|
||||||
|
export 'src/button_tokens_adapter.dart'
|
||||||
|
show ButtonTokensAdapter, ButtonMeasurements;
|
||||||
|
export 'src/enums.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
import 'package:m3e_design/m3e_design.dart';
|
||||||
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
|
|
@ -19,14 +19,15 @@ class ButtonMeasurements {
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class ButtonTokensAdapter {
|
class ButtonTokensAdapter {
|
||||||
const ButtonTokensAdapter(this.context, {this.smallPaddingDeprecated24 = false});
|
const ButtonTokensAdapter(this.context,
|
||||||
|
{this.smallPaddingDeprecated24 = false});
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
final bool smallPaddingDeprecated24;
|
final bool smallPaddingDeprecated24;
|
||||||
|
|
||||||
M3ETheme get _m3e {
|
M3ETheme get _m3e {
|
||||||
final t = Theme.of(context);
|
final t = Theme.of(context);
|
||||||
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color container(ButtonM3EStyle style) {
|
Color container(ButtonM3EStyle style) {
|
||||||
final c = _m3e.colors;
|
final c = _m3e.colors;
|
||||||
|
|
@ -59,17 +60,25 @@ class ButtonTokensAdapter {
|
||||||
|
|
||||||
Color outline() => _m3e.colors.outline;
|
Color outline() => _m3e.colors.outline;
|
||||||
|
|
||||||
double elevation(ButtonM3EStyle style, Set<MaterialState> states) {
|
double elevation(ButtonM3EStyle style, Set<WidgetState> states) {
|
||||||
final hovered = states.contains(MaterialState.hovered);
|
final hovered = states.contains(WidgetState.hovered);
|
||||||
final pressed = states.contains(MaterialState.pressed);
|
final pressed = states.contains(WidgetState.pressed);
|
||||||
final disabled = states.contains(MaterialState.disabled);
|
final disabled = states.contains(WidgetState.disabled);
|
||||||
if (disabled) return 0;
|
if (disabled) return 0;
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case ButtonM3EStyle.elevated:
|
case ButtonM3EStyle.elevated:
|
||||||
return pressed ? 0 : hovered ? 3 : 1;
|
return pressed
|
||||||
|
? 0
|
||||||
|
: hovered
|
||||||
|
? 3
|
||||||
|
: 1;
|
||||||
case ButtonM3EStyle.filled:
|
case ButtonM3EStyle.filled:
|
||||||
case ButtonM3EStyle.tonal:
|
case ButtonM3EStyle.tonal:
|
||||||
return pressed ? 0 : hovered ? 1 : 0;
|
return pressed
|
||||||
|
? 0
|
||||||
|
: hovered
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
case ButtonM3EStyle.outlined:
|
case ButtonM3EStyle.outlined:
|
||||||
case ButtonM3EStyle.text:
|
case ButtonM3EStyle.text:
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -91,12 +100,14 @@ class ButtonTokensAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double pressedRadius(ButtonM3ESize size) => (squareRadius(size) * 0.6).clamp(6, 18);
|
double pressedRadius(ButtonM3ESize size) =>
|
||||||
|
(squareRadius(size) * 0.6).clamp(6, 18);
|
||||||
|
|
||||||
ButtonMeasurements measurements(ButtonM3ESize size) {
|
ButtonMeasurements measurements(ButtonM3ESize size) {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case ButtonM3ESize.xs:
|
case ButtonM3ESize.xs:
|
||||||
return const ButtonMeasurements(height: 32, hPadding: 12, iconSize: 20, iconGap: 4);
|
return const ButtonMeasurements(
|
||||||
|
height: 32, hPadding: 12, iconSize: 20, iconGap: 4);
|
||||||
case ButtonM3ESize.sm:
|
case ButtonM3ESize.sm:
|
||||||
return ButtonMeasurements(
|
return ButtonMeasurements(
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|
@ -105,11 +116,14 @@ class ButtonTokensAdapter {
|
||||||
iconGap: 8,
|
iconGap: 8,
|
||||||
);
|
);
|
||||||
case ButtonM3ESize.md:
|
case ButtonM3ESize.md:
|
||||||
return const ButtonMeasurements(height: 56, hPadding: 24, iconSize: 24, iconGap: 8);
|
return const ButtonMeasurements(
|
||||||
|
height: 56, hPadding: 24, iconSize: 24, iconGap: 8);
|
||||||
case ButtonM3ESize.lg:
|
case ButtonM3ESize.lg:
|
||||||
return const ButtonMeasurements(height: 96, hPadding: 48, iconSize: 32, iconGap: 12);
|
return const ButtonMeasurements(
|
||||||
|
height: 96, hPadding: 48, iconSize: 32, iconGap: 12);
|
||||||
case ButtonM3ESize.xl:
|
case ButtonM3ESize.xl:
|
||||||
return const ButtonMeasurements(height: 136, hPadding: 64, iconSize: 40, iconGap: 16);
|
return const ButtonMeasurements(
|
||||||
|
height: 136, hPadding: 64, iconSize: 40, iconGap: 16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
library fab_m3e;
|
library fab_m3e;
|
||||||
|
|
||||||
export 'src/enums.dart';
|
export 'src/enums.dart';
|
||||||
export 'src/fab_theme_m3e.dart' show FabTokensAdapter;
|
|
||||||
export 'src/fab_m3e.dart';
|
|
||||||
export 'src/extended_fab_m3e.dart';
|
export 'src/extended_fab_m3e.dart';
|
||||||
|
export 'src/fab_m3e.dart';
|
||||||
export 'src/fab_menu_m3e.dart';
|
export 'src/fab_menu_m3e.dart';
|
||||||
|
export 'src/fab_theme_m3e.dart' show FabTokensAdapter;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class ExtendedFabM3E extends StatelessWidget {
|
||||||
final m = tokens.metrics(density);
|
final m = tokens.metrics(density);
|
||||||
final bg = tokens.bg(kind);
|
final bg = tokens.bg(kind);
|
||||||
final fg = tokens.fg(kind);
|
final fg = tokens.fg(kind);
|
||||||
final shape = tokens.shape(shapeFamily, size, extended: true);
|
final shape = tokens.shape(shapeFamily, size);
|
||||||
|
|
||||||
final minH = m.extendedHeight;
|
final minH = m.extendedHeight;
|
||||||
final child = DefaultTextStyle.merge(
|
final child = DefaultTextStyle.merge(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
import 'package:m3e_design/m3e_design.dart';
|
||||||
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
|
|
@ -28,7 +29,8 @@ class _FabMetrics {
|
||||||
|
|
||||||
_FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
|
_FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
final m3e =
|
||||||
|
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||||
final sp = m3e.spacing;
|
final sp = m3e.spacing;
|
||||||
|
|
||||||
double small = 40;
|
double small = 40;
|
||||||
|
|
@ -38,7 +40,10 @@ _FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
|
||||||
double icon = 24;
|
double icon = 24;
|
||||||
|
|
||||||
if (density == FabM3EDensity.compact) {
|
if (density == FabM3EDensity.compact) {
|
||||||
small -= 4; regular -= 4; large -= 4; extH -= 4;
|
small -= 4;
|
||||||
|
regular -= 4;
|
||||||
|
large -= 4;
|
||||||
|
extH -= 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _FabMetrics(
|
return _FabMetrics(
|
||||||
|
|
@ -93,11 +98,16 @@ class FabTokensAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shapes
|
// Shapes
|
||||||
ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size, {bool extended = false}) {
|
ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size) {
|
||||||
final set = family == FabM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
|
final set = family == FabM3EShapeFamily.round
|
||||||
if (extended) return StadiumBorder(side: BorderSide.none);
|
? _m3e.shapes.round
|
||||||
|
: _m3e.shapes.square;
|
||||||
// circular-ish fab: use large radius to approach circle; actual size enforced by constraints
|
// circular-ish fab: use large radius to approach circle; actual size enforced by constraints
|
||||||
final radius = switch (size) { FabM3ESize.small => set.lg, FabM3ESize.regular => set.xl, FabM3ESize.large => set.xl };
|
final radius = switch (size) {
|
||||||
|
FabM3ESize.small => set.lg,
|
||||||
|
FabM3ESize.regular => set.xl,
|
||||||
|
FabM3ESize.large => set.xl
|
||||||
|
};
|
||||||
return RoundedRectangleBorder(borderRadius: radius);
|
return RoundedRectangleBorder(borderRadius: radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
library loading_indicator_m3e;
|
library loading_indicator_m3e;
|
||||||
|
|
||||||
export 'src/enums.dart';
|
export 'src/enums.dart';
|
||||||
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
|
|
||||||
export 'src/expressive_loading_indicator.dart';
|
export 'src/expressive_loading_indicator.dart';
|
||||||
export 'src/loading_indicator_m3e.dart';
|
export 'src/loading_indicator_m3e.dart';
|
||||||
|
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||||
|
|
||||||
|
import 'enums.dart';
|
||||||
import 'expressive_loading_indicator.dart';
|
import 'expressive_loading_indicator.dart';
|
||||||
import 'loading_tokens_adapter.dart';
|
import 'loading_tokens_adapter.dart';
|
||||||
import 'enums.dart';
|
|
||||||
|
|
||||||
/// Material 3 Expressive Loading Indicator
|
/// Material 3 Expressive Loading Indicator
|
||||||
/// - Default: floating morphing shape on surface
|
/// - Default: floating morphing shape on surface
|
||||||
|
|
@ -38,12 +39,15 @@ class LoadingIndicatorM3E extends StatelessWidget {
|
||||||
|
|
||||||
final activeColor = switch (variant) {
|
final activeColor = switch (variant) {
|
||||||
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
|
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
|
||||||
LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(),
|
LoadingIndicatorM3EVariant.contained =>
|
||||||
|
color ?? tokens.containedActiveColor(),
|
||||||
};
|
};
|
||||||
|
|
||||||
final containerBg = switch (variant) {
|
final containerBg = switch (variant) {
|
||||||
LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(),
|
LoadingIndicatorM3EVariant.defaultStyle =>
|
||||||
LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(),
|
containerColor ?? tokens.containerColorDefault(),
|
||||||
|
LoadingIndicatorM3EVariant.contained =>
|
||||||
|
containerColor ?? tokens.containedContainerColor(),
|
||||||
};
|
};
|
||||||
|
|
||||||
final indicator = ExpressiveLoadingIndicator(
|
final indicator = ExpressiveLoadingIndicator(
|
||||||
|
|
|
||||||
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
|
MIT License
|
||||||
|
|
||||||
Copyright (c) ...
|
Copyright (c) 2025
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to do so, subject to the
|
||||||
furnished to do so, subject to the following conditions:
|
following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
@ -1,84 +1,51 @@
|
||||||
# navigation_rail_m3e
|
# navigation_rail_m3e
|
||||||
|
|
||||||
Material 3 **Expressive** Navigation Rail for Flutter with badges, pill/stripe indicators, and token-driven styling.
|
Material 3 **Expressive** Navigation Rail for Flutter — featuring **collapsed** & **expanded** variants,
|
||||||
|
**modal** and **standard** presentation, **sections**, **badges**, **menu** and **FAB** slots, and smooth
|
||||||
|
**expand/collapse transitions**. Built to match the M3 Expressive spec and integrate with the `m3e_design`
|
||||||
|
token package.
|
||||||
|
|
||||||
- `NavigationRailM3E` — wrapper around Flutter's `NavigationRail` with M3E tokens
|
<img src="https://raw.githubusercontent.com/EmilyMonestone/material_3_expressive/main/.github/images/nav_rail_m3e_cover.png" width="980"/>
|
||||||
- `RailDestinationM3E` — destination data (icon, selectedIcon, label, badge)
|
|
||||||
- `RailBadgeM3E` — small badge/dot utility for icons
|
|
||||||
|
|
||||||
All styling is driven by the `m3e_design` ThemeExtension (**M3ETheme**).
|
## Highlights
|
||||||
|
|
||||||
## Monorepo Layout
|
- Collapsed (96 dp) and Expanded (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)
|
||||||
|
|
||||||
```
|
## Quick start
|
||||||
packages/
|
|
||||||
m3e_design/
|
|
||||||
navigation_rail_m3e/
|
|
||||||
```
|
|
||||||
|
|
||||||
`pubspec.yaml` references `../m3e_design`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
import 'package:navigation_rail_m3e/navigation_rail_m3e.dart';
|
|
||||||
|
|
||||||
final items = [
|
|
||||||
const RailDestinationM3E(
|
|
||||||
icon: Icon(Icons.inbox_outlined),
|
|
||||||
selectedIcon: Icon(Icons.inbox),
|
|
||||||
label: 'Inbox',
|
|
||||||
),
|
|
||||||
const RailDestinationM3E(
|
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
|
||||||
label: 'Chat',
|
|
||||||
badgeCount: 5,
|
|
||||||
),
|
|
||||||
const RailDestinationM3E(
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
label: 'Settings',
|
|
||||||
badgeDot: true,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
NavigationRailM3E(
|
NavigationRailM3E(
|
||||||
destinations: items,
|
type: NavigationRailM3EType.expanded,
|
||||||
|
modality: NavigationRailM3EModality.standard,
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
onDestinationSelected: (i) {},
|
onDestinationSelected: (i) => setState(() => _index = i),
|
||||||
labelBehavior: RailLabelBehavior.onlySelected, // none | onlySelected | alwaysShow
|
onTypeChanged: (t) => setState(() => type = t),
|
||||||
indicatorStyle: RailIndicatorStyle.pill, // pill | stripe | none
|
fab: NavigationRailM3EFabSlot(icon: const Icon(Icons.add), label: 'New', onPressed: () {}),
|
||||||
size: RailSize.regular, // compact | regular
|
sections: [
|
||||||
density: RailDensity.regular, // regular | compact
|
NavigationRailM3ESection(
|
||||||
shapeFamily: RailShapeFamily.round, // round | square
|
header: const Text('Main'),
|
||||||
extended: false, // true to show labels permanently (wide rail)
|
destinations: [
|
||||||
groupAlignment: -1.0, // -1 top .. 1 bottom
|
NavigationRailM3EDestination(
|
||||||
leading: const Padding(
|
icon: const Icon(Icons.edit_outlined),
|
||||||
padding: EdgeInsets.all(8.0),
|
selectedIcon: const Icon(Icons.edit),
|
||||||
child: FlutterLogo(size: 24),
|
label: 'Edit',
|
||||||
),
|
largeBadgeCount: 0,
|
||||||
|
),
|
||||||
|
NavigationRailM3EDestination(
|
||||||
|
icon: const Icon(Icons.star_outline),
|
||||||
|
selectedIcon: const Icon(Icons.star),
|
||||||
|
label: 'Starred',
|
||||||
|
smallBadge: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tokens mapping
|
See the `/example` app for a runnable demo.
|
||||||
|
|
||||||
- **Container**: `surfaceContainerHigh`
|
|
||||||
- **Indicator**: `secondaryContainer` (color). `pill` uses NavigationRail's indicator; `stripe` draws a left border on the selected icon.
|
|
||||||
- **Selected**: `onSecondaryContainer` (icon/label)
|
|
||||||
- **Unselected**: `onSurfaceVariant`
|
|
||||||
- **Label style**: `labelMedium`
|
|
||||||
- **Widths**: compact **≈64dp**, regular **≈80dp**, extended min **≈256dp**
|
|
||||||
- **Icon size**: **24dp**
|
|
||||||
- **Item padding**: from `spacing.sm/md`
|
|
||||||
|
|
||||||
## Badges
|
|
||||||
|
|
||||||
Use `badgeCount` for numeric badges or `badgeDot: true` for a small dot. Colors default to `errorContainer / onErrorContainer` and can be overridden via `RailBadgeM3E`.
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
- Provide `semanticLabel` per destination (used as tooltip) or on the rail (`semanticLabel` on the widget).
|
|
||||||
- Choose the label behavior to balance density with readability.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
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;
|
library navigation_rail_m3e;
|
||||||
|
|
||||||
export 'src/enums.dart';
|
export 'src/type.dart';
|
||||||
export 'src/rail_tokens_adapter.dart' show RailTokensAdapter;
|
export 'src/modality.dart';
|
||||||
export 'src/navigation_rail_m3e.dart';
|
export 'src/rail_theme.dart';
|
||||||
|
export 'src/rail_tokens_adapter.dart';
|
||||||
export 'src/rail_badge_m3e.dart';
|
export 'src/rail_badge_m3e.dart';
|
||||||
export 'src/rail_destination_m3e.dart';
|
export 'src/rail_destination_m3e.dart';
|
||||||
|
export 'src/rail_section_m3e.dart';
|
||||||
|
export 'src/rail_fab_slot.dart';
|
||||||
|
export 'src/navigation_rail_m3e_widget.dart';
|
||||||
|
|
@ -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:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
import 'package:icon_button_m3e/icon_button_m3e.dart';
|
||||||
|
|
||||||
class NavigationRailM3EWidget extends StatelessWidget {
|
import 'modality.dart';
|
||||||
const NavigationRailM3EWidget({super.key});
|
import 'rail_destination_m3e.dart';
|
||||||
|
import 'rail_fab_slot.dart';
|
||||||
|
import 'rail_item.dart';
|
||||||
|
import 'rail_section_m3e.dart';
|
||||||
|
import 'rail_theme.dart';
|
||||||
|
import 'rail_tokens_adapter.dart';
|
||||||
|
import 'type.dart';
|
||||||
|
|
||||||
|
/// Material 3 Expressive Navigation Rail — single widget that animates between states.
|
||||||
|
class NavigationRailM3E extends StatefulWidget {
|
||||||
|
/// Creates a Material 3 Expressive navigation rail.
|
||||||
|
const NavigationRailM3E({
|
||||||
|
super.key,
|
||||||
|
required this.type,
|
||||||
|
this.modality = NavigationRailM3EModality.standard,
|
||||||
|
required this.sections,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onDestinationSelected,
|
||||||
|
this.fab,
|
||||||
|
this.hideWhenCollapsed = false,
|
||||||
|
this.expandedWidth,
|
||||||
|
this.onDismissModal,
|
||||||
|
this.onTypeChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Presentation type for the rail (collapsed or expanded).
|
||||||
|
final NavigationRailM3EType type;
|
||||||
|
|
||||||
|
/// How the rail is shown (standard or modal overlay).
|
||||||
|
final NavigationRailM3EModality modality;
|
||||||
|
|
||||||
|
/// Sections and destinations to display.
|
||||||
|
final List<NavigationRailM3ESection> sections;
|
||||||
|
|
||||||
|
/// Index of the currently selected destination.
|
||||||
|
final int selectedIndex;
|
||||||
|
|
||||||
|
/// Called when a destination is selected.
|
||||||
|
final ValueChanged<int> onDestinationSelected;
|
||||||
|
|
||||||
|
/// Optional FAB/extended FAB shown near the top cluster.
|
||||||
|
final NavigationRailM3EFabSlot? fab;
|
||||||
|
|
||||||
|
/// When [type] is collapsed and this is true, rail animates to width 0.
|
||||||
|
final bool hideWhenCollapsed;
|
||||||
|
|
||||||
|
/// Custom expanded width (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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final m3e = context.m3e;
|
// Keep overlay in sync after build completes to avoid layout side-effects.
|
||||||
return Container(
|
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
||||||
padding: EdgeInsets.all(m3e.spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
if (_needsOverlay) {
|
||||||
color: m3e.colors.surfaceStrong,
|
// When showing modal via overlay, render nothing in the layout slot so
|
||||||
borderRadius: m3e.shapes.square.md,
|
// content underneath can occupy the width. The overlay covers it.
|
||||||
),
|
return const SizedBox.shrink();
|
||||||
child: Text('NavigationRail placeholder', style: m3e.typography.base.titleMedium),
|
}
|
||||||
);
|
|
||||||
|
return _buildRailCore(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _destinationIndex(List<NavigationRailM3ESection> sections,
|
||||||
|
NavigationRailM3EDestination dest) {
|
||||||
|
var i = 0;
|
||||||
|
for (final s in sections) {
|
||||||
|
for (final d in s.destinations) {
|
||||||
|
if (identical(d, dest)) return i;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();
|
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,56 @@
|
||||||
|
import 'dart:ui' show FontFeature;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
|
||||||
|
|
||||||
|
import 'rail_tokens_adapter.dart';
|
||||||
|
|
||||||
|
/// Large numeric badge for rail items (0..999+). One class per file.
|
||||||
class RailBadgeM3E extends StatelessWidget {
|
class RailBadgeM3E extends StatelessWidget {
|
||||||
|
/// Creates a large numeric badge.
|
||||||
const RailBadgeM3E({
|
const RailBadgeM3E({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.count,
|
||||||
this.count,
|
this.maxDigits = 3,
|
||||||
this.showDot = false,
|
this.dense = false,
|
||||||
this.maxCount = 99,
|
});
|
||||||
this.backgroundColor,
|
|
||||||
this.foregroundColor,
|
|
||||||
this.semanticLabel,
|
|
||||||
this.offset = const Offset(8, -6),
|
|
||||||
}) : assert(count == null || count >= 0);
|
|
||||||
|
|
||||||
final Widget child;
|
/// The numeric value to display in the badge.
|
||||||
final int? count;
|
final int count;
|
||||||
final bool showDot;
|
|
||||||
final int maxCount;
|
/// Maximum digits before showing a trailing '+' (e.g. 999+).
|
||||||
final Color? backgroundColor;
|
final int maxDigits;
|
||||||
final Color? foregroundColor;
|
|
||||||
final String? semanticLabel;
|
/// Whether to use a denser (smaller padding) variant.
|
||||||
final Offset offset;
|
final bool dense;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = Theme.of(context);
|
final tokens = NavigationRailTokensAdapter(context);
|
||||||
final m3e = t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
final String text = count > (10 * (pow10(maxDigits) - 1))
|
||||||
final bg = backgroundColor ?? m3e.colors.errorContainer;
|
? '${pow10(maxDigits) - 1}+'
|
||||||
final fg = foregroundColor ?? m3e.colors.onErrorContainer;
|
: '$count';
|
||||||
|
final double pad = dense ? 2 : 4;
|
||||||
final badge = showDot
|
|
||||||
? _dot(bg)
|
|
||||||
: _label(bg, fg, count == null ? '' : _format(count!, maxCount));
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
Positioned(
|
|
||||||
right: offset.dx,
|
|
||||||
top: offset.dy,
|
|
||||||
child: Semantics(
|
|
||||||
label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'),
|
|
||||||
child: badge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _dot(Color bg) {
|
|
||||||
return Container(
|
return Container(
|
||||||
width: 8, height: 8,
|
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
|
||||||
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _label(Color bg, Color fg, String text) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: tokens.badgeLargeBackground,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
|
||||||
child: DefaultTextStyle(
|
child: DefaultTextStyle(
|
||||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600),
|
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||||
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)),
|
color: tokens.badgeLargeLabel,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
child: Text(text, maxLines: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _format(int c, int max) => (c > max) ? '$max+' : '$c';
|
/// Returns 10 to the power of [n].
|
||||||
|
static int pow10(int n) {
|
||||||
|
var v = 1;
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
v *= 10;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 'package:flutter/material.dart';
|
||||||
import 'rail_badge_m3e.dart';
|
|
||||||
|
|
||||||
class RailDestinationM3E {
|
/// Model for a navigation destination. One class per file.
|
||||||
const RailDestinationM3E({
|
class NavigationRailM3EDestination {
|
||||||
|
const NavigationRailM3EDestination({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
|
||||||
this.selectedIcon,
|
this.selectedIcon,
|
||||||
this.badgeCount,
|
required this.label,
|
||||||
this.badgeDot = false,
|
this.largeBadgeCount,
|
||||||
|
this.smallBadge = false,
|
||||||
this.semanticLabel,
|
this.semanticLabel,
|
||||||
|
this.short = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
final Widget? selectedIcon;
|
final Widget? selectedIcon;
|
||||||
final String label;
|
final String label;
|
||||||
|
final int? largeBadgeCount;
|
||||||
/// Optional badge counter
|
final bool smallBadge;
|
||||||
final int? badgeCount;
|
|
||||||
|
|
||||||
/// If true, show a small dot instead of a counter.
|
|
||||||
final bool badgeDot;
|
|
||||||
|
|
||||||
final String? semanticLabel;
|
final String? semanticLabel;
|
||||||
|
|
||||||
Widget buildIcon([bool selected = false]) {
|
/// If true, uses short item height (56dp) instead of 64dp.
|
||||||
final base = selected && selectedIcon != null ? selectedIcon! : icon;
|
final bool short;
|
||||||
if (badgeCount != null || badgeDot) {
|
|
||||||
return RailBadgeM3E(
|
|
||||||
child: base,
|
|
||||||
count: badgeCount,
|
|
||||||
showDot: badgeDot,
|
|
||||||
semanticLabel: semanticLabel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
import 'package:m3e_design/m3e_design.dart' as m3e;
|
||||||
import 'enums.dart';
|
|
||||||
|
|
||||||
@immutable
|
/// Provides colors & shapes from `m3e_design` with safe fallbacks to Theme.of(context).
|
||||||
class _RailMetrics {
|
class NavigationRailTokensAdapter {
|
||||||
final double widthCompact;
|
const NavigationRailTokensAdapter(this.context);
|
||||||
final double widthRegular;
|
|
||||||
final double extendedMinWidth;
|
|
||||||
final double iconSize;
|
|
||||||
final EdgeInsetsGeometry itemPadding;
|
|
||||||
final double stripeThickness;
|
|
||||||
const _RailMetrics({
|
|
||||||
required this.widthCompact,
|
|
||||||
required this.widthRegular,
|
|
||||||
required this.extendedMinWidth,
|
|
||||||
required this.iconSize,
|
|
||||||
required this.itemPadding,
|
|
||||||
required this.stripeThickness,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_RailMetrics _metricsFor(BuildContext context, RailDensity density) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
|
||||||
final sp = m3e.spacing;
|
|
||||||
|
|
||||||
double wC = 64; // compact width
|
|
||||||
double wR = 80; // regular width
|
|
||||||
double ext = 256; // extended min width
|
|
||||||
double icon = 24;
|
|
||||||
double stripe = 3;
|
|
||||||
|
|
||||||
if (density == RailDensity.compact) {
|
|
||||||
wC -= 4; wR -= 4; stripe -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _RailMetrics(
|
|
||||||
widthCompact: wC,
|
|
||||||
widthRegular: wR,
|
|
||||||
extendedMinWidth: ext,
|
|
||||||
iconSize: icon,
|
|
||||||
itemPadding: EdgeInsets.symmetric(horizontal: sp.md, vertical: sp.sm),
|
|
||||||
stripeThickness: stripe,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RailTokensAdapter {
|
|
||||||
RailTokensAdapter(this.context);
|
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
|
|
||||||
M3ETheme get _m3e {
|
ColorScheme get _cs => Theme.of(context).colorScheme;
|
||||||
final t = Theme.of(context);
|
|
||||||
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
// Colors per spec
|
||||||
|
Color get containerColor {
|
||||||
|
// Use surface container token if present, else fallback.
|
||||||
|
return _maybe(() => context.m3e.colors.surfaceContainer) ??
|
||||||
|
_cs.surfaceContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
_RailMetrics metrics(RailDensity density) => _metricsFor(context, density);
|
Color get activeIndicatorColor {
|
||||||
|
return _maybe(() => context.m3e.colors.secondaryContainer) ??
|
||||||
// Container/background
|
_cs.secondaryContainer;
|
||||||
Color containerColor() => _m3e.colors.surfaceContainerHigh;
|
|
||||||
|
|
||||||
// Indicator
|
|
||||||
Color indicatorColor() => _m3e.colors.secondaryContainer;
|
|
||||||
|
|
||||||
// Icon/label colors
|
|
||||||
Color selectedColor() => _m3e.colors.onSecondaryContainer;
|
|
||||||
Color unselectedColor() => _m3e.colors.onSurfaceVariant;
|
|
||||||
|
|
||||||
// Typography
|
|
||||||
TextStyle labelStyle() => _m3e.type.labelMedium;
|
|
||||||
|
|
||||||
// Shapes
|
|
||||||
ShapeBorder containerShape(RailShapeFamily family) {
|
|
||||||
final set = family == RailShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
|
|
||||||
return RoundedRectangleBorder(borderRadius: set.lg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ShapeBorder indicatorShapePill() => const StadiumBorder();
|
Color get activeIconAndLabel {
|
||||||
|
return _maybe(() => context.m3e.colors.secondary) ?? _cs.secondary;
|
||||||
|
}
|
||||||
|
|
||||||
// Stripe decoration for selected destination
|
Color get inactiveIconAndLabel {
|
||||||
BoxDecoration stripeDecoration(Color color, double thickness) {
|
return _maybe(() => context.m3e.colors.onSurfaceVariant) ??
|
||||||
return BoxDecoration(
|
_cs.onSurfaceVariant;
|
||||||
border: Border(
|
}
|
||||||
left: BorderSide(color: color, width: thickness),
|
|
||||||
),
|
Color get menuColor {
|
||||||
);
|
return _maybe(() => context.m3e.colors.onSecondaryContainer) ??
|
||||||
|
_cs.onSecondaryContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get badgeLargeBackground =>
|
||||||
|
_maybe(() => context.m3e.colors.error) ?? _cs.error;
|
||||||
|
Color get badgeLargeLabel =>
|
||||||
|
_maybe(() => context.m3e.colors.onError) ?? _cs.onError;
|
||||||
|
Color get badgeSmallDot =>
|
||||||
|
_maybe(() => context.m3e.colors.error) ?? _cs.error;
|
||||||
|
|
||||||
|
ShapeBorder get indicatorShapeFull {
|
||||||
|
// Full corner per M3E: use the most rounded token, fallback to StadiumBorder.
|
||||||
|
final br = _maybe(() => context.m3e.shapes.round.xs);
|
||||||
|
if (br != null) return RoundedRectangleBorder(borderRadius: br);
|
||||||
|
return const StadiumBorder();
|
||||||
|
}
|
||||||
|
|
||||||
|
T? _maybe<T>(T Function() pick) {
|
||||||
|
try {
|
||||||
|
return pick();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
name: navigation_rail_m3e
|
||||||
description: Material 3 Expressive Navigation Rail for Flutter with token-driven colors, shapes, indicators, and badges.
|
description: Material 3 Expressive navigation rail (collapsed & expanded) with modal/standard modes, badges, sections, and m3e_design token integration.
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
publish_to: none
|
homepage: https://github.com/EmilyMonestone/material_3_expressive
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.5.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
flutter: ">=3.22.0"
|
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# Integrates with your design system tokens.
|
||||||
m3e_design:
|
m3e_design:
|
||||||
path: ../m3e_design
|
path: ../m3e_design
|
||||||
|
# FAB components used by the rail (FabM3E and ExtendedFabM3E).
|
||||||
|
fab_m3e:
|
||||||
|
path: ../fab_m3e
|
||||||
|
# Icon button used by the rail's menu control.
|
||||||
|
icon_button_m3e:
|
||||||
|
path: ../icon_button_m3e
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
@ -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;
|
library progress_indicator_m3e;
|
||||||
|
|
||||||
|
export 'src/circular_progress_m3e.dart';
|
||||||
export 'src/enums.dart';
|
export 'src/enums.dart';
|
||||||
export 'src/linear_progress_m3e.dart';
|
export 'src/linear_progress_m3e.dart';
|
||||||
export 'src/circular_progress_m3e.dart';
|
|
||||||
export 'src/progress_with_label_m3e.dart';
|
export 'src/progress_with_label_m3e.dart';
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class Palette {
|
||||||
|
|
||||||
// Use theme roles; callers can override colors if needed.
|
// Use theme roles; callers can override colors if needed.
|
||||||
Color get active => cs.primary;
|
Color get active => cs.primary;
|
||||||
Color get track => cs.onSurfaceVariant.withOpacity(0.24);
|
Color get track => cs.onSurfaceVariant.withValues(alpha: 0.24);
|
||||||
Color get bg => cs.surface;
|
Color get bg => cs.surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +39,8 @@ class LinearSpec {
|
||||||
LinearSpec specForLinear({
|
LinearSpec specForLinear({
|
||||||
required LinearProgressM3ESize size,
|
required LinearProgressM3ESize size,
|
||||||
required ProgressM3EShape shape,
|
required ProgressM3EShape shape,
|
||||||
}) => switch ((shape, size)) {
|
}) =>
|
||||||
|
switch ((shape, size)) {
|
||||||
(ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec(
|
(ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec(
|
||||||
trackHeight: 4,
|
trackHeight: 4,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,8 @@ class _CircularProgressIndicatorM3EState
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final active = widget.activeColor ?? cs.primary;
|
final active = widget.activeColor ?? cs.primary;
|
||||||
final track = widget.trackColor ?? cs.onSurfaceVariant.withOpacity(0.24);
|
final track =
|
||||||
|
widget.trackColor ?? cs.onSurfaceVariant.withValues(alpha: 0.24);
|
||||||
final wantsWavy = widget.shape == ProgressM3EShape.wavy;
|
final wantsWavy = widget.shape == ProgressM3EShape.wavy;
|
||||||
final diameter =
|
final diameter =
|
||||||
wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat;
|
wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
library slider_m3e;
|
library slider_m3e;
|
||||||
|
|
||||||
export 'src/enums.dart';
|
export 'src/enums.dart';
|
||||||
export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter;
|
|
||||||
export 'src/slider_theme_m3e.dart';
|
|
||||||
export 'src/slider_m3e.dart';
|
|
||||||
export 'src/range_slider_m3e.dart';
|
export 'src/range_slider_m3e.dart';
|
||||||
|
export 'src/slider_m3e.dart';
|
||||||
|
export 'src/slider_theme_m3e.dart';
|
||||||
|
export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'slider_theme_m3e.dart';
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
import 'slider_theme_m3e.dart';
|
||||||
|
|
||||||
class RangeSliderM3E extends StatelessWidget {
|
class RangeSliderM3E extends StatelessWidget {
|
||||||
const RangeSliderM3E({
|
const RangeSliderM3E({
|
||||||
|
|
@ -63,7 +64,8 @@ class RangeSliderM3E extends StatelessWidget {
|
||||||
divisions: divisions,
|
divisions: divisions,
|
||||||
labels: labels,
|
labels: labels,
|
||||||
semanticFormatterCallback: semanticLabel != null
|
semanticFormatterCallback: semanticLabel != null
|
||||||
? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
|
? (v) =>
|
||||||
|
'$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'slider_theme_m3e.dart';
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
import 'slider_theme_m3e.dart';
|
||||||
|
|
||||||
class SliderM3E extends StatelessWidget {
|
class SliderM3E extends StatelessWidget {
|
||||||
const SliderM3E({
|
const SliderM3E({
|
||||||
|
|
@ -63,7 +64,8 @@ class SliderM3E extends StatelessWidget {
|
||||||
divisions: divisions,
|
divisions: divisions,
|
||||||
label: label,
|
label: label,
|
||||||
semanticFormatterCallback: semanticLabel != null
|
semanticFormatterCallback: semanticLabel != null
|
||||||
? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
|
? (v) =>
|
||||||
|
'$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'slider_tokens_adapter.dart';
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
import 'slider_tokens_adapter.dart';
|
||||||
|
|
||||||
SliderThemeData sliderThemeM3E(
|
SliderThemeData sliderThemeM3E(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
|
|
@ -42,7 +43,9 @@ SliderThemeData sliderThemeM3E(
|
||||||
overlayColor: t.overlayColor(emphasis),
|
overlayColor: t.overlayColor(emphasis),
|
||||||
valueIndicatorColor: t.valueIndicatorColor(),
|
valueIndicatorColor: t.valueIndicatorColor(),
|
||||||
valueIndicatorTextStyle: t.valueIndicatorTextStyle(),
|
valueIndicatorTextStyle: t.valueIndicatorTextStyle(),
|
||||||
showValueIndicator: showValueIndicator ? ShowValueIndicator.onDrag : ShowValueIndicator.onlyForDiscrete,
|
showValueIndicator: showValueIndicator
|
||||||
|
? ShowValueIndicator.onDrag
|
||||||
|
: ShowValueIndicator.onlyForDiscrete,
|
||||||
thumbShape: thumbShape,
|
thumbShape: thumbShape,
|
||||||
overlayShape: RoundSliderOverlayShape(overlayRadius: m.overlayRadius),
|
overlayShape: RoundSliderOverlayShape(overlayRadius: m.overlayRadius),
|
||||||
rangeThumbShape: shapeFamily == SliderM3EShapeFamily.round
|
rangeThumbShape: shapeFamily == SliderM3EShapeFamily.round
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,11 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||||
final pressedRadius = widget.size.pressedRadius;
|
final pressedRadius = widget.size.pressedRadius;
|
||||||
final innerRadius = widget.size.innerCornerRadius;
|
final innerRadius = widget.size.innerCornerRadius;
|
||||||
const innerGap = SplitButtonM3ETokens.innerGap;
|
const innerGap = SplitButtonM3ETokens.innerGap;
|
||||||
|
// Elevated style needs larger perceived separation between segments.
|
||||||
|
final double effectiveInnerGap =
|
||||||
|
widget.emphasis == SplitButtonM3EEmphasis.elevated
|
||||||
|
? innerGap * 2
|
||||||
|
: innerGap;
|
||||||
final chevronTurns = _menuOpen
|
final chevronTurns = _menuOpen
|
||||||
? SplitButtonM3ETokens.chevronOpenTurns
|
? SplitButtonM3ETokens.chevronOpenTurns
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
@ -220,7 +225,9 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||||
if (!widget.enabled) return;
|
if (!widget.enabled) return;
|
||||||
setState(() => _trailingPressed = v);
|
setState(() => _trailingPressed = v);
|
||||||
},
|
},
|
||||||
onTap: widget.enabled ? () => _openMenu(context) : null,
|
onTap: widget.enabled
|
||||||
|
? () => _openMenu(_trailingKey.currentContext ?? context)
|
||||||
|
: null,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsetsDirectional.only(
|
padding: EdgeInsetsDirectional.only(
|
||||||
start: trailingLeftPad,
|
start: trailingLeftPad,
|
||||||
|
|
@ -241,18 +248,39 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return FocusTraversalGroup(
|
// Menu theme to match SplitButton design (colors, font, shape)
|
||||||
policy: ReadingOrderTraversalPolicy(),
|
final theme = Theme.of(context);
|
||||||
child: ConstrainedBox(
|
final m3e = context.m3e;
|
||||||
constraints: const BoxConstraints(minHeight: minTap),
|
final bool contIsTransparent = cont.a == 0.0;
|
||||||
child: Row(
|
final Color menuColor = contIsTransparent
|
||||||
mainAxisSize: MainAxisSize.min,
|
? theme.colorScheme.surfaceContainerHigh
|
||||||
textDirection: dir,
|
: cont;
|
||||||
children: [
|
final TextStyle? menuTextStyle = m3e.typography.base.labelLarge?.copyWith(
|
||||||
leading,
|
color: contIsTransparent ? theme.colorScheme.onSurface : onCont,
|
||||||
const SizedBox(width: innerGap),
|
);
|
||||||
trailing,
|
final shape = RoundedRectangleBorder(
|
||||||
],
|
borderRadius: BorderRadius.circular(widget.size.pressedRadius),
|
||||||
|
);
|
||||||
|
|
||||||
|
return PopupMenuTheme(
|
||||||
|
data: theme.popupMenuTheme.copyWith(
|
||||||
|
color: menuColor,
|
||||||
|
textStyle: menuTextStyle,
|
||||||
|
shape: shape,
|
||||||
|
),
|
||||||
|
child: FocusTraversalGroup(
|
||||||
|
policy: ReadingOrderTraversalPolicy(),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: minTap),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
textDirection: dir,
|
||||||
|
children: [
|
||||||
|
leading,
|
||||||
|
SizedBox(width: effectiveInnerGap),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -311,9 +339,21 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||||
Future<void> _openMenu(BuildContext context) async {
|
Future<void> _openMenu(BuildContext context) async {
|
||||||
if (widget.menuBuilder != null) {
|
if (widget.menuBuilder != null) {
|
||||||
setState(() => _menuOpen = true);
|
setState(() => _menuOpen = true);
|
||||||
|
// Enforce menu min width to trailing button width
|
||||||
|
Size _tSize = Size.zero;
|
||||||
|
final tCtx = _trailingKey.currentContext;
|
||||||
|
if (tCtx != null) {
|
||||||
|
final tb = tCtx.findRenderObject() as RenderBox?;
|
||||||
|
if (tb != null) _tSize = tb.size;
|
||||||
|
}
|
||||||
|
final double _minMenuWidth = _tSize.width > 0
|
||||||
|
? _tSize.width
|
||||||
|
: widget.size.trailingWidthCentered;
|
||||||
|
|
||||||
final res = await showMenu<T>(
|
final res = await showMenu<T>(
|
||||||
context: context,
|
context: context,
|
||||||
position: _menuPosition(context),
|
position: _menuPosition(context),
|
||||||
|
constraints: BoxConstraints(minWidth: _minMenuWidth),
|
||||||
items: widget.menuBuilder!(context),
|
items: widget.menuBuilder!(context),
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -326,18 +366,54 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||||
// Convert simple items to PopupMenuEntries
|
// Convert simple items to PopupMenuEntries
|
||||||
final items = widget.items!;
|
final items = widget.items!;
|
||||||
setState(() => _menuOpen = true);
|
setState(() => _menuOpen = true);
|
||||||
|
|
||||||
|
// Ensure menu item text/icon colors match the button's foreground (onCont)
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final m3e = context.m3e;
|
||||||
|
final (
|
||||||
|
Color _cont,
|
||||||
|
Color onCont,
|
||||||
|
BorderSide? _outlineSide,
|
||||||
|
double? _elevation,
|
||||||
|
) = _resolveColorsAndShapes(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enforce menu min width to trailing button width
|
||||||
|
Size _tSize = Size.zero;
|
||||||
|
final tCtx2 = _trailingKey.currentContext;
|
||||||
|
if (tCtx2 != null) {
|
||||||
|
final tb2 = tCtx2.findRenderObject() as RenderBox?;
|
||||||
|
if (tb2 != null) _tSize = tb2.size;
|
||||||
|
}
|
||||||
|
final double _minMenuWidth2 = _tSize.width > 0
|
||||||
|
? _tSize.width
|
||||||
|
: widget.size.trailingWidthCentered;
|
||||||
|
|
||||||
final res = await showMenu<T>(
|
final res = await showMenu<T>(
|
||||||
context: context,
|
context: context,
|
||||||
position: _menuPosition(context),
|
position: _menuPosition(context),
|
||||||
items: items
|
constraints: BoxConstraints(minWidth: _minMenuWidth2),
|
||||||
.map(
|
items: items.map((e) {
|
||||||
(e) => PopupMenuItem<T>(
|
final Color effective = e.enabled
|
||||||
value: e.value,
|
? onCont
|
||||||
enabled: e.enabled,
|
: onCont.withValues(alpha: 0.38);
|
||||||
child: e.child is Widget ? e.child as Widget : Text('${e.child}'),
|
final Widget baseChild = e.child is Widget
|
||||||
),
|
? e.child as Widget
|
||||||
)
|
: Text('${e.child}');
|
||||||
.toList(),
|
final Widget styledChild = IconTheme.merge(
|
||||||
|
data: IconThemeData(color: effective, size: widget.size.iconPx),
|
||||||
|
child: DefaultTextStyle.merge(
|
||||||
|
style: TextStyle(color: effective),
|
||||||
|
child: baseChild,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return PopupMenuItem<T>(
|
||||||
|
value: e.value,
|
||||||
|
enabled: e.enabled,
|
||||||
|
child: styledChild,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _menuOpen = false);
|
setState(() => _menuOpen = false);
|
||||||
|
|
@ -345,41 +421,46 @@ class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
RelativeRect _menuPosition(BuildContext context) {
|
RelativeRect _menuPosition(BuildContext context) {
|
||||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
final RenderBox overlay =
|
||||||
final textDir = Directionality.of(context);
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
|
|
||||||
// Default to whole control if trailing key is missing
|
// Prefer the trailing segment as the anchor, fallback to the whole control.
|
||||||
RenderBox? tb;
|
final BuildContext? tCtx = _trailingKey.currentContext;
|
||||||
Offset tTopLeft = Offset.zero;
|
RenderBox? targetBox = tCtx?.findRenderObject() as RenderBox?;
|
||||||
Size tSize = Size.zero;
|
targetBox ??= context.findRenderObject() as RenderBox?;
|
||||||
final tCtx = _trailingKey.currentContext;
|
if (targetBox == null) {
|
||||||
if (tCtx != null) {
|
// If we can't resolve a box, fill as a safe (rare) fallback.
|
||||||
tb = tCtx.findRenderObject() as RenderBox?;
|
return RelativeRect.fill;
|
||||||
}
|
|
||||||
if (tb != null) {
|
|
||||||
tTopLeft = tb.localToGlobal(Offset.zero);
|
|
||||||
tSize = tb.size;
|
|
||||||
} else {
|
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
|
||||||
if (box != null) {
|
|
||||||
tTopLeft = box.localToGlobal(Offset.zero);
|
|
||||||
tSize = box.size;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final top = tTopLeft.dy + tSize.height;
|
final Offset targetTopLeft = targetBox.localToGlobal(
|
||||||
|
Offset.zero,
|
||||||
|
ancestor: overlay,
|
||||||
|
);
|
||||||
|
final Rect targetRect = Rect.fromLTWH(
|
||||||
|
targetTopLeft.dx,
|
||||||
|
targetTopLeft.dy,
|
||||||
|
targetBox.size.width,
|
||||||
|
targetBox.size.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Place the menu just below the trailing segment with a small vertical gap,
|
||||||
|
// keeping horizontal alignment anchored to the trailing edge.
|
||||||
|
const double _kMenuVerticalOffset = 4.0;
|
||||||
|
final double top = targetRect.bottom + _kMenuVerticalOffset;
|
||||||
|
|
||||||
|
final TextDirection textDir = Directionality.of(context);
|
||||||
late double left;
|
late double left;
|
||||||
late double right;
|
late double right;
|
||||||
|
|
||||||
if (textDir == TextDirection.ltr) {
|
if (textDir == TextDirection.ltr) {
|
||||||
final endX = tTopLeft.dx + tSize.width; // right edge
|
final double endX = targetRect.right; // trailing edge in LTR
|
||||||
left = endX;
|
left = 0.0;
|
||||||
right = overlay.size.width - endX;
|
right = overlay.size.width - endX; // align menu's right edge to endX
|
||||||
} else {
|
} else {
|
||||||
final startX = tTopLeft.dx; // left edge is trailing in RTL
|
final double startX = targetRect.left; // trailing edge in RTL
|
||||||
left = startX;
|
left = startX; // align menu's left edge to startX
|
||||||
right = overlay.size.width - startX;
|
right = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top);
|
return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
import 'package:m3e_design/m3e_design.dart';
|
||||||
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
import 'toolbar_tokens_adapter.dart';
|
|
||||||
import 'toolbar_action_m3e.dart';
|
import 'toolbar_action_m3e.dart';
|
||||||
|
import 'toolbar_tokens_adapter.dart';
|
||||||
|
|
||||||
class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
const ToolbarM3E({
|
const ToolbarM3E({
|
||||||
|
|
@ -59,9 +60,12 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
Size get preferredSize {
|
Size get preferredSize {
|
||||||
// A rough default; actual height is resolved at build based on size/density.
|
// A rough default; actual height is resolved at build based on size/density.
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case ToolbarM3ESize.small: return const Size.fromHeight(40);
|
case ToolbarM3ESize.small:
|
||||||
case ToolbarM3ESize.medium: return const Size.fromHeight(48);
|
return const Size.fromHeight(40);
|
||||||
case ToolbarM3ESize.large: return const Size.fromHeight(56);
|
case ToolbarM3ESize.medium:
|
||||||
|
return const Size.fromHeight(48);
|
||||||
|
case ToolbarM3ESize.large:
|
||||||
|
return const Size.fromHeight(56);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +73,8 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ToolbarTokensAdapter(context);
|
final tokens = ToolbarTokensAdapter(context);
|
||||||
final metrics = tokens.metrics(density);
|
final metrics = tokens.metrics(density);
|
||||||
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
|
final m3e = Theme.of(context).extension<M3ETheme>() ??
|
||||||
|
M3ETheme.defaults(Theme.of(context).colorScheme);
|
||||||
|
|
||||||
final height = switch (size) {
|
final height = switch (size) {
|
||||||
ToolbarM3ESize.small => metrics.heightSmall,
|
ToolbarM3ESize.small => metrics.heightSmall,
|
||||||
|
|
@ -84,12 +89,18 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
|
||||||
final resolvedTitle = title ??
|
final resolvedTitle = title ??
|
||||||
(titleText != null
|
(titleText != null
|
||||||
? Text(titleText!, style: tokens.titleStyle().copyWith(color: fg), overflow: TextOverflow.ellipsis)
|
? Text(titleText!,
|
||||||
|
style: tokens.titleStyle().copyWith(color: fg),
|
||||||
|
overflow: TextOverflow.ellipsis)
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
final resolvedSubtitle = subtitle ??
|
final resolvedSubtitle = subtitle ??
|
||||||
(subtitleText != null
|
(subtitleText != null
|
||||||
? Text(subtitleText!, style: tokens.subtitleStyle().copyWith(color: fg.withValues(alpha: 0.8)), overflow: TextOverflow.ellipsis)
|
? Text(subtitleText!,
|
||||||
|
style: tokens
|
||||||
|
.subtitleStyle()
|
||||||
|
.copyWith(color: fg.withValues(alpha: 0.8)),
|
||||||
|
overflow: TextOverflow.ellipsis)
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
final toolbarRow = Row(
|
final toolbarRow = Row(
|
||||||
|
|
@ -118,7 +129,10 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
|
||||||
final bar = Material(
|
final bar = Material(
|
||||||
color: bg,
|
color: bg,
|
||||||
elevation: elevation ?? (variant == ToolbarM3EVariant.surface ? metrics.elevationSurface : metrics.elevationProminent),
|
elevation: elevation ??
|
||||||
|
(variant == ToolbarM3EVariant.surface
|
||||||
|
? metrics.elevationSurface
|
||||||
|
: metrics.elevationProminent),
|
||||||
shape: shape,
|
shape: shape,
|
||||||
clipBehavior: clipBehavior,
|
clipBehavior: clipBehavior,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|
@ -127,13 +141,17 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
padding: pad,
|
padding: pad,
|
||||||
child: IconTheme.merge(
|
child: IconTheme.merge(
|
||||||
data: IconThemeData(color: fg, size: metrics.iconSize),
|
data: IconThemeData(color: fg, size: metrics.iconSize),
|
||||||
child: DefaultTextStyle.merge(style: TextStyle(color: fg), child: toolbarRow),
|
child: DefaultTextStyle.merge(
|
||||||
|
style: TextStyle(color: fg), child: toolbarRow),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final content = safeArea ? SafeArea(top: false, left: false, right: false, bottom: false, child: bar) : bar;
|
final content = safeArea
|
||||||
|
? SafeArea(
|
||||||
|
top: false, left: false, right: false, bottom: false, child: bar)
|
||||||
|
: bar;
|
||||||
|
|
||||||
if (semanticLabel == null) return content;
|
if (semanticLabel == null) return content;
|
||||||
return Semantics(container: true, label: semanticLabel!, child: content);
|
return Semantics(container: true, label: semanticLabel!, child: content);
|
||||||
|
|
@ -141,7 +159,8 @@ class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TitleBlock extends StatelessWidget {
|
class _TitleBlock extends StatelessWidget {
|
||||||
const _TitleBlock({required this.title, required this.subtitle, required this.center});
|
const _TitleBlock(
|
||||||
|
{required this.title, required this.subtitle, required this.center});
|
||||||
final Widget? title;
|
final Widget? title;
|
||||||
final Widget? subtitle;
|
final Widget? subtitle;
|
||||||
final bool center;
|
final bool center;
|
||||||
|
|
@ -152,10 +171,15 @@ class _TitleBlock extends StatelessWidget {
|
||||||
|
|
||||||
final col = Column(
|
final col = Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: center ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
center ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (title != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.titleSmall!, child: title!),
|
if (title != null)
|
||||||
if (subtitle != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.bodySmall!, child: subtitle!),
|
DefaultTextStyle.merge(
|
||||||
|
style: Theme.of(context).textTheme.titleSmall!, child: title!),
|
||||||
|
if (subtitle != null)
|
||||||
|
DefaultTextStyle.merge(
|
||||||
|
style: Theme.of(context).textTheme.bodySmall!, child: subtitle!),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -187,17 +211,23 @@ class _ActionsRow extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (actions.isEmpty) return const SizedBox.shrink();
|
if (actions.isEmpty) return const SizedBox.shrink();
|
||||||
final inline = actions.take(maxInline).toList(growable: false);
|
final inline = actions.take(maxInline).toList(growable: false);
|
||||||
final overflow = actions.length > maxInline ? actions.sublist(maxInline) : const <ToolbarActionM3E>[];
|
final overflow = actions.length > maxInline
|
||||||
|
? actions.sublist(maxInline)
|
||||||
|
: const <ToolbarActionM3E>[];
|
||||||
|
|
||||||
final row = Row(
|
final row = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (final a in inline) ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize),
|
for (final a in inline)
|
||||||
|
ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize),
|
||||||
if (overflow.isNotEmpty)
|
if (overflow.isNotEmpty)
|
||||||
_OverflowMenu(
|
_OverflowMenu(
|
||||||
actions: overflow,
|
actions: overflow,
|
||||||
icon: overflowIcon,
|
icon: overflowIcon,
|
||||||
textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: m3e.colors.onSurface),
|
textStyle: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelLarge
|
||||||
|
?.copyWith(color: m3e.colors.onSurface),
|
||||||
destructiveColor: m3e.colors.error,
|
destructiveColor: m3e.colors.error,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -230,10 +260,14 @@ class _OverflowMenu extends StatelessWidget {
|
||||||
enabled: actions[i].enabled,
|
enabled: actions[i].enabled,
|
||||||
child: DefaultTextStyle.merge(
|
child: DefaultTextStyle.merge(
|
||||||
style: (actions[i].isDestructive
|
style: (actions[i].isDestructive
|
||||||
? (textStyle?.copyWith(color: destructiveColor) ?? TextStyle(color: destructiveColor))
|
? (textStyle?.copyWith(color: destructiveColor) ??
|
||||||
|
TextStyle(color: destructiveColor))
|
||||||
: textStyle) ??
|
: textStyle) ??
|
||||||
const TextStyle(),
|
const TextStyle(),
|
||||||
child: Text(actions[i].label ?? actions[i].tooltip ?? actions[i].semanticLabel ?? 'Action ${i + 1}'),
|
child: Text(actions[i].label ??
|
||||||
|
actions[i].tooltip ??
|
||||||
|
actions[i].semanticLabel ??
|
||||||
|
'Action ${i + 1}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
library toolbar_m3e;
|
library toolbar_m3e;
|
||||||
|
|
||||||
export 'src/enums.dart';
|
export 'src/enums.dart';
|
||||||
export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter;
|
|
||||||
export 'src/toolbar_action_m3e.dart';
|
export 'src/toolbar_action_m3e.dart';
|
||||||
export 'src/toolbar_m3e.dart';
|
export 'src/toolbar_m3e.dart';
|
||||||
|
export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue