forked from mirrors/material_3_expressive
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:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
100
packages/button_group_m3e/README.md
Normal file
100
packages/button_group_m3e/README.md
Normal 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 (XS–XL), 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...
|
||||
],
|
||||
)
|
||||
```
|
||||
5
packages/button_group_m3e/lib/button_group_m3e.dart
Normal file
5
packages/button_group_m3e/lib/button_group_m3e.dart
Normal 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;
|
||||
44
packages/button_group_m3e/lib/src/_tokens_adapter.dart
Normal file
44
packages/button_group_m3e/lib/src/_tokens_adapter.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
enum ButtonGroupM3EType { standard, connected }
|
||||
enum ButtonGroupM3EShape { round, square }
|
||||
enum ButtonGroupM3ESize { xs, sm, md, lg, xl }
|
||||
enum ButtonGroupM3EDensity { regular, compact }
|
||||
|
|
@ -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;
|
||||
}
|
||||
186
packages/button_group_m3e/lib/src/button_group_m3e_widget.dart
Normal file
186
packages/button_group_m3e/lib/src/button_group_m3e_widget.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
29
packages/button_group_m3e/melos_button_group_m3e.iml
Normal file
29
packages/button_group_m3e/melos_button_group_m3e.iml
Normal 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>
|
||||
18
packages/button_group_m3e/pubspec.yaml
Normal file
18
packages/button_group_m3e/pubspec.yaml
Normal 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
|
||||
4
packages/button_group_m3e/pubspec_overrides.yaml
Normal file
4
packages/button_group_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# melos_managed_dependency_overrides: m3e_design
|
||||
dependency_overrides:
|
||||
m3e_design:
|
||||
path: ..\\m3e_design
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('placeholder', () {
|
||||
expect(1 + 2, 3);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue