Refactor NavigationRailM3E to simplify rail types; update badge handling and expand functionality of navigation components.
This commit is contained in:
parent
83f5a02943
commit
5b27a91894
20 changed files with 360 additions and 486 deletions
|
|
@ -15,24 +15,9 @@ migration:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
||||||
- platform: android
|
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
- platform: ios
|
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
- platform: linux
|
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
- platform: macos
|
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
- platform: web
|
- platform: web
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
||||||
- platform: windows
|
|
||||||
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,26 @@ class ButtonSection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||||
|
child: Text('with Icon',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
for (final variant in ButtonM3EStyle.values)
|
||||||
|
ButtonM3E(
|
||||||
|
icon: Icon(Icons.add),
|
||||||
|
label: Text(variant.name),
|
||||||
|
style: variant,
|
||||||
|
size: ButtonM3ESize.sm,
|
||||||
|
shape: ButtonM3EShape.round,
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,24 @@ class IconButtonSection extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text('with badgeValue',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
for (final size in IconButtonM3ESize.values)
|
||||||
|
IconButtonM3E(
|
||||||
|
icon: const Icon(Icons.favorite),
|
||||||
|
variant: IconButtonM3EVariant.filled,
|
||||||
|
size: size,
|
||||||
|
onPressed: () {},
|
||||||
|
badgeValue: size.index + 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,13 @@ class _NavigationSectionState extends State<NavigationSection> {
|
||||||
icon: Icon(Icons.analytics_outlined),
|
icon: Icon(Icons.analytics_outlined),
|
||||||
selectedIcon: Icon(Icons.analytics),
|
selectedIcon: Icon(Icons.analytics),
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
smallBadge: true,
|
badgeCount: 0,
|
||||||
),
|
),
|
||||||
NavigationRailM3EDestination(
|
NavigationRailM3EDestination(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: Icon(Icons.settings_outlined),
|
||||||
selectedIcon: Icon(Icons.settings),
|
selectedIcon: Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
largeBadgeCount: 2,
|
badgeCount: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ scripts:
|
||||||
test: melos exec -- flutter test --coverage
|
test: melos exec -- flutter test --coverage
|
||||||
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=badgeValue)
|
||||||
pub-dry-run:
|
pub-dry-run:
|
||||||
run: melos exec -c 1 --no-private -- "flutter pub publish --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
|
description: Run 'flutter pub publish --dry-run' in all publishable packages
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'enums.dart';
|
||||||
/// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected.
|
/// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected.
|
||||||
/// - Widths: default, narrow, wide
|
/// - Widths: default, narrow, wide
|
||||||
/// - Toggle: [isSelected] + [selectedIcon]
|
/// - Toggle: [isSelected] + [selectedIcon]
|
||||||
|
/// - Badge: [string] or [number]
|
||||||
class IconButtonM3E extends StatelessWidget {
|
class IconButtonM3E extends StatelessWidget {
|
||||||
const IconButtonM3E({
|
const IconButtonM3E({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -24,6 +25,7 @@ class IconButtonM3E extends StatelessWidget {
|
||||||
this.isSelected,
|
this.isSelected,
|
||||||
this.selectedIcon,
|
this.selectedIcon,
|
||||||
this.enableFeedback,
|
this.enableFeedback,
|
||||||
|
this.badgeValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
|
|
@ -37,6 +39,7 @@ class IconButtonM3E extends StatelessWidget {
|
||||||
final bool? isSelected;
|
final bool? isSelected;
|
||||||
final Widget? selectedIcon;
|
final Widget? selectedIcon;
|
||||||
final bool? enableFeedback;
|
final bool? enableFeedback;
|
||||||
|
final Object? badgeValue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -123,7 +126,46 @@ class IconButtonM3E extends StatelessWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: visual.width,
|
width: visual.width,
|
||||||
height: visual.height,
|
height: visual.height,
|
||||||
child: button,
|
child: () {
|
||||||
|
final Object? v = badgeValue;
|
||||||
|
Widget? badge;
|
||||||
|
if (v == null) {
|
||||||
|
badge = null;
|
||||||
|
} else if (v is num) {
|
||||||
|
final int c = v.round().clamp(0, 999999);
|
||||||
|
badge = Badge.count(
|
||||||
|
count: c,
|
||||||
|
backgroundColor: scheme.error,
|
||||||
|
textColor: scheme.onError,
|
||||||
|
);
|
||||||
|
} else if (v is String) {
|
||||||
|
if (v.isEmpty) {
|
||||||
|
badge = null;
|
||||||
|
} else {
|
||||||
|
badge = Badge(
|
||||||
|
label: Text(v),
|
||||||
|
backgroundColor: scheme.error,
|
||||||
|
textColor: scheme.onError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert(() {
|
||||||
|
throw FlutterError(
|
||||||
|
'IconButtonM3E.badgeValue must be a String or num, but got \'${v.runtimeType}\'.',
|
||||||
|
);
|
||||||
|
}());
|
||||||
|
badge = null;
|
||||||
|
}
|
||||||
|
return badge == null
|
||||||
|
? button
|
||||||
|
: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
button,
|
||||||
|
PositionedDirectional(top: 0, end: 0, child: badge),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
import 'nav_badge_m3e.dart';
|
import 'nav_badge_m3e.dart';
|
||||||
|
|
||||||
class NavigationDestinationM3E {
|
class NavigationDestinationM3E {
|
||||||
|
|
@ -15,7 +17,7 @@ class NavigationDestinationM3E {
|
||||||
final Widget? selectedIcon;
|
final Widget? selectedIcon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
/// Optional badge counter
|
/// Optional badgeValue counter
|
||||||
final int? badgeCount;
|
final int? badgeCount;
|
||||||
|
|
||||||
/// If true, show a small dot instead of a counter.
|
/// If true, show a small dot instead of a counter.
|
||||||
|
|
|
||||||
|
|
@ -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 'nav_tokens_adapter.dart';
|
|
||||||
import 'nav_destination_m3e.dart';
|
import 'nav_destination_m3e.dart';
|
||||||
|
import 'nav_tokens_adapter.dart';
|
||||||
|
|
||||||
class NavigationBarM3E extends StatelessWidget {
|
class NavigationBarM3E extends StatelessWidget {
|
||||||
const NavigationBarM3E({
|
const NavigationBarM3E({
|
||||||
|
|
@ -10,7 +11,7 @@ class NavigationBarM3E extends StatelessWidget {
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
this.selectedIndex = 0,
|
this.selectedIndex = 0,
|
||||||
this.onDestinationSelected,
|
this.onDestinationSelected,
|
||||||
this.labelBehavior = NavBarM3ELabelBehavior.onlySelected,
|
this.labelBehavior = NavBarM3ELabelBehavior.alwaysShow,
|
||||||
this.size = NavBarM3ESize.medium,
|
this.size = NavBarM3ESize.medium,
|
||||||
this.shapeFamily = NavBarM3EShapeFamily.round,
|
this.shapeFamily = NavBarM3EShapeFamily.round,
|
||||||
this.density = NavBarM3EDensity.regular,
|
this.density = NavBarM3EDensity.regular,
|
||||||
|
|
@ -49,9 +50,12 @@ class NavigationBarM3E extends StatelessWidget {
|
||||||
|
|
||||||
final tokens = NavTokensAdapter(context);
|
final tokens = NavTokensAdapter(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 = size == NavBarM3ESize.small ? metrics.heightSmall : metrics.heightMedium;
|
final height = size == NavBarM3ESize.small
|
||||||
|
? metrics.heightSmall
|
||||||
|
: metrics.heightMedium;
|
||||||
final bg = backgroundColor ?? tokens.containerColor();
|
final bg = backgroundColor ?? tokens.containerColor();
|
||||||
final shape = tokens.containerShape(shapeFamily);
|
final shape = tokens.containerShape(shapeFamily);
|
||||||
|
|
||||||
|
|
@ -69,21 +73,27 @@ class NavigationBarM3E extends StatelessWidget {
|
||||||
: (indicatorColor ?? tokens.indicatorColor()),
|
: (indicatorColor ?? tokens.indicatorColor()),
|
||||||
indicatorShape: switch (indicatorStyle) {
|
indicatorShape: switch (indicatorStyle) {
|
||||||
NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(),
|
NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(),
|
||||||
NavBarM3EIndicatorStyle.underline => const StadiumBorder(), // we'll fake underline via decoration below
|
NavBarM3EIndicatorStyle.underline =>
|
||||||
|
const StadiumBorder(), // we'll fake underline via decoration below
|
||||||
NavBarM3EIndicatorStyle.none => const StadiumBorder(),
|
NavBarM3EIndicatorStyle.none => const StadiumBorder(),
|
||||||
},
|
},
|
||||||
backgroundColor: Colors.transparent, // outer Material supplies bg + shape
|
backgroundColor:
|
||||||
|
Colors.transparent, // outer Material supplies bg + shape
|
||||||
labelBehavior: switch (labelBehavior) {
|
labelBehavior: switch (labelBehavior) {
|
||||||
NavBarM3ELabelBehavior.alwaysShow => NavigationDestinationLabelBehavior.alwaysShow,
|
NavBarM3ELabelBehavior.alwaysShow =>
|
||||||
NavBarM3ELabelBehavior.onlySelected => NavigationDestinationLabelBehavior.onlyShowSelected,
|
NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
NavBarM3ELabelBehavior.alwaysHide => NavigationDestinationLabelBehavior.alwaysHide,
|
NavBarM3ELabelBehavior.onlySelected =>
|
||||||
|
NavigationDestinationLabelBehavior.onlyShowSelected,
|
||||||
|
NavBarM3ELabelBehavior.alwaysHide =>
|
||||||
|
NavigationDestinationLabelBehavior.alwaysHide,
|
||||||
},
|
},
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
destinations: List.generate(destinations.length, (i) {
|
destinations: List.generate(destinations.length, (i) {
|
||||||
final d = destinations[i];
|
final d = destinations[i];
|
||||||
return NavigationDestination(
|
return NavigationDestination(
|
||||||
icon: _icon(context, false, d, metrics.iconSize),
|
icon: _icon(context, false, d, metrics.iconSize),
|
||||||
selectedIcon: _selectedIcon(context, true, d, metrics.iconSize, tokens, indicatorStyle),
|
selectedIcon: _selectedIcon(
|
||||||
|
context, true, d, metrics.iconSize, tokens, indicatorStyle),
|
||||||
label: d.label,
|
label: d.label,
|
||||||
tooltip: d.semanticLabel,
|
tooltip: d.semanticLabel,
|
||||||
);
|
);
|
||||||
|
|
@ -100,22 +110,29 @@ class NavigationBarM3E extends StatelessWidget {
|
||||||
|
|
||||||
final content = DefaultTextStyle.merge(
|
final content = DefaultTextStyle.merge(
|
||||||
style: tokens.labelStyle().copyWith(
|
style: tokens.labelStyle().copyWith(
|
||||||
color: m3e.colors.onSurfaceVariant,
|
color: m3e.colors.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
child: IconTheme.merge(
|
child: IconTheme.merge(
|
||||||
data: IconThemeData(size: metrics.iconSize, color: m3e.colors.onSurfaceVariant),
|
data: IconThemeData(
|
||||||
|
size: metrics.iconSize, color: m3e.colors.onSurfaceVariant),
|
||||||
child: padded,
|
child: padded,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!safeArea && semanticLabel == null) return content;
|
if (!safeArea && semanticLabel == null) return content;
|
||||||
final wrapped = SafeArea(top: false, left: false, right: false, bottom: safeArea, child: content);
|
final wrapped = SafeArea(
|
||||||
|
top: false,
|
||||||
|
left: false,
|
||||||
|
right: false,
|
||||||
|
bottom: safeArea,
|
||||||
|
child: content);
|
||||||
|
|
||||||
if (semanticLabel == null) return wrapped;
|
if (semanticLabel == null) return wrapped;
|
||||||
return Semantics(container: true, label: semanticLabel!, child: wrapped);
|
return Semantics(container: true, label: semanticLabel!, child: wrapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d, double iconSize) {
|
Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d,
|
||||||
|
double iconSize) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: iconSize + 8, // give a little space for underline
|
width: iconSize + 8, // give a little space for underline
|
||||||
height: iconSize + 8,
|
height: iconSize + 8,
|
||||||
|
|
@ -135,7 +152,8 @@ class NavigationBarM3E extends StatelessWidget {
|
||||||
if (style != NavBarM3EIndicatorStyle.underline) return w;
|
if (style != NavBarM3EIndicatorStyle.underline) return w;
|
||||||
|
|
||||||
final metrics = tokens.metrics(density);
|
final metrics = tokens.metrics(density);
|
||||||
final deco = tokens.underlineDecoration(tokens.indicatorColor(), metrics.indicatorThickness);
|
final deco = tokens.underlineDecoration(
|
||||||
|
tokens.indicatorColor(), metrics.indicatorThickness);
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: deco,
|
decoration: deco,
|
||||||
child: w,
|
child: w,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:m3e_design/m3e_design.dart';
|
|
||||||
|
|
||||||
class NavigationBarM3EWidget extends StatelessWidget {
|
|
||||||
const NavigationBarM3EWidget({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final m3e = context.m3e;
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.all(m3e.spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: m3e.colors.surfaceStrong,
|
|
||||||
borderRadius: m3e.shapes.square.md,
|
|
||||||
),
|
|
||||||
child: Text('NavigationBar placeholder', style: m3e.typography.base.titleMedium),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();
|
|
||||||
|
|
@ -29,13 +29,13 @@ class _DemoAppState extends State<DemoApp> {
|
||||||
icon: Icon(Icons.star_outline),
|
icon: Icon(Icons.star_outline),
|
||||||
selectedIcon: Icon(Icons.star),
|
selectedIcon: Icon(Icons.star),
|
||||||
label: 'Starred',
|
label: 'Starred',
|
||||||
smallBadge: true,
|
badgeCount: 0,
|
||||||
),
|
),
|
||||||
NavigationRailM3EDestination(
|
NavigationRailM3EDestination(
|
||||||
icon: Icon(Icons.inbox_outlined),
|
icon: Icon(Icons.inbox_outlined),
|
||||||
selectedIcon: Icon(Icons.inbox),
|
selectedIcon: Icon(Icons.inbox),
|
||||||
label: 'Inbox',
|
label: 'Inbox',
|
||||||
largeBadgeCount: 3,
|
badgeCount: 3,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
library navigation_rail_m3e;
|
library navigation_rail_m3e;
|
||||||
|
|
||||||
export 'src/type.dart';
|
|
||||||
export 'src/modality.dart';
|
export 'src/modality.dart';
|
||||||
export 'src/rail_theme.dart';
|
export 'src/navigation_rail_m3e_widget.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/rail_fab_slot.dart';
|
||||||
export 'src/navigation_rail_m3e_widget.dart';
|
export 'src/rail_item_button_m3e.dart';
|
||||||
|
export 'src/rail_section_m3e.dart';
|
||||||
|
export 'src/rail_theme.dart';
|
||||||
|
export 'src/rail_tokens_adapter.dart';
|
||||||
|
export 'src/type.dart';
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class NavigationRailM3E extends StatefulWidget {
|
||||||
this.expandedWidth,
|
this.expandedWidth,
|
||||||
this.onDismissModal,
|
this.onDismissModal,
|
||||||
this.onTypeChanged,
|
this.onTypeChanged,
|
||||||
|
this.labelBehavior = NavigationRailM3ELabelBehavior.alwaysShow,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Presentation type for the rail (collapsed or expanded).
|
/// Presentation type for the rail (collapsed or expanded).
|
||||||
|
|
@ -58,6 +59,9 @@ class NavigationRailM3E extends StatefulWidget {
|
||||||
/// Called when the built-in menu button toggles the rail type.
|
/// Called when the built-in menu button toggles the rail type.
|
||||||
final ValueChanged<NavigationRailM3EType>? onTypeChanged;
|
final ValueChanged<NavigationRailM3EType>? onTypeChanged;
|
||||||
|
|
||||||
|
/// Controls how labels are shown when the rail is expanded.
|
||||||
|
final NavigationRailM3ELabelBehavior labelBehavior;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NavigationRailM3E> createState() => _NavigationRailM3EState();
|
State<NavigationRailM3E> createState() => _NavigationRailM3EState();
|
||||||
}
|
}
|
||||||
|
|
@ -65,10 +69,15 @@ class NavigationRailM3E extends StatefulWidget {
|
||||||
class _NavigationRailM3EState extends State<NavigationRailM3E>
|
class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
OverlayEntry? _modalEntry;
|
OverlayEntry? _modalEntry;
|
||||||
|
OverlayEntry? _collapsedPeekEntry;
|
||||||
|
final LayerLink _anchor = LayerLink();
|
||||||
|
bool _suppressInk = false;
|
||||||
|
|
||||||
bool get _isExpanded => widget.type == NavigationRailM3EType.expanded;
|
bool get _isExpanded => widget.type == NavigationRailM3EType.expanded;
|
||||||
bool get _isModal => widget.modality == NavigationRailM3EModality.modal;
|
bool get _isModal => widget.modality == NavigationRailM3EModality.modal;
|
||||||
bool get _needsOverlay => _isModal;
|
bool get _needsOverlay => _isModal && _isExpanded;
|
||||||
|
bool get _needsCollapsedPeek =>
|
||||||
|
!_isExpanded && !_isModal && widget.hideWhenCollapsed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -79,17 +88,29 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant NavigationRailM3E oldWidget) {
|
void didUpdateWidget(covariant NavigationRailM3E oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
// Suppress ink effects briefly during type transitions to avoid flicker.
|
||||||
|
if (oldWidget.type != widget.type) {
|
||||||
|
setState(() => _suppressInk = true);
|
||||||
|
Future.delayed(const Duration(milliseconds: 320), () {
|
||||||
|
if (mounted) setState(() => _suppressInk = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
|
_removeCollapsedPeekOverlay();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncOverlay() {
|
void _syncOverlay() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Expanded modal overlay management
|
||||||
if (_needsOverlay) {
|
if (_needsOverlay) {
|
||||||
if (_modalEntry == null) {
|
if (_modalEntry == null) {
|
||||||
_insertOverlay();
|
_insertOverlay();
|
||||||
|
|
@ -99,6 +120,17 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
} else {
|
} else {
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsed peek overlay management (standard modality with hideWhenCollapsed)
|
||||||
|
if (_needsCollapsedPeek) {
|
||||||
|
if (_collapsedPeekEntry == null) {
|
||||||
|
_insertCollapsedPeekOverlay();
|
||||||
|
} else {
|
||||||
|
_collapsedPeekEntry!.markNeedsBuild();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_removeCollapsedPeekOverlay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _insertOverlay() {
|
void _insertOverlay() {
|
||||||
|
|
@ -113,6 +145,19 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
_modalEntry = null;
|
_modalEntry = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _insertCollapsedPeekOverlay() {
|
||||||
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
|
if (overlay == null) return;
|
||||||
|
_collapsedPeekEntry =
|
||||||
|
OverlayEntry(builder: (ctx) => _buildCollapsedPeekOverlay(ctx));
|
||||||
|
overlay.insert(_collapsedPeekEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeCollapsedPeekOverlay() {
|
||||||
|
_collapsedPeekEntry?.remove();
|
||||||
|
_collapsedPeekEntry = null;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildModalOverlay(BuildContext context) {
|
Widget _buildModalOverlay(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -143,6 +188,39 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCollapsedPeekOverlay(BuildContext context) {
|
||||||
|
// A small floating menu button anchored to the rail's target, visible when
|
||||||
|
// the rail is fully hidden (hideWhenCollapsed == true).
|
||||||
|
Widget btn = IconButtonM3E(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
tooltip: 'Expand',
|
||||||
|
onPressed: widget.onTypeChanged == null
|
||||||
|
? null
|
||||||
|
: () => widget.onTypeChanged!(NavigationRailM3EType.expanded),
|
||||||
|
);
|
||||||
|
if (_suppressInk) {
|
||||||
|
final t = Theme.of(context);
|
||||||
|
btn = Theme(
|
||||||
|
data: t.copyWith(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
hoverColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: btn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompositedTransformFollower(
|
||||||
|
link: _anchor,
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
offset: const Offset(8, 36), // slight inset and same top spacing as rail
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: btn,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
double _targetWidth(BuildContext context) {
|
double _targetWidth(BuildContext context) {
|
||||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||||
const NavigationRailM3ETheme();
|
const NavigationRailM3ETheme();
|
||||||
|
|
@ -157,21 +235,35 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
Widget _buildMenuButton(BuildContext context,
|
Widget _buildMenuButton(BuildContext context,
|
||||||
{required Alignment alignment}) {
|
{required Alignment alignment}) {
|
||||||
final isExpanded = _isExpanded;
|
final isExpanded = _isExpanded;
|
||||||
|
Widget button = 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_suppressInk) {
|
||||||
|
final t = Theme.of(context);
|
||||||
|
button = Theme(
|
||||||
|
data: t.copyWith(
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
hoverColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: button,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
|
padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: alignment,
|
alignment: alignment,
|
||||||
child: IconButtonM3E(
|
child: button,
|
||||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
|
||||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
|
||||||
onPressed: widget.onTypeChanged == null
|
|
||||||
? null
|
|
||||||
: () => widget.onTypeChanged!(
|
|
||||||
isExpanded
|
|
||||||
? NavigationRailM3EType.collapsed
|
|
||||||
: NavigationRailM3EType.expanded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +285,7 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
size: fab.size,
|
size: fab.size,
|
||||||
shapeFamily: FabM3EShapeFamily.square,
|
shapeFamily: FabM3EShapeFamily.square,
|
||||||
density: fab.density,
|
density: fab.density,
|
||||||
elevation: fab.elevation,
|
elevation: 0,
|
||||||
semanticLabel: fab.semanticLabel,
|
semanticLabel: fab.semanticLabel,
|
||||||
)
|
)
|
||||||
: FabM3E(
|
: FabM3E(
|
||||||
|
|
@ -205,13 +297,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
size: fab.size,
|
size: fab.size,
|
||||||
shapeFamily: FabM3EShapeFamily.square,
|
shapeFamily: FabM3EShapeFamily.square,
|
||||||
density: fab.density,
|
density: fab.density,
|
||||||
elevation: fab.elevation,
|
elevation: 0,
|
||||||
semanticLabel: fab.semanticLabel,
|
semanticLabel: fab.semanticLabel,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildChildren(BuildContext context) {
|
List<Widget> _buildChildren(BuildContext context,
|
||||||
|
{required bool showLabels}) {
|
||||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||||
const NavigationRailM3ETheme();
|
const NavigationRailM3ETheme();
|
||||||
final isExpanded = _isExpanded;
|
final isExpanded = _isExpanded;
|
||||||
|
|
@ -250,6 +343,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
selected: index == widget.selectedIndex,
|
selected: index == widget.selectedIndex,
|
||||||
onTap: () => widget.onDestinationSelected(index),
|
onTap: () => widget.onDestinationSelected(index),
|
||||||
expanded: true,
|
expanded: true,
|
||||||
|
labelBehavior: widget.labelBehavior,
|
||||||
|
suppressInk: _suppressInk,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -265,6 +360,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
selected: i == widget.selectedIndex,
|
selected: i == widget.selectedIndex,
|
||||||
onTap: () => widget.onDestinationSelected(i),
|
onTap: () => widget.onDestinationSelected(i),
|
||||||
expanded: false,
|
expanded: false,
|
||||||
|
labelBehavior: widget.labelBehavior,
|
||||||
|
suppressInk: _suppressInk,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -280,9 +377,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
width: width,
|
width: width,
|
||||||
decoration: BoxDecoration(color: tokens.containerColor),
|
decoration: BoxDecoration(color: tokens.containerColor),
|
||||||
child: ListView(
|
child: LayoutBuilder(
|
||||||
padding: EdgeInsets.zero,
|
builder: (ctx, constraints) {
|
||||||
children: _buildChildren(context),
|
final showLabels = _isExpanded && constraints.maxWidth >= 180;
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: _buildChildren(ctx, showLabels: showLabels),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -292,13 +394,11 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||||
// Keep overlay in sync after build completes to avoid layout side-effects.
|
// Keep overlay in sync after build completes to avoid layout side-effects.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
||||||
|
|
||||||
if (_needsOverlay) {
|
final Widget child =
|
||||||
// When showing modal via overlay, render nothing in the layout slot so
|
_needsOverlay ? const SizedBox.shrink() : _buildRailCore(context);
|
||||||
// content underneath can occupy the width. The overlay covers it.
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildRailCore(context);
|
// Always provide an anchor target for positioning collapsed peek overlay.
|
||||||
|
return CompositedTransformTarget(link: _anchor, child: child);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _destinationIndex(List<NavigationRailM3ESection> sections,
|
static int _destinationIndex(List<NavigationRailM3ESection> sections,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
import 'dart:ui' show FontFeature;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'rail_tokens_adapter.dart';
|
import 'rail_tokens_adapter.dart';
|
||||||
|
|
||||||
/// Large numeric badge for rail items (0..999+). One class per file.
|
/// Large numeric badgeValue for rail items (0..999+). One class per file.
|
||||||
class RailBadgeM3E extends StatelessWidget {
|
class RailBadgeM3E extends StatelessWidget {
|
||||||
/// Creates a large numeric badge.
|
/// Creates a large numeric badgeValue.
|
||||||
const RailBadgeM3E({
|
const RailBadgeM3E({
|
||||||
super.key,
|
super.key,
|
||||||
required this.count,
|
this.count,
|
||||||
this.maxDigits = 3,
|
this.maxDigits = 3,
|
||||||
this.dense = false,
|
this.dense = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The numeric value to display in the badge.
|
/// The numeric value to display in the badgeValue.
|
||||||
final int count;
|
final int? count;
|
||||||
|
|
||||||
/// Maximum digits before showing a trailing '+' (e.g. 999+).
|
/// Maximum digits before showing a trailing '+' (e.g. 999+).
|
||||||
final int maxDigits;
|
final int maxDigits;
|
||||||
|
|
@ -24,25 +23,37 @@ class RailBadgeM3E extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (count == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
final tokens = NavigationRailTokensAdapter(context);
|
final tokens = NavigationRailTokensAdapter(context);
|
||||||
final String text = count > (10 * (pow10(maxDigits) - 1))
|
final String text = count! > (10 * (pow10(maxDigits) - 1))
|
||||||
? '${pow10(maxDigits) - 1}+'
|
? '${pow10(maxDigits) - 1}+'
|
||||||
: '$count';
|
: '$count';
|
||||||
final double pad = dense ? 2 : 4;
|
final double pad = dense ? 2 : 4;
|
||||||
return Container(
|
return count == 0
|
||||||
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
|
? Container(
|
||||||
decoration: BoxDecoration(
|
width: 8,
|
||||||
color: tokens.badgeLargeBackground,
|
height: 8,
|
||||||
borderRadius: BorderRadius.circular(999),
|
decoration: BoxDecoration(
|
||||||
),
|
color: tokens.badgeBackground,
|
||||||
child: DefaultTextStyle(
|
shape: BoxShape.circle,
|
||||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
),
|
||||||
color: tokens.badgeLargeLabel,
|
)
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
: Container(
|
||||||
),
|
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
|
||||||
child: Text(text, maxLines: 1),
|
decoration: BoxDecoration(
|
||||||
),
|
color: tokens.badgeBackground,
|
||||||
);
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||||
|
color: tokens.badgeLargeLabel,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
child: Text(text, maxLines: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns 10 to the power of [n].
|
/// Returns 10 to the power of [n].
|
||||||
|
|
@ -54,3 +65,19 @@ class RailBadgeM3E extends StatelessWidget {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,8 +6,7 @@ class NavigationRailM3EDestination {
|
||||||
required this.icon,
|
required this.icon,
|
||||||
this.selectedIcon,
|
this.selectedIcon,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.largeBadgeCount,
|
this.badgeCount,
|
||||||
this.smallBadge = false,
|
|
||||||
this.semanticLabel,
|
this.semanticLabel,
|
||||||
this.short = false,
|
this.short = false,
|
||||||
});
|
});
|
||||||
|
|
@ -15,8 +14,7 @@ class NavigationRailM3EDestination {
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
final Widget? selectedIcon;
|
final Widget? selectedIcon;
|
||||||
final String label;
|
final String label;
|
||||||
final int? largeBadgeCount;
|
final int? badgeCount;
|
||||||
final bool smallBadge;
|
|
||||||
final String? semanticLabel;
|
final String? semanticLabel;
|
||||||
|
|
||||||
/// If true, uses short item height (56dp) instead of 64dp.
|
/// If true, uses short item height (56dp) instead of 64dp.
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:navigation_rail_m3e/navigation_rail_m3e.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.
|
/// Single rail item (private to package). One class per file.
|
||||||
class RailItem extends StatelessWidget {
|
class RailItem extends StatelessWidget {
|
||||||
|
|
@ -14,17 +10,28 @@ class RailItem extends StatelessWidget {
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.expanded,
|
required this.expanded,
|
||||||
|
required this.labelBehavior,
|
||||||
|
this.suppressInk = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Destination data driving this item.
|
/// Destination data driving this item.
|
||||||
final NavigationRailM3EDestination destination;
|
final NavigationRailM3EDestination destination;
|
||||||
|
|
||||||
/// Whether this item is currently selected.
|
/// Whether this item is currently selected.
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
|
||||||
/// Called when the item is tapped.
|
/// Called when the item is tapped.
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
/// Whether the rail is expanded (shows label and badges inline).
|
/// Whether the rail is expanded (shows label and badges inline).
|
||||||
final bool expanded;
|
final bool expanded;
|
||||||
|
|
||||||
|
/// Whether this item's label should be visible.
|
||||||
|
final NavigationRailM3ELabelBehavior labelBehavior;
|
||||||
|
|
||||||
|
/// When true, disables splash/hover/highlight effects to prevent flicker during transitions.
|
||||||
|
final bool suppressInk;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||||
|
|
@ -32,95 +39,41 @@ class RailItem extends StatelessWidget {
|
||||||
final tokens = NavigationRailTokensAdapter(context);
|
final tokens = NavigationRailTokensAdapter(context);
|
||||||
final height = destination.short ? theme.itemShortHeight : theme.itemHeight;
|
final height = destination.short ? theme.itemShortHeight : theme.itemHeight;
|
||||||
|
|
||||||
final icon = IconTheme.merge(
|
final Widget button = RailItemButtonM3E(
|
||||||
data: IconThemeData(
|
icon: destination.icon,
|
||||||
size: theme.iconSize,
|
selectedIcon: destination.selectedIcon,
|
||||||
color:
|
isSelected: selected,
|
||||||
selected ? tokens.activeIconAndLabel : tokens.inactiveIconAndLabel,
|
onPressed: onTap,
|
||||||
),
|
expanded: expanded,
|
||||||
child: selected
|
labelBehavior: labelBehavior,
|
||||||
? (destination.selectedIcon ?? destination.icon)
|
label: destination.label,
|
||||||
: destination.icon,
|
semanticLabel: destination.semanticLabel,
|
||||||
|
suppressInk: suppressInk,
|
||||||
|
badgeCount: destination.badgeCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
final label = DefaultTextStyle(
|
Widget core;
|
||||||
style: Theme.of(context).textTheme.labelLarge!.copyWith(
|
if (!expanded) {
|
||||||
color: selected
|
// Collapsed: left-aligned icon-only button with 48x48 tap target.
|
||||||
? tokens.activeIconAndLabel
|
core = SizedBox(
|
||||||
: tokens.inactiveIconAndLabel,
|
height: height,
|
||||||
),
|
child: Align(alignment: Alignment.centerLeft, child: button),
|
||||||
child: Text(
|
);
|
||||||
destination.label,
|
} else {
|
||||||
overflow: TextOverflow.ellipsis,
|
core = ConstrainedBox(
|
||||||
maxLines: 1,
|
constraints: BoxConstraints(minHeight: height),
|
||||||
semanticsLabel: destination.semanticLabel ?? destination.label,
|
child: Row(
|
||||||
),
|
children: [
|
||||||
);
|
Expanded(child: button),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
return Semantics(
|
||||||
selected: selected,
|
selected: selected,
|
||||||
button: true,
|
button: true,
|
||||||
child: InkWell(
|
child: core,
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class NavigationRailTokensAdapter {
|
||||||
_cs.onSecondaryContainer;
|
_cs.onSecondaryContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color get badgeLargeBackground =>
|
Color get badgeBackground =>
|
||||||
_maybe(() => context.m3e.colors.error) ?? _cs.error;
|
_maybe(() => context.m3e.colors.error) ?? _cs.error;
|
||||||
Color get badgeLargeLabel =>
|
Color get badgeLargeLabel =>
|
||||||
_maybe(() => context.m3e.colors.onError) ?? _cs.onError;
|
_maybe(() => context.m3e.colors.onError) ?? _cs.onError;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// M3 Expressive types for the rail.
|
/// M3 Expressive types for the rail.
|
||||||
enum NavigationRailM3EType {
|
enum NavigationRailM3EType {
|
||||||
/// Slim 96dp rail.
|
/// Slim 96dp rail.
|
||||||
|
|
@ -12,4 +10,15 @@ enum NavigationRailM3EType {
|
||||||
extension NavigationRailM3ETypeX on NavigationRailM3EType {
|
extension NavigationRailM3ETypeX on NavigationRailM3EType {
|
||||||
bool get isCollapsed => this == NavigationRailM3EType.collapsed;
|
bool get isCollapsed => this == NavigationRailM3EType.collapsed;
|
||||||
bool get isExpanded => this == NavigationRailM3EType.expanded;
|
bool get isExpanded => this == NavigationRailM3EType.expanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Controls how labels are shown for rail destinations when the rail is expanded.
|
||||||
|
///
|
||||||
|
/// - alwaysShow (default): show all labels (subject to width constraints).
|
||||||
|
/// - onlySelected: show the label only for the selected destination.
|
||||||
|
/// - alwaysHide: never show labels even when expanded.
|
||||||
|
enum NavigationRailM3ELabelBehavior {
|
||||||
|
alwaysShow,
|
||||||
|
onlySelected,
|
||||||
|
alwaysHide,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ dependencies:
|
||||||
# Icon button used by the rail's menu control.
|
# Icon button used by the rail's menu control.
|
||||||
icon_button_m3e:
|
icon_button_m3e:
|
||||||
path: ../icon_button_m3e
|
path: ../icon_button_m3e
|
||||||
|
# Buttons used by rail items in expanded state.
|
||||||
|
button_m3e:
|
||||||
|
path: ../button_m3e
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue