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

21
packages/fab_m3e/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,93 @@
# fab_m3e
Material 3 **Expressive** Floating Action Buttons for Flutter:
- **FabM3E**: circular FAB (small / regular / large)
- **ExtendedFabM3E**: pill-shaped FAB with label (and optional icon)
- **FabMenuM3E**: FAB menu (speed dial) with animated items (up/down/left/right)
All components read **M3E tokens** from `m3e_design` (ThemeExtension).
## Monorepo Layout
```
packages/
m3e_design/
fab_m3e/
```
This package's `pubspec.yaml` references `../m3e_design`.
## Usage
### FAB
```dart
import 'package:fab_m3e/fab_m3e.dart';
FabM3E(
icon: const Icon(Icons.add),
kind: FabM3EKind.primary,
size: FabM3ESize.regular,
onPressed: () {},
);
```
### Extended FAB
```dart
ExtendedFabM3E(
icon: const Icon(Icons.edit),
label: const Text('Compose'),
kind: FabM3EKind.secondary,
onPressed: () {},
);
```
### FAB Menu (Speed dial)
```dart
final controller = FabMenuController();
FabMenuM3E(
controller: controller,
alignment: Alignment.bottomRight,
direction: FabMenuDirection.up,
primaryFab: FabM3E(icon: const Icon(Icons.add), onPressed: controller.toggle),
items: [
FabMenuItem(
icon: const Icon(Icons.photo),
label: const Text('Photo'),
onPressed: () {},
),
FabMenuItem(
icon: const Icon(Icons.note),
label: const Text('Note'),
onPressed: () {},
),
],
);
```
## Theming via `m3e_design`
- Background/foreground colors derive from kind:
- `primary``primaryContainer` / `onPrimaryContainer`
- `secondary``secondaryContainer` / `onSecondaryContainer`
- `tertiary``tertiaryContainer` / `onTertiaryContainer`
- `surface``surfaceContainerHigh` / `onSurface`
- Sizes: **small ≈40dp**, **regular ≈56dp**, **large ≈96dp**
- Extended FAB height ≈56dp
- Elevations: rest 6, hover 8, pressed 12 (tweak in code or via tokens)
- Shapes: `round`/`square` from `m3e_design.shapes` (extended uses StadiumBorder)
## Notes
- `FabM3E` uses `RawMaterialButton` to directly inject shape/elevation/colors with tokens.
- `ExtendedFabM3E` uses `Material` + `InkWell` with stadium shape and token paddings.
- `FabMenuM3E` stacks items near the primary FAB and animates **scale + fade**.
- Provide your own `Hero` tags if coordinating transitions across pages.
## License
MIT

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

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.idea" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/example/build" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Flutter Plugins" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

View file

@ -0,0 +1,18 @@
name: fab_m3e
description: Material 3 Expressive Floating Action Button (FAB), Extended FAB, and FAB Menu for Flutter using M3E tokens.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
sdk: flutter
m3e_design:
path: ../m3e_design
dev_dependencies:
flutter_test:
sdk: flutter

View file

@ -0,0 +1,4 @@
# melos_managed_dependency_overrides: m3e_design
dependency_overrides:
m3e_design:
path: ..\\m3e_design

View file

@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () {
expect(3 * 3, 9);
});
}