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 fab_m3e;
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/fab_menu_m3e.dart';

View file

@ -0,0 +1,8 @@
enum FabM3EKind { primary, secondary, tertiary, surface }
/// Size mapping follows Material 3: small (40), regular (56), large (96).
enum FabM3ESize { small, regular, large }
enum FabM3EShapeFamily { round, square }
enum FabM3EDensity { regular, compact }
/// Direction for the FAB menu children to expand.
enum FabMenuDirection { up, down, left, right }

View file

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'fab_theme_m3e.dart';
class ExtendedFabM3E extends StatelessWidget {
const ExtendedFabM3E({
super.key,
required this.label,
this.icon,
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.expand = false,
this.semanticLabel,
});
final Widget label;
final Widget? icon;
final VoidCallback? onPressed;
final String? tooltip;
final Object? heroTag;
final FabM3EKind kind;
final FabM3ESize size;
final FabM3EShapeFamily shapeFamily;
final FabM3EDensity density;
final double? elevation;
final bool expand;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final tokens = FabTokensAdapter(context);
final m = tokens.metrics(density);
final bg = tokens.bg(kind);
final fg = tokens.fg(kind);
final shape = tokens.shape(shapeFamily, size, extended: true);
final minH = m.extendedHeight;
final child = DefaultTextStyle.merge(
style: tokens.labelStyle().copyWith(color: fg),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
IconTheme.merge(
data: IconThemeData(color: fg, size: m.iconSize), child: icon!),
const SizedBox(width: 12)
],
Flexible(child: label),
],
),
);
final btn = ConstrainedBox(
constraints: BoxConstraints(minHeight: minH),
child: Material(
shape: shape,
color: bg,
elevation: elevation ?? m.elevationRest,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
onHover: (_) {},
child: Padding(
padding: m.extendedPadding,
child: Align(alignment: Alignment.center, child: child),
),
),
),
);
final core = Tooltip(
message: tooltip ?? '',
preferBelow: false,
child: expand ? SizedBox(width: double.infinity, child: btn) : btn,
);
Widget wrapped = core;
if (heroTag != null &&
context.findAncestorWidgetOfExactType<Hero>() == null) {
wrapped = Hero(tag: heroTag!, child: core);
}
if (semanticLabel == null) return wrapped;
return Semantics(button: true, label: semanticLabel, child: wrapped);
}
}

View file

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'fab_theme_m3e.dart';
class FabM3E extends StatelessWidget {
const FabM3E({
super.key,
required this.icon,
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.focusNode,
this.autofocus = false,
this.isPrimaryAction = true,
this.semanticLabel,
});
final Widget icon;
final VoidCallback? onPressed;
final String? tooltip;
final Object? heroTag;
final FabM3EKind kind;
final FabM3ESize size;
final FabM3EShapeFamily shapeFamily;
final FabM3EDensity density;
final double? elevation;
final FocusNode? focusNode;
final bool autofocus;
final bool isPrimaryAction;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final tokens = FabTokensAdapter(context);
final m = tokens.metrics(density);
final bg = tokens.bg(kind);
final fg = tokens.fg(kind);
final shape = tokens.shape(shapeFamily, size);
final double dim = switch (size) {
FabM3ESize.small => m.small,
FabM3ESize.regular => m.regular,
FabM3ESize.large => m.large,
};
final button = SizedBox(
width: dim,
height: dim,
child: RawMaterialButton(
onPressed: onPressed,
fillColor: bg,
elevation: elevation ?? m.elevationRest,
hoverElevation: m.elevationHover,
highlightElevation: m.elevationPressed,
focusElevation: m.elevationHover,
shape: shape,
focusNode: focusNode,
autofocus: autofocus,
clipBehavior: Clip.antiAlias,
child: IconTheme.merge(
data: IconThemeData(color: fg, size: m.iconSize),
child: icon,
),
),
);
final core = Tooltip(
message: tooltip ?? '',
preferBelow: false,
child: button,
);
// Only wrap with Hero when an explicit tag is provided and there is no ancestor hero.
Widget wrapped = core;
if (heroTag != null &&
context.findAncestorWidgetOfExactType<Hero>() == null) {
wrapped = Hero(tag: heroTag!, child: core);
}
if (semanticLabel == null) return wrapped;
return Semantics(button: true, label: semanticLabel, child: wrapped);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class FabM3EWidget extends StatelessWidget {
const FabM3EWidget({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('Fab 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,247 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'extended_fab_m3e.dart';
class FabMenuItem {
FabMenuItem({
required this.icon,
required this.label,
required this.onPressed,
this.semanticLabel,
});
final Widget icon;
final Widget label;
final VoidCallback onPressed;
final String? semanticLabel;
}
class FabMenuController extends ChangeNotifier {
bool _open = false;
bool get isOpen => _open;
void open() {
if (!_open) {
_open = true;
notifyListeners();
}
}
void close() {
if (_open) {
_open = false;
notifyListeners();
}
}
void toggle() {
_open = !_open;
notifyListeners();
}
}
class FabMenuM3E extends StatefulWidget {
const FabMenuM3E({
super.key,
required this.primaryFab,
required this.items,
this.direction = FabMenuDirection.up,
this.spacing,
this.overlay = true,
this.overlayColor,
this.controller,
this.alignment = Alignment.bottomRight,
this.popOnItemTap = true,
this.heroTag,
});
/// The FAB that toggles the menu (typically a primary FabM3E or ExtendedFabM3E).
final Widget primaryFab;
/// Menu items displayed when open.
final List<FabMenuItem> items;
/// Direction in which children expand.
final FabMenuDirection direction;
/// Spacing between items.
final double? spacing;
/// Show a scrim overlay behind the menu when open.
final bool overlay;
final Color? overlayColor;
/// Optional external controller; if omitted, an internal one is created.
final FabMenuController? controller;
/// Alignment within the Stack (e.g., bottomRight in a Scaffold).
final Alignment alignment;
/// Whether to automatically close the menu when an item is tapped.
final bool popOnItemTap;
final Object? heroTag;
@override
State<FabMenuM3E> createState() => _FabMenuM3EState();
}
class _FabMenuM3EState extends State<FabMenuM3E>
with SingleTickerProviderStateMixin {
late final FabMenuController _controller =
widget.controller ?? FabMenuController();
late final AnimationController _anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
reverseDuration: const Duration(milliseconds: 150),
);
late final Animation<double> _scale = CurvedAnimation(
parent: _anim,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic);
@override
void initState() {
super.initState();
_controller.addListener(_onChange);
}
@override
void dispose() {
_controller.removeListener(_onChange);
if (widget.controller == null) _controller.dispose();
_anim.dispose();
super.dispose();
}
void _onChange() {
if (_controller.isOpen) {
_anim.forward();
} else {
_anim.reverse();
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final sp = context.m3e.spacing; // use spacing scale via context extension
final gap = widget.spacing ?? sp.md;
final children = <Widget>[];
for (int i = 0; i < widget.items.length; i++) {
final item = widget.items[i];
final w = _buildMenuItem(context, item);
final animatedChild = ScaleTransition(
scale: _scale,
child: FadeTransition(
opacity: _scale,
child: w,
),
);
// Ensure Positioned is a direct child of the Stack
children.add(_positioned(animatedChild, i, gap));
}
final menu = Stack(
alignment: widget.alignment,
clipBehavior: Clip.none,
children: [
// Primary FAB
Align(
alignment: widget.alignment,
child: _wrapToggle(widget.primaryFab),
),
// Menu items
...children,
],
);
final overlay = widget.overlay && _controller.isOpen
? Positioned.fill(
child: GestureDetector(
onTap: _controller.toggle,
child: ColoredBox(
color:
widget.overlayColor ?? Colors.black.withValues(alpha: 0.25),
),
),
)
: const SizedBox.shrink();
return Stack(
children: [
overlay,
Positioned.fill(
child: IgnorePointer(
ignoring: !_controller.isOpen, child: Container())),
menu,
],
);
}
Widget _wrapToggle(Widget child) {
final core = GestureDetector(
onTap: _controller.toggle,
behavior: HitTestBehavior.opaque,
child: child,
);
if (widget.heroTag != null && context.findAncestorWidgetOfExactType<Hero>() == null) {
return Hero(tag: widget.heroTag!, child: core);
}
return core;
}
Widget _positioned(Widget child, int index, double gap) {
final offset = (index + 1) *
(gap +
56); // base step; extended affects height, but 56 is a practical default
switch (widget.direction) {
case FabMenuDirection.up:
return Positioned(
right: 0,
bottom: offset,
child: child,
);
case FabMenuDirection.down:
return Positioned(
right: 0,
top: offset,
child: child,
);
case FabMenuDirection.left:
return Positioned(
right: offset,
bottom: 0,
child: child,
);
case FabMenuDirection.right:
return Positioned(
left: offset,
bottom: 0,
child: child,
);
}
}
Widget _buildMenuItem(BuildContext context, FabMenuItem item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ExtendedFabM3E(
icon: item.icon,
label: item.label,
onPressed: () {
item.onPressed();
if (widget.popOnItemTap) _controller.close();
},
kind: FabM3EKind.surface,
size: FabM3ESize.regular,
density: FabM3EDensity.regular,
semanticLabel: item.semanticLabel,
),
);
}
}

View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _FabMetrics {
final double small;
final double regular;
final double large;
final double extendedHeight;
final EdgeInsetsGeometry extendedPadding;
final double iconSize;
final double elevationRest;
final double elevationHover;
final double elevationPressed;
const _FabMetrics({
required this.small,
required this.regular,
required this.large,
required this.extendedHeight,
required this.extendedPadding,
required this.iconSize,
required this.elevationRest,
required this.elevationHover,
required this.elevationPressed,
});
}
_FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double small = 40;
double regular = 56;
double large = 96;
double extH = 56;
double icon = 24;
if (density == FabM3EDensity.compact) {
small -= 4; regular -= 4; large -= 4; extH -= 4;
}
return _FabMetrics(
small: small,
regular: regular,
large: large,
extendedHeight: extH,
extendedPadding: EdgeInsets.symmetric(horizontal: sp.lg),
iconSize: icon,
elevationRest: 6.0,
elevationHover: 8.0,
elevationPressed: 12.0,
);
}
class FabTokensAdapter {
FabTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_FabMetrics metrics(FabM3EDensity density) => _metricsFor(context, density);
// Colors by kind
Color bg(FabM3EKind kind) {
switch (kind) {
case FabM3EKind.primary:
return _m3e.colors.primaryContainer;
case FabM3EKind.secondary:
return _m3e.colors.secondaryContainer;
case FabM3EKind.tertiary:
return _m3e.colors.tertiaryContainer;
case FabM3EKind.surface:
return _m3e.colors.surfaceContainerHigh;
}
}
Color fg(FabM3EKind kind) {
switch (kind) {
case FabM3EKind.primary:
return _m3e.colors.onPrimaryContainer;
case FabM3EKind.secondary:
return _m3e.colors.onSecondaryContainer;
case FabM3EKind.tertiary:
return _m3e.colors.onTertiaryContainer;
case FabM3EKind.surface:
return _m3e.colors.onSurface;
}
}
// Shapes
ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size, {bool extended = false}) {
final set = family == FabM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
if (extended) return StadiumBorder(side: BorderSide.none);
// 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 };
return RoundedRectangleBorder(borderRadius: radius);
}
// Typography
TextStyle labelStyle() => _m3e.type.labelLarge;
}