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_bar_m3e;
export 'src/enums.dart';
export 'src/nav_tokens_adapter.dart' show NavTokensAdapter;
export 'src/navigation_bar_m3e.dart';
export 'src/nav_badge_m3e.dart';
export 'src/nav_destination_m3e.dart';

View file

@ -0,0 +1,5 @@
enum NavBarM3ELabelBehavior { alwaysShow, onlySelected, alwaysHide }
enum NavBarM3ESize { small, medium }
enum NavBarM3EShapeFamily { round, square }
enum NavBarM3EDensity { regular, compact }
enum NavBarM3EIndicatorStyle { pill, underline, none }

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class NavBadgeM3E extends StatelessWidget {
const NavBadgeM3E({
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));
final stack = Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
right: offset.dx,
top: offset.dy,
child: Semantics(
label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'),
child: badge,
),
),
],
);
return stack;
}
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 'nav_badge_m3e.dart';
class NavigationDestinationM3E {
const NavigationDestinationM3E({
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 NavBadgeM3E(
child: base,
count: badgeCount,
showDot: badgeDot,
semanticLabel: semanticLabel,
);
}
return base;
}
}

View file

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _NavMetrics {
final double heightSmall;
final double heightMedium;
final double iconSize;
final EdgeInsetsGeometry padding;
final double indicatorThickness; // for underline
const _NavMetrics({
required this.heightSmall,
required this.heightMedium,
required this.iconSize,
required this.padding,
required this.indicatorThickness,
});
}
_NavMetrics _metricsFor(BuildContext context, NavBarM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double hSmall = 64; // compact/phone-tight
double hMedium = 80; // default M3 nav bar height
double icon = 24;
double underline = 3;
if (density == NavBarM3EDensity.compact) {
hSmall -= 4; hMedium -= 4; underline -= 1;
}
return _NavMetrics(
heightSmall: hSmall,
heightMedium: hMedium,
iconSize: icon,
padding: EdgeInsets.symmetric(horizontal: sp.md),
indicatorThickness: underline,
);
}
class NavTokensAdapter {
NavTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_NavMetrics metrics(NavBarM3EDensity 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(NavBarM3EShapeFamily family) {
final set = family == NavBarM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
return RoundedRectangleBorder(borderRadius: set.lg);
}
ShapeBorder indicatorShapePill() => const StadiumBorder();
// Underline decoration for selected.
BoxDecoration underlineDecoration(Color color, double thickness) {
return BoxDecoration(
border: Border(
bottom: BorderSide(color: color, width: thickness),
),
);
}
}

View file

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'nav_tokens_adapter.dart';
import 'nav_destination_m3e.dart';
class NavigationBarM3E extends StatelessWidget {
const NavigationBarM3E({
super.key,
required this.destinations,
this.selectedIndex = 0,
this.onDestinationSelected,
this.labelBehavior = NavBarM3ELabelBehavior.onlySelected,
this.size = NavBarM3ESize.medium,
this.shapeFamily = NavBarM3EShapeFamily.round,
this.density = NavBarM3EDensity.regular,
this.backgroundColor,
this.elevation,
this.indicatorStyle = NavBarM3EIndicatorStyle.pill,
this.indicatorColor,
this.padding,
this.safeArea = true,
this.semanticLabel,
});
final List<NavigationDestinationM3E> destinations;
final int selectedIndex;
final ValueChanged<int>? onDestinationSelected;
final NavBarM3ELabelBehavior labelBehavior;
final NavBarM3ESize size;
final NavBarM3EShapeFamily shapeFamily;
final NavBarM3EDensity density;
final Color? backgroundColor;
final double? elevation;
final NavBarM3EIndicatorStyle indicatorStyle;
final Color? indicatorColor;
final EdgeInsetsGeometry? padding;
final bool safeArea;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
assert(destinations.isNotEmpty, 'Provide at least one destination');
final tokens = NavTokensAdapter(context);
final metrics = tokens.metrics(density);
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final height = size == NavBarM3ESize.small ? metrics.heightSmall : metrics.heightMedium;
final bg = backgroundColor ?? tokens.containerColor();
final shape = tokens.containerShape(shapeFamily);
final nav = Material(
color: bg,
elevation: elevation ?? 0,
shape: shape,
child: SizedBox(
height: height,
child: NavigationBar(
height: height,
elevation: elevation ?? 0,
indicatorColor: indicatorStyle == NavBarM3EIndicatorStyle.none
? Colors.transparent
: (indicatorColor ?? tokens.indicatorColor()),
indicatorShape: switch (indicatorStyle) {
NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(),
NavBarM3EIndicatorStyle.underline => const StadiumBorder(), // we'll fake underline via decoration below
NavBarM3EIndicatorStyle.none => const StadiumBorder(),
},
backgroundColor: Colors.transparent, // outer Material supplies bg + shape
labelBehavior: switch (labelBehavior) {
NavBarM3ELabelBehavior.alwaysShow => NavigationDestinationLabelBehavior.alwaysShow,
NavBarM3ELabelBehavior.onlySelected => NavigationDestinationLabelBehavior.onlyShowSelected,
NavBarM3ELabelBehavior.alwaysHide => NavigationDestinationLabelBehavior.alwaysHide,
},
selectedIndex: selectedIndex,
destinations: List.generate(destinations.length, (i) {
final d = destinations[i];
return NavigationDestination(
icon: _icon(context, false, d, metrics.iconSize),
selectedIcon: _selectedIcon(context, true, d, metrics.iconSize, tokens, indicatorStyle),
label: d.label,
tooltip: d.semanticLabel,
);
}),
onDestinationSelected: onDestinationSelected,
),
),
);
final padded = Padding(
padding: padding ?? EdgeInsets.zero,
child: nav,
);
final content = DefaultTextStyle.merge(
style: tokens.labelStyle().copyWith(
color: m3e.colors.onSurfaceVariant,
),
child: IconTheme.merge(
data: IconThemeData(size: metrics.iconSize, color: m3e.colors.onSurfaceVariant),
child: padded,
),
);
if (!safeArea && semanticLabel == null) return content;
final wrapped = SafeArea(top: false, left: false, right: false, bottom: safeArea, child: content);
if (semanticLabel == null) return wrapped;
return Semantics(container: true, label: semanticLabel!, child: wrapped);
}
Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d, double iconSize) {
return SizedBox(
width: iconSize + 8, // give a little space for underline
height: iconSize + 8,
child: Center(child: d.buildIcon(selected)),
);
}
Widget _selectedIcon(
BuildContext context,
bool selected,
NavigationDestinationM3E d,
double iconSize,
NavTokensAdapter tokens,
NavBarM3EIndicatorStyle style,
) {
final w = _icon(context, selected, d, iconSize);
if (style != NavBarM3EIndicatorStyle.underline) return w;
final metrics = tokens.metrics(density);
final deco = tokens.underlineDecoration(tokens.indicatorColor(), metrics.indicatorThickness);
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 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();