Add initial configuration, tokens, and widgets for M3E components
- Introduced `.gitignore` and `.metadata` for apps and examples. - Added Flutter/Dart analysis configurations (`analysis_options.yaml`). - Implemented foundational tokens and themes for M3E (colors, shapes). - Created base implementations for `IconButtonM3E` and `SplitButtonM3E`. - Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
|
|
@ -0,0 +1,7 @@
|
|||
library navigation_rail_m3e;
|
||||
|
||||
export 'src/enums.dart';
|
||||
export 'src/rail_tokens_adapter.dart' show RailTokensAdapter;
|
||||
export 'src/navigation_rail_m3e.dart';
|
||||
export 'src/rail_badge_m3e.dart';
|
||||
export 'src/rail_destination_m3e.dart';
|
||||
5
packages/navigation_rail_m3e/lib/src/enums.dart
Normal file
5
packages/navigation_rail_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
enum RailLabelBehavior { alwaysShow, onlySelected, alwaysHide }
|
||||
enum RailSize { compact, regular }
|
||||
enum RailShapeFamily { round, square }
|
||||
enum RailDensity { regular, compact }
|
||||
enum RailIndicatorStyle { pill, stripe, none }
|
||||
177
packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart
Normal file
177
packages/navigation_rail_m3e/lib/src/navigation_rail_m3e.dart
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
|
||||
class NavigationRailM3EWidget extends StatelessWidget {
|
||||
const NavigationRailM3EWidget({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('NavigationRail placeholder', style: m3e.typography.base.titleMedium),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();
|
||||
76
packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart
Normal file
76
packages/navigation_rail_m3e/lib/src/rail_badge_m3e.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
|
||||
class RailBadgeM3E extends StatelessWidget {
|
||||
const RailBadgeM3E({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.count,
|
||||
this.showDot = false,
|
||||
this.maxCount = 99,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.semanticLabel,
|
||||
this.offset = const Offset(8, -6),
|
||||
}) : assert(count == null || count >= 0);
|
||||
|
||||
final Widget child;
|
||||
final int? count;
|
||||
final bool showDot;
|
||||
final int maxCount;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final String? semanticLabel;
|
||||
final Offset offset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context);
|
||||
final m3e = t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
||||
final bg = backgroundColor ?? m3e.colors.errorContainer;
|
||||
final fg = foregroundColor ?? m3e.colors.onErrorContainer;
|
||||
|
||||
final badge = showDot
|
||||
? _dot(bg)
|
||||
: _label(bg, fg, count == null ? '' : _format(count!, maxCount));
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child,
|
||||
Positioned(
|
||||
right: offset.dx,
|
||||
top: offset.dy,
|
||||
child: Semantics(
|
||||
label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'),
|
||||
child: badge,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dot(Color bg) {
|
||||
return Container(
|
||||
width: 8, height: 8,
|
||||
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(Color bg, Color fg, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600),
|
||||
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _format(int c, int max) => (c > max) ? '$max+' : '$c';
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'rail_badge_m3e.dart';
|
||||
|
||||
class RailDestinationM3E {
|
||||
const RailDestinationM3E({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.selectedIcon,
|
||||
this.badgeCount,
|
||||
this.badgeDot = false,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final Widget? selectedIcon;
|
||||
final String label;
|
||||
|
||||
/// Optional badge counter
|
||||
final int? badgeCount;
|
||||
|
||||
/// If true, show a small dot instead of a counter.
|
||||
final bool badgeDot;
|
||||
|
||||
final String? semanticLabel;
|
||||
|
||||
Widget buildIcon([bool selected = false]) {
|
||||
final base = selected && selectedIcon != null ? selectedIcon! : icon;
|
||||
if (badgeCount != null || badgeDot) {
|
||||
return RailBadgeM3E(
|
||||
child: base,
|
||||
count: badgeCount,
|
||||
showDot: badgeDot,
|
||||
semanticLabel: semanticLabel,
|
||||
);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
import 'enums.dart';
|
||||
|
||||
@immutable
|
||||
class _RailMetrics {
|
||||
final double widthCompact;
|
||||
final double widthRegular;
|
||||
final double extendedMinWidth;
|
||||
final double iconSize;
|
||||
final EdgeInsetsGeometry itemPadding;
|
||||
final double stripeThickness;
|
||||
const _RailMetrics({
|
||||
required this.widthCompact,
|
||||
required this.widthRegular,
|
||||
required this.extendedMinWidth,
|
||||
required this.iconSize,
|
||||
required this.itemPadding,
|
||||
required this.stripeThickness,
|
||||
});
|
||||
}
|
||||
|
||||
_RailMetrics _metricsFor(BuildContext context, RailDensity density) {
|
||||
final theme = Theme.of(context);
|
||||
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||
final sp = m3e.spacing;
|
||||
|
||||
double wC = 64; // compact width
|
||||
double wR = 80; // regular width
|
||||
double ext = 256; // extended min width
|
||||
double icon = 24;
|
||||
double stripe = 3;
|
||||
|
||||
if (density == RailDensity.compact) {
|
||||
wC -= 4; wR -= 4; stripe -= 1;
|
||||
}
|
||||
|
||||
return _RailMetrics(
|
||||
widthCompact: wC,
|
||||
widthRegular: wR,
|
||||
extendedMinWidth: ext,
|
||||
iconSize: icon,
|
||||
itemPadding: EdgeInsets.symmetric(horizontal: sp.md, vertical: sp.sm),
|
||||
stripeThickness: stripe,
|
||||
);
|
||||
}
|
||||
|
||||
class RailTokensAdapter {
|
||||
RailTokensAdapter(this.context);
|
||||
final BuildContext context;
|
||||
|
||||
M3ETheme get _m3e {
|
||||
final t = Theme.of(context);
|
||||
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
||||
}
|
||||
|
||||
_RailMetrics metrics(RailDensity density) => _metricsFor(context, density);
|
||||
|
||||
// Container/background
|
||||
Color containerColor() => _m3e.colors.surfaceContainerHigh;
|
||||
|
||||
// Indicator
|
||||
Color indicatorColor() => _m3e.colors.secondaryContainer;
|
||||
|
||||
// Icon/label colors
|
||||
Color selectedColor() => _m3e.colors.onSecondaryContainer;
|
||||
Color unselectedColor() => _m3e.colors.onSurfaceVariant;
|
||||
|
||||
// Typography
|
||||
TextStyle labelStyle() => _m3e.type.labelMedium;
|
||||
|
||||
// Shapes
|
||||
ShapeBorder containerShape(RailShapeFamily family) {
|
||||
final set = family == RailShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
|
||||
return RoundedRectangleBorder(borderRadius: set.lg);
|
||||
}
|
||||
|
||||
ShapeBorder indicatorShapePill() => const StadiumBorder();
|
||||
|
||||
// Stripe decoration for selected destination
|
||||
BoxDecoration stripeDecoration(Color color, double thickness) {
|
||||
return BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: color, width: thickness),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue