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:
Emily Pauli 2025-10-21 22:15:15 +02:00
commit 62ecb86b76
184 changed files with 9872 additions and 0 deletions

View file

@ -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';

View 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 }

View 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,
);
}
}

View file

@ -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();

View 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';
}

View file

@ -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;
}
}

View file

@ -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),
),
);
}
}