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,100 @@
# button_group_m3e
Wrapper-only **Button Group** for Material 3 Expressive (M3E).
Arranges arbitrary action buttons and applies **group-level presentation**: type (standard/connected), shape family (round/square), size (XSXL), density, and layout (axis, wrap).
> Buttons themselves remain independent (no selection logic). Use your own M3E buttons (`icon_button_m3e`, `split_button_m3e`, etc.).
## Install (in monorepo)
Place this folder alongside `m3e_design`:
```
packages/
m3e_design/
button_group_m3e/
```
`pubspec.yaml` already expects `m3e_design` at `../m3e_design`.
## API
```dart
class ButtonGroupM3E extends StatelessWidget {
const ButtonGroupM3E({
required List<Widget> children,
ButtonGroupM3EType type = ButtonGroupM3EType.standard,
ButtonGroupM3EShape shape = ButtonGroupM3EShape.round,
ButtonGroupM3ESize size = ButtonGroupM3ESize.md,
ButtonGroupM3EDensity density = ButtonGroupM3EDensity.regular,
Axis direction = Axis.horizontal,
bool wrap = false,
double? spacing,
double? runSpacing,
WrapAlignment alignment = WrapAlignment.start,
WrapAlignment runAlignment = WrapAlignment.start,
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
bool showDividers = false,
Color? dividerColor,
double? dividerThickness,
bool equalizeWidths = false,
String? semanticLabel,
Clip clipBehavior = Clip.none,
});
}
```
Enums:
```dart
enum ButtonGroupM3EType { standard, connected }
enum ButtonGroupM3EShape { round, square }
enum ButtonGroupM3ESize { xs, sm, md, lg, xl }
enum ButtonGroupM3EDensity { regular, compact }
```
## Scope for cooperative buttons
The group exposes: `ButtonGroupM3EScope` and `ButtonGroupM3EItemScope`:
```dart
final g = ButtonGroupM3EScope.of(context);
final i = ButtonGroupM3EItemScope.of(context);
// g.size, g.shape, g.isConnected, g.direction ...
// i.index, i.count, i.isFirst, i.isLast ...
```
Buttons can read these to adopt **height, corner radii (outer vs inner), compact paddings**, etc.
## Defaults (recommended)
- type: `standard`
- shape: `round`
- size: `md`
- density: `regular`
- direction: `Axis.horizontal`
- wrap: `false`
- standard spacing: token-based (≈8dp at md)
- connected spacing: `0`
- dividers: `false` by default (connected only)
- dividerThickness: `1dp` (hairline)
## Notes
- In **wrap** mode, connected **dividers** are not auto-drawn per run (Flutter Wrap lacks per-run hooks). Use standard type, or accept flush seams.
- `equalizeWidths` uses min-widths by size (40, 56, 72, 96, 120). For true equalization per run, implement a custom multi-pass layout if needed.
- The widget does **not** clip children unless `clipBehavior` is set. Prefer cooperative styling via scope.
## Example
```dart
ButtonGroupM3E(
type: ButtonGroupM3EType.connected,
shape: ButtonGroupM3EShape.round,
size: ButtonGroupM3ESize.lg,
showDividers: true,
semanticLabel: 'Playback controls',
children: [
// Your M3E buttons here...
],
)
```

View file

@ -0,0 +1,5 @@
library button_group_m3e;
export 'src/button_group_m3e_widget.dart';
export 'src/button_group_m3e_enums.dart';
export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope;

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'button_group_m3e_enums.dart';
class _GroupMetrics {
final double spacing;
final double runSpacing;
final double dividerThickness;
const _GroupMetrics({required this.spacing, required this.runSpacing, required this.dividerThickness});
}
_GroupMetrics metricsFor(BuildContext context, ButtonGroupM3ESize size, ButtonGroupM3EDensity density) {
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final sp = m3e.spacing;
double space;
double run;
switch (size) {
case ButtonGroupM3ESize.xs: space = 6; run = 6; break;
case ButtonGroupM3ESize.sm: space = sp.sm; run = sp.sm; break;
case ButtonGroupM3ESize.md: space = sp.sm; run = sp.md; break;
case ButtonGroupM3ESize.lg: space = sp.md; run = sp.lg; break;
case ButtonGroupM3ESize.xl: space = sp.lg; run = sp.lg; break;
}
if (density == ButtonGroupM3EDensity.compact) {
space = (space * 0.75).floorToDouble();
run = (run * 0.75).floorToDouble();
}
return _GroupMetrics(spacing: space, runSpacing: run, dividerThickness: 1);
}
BorderRadius radiusFor(BuildContext context, ButtonGroupM3EShape shape, ButtonGroupM3ESize size) {
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final set = shape == ButtonGroupM3EShape.round ? m3e.shapes.round : m3e.shapes.square;
switch (size) {
case ButtonGroupM3ESize.xs: return set.xs;
case ButtonGroupM3ESize.sm: return set.sm;
case ButtonGroupM3ESize.md: return set.md;
case ButtonGroupM3ESize.lg: return set.lg;
case ButtonGroupM3ESize.xl: return set.xl;
}
}

View file

@ -0,0 +1,4 @@
enum ButtonGroupM3EType { standard, connected }
enum ButtonGroupM3EShape { round, square }
enum ButtonGroupM3ESize { xs, sm, md, lg, xl }
enum ButtonGroupM3EDensity { regular, compact }

View file

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'button_group_m3e_enums.dart';
class ButtonGroupM3EScope extends InheritedWidget {
const ButtonGroupM3EScope({
super.key,
required super.child,
required this.type,
required this.shape,
required this.size,
required this.density,
required this.direction,
required this.isConnected,
});
final ButtonGroupM3EType type;
final ButtonGroupM3EShape shape;
final ButtonGroupM3ESize size;
final ButtonGroupM3EDensity density;
final Axis direction;
final bool isConnected;
static ButtonGroupM3EScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ButtonGroupM3EScope>();
static ButtonGroupM3EScope of(BuildContext context) =>
maybeOf(context)!;
@override
bool updateShouldNotify(covariant ButtonGroupM3EScope oldWidget) =>
type != oldWidget.type ||
shape != oldWidget.shape ||
size != oldWidget.size ||
density != oldWidget.density ||
direction != oldWidget.direction ||
isConnected != oldWidget.isConnected;
}
class ButtonGroupM3EItemScope extends InheritedWidget {
const ButtonGroupM3EItemScope({
super.key,
required super.child,
required this.index,
required this.count,
required this.isFirst,
required this.isLast,
});
final int index;
final int count;
final bool isFirst;
final bool isLast;
static ButtonGroupM3EItemScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ButtonGroupM3EItemScope>();
static ButtonGroupM3EItemScope of(BuildContext context) =>
maybeOf(context)!;
@override
bool updateShouldNotify(covariant ButtonGroupM3EItemScope oldWidget) =>
index != oldWidget.index ||
count != oldWidget.count ||
isFirst != oldWidget.isFirst ||
isLast != oldWidget.isLast;
}

View file

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'button_group_m3e_enums.dart';
import '_tokens_adapter.dart';
import 'button_group_m3e_scope.dart';
class ButtonGroupM3E extends StatelessWidget {
const ButtonGroupM3E({
super.key,
required this.children,
this.type = ButtonGroupM3EType.standard,
this.shape = ButtonGroupM3EShape.round,
this.size = ButtonGroupM3ESize.md,
this.density = ButtonGroupM3EDensity.regular,
this.direction = Axis.horizontal,
this.wrap = false,
this.spacing,
this.runSpacing,
this.alignment = WrapAlignment.start,
this.runAlignment = WrapAlignment.start,
this.crossAxisAlignment = WrapCrossAlignment.center,
this.showDividers = false,
this.dividerColor,
this.dividerThickness,
this.equalizeWidths = false,
this.semanticLabel,
this.clipBehavior = Clip.none,
});
final List<Widget> children;
final ButtonGroupM3EType type;
final ButtonGroupM3EShape shape;
final ButtonGroupM3ESize size;
final ButtonGroupM3EDensity density;
final Axis direction;
final bool wrap;
final double? spacing;
final double? runSpacing;
final WrapAlignment alignment;
final WrapAlignment runAlignment;
final WrapCrossAlignment crossAxisAlignment;
final bool showDividers;
final Color? dividerColor;
final double? dividerThickness;
final bool equalizeWidths;
final String? semanticLabel;
final Clip clipBehavior;
bool get _connected => type == ButtonGroupM3EType.connected;
@override
Widget build(BuildContext context) {
final tokens = metricsFor(context, size, density);
final cs = Theme.of(context).colorScheme;
final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6);
final dividerThk = (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0);
final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing);
final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0;
final group = ButtonGroupM3EScope(
type: type,
shape: shape,
size: size,
density: density,
direction: direction,
isConnected: _connected,
child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk),
);
final semantics = Semantics(
container: true,
label: semanticLabel,
child: group,
);
if (clipBehavior == Clip.none) return semantics;
return ClipRRect(
clipBehavior: clipBehavior,
borderRadius: radiusFor(context, shape, size),
child: semantics,
);
}
Widget _buildContent(BuildContext context, double spacing, double runSpacing,
Color dividerColor, double dividerThickness) {
if (children.isEmpty) return const SizedBox.shrink();
if (wrap) {
return _wrapLayout(context, spacing, runSpacing);
}
final list = <Widget>[];
for (var i = 0; i < children.length; i++) {
final isFirst = i == 0;
final isLast = i == children.length - 1;
final child = _wrapItemScope(
context,
index: i,
count: children.length,
isFirst: isFirst,
isLast: isLast,
child: _maybeEqualized(children[i]),
);
list.add(child);
final isBetween = i < children.length - 1;
if (!isBetween) continue;
if (_connected) {
if (showDividers) {
list.add(_buildDivider(dividerColor, dividerThickness));
}
} else {
list.add(_spacer(spacing));
}
}
return direction == Axis.horizontal
? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list)
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list);
}
Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) {
final wrapped = List<Widget>.generate(children.length, (i) {
final isFirst = i == 0;
final isLast = i == children.length - 1;
return _wrapItemScope(
context,
index: i,
count: children.length,
isFirst: isFirst,
isLast: isLast,
child: _maybeEqualized(children[i]),
);
});
return Wrap(
direction: direction,
spacing: spacing,
runSpacing: runSpacing,
alignment: alignment,
runAlignment: runAlignment,
crossAxisAlignment: crossAxisAlignment,
children: wrapped,
);
}
Widget _wrapItemScope(BuildContext context,
{required int index, required int count, required bool isFirst, required bool isLast, required Widget child}) {
return ButtonGroupM3EItemScope(
index: index,
count: count,
isFirst: isFirst,
isLast: isLast,
child: child,
);
}
Widget _spacer(double spacing) =>
direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing);
Widget _buildDivider(Color color, double thickness) {
return direction == Axis.horizontal
? Container(width: thickness, height: 24, color: color)
: Container(height: thickness, width: 24, color: color);
}
Widget _maybeEqualized(Widget child) {
if (!equalizeWidths) return child;
final minW = switch (size) {
ButtonGroupM3ESize.xs => 40.0,
ButtonGroupM3ESize.sm => 56.0,
ButtonGroupM3ESize.md => 72.0,
ButtonGroupM3ESize.lg => 96.0,
ButtonGroupM3ESize.xl => 120.0,
};
return ConstrainedBox(constraints: BoxConstraints(minWidth: minW), child: child);
}
}

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: button_group_m3e
description: Wrapper-only Button Group for Material 3 Expressive (layout, shape, size propagation).
version: 0.1.0
publish_to: none
repository: https://example.com/your-repo
environment:
sdk: ">=3.5.0 <4.0.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(1 + 2, 3);
});
}