forked from mirrors/material_3_expressive
Refactor button component and add new sections for loading indicators, icons, and navigation; update enums and pubspec description
This commit is contained in:
parent
62ecb86b76
commit
020db0ac38
23 changed files with 1033 additions and 828 deletions
|
|
@ -1,21 +1 @@
|
|||
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.
|
||||
MIT License
|
||||
|
|
@ -1,70 +1,7 @@
|
|||
|
||||
# button_m3e
|
||||
|
||||
Material 3 **Expressive** Buttons for Flutter, built on top of Flutter's buttons but styled via **M3E** tokens.
|
||||
Material 3 **Expressive Buttons** for Flutter — sizes XS→XL, round/square shapes,
|
||||
toggle selection, and 5 styles (filled/tonal/elevated/outlined/text).
|
||||
|
||||
Variants: **filled**, **tonal**, **outlined**, **text**, **elevated**
|
||||
Sizes: **small**, **medium**, **large**
|
||||
Shape families: **round**, **square**
|
||||
Density: **regular**, **compact**
|
||||
|
||||
> Depends on `m3e_design` (ThemeExtension with colors/typography/spacing/shapes).
|
||||
|
||||
## Monorepo Layout
|
||||
|
||||
```
|
||||
packages/
|
||||
m3e_design/
|
||||
button_m3e/
|
||||
```
|
||||
|
||||
In `pubspec.yaml` this package references `../m3e_design`.
|
||||
|
||||
## Usage
|
||||
|
||||
```dart
|
||||
import 'package:button_m3e/button_m3e.dart';
|
||||
|
||||
ButtonM3E(
|
||||
variant: ButtonM3EVariant.filled,
|
||||
size: ButtonM3ESize.medium,
|
||||
labelText: 'Continue',
|
||||
leading: const Icon(Icons.arrow_forward),
|
||||
onPressed: () {},
|
||||
);
|
||||
```
|
||||
|
||||
Full-width button:
|
||||
|
||||
```dart
|
||||
const ButtonM3E(
|
||||
variant: ButtonM3EVariant.tonal,
|
||||
size: ButtonM3ESize.large,
|
||||
labelText: 'Buy now',
|
||||
expand: true,
|
||||
);
|
||||
```
|
||||
|
||||
Outlined/Text/Elevated work similarly.
|
||||
|
||||
## Theming via `m3e_design`
|
||||
|
||||
`button_m3e` reads tokens from your theme:
|
||||
|
||||
- `m3e.colors.*` for background/foreground/border/disabled
|
||||
- `m3e.type.labelLarge` for the button label
|
||||
- `m3e.shapes.round|square` (uses `.lg` radius for buttons)
|
||||
- `m3e.spacing` for horizontal paddings (`sm`, `md`, `lg`)
|
||||
|
||||
If the extension is not present, it falls back to `M3ETheme.defaults(ColorScheme)`.
|
||||
You can still override `ThemeData.colorScheme` to influence defaults globally.
|
||||
|
||||
## Notes
|
||||
|
||||
- Label can be provided as `labelText` (String) or `label` (Widget).
|
||||
- `leading`/`trailing` are optional helpers for icons.
|
||||
- `expand: true` makes the button take full width.
|
||||
- `density: compact` slightly reduces height for each size.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
See `lib/src/button_tokens_adapter.dart` for measurements & color mapping.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
library button_m3e;
|
||||
|
||||
export 'src/enums.dart';
|
||||
export 'src/button_tokens_adapter.dart' show ButtonTokensAdapter, ButtonMeasurements;
|
||||
export 'src/button_m3e.dart';
|
||||
export 'src/button_theme_m3e.dart';
|
||||
|
|
|
|||
|
|
@ -1,144 +1,212 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'enums.dart';
|
||||
import 'button_theme_m3e.dart';
|
||||
|
||||
class ButtonM3E extends StatelessWidget {
|
||||
import 'button_tokens_adapter.dart';
|
||||
import 'enums.dart';
|
||||
|
||||
class ButtonM3E extends StatefulWidget {
|
||||
const ButtonM3E({
|
||||
super.key,
|
||||
this.onPressed,
|
||||
this.onLongPress,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.label,
|
||||
this.labelText,
|
||||
this.expand = false,
|
||||
this.variant = ButtonM3EVariant.filled,
|
||||
this.size = ButtonM3ESize.medium,
|
||||
this.shapeFamily = ButtonM3EShapeFamily.round,
|
||||
this.density = ButtonM3EDensity.regular,
|
||||
this.semanticLabel,
|
||||
}) : assert(label != null || labelText != null, 'Provide either label or labelText');
|
||||
required this.onPressed,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.style = ButtonM3EStyle.filled,
|
||||
this.size = ButtonM3ESize.sm,
|
||||
this.shape = ButtonM3EShape.round,
|
||||
this.selected = false,
|
||||
this.toggleable = false,
|
||||
this.onSelectedChange,
|
||||
this.smallPaddingDeprecated24 = false,
|
||||
this.enabled = true,
|
||||
this.statesController,
|
||||
});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
final VoidCallback? onLongPress;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final Widget? label;
|
||||
final String? labelText;
|
||||
final bool expand;
|
||||
|
||||
final ButtonM3EVariant variant;
|
||||
final Widget label;
|
||||
final Widget? icon;
|
||||
final ButtonM3EStyle style;
|
||||
final ButtonM3ESize size;
|
||||
final ButtonM3EShapeFamily shapeFamily;
|
||||
final ButtonM3EDensity density;
|
||||
final String? semanticLabel;
|
||||
final ButtonM3EShape shape;
|
||||
final bool selected;
|
||||
final bool toggleable;
|
||||
final ValueChanged<bool>? onSelectedChange;
|
||||
final bool smallPaddingDeprecated24;
|
||||
final bool enabled;
|
||||
final WidgetStatesController? statesController;
|
||||
|
||||
@override
|
||||
State<ButtonM3E> createState() => _ButtonM3EState();
|
||||
}
|
||||
|
||||
class _ButtonM3EState extends State<ButtonM3E> {
|
||||
late WidgetStatesController _statesController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_statesController = widget.statesController ?? WidgetStatesController();
|
||||
_syncSelectedToController();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ButtonM3E oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selected != widget.selected && widget.toggleable) {
|
||||
_syncSelectedToController();
|
||||
}
|
||||
}
|
||||
|
||||
void _syncSelectedToController() {
|
||||
_statesController.update(WidgetState.selected, widget.selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = ButtonTokensAdapter(context);
|
||||
final m = t.metrics(density);
|
||||
final shape = t.shape(shapeFamily);
|
||||
final tokens = ButtonTokensAdapter(context,
|
||||
smallPaddingDeprecated24: widget.smallPaddingDeprecated24);
|
||||
final m = tokens.measurements(widget.size);
|
||||
final style = _resolveStyle(tokens, m);
|
||||
|
||||
final (minH, pad) = switch (size) {
|
||||
ButtonM3ESize.small => (m.heightSmall, m.paddingSmall),
|
||||
ButtonM3ESize.medium => (m.heightMedium, m.paddingMedium),
|
||||
ButtonM3ESize.large => (m.heightLarge, m.paddingLarge),
|
||||
};
|
||||
final onPressed = widget.enabled
|
||||
? () {
|
||||
if (widget.toggleable) {
|
||||
final newVal = !widget.selected;
|
||||
widget.onSelectedChange?.call(newVal);
|
||||
if (widget.onSelectedChange == null) {
|
||||
_statesController.update(WidgetState.selected, newVal);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
: null;
|
||||
|
||||
final style = _styleFor(context, t, shape, minH, pad);
|
||||
final child = _buildContent(m);
|
||||
|
||||
final childLabel = label ?? Text(labelText!, overflow: TextOverflow.ellipsis);
|
||||
final content = _buildContent(context, t, childLabel);
|
||||
|
||||
final Widget btn = switch (variant) {
|
||||
ButtonM3EVariant.filled => FilledButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
|
||||
ButtonM3EVariant.tonal => FilledButton.tonal(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
|
||||
ButtonM3EVariant.outlined => OutlinedButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
|
||||
ButtonM3EVariant.text => TextButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
|
||||
ButtonM3EVariant.elevated => ElevatedButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
|
||||
};
|
||||
|
||||
if (!expand && semanticLabel == null) return btn;
|
||||
final wrapped = expand ? SizedBox(width: double.infinity, child: btn) : btn;
|
||||
if (semanticLabel == null) return wrapped;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticLabel,
|
||||
child: wrapped,
|
||||
);
|
||||
}
|
||||
|
||||
ButtonStyle _styleFor(BuildContext context, ButtonTokensAdapter t, OutlinedBorder shape, double minH, EdgeInsetsGeometry pad) {
|
||||
switch (variant) {
|
||||
case ButtonM3EVariant.filled:
|
||||
return FilledButton.styleFrom(
|
||||
backgroundColor: t.bgFilled(),
|
||||
foregroundColor: t.fgFilled(),
|
||||
textStyle: t.labelStyle(),
|
||||
minimumSize: Size(0, minH),
|
||||
padding: pad,
|
||||
shape: shape,
|
||||
switch (widget.style) {
|
||||
case ButtonM3EStyle.filled:
|
||||
return FilledButton(
|
||||
style: style,
|
||||
onPressed: onPressed,
|
||||
statesController: _statesController,
|
||||
child: child,
|
||||
);
|
||||
case ButtonM3EVariant.tonal:
|
||||
return FilledButton.styleFrom(
|
||||
backgroundColor: t.bgTonal(),
|
||||
foregroundColor: t.fgTonal(),
|
||||
textStyle: t.labelStyle(),
|
||||
minimumSize: Size(0, minH),
|
||||
padding: pad,
|
||||
shape: shape,
|
||||
case ButtonM3EStyle.tonal:
|
||||
return FilledButton.tonal(
|
||||
style: style,
|
||||
onPressed: onPressed,
|
||||
statesController: _statesController,
|
||||
child: child,
|
||||
);
|
||||
case ButtonM3EVariant.outlined:
|
||||
return OutlinedButton.styleFrom(
|
||||
foregroundColor: t.fgOutlined(),
|
||||
textStyle: t.labelStyle(),
|
||||
minimumSize: Size(0, minH),
|
||||
padding: pad,
|
||||
shape: shape,
|
||||
side: BorderSide(color: t.borderOutlined()),
|
||||
case ButtonM3EStyle.elevated:
|
||||
return ElevatedButton(
|
||||
style: style,
|
||||
onPressed: onPressed,
|
||||
statesController: _statesController,
|
||||
child: child,
|
||||
);
|
||||
case ButtonM3EVariant.text:
|
||||
return TextButton.styleFrom(
|
||||
foregroundColor: t.fgText(),
|
||||
textStyle: t.labelStyle(),
|
||||
minimumSize: Size(0, minH),
|
||||
padding: pad,
|
||||
shape: shape,
|
||||
case ButtonM3EStyle.outlined:
|
||||
return OutlinedButton(
|
||||
style: style.copyWith(
|
||||
side: WidgetStateProperty.resolveWith((states) {
|
||||
final disabled = states.contains(WidgetState.disabled);
|
||||
return BorderSide(
|
||||
color:
|
||||
tokens.outline().withValues(alpha: disabled ? 0.12 : 1),
|
||||
width: 1);
|
||||
}),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
statesController: _statesController,
|
||||
child: child,
|
||||
);
|
||||
case ButtonM3EVariant.elevated:
|
||||
return ElevatedButton.styleFrom(
|
||||
backgroundColor: t.bgElevated(),
|
||||
foregroundColor: t.fgElevated(),
|
||||
textStyle: t.labelStyle(),
|
||||
minimumSize: Size(0, minH),
|
||||
padding: pad,
|
||||
shape: shape,
|
||||
elevation: _elevationFor(context, t),
|
||||
case ButtonM3EStyle.text:
|
||||
return TextButton(
|
||||
style: style,
|
||||
onPressed: onPressed,
|
||||
statesController: _statesController,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double _elevationFor(BuildContext context, ButtonTokensAdapter t) {
|
||||
// Simple mapping; can be themed further via tokens.
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, ButtonTokensAdapter t, Widget childLabel) {
|
||||
final style = t.labelStyle();
|
||||
final text = DefaultTextStyle.merge(style: style, child: childLabel);
|
||||
final hasLeading = leading != null;
|
||||
final hasTrailing = trailing != null;
|
||||
|
||||
if (!hasLeading && !hasTrailing) return text;
|
||||
|
||||
Widget _buildContent(ButtonMeasurements m) {
|
||||
final text = DefaultTextStyle.merge(child: widget.label);
|
||||
if (widget.icon == null) return text;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (hasLeading) ...[leading!, const SizedBox(width: 8)],
|
||||
Flexible(child: text),
|
||||
if (hasTrailing) ...[const SizedBox(width: 8), trailing!],
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(size: m.iconSize),
|
||||
child: widget.icon!,
|
||||
),
|
||||
SizedBox(width: m.iconGap),
|
||||
text,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
OutlinedBorder _shapeFor(
|
||||
Set<WidgetState> states, ButtonTokensAdapter tokens) {
|
||||
final selected = states.contains(WidgetState.selected) || widget.selected;
|
||||
final pressed = states.contains(WidgetState.pressed);
|
||||
|
||||
OutlinedBorder round = const StadiumBorder();
|
||||
OutlinedBorder square = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.squareRadius(widget.size)),
|
||||
);
|
||||
OutlinedBorder pressedSquare = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.pressedRadius(widget.size)),
|
||||
);
|
||||
|
||||
OutlinedBorder base = widget.shape == ButtonM3EShape.round ? round : square;
|
||||
OutlinedBorder alt = widget.shape == ButtonM3EShape.round ? square : round;
|
||||
|
||||
if (selected && pressed) {
|
||||
return OutlinedBorder.lerp(alt, pressedSquare, 0.5)!;
|
||||
} else if (selected) {
|
||||
return alt;
|
||||
} else if (pressed) {
|
||||
return OutlinedBorder.lerp(base, pressedSquare, 0.7)!;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
ButtonStyle _resolveStyle(ButtonTokensAdapter tokens, ButtonMeasurements m) {
|
||||
final fg = WidgetStateProperty.resolveWith<Color?>((states) {
|
||||
final disabled = states.contains(WidgetState.disabled);
|
||||
final color = tokens.foreground(widget.style);
|
||||
return disabled ? color.withValues(alpha: 0.38) : color;
|
||||
});
|
||||
|
||||
final bg = WidgetStateProperty.resolveWith<Color?>((states) {
|
||||
final disabled = states.contains(WidgetState.disabled);
|
||||
final color = tokens.container(widget.style);
|
||||
if (widget.style == ButtonM3EStyle.outlined ||
|
||||
widget.style == ButtonM3EStyle.text) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return disabled ? color.withValues(alpha: .12) : color;
|
||||
});
|
||||
|
||||
final elevation = WidgetStateProperty.resolveWith<double>((states) {
|
||||
return tokens.elevation(widget.style, states);
|
||||
});
|
||||
|
||||
final shape = WidgetStateProperty.resolveWith<OutlinedBorder>((states) {
|
||||
return _shapeFor(states, tokens);
|
||||
});
|
||||
|
||||
return ButtonStyle(
|
||||
minimumSize: WidgetStateProperty.all(Size(48, m.height)),
|
||||
padding:
|
||||
WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: m.hPadding)),
|
||||
foregroundColor: fg,
|
||||
backgroundColor: bg,
|
||||
shape: shape,
|
||||
elevation: elevation,
|
||||
animationDuration: const Duration(milliseconds: 140),
|
||||
visualDensity: VisualDensity.standard,
|
||||
splashFactory: InkRipple.splashFactory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
|
||||
class ButtonM3EWidget extends StatelessWidget {
|
||||
const ButtonM3EWidget({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('Button placeholder', style: m3e.typography.base.labelLarge),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _pascal(String s) => s
|
||||
.split('_')
|
||||
.map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1)))
|
||||
.join();
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
@immutable
|
||||
class _ButtonMetrics {
|
||||
final double heightSmall;
|
||||
final double heightMedium;
|
||||
final double heightLarge;
|
||||
final EdgeInsetsGeometry paddingSmall;
|
||||
final EdgeInsetsGeometry paddingMedium;
|
||||
final EdgeInsetsGeometry paddingLarge;
|
||||
final BorderSide outlinedBorder;
|
||||
final double elevation;
|
||||
const _ButtonMetrics({
|
||||
required this.heightSmall,
|
||||
required this.heightMedium,
|
||||
required this.heightLarge,
|
||||
required this.paddingSmall,
|
||||
required this.paddingMedium,
|
||||
required this.paddingLarge,
|
||||
required this.outlinedBorder,
|
||||
required this.elevation,
|
||||
});
|
||||
}
|
||||
|
||||
_ButtonMetrics _metricsFor(BuildContext context, ButtonM3EDensity density) {
|
||||
final theme = Theme.of(context);
|
||||
final m3e =
|
||||
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||
final sp = m3e.spacing;
|
||||
|
||||
// Heights based on Material 3 expectations; tweakable by density.
|
||||
double hS = 36;
|
||||
double hM = 40;
|
||||
double hL = 48;
|
||||
|
||||
if (density == ButtonM3EDensity.compact) {
|
||||
hS -= 4;
|
||||
hM -= 4;
|
||||
hL -= 4;
|
||||
}
|
||||
|
||||
return _ButtonMetrics(
|
||||
heightSmall: hS,
|
||||
heightMedium: hM,
|
||||
heightLarge: hL,
|
||||
paddingSmall: EdgeInsets.symmetric(horizontal: sp.sm),
|
||||
paddingMedium: EdgeInsets.symmetric(horizontal: sp.md),
|
||||
paddingLarge: EdgeInsets.symmetric(horizontal: sp.lg),
|
||||
outlinedBorder: BorderSide(color: m3e.colors.outline, width: 1.0),
|
||||
elevation: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
class ButtonTokensAdapter {
|
||||
ButtonTokensAdapter(this.context);
|
||||
final BuildContext context;
|
||||
|
||||
M3ETheme get _m3e {
|
||||
final t = Theme.of(context);
|
||||
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
||||
}
|
||||
|
||||
// Colors
|
||||
Color bgFilled() => _m3e.colors.primary;
|
||||
Color fgFilled() => _m3e.colors.onPrimary;
|
||||
Color bgTonal() => _m3e.colors.secondaryContainer;
|
||||
Color fgTonal() => _m3e.colors.onSecondaryContainer;
|
||||
Color bgElevated() => _m3e.colors.surfaceContainerLowest;
|
||||
Color fgElevated() => _m3e.colors.primary;
|
||||
Color fgText() => _m3e.colors.primary;
|
||||
Color borderOutlined() => _m3e.colors.outline;
|
||||
Color fgOutlined() => _m3e.colors.primary;
|
||||
Color disabledFg() => _m3e.colors.onSurface.withValues(alpha: 0.38);
|
||||
Color disabledBg() => _m3e.colors.onSurface.withValues(alpha: 0.12);
|
||||
|
||||
// Typography
|
||||
TextStyle labelStyle() => _m3e.type.labelLarge;
|
||||
|
||||
// Shapes
|
||||
OutlinedBorder shape(ButtonM3EShapeFamily family) {
|
||||
if (family == ButtonM3EShapeFamily.round) {
|
||||
return RoundedRectangleBorder(borderRadius: _m3e.shapes.round.lg);
|
||||
}
|
||||
// Square family should have sharp corners (no rounding)
|
||||
return const RoundedRectangleBorder(borderRadius: BorderRadius.zero);
|
||||
}
|
||||
|
||||
// Spacing & heights
|
||||
_ButtonMetrics metrics(ButtonM3EDensity density) =>
|
||||
_metricsFor(context, density);
|
||||
}
|
||||
115
packages/button_m3e/lib/src/button_tokens_adapter.dart
Normal file
115
packages/button_m3e/lib/src/button_tokens_adapter.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
import 'enums.dart';
|
||||
|
||||
@immutable
|
||||
class ButtonMeasurements {
|
||||
const ButtonMeasurements({
|
||||
required this.height,
|
||||
required this.hPadding,
|
||||
required this.iconSize,
|
||||
required this.iconGap,
|
||||
});
|
||||
final double height;
|
||||
final double hPadding;
|
||||
final double iconSize;
|
||||
final double iconGap;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ButtonTokensAdapter {
|
||||
const ButtonTokensAdapter(this.context, {this.smallPaddingDeprecated24 = false});
|
||||
final BuildContext context;
|
||||
final bool smallPaddingDeprecated24;
|
||||
|
||||
M3ETheme get _m3e {
|
||||
final t = Theme.of(context);
|
||||
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
||||
}
|
||||
|
||||
Color container(ButtonM3EStyle style) {
|
||||
final c = _m3e.colors;
|
||||
switch (style) {
|
||||
case ButtonM3EStyle.filled:
|
||||
return c.primary;
|
||||
case ButtonM3EStyle.tonal:
|
||||
return c.secondaryContainer;
|
||||
case ButtonM3EStyle.elevated:
|
||||
return c.surface;
|
||||
case ButtonM3EStyle.outlined:
|
||||
case ButtonM3EStyle.text:
|
||||
return Colors.transparent;
|
||||
}
|
||||
}
|
||||
|
||||
Color foreground(ButtonM3EStyle style) {
|
||||
final c = _m3e.colors;
|
||||
switch (style) {
|
||||
case ButtonM3EStyle.filled:
|
||||
return c.onPrimary;
|
||||
case ButtonM3EStyle.tonal:
|
||||
return c.onSecondaryContainer;
|
||||
case ButtonM3EStyle.elevated:
|
||||
case ButtonM3EStyle.outlined:
|
||||
case ButtonM3EStyle.text:
|
||||
return c.primary;
|
||||
}
|
||||
}
|
||||
|
||||
Color outline() => _m3e.colors.outline;
|
||||
|
||||
double elevation(ButtonM3EStyle style, Set<MaterialState> states) {
|
||||
final hovered = states.contains(MaterialState.hovered);
|
||||
final pressed = states.contains(MaterialState.pressed);
|
||||
final disabled = states.contains(MaterialState.disabled);
|
||||
if (disabled) return 0;
|
||||
switch (style) {
|
||||
case ButtonM3EStyle.elevated:
|
||||
return pressed ? 0 : hovered ? 3 : 1;
|
||||
case ButtonM3EStyle.filled:
|
||||
case ButtonM3EStyle.tonal:
|
||||
return pressed ? 0 : hovered ? 1 : 0;
|
||||
case ButtonM3EStyle.outlined:
|
||||
case ButtonM3EStyle.text:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
double squareRadius(ButtonM3ESize size) {
|
||||
switch (size) {
|
||||
case ButtonM3ESize.xs:
|
||||
return 8;
|
||||
case ButtonM3ESize.sm:
|
||||
return 8;
|
||||
case ButtonM3ESize.md:
|
||||
return 12;
|
||||
case ButtonM3ESize.lg:
|
||||
return 16;
|
||||
case ButtonM3ESize.xl:
|
||||
return 20;
|
||||
}
|
||||
}
|
||||
|
||||
double pressedRadius(ButtonM3ESize size) => (squareRadius(size) * 0.6).clamp(6, 18);
|
||||
|
||||
ButtonMeasurements measurements(ButtonM3ESize size) {
|
||||
switch (size) {
|
||||
case ButtonM3ESize.xs:
|
||||
return const ButtonMeasurements(height: 32, hPadding: 12, iconSize: 20, iconGap: 4);
|
||||
case ButtonM3ESize.sm:
|
||||
return ButtonMeasurements(
|
||||
height: 40,
|
||||
hPadding: smallPaddingDeprecated24 ? 24 : 16,
|
||||
iconSize: 20,
|
||||
iconGap: 8,
|
||||
);
|
||||
case ButtonM3ESize.md:
|
||||
return const ButtonMeasurements(height: 56, hPadding: 24, iconSize: 24, iconGap: 8);
|
||||
case ButtonM3ESize.lg:
|
||||
return const ButtonMeasurements(height: 96, hPadding: 48, iconSize: 32, iconGap: 12);
|
||||
case ButtonM3ESize.xl:
|
||||
return const ButtonMeasurements(height: 136, hPadding: 64, iconSize: 40, iconGap: 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
enum ButtonM3EVariant { filled, tonal, outlined, text, elevated }
|
||||
enum ButtonM3ESize { small, medium, large }
|
||||
enum ButtonM3EShapeFamily { round, square }
|
||||
enum ButtonM3EDensity { regular, compact }
|
||||
|
||||
enum ButtonM3EStyle { elevated, filled, tonal, outlined, text }
|
||||
enum ButtonM3ESize { xs, sm, md, lg, xl }
|
||||
enum ButtonM3EShape { round, square }
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
|
||||
name: button_m3e
|
||||
description: Material 3 Expressive Buttons for Flutter (filled, tonal, outlined, text, elevated) with M3E tokens.
|
||||
description: Material 3 Expressive Buttons for Flutter with 5 styles, 5 sizes, round/square shapes, and toggle selection.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.22.0"
|
||||
flutter: ">=3.19.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
# melos_managed_dependency_overrides: m3e_design
|
||||
dependency_overrides:
|
||||
m3e_design:
|
||||
path: ..\\m3e_design
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('placeholder', () {
|
||||
expect(1 + 1, 2);
|
||||
test('sanity', () {
|
||||
expect(2 + 2, 4);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Expressive emphasis tweaks layered on top of baseline M3 type.
|
||||
/// Keep line-height the same; only tune weight/letter-spacing for emphasis.
|
||||
@immutable
|
||||
class M3EEmphasized {
|
||||
final TextStyle display;
|
||||
|
|
@ -14,12 +16,24 @@ class M3EEmphasized {
|
|||
required this.label,
|
||||
});
|
||||
|
||||
/// M3E guidance: slightly heavier weights and tighter tracking for large roles.
|
||||
static M3EEmphasized forBrightness(Brightness b) {
|
||||
// You could vary by brightness if desired; values below are neutral.
|
||||
return const M3EEmphasized(
|
||||
display: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
|
||||
headline: TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.25),
|
||||
title: TextStyle(fontWeight: FontWeight.w700),
|
||||
label: TextStyle(fontWeight: FontWeight.w700),
|
||||
display: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5, // subtle tightening on big sizes
|
||||
),
|
||||
headline: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.25,
|
||||
),
|
||||
title: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
label: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +46,11 @@ class M3EEmphasized {
|
|||
);
|
||||
}
|
||||
|
||||
/// Material 3 Expressive typography tokens.
|
||||
/// - `base` starts from **M3 (Typography.material2021)**.
|
||||
/// - Optionally remaps fonts: brand (UI) for display/headline/title/label
|
||||
/// and plain (reading) for body.
|
||||
/// - Adds an emphasized set (weight/tracking tweaks) for expressive hierarchy.
|
||||
@immutable
|
||||
class M3ETypography {
|
||||
final TextTheme base;
|
||||
|
|
@ -39,16 +58,73 @@ class M3ETypography {
|
|||
|
||||
const M3ETypography({required this.base, required this.emphasized});
|
||||
|
||||
factory M3ETypography.defaultFor(Brightness b) {
|
||||
// Use a minimal baseline; app's ThemeData will provide fuller TextTheme.
|
||||
const textTheme = TextTheme();
|
||||
/// Build default M3E typography from M3.
|
||||
///
|
||||
/// [brandFontFamily] is typically your UI/brand face (e.g., Roboto Flex).
|
||||
/// [plainFontFamily] is typically your reading face (e.g., Roboto Serif).
|
||||
/// If you pass neither, you’ll get pure M3 defaults (no family swap),
|
||||
/// but still keep the M3E emphasized set for optional use.
|
||||
factory M3ETypography.defaultFor(
|
||||
Brightness brightness, {
|
||||
String? brandFontFamily,
|
||||
String? plainFontFamily,
|
||||
TextTheme? baseOverride,
|
||||
}) {
|
||||
// 1) Start from Material 3 baseline type.
|
||||
final m3 = Typography.material2021();
|
||||
final TextTheme m3Base =
|
||||
baseOverride ?? (brightness == Brightness.dark ? m3.white : m3.black);
|
||||
|
||||
// 2) Optionally map brand/plain families to role groups (M3E guidance).
|
||||
final TextTheme baseWithFamilies = _applyFamilies(
|
||||
m3Base,
|
||||
brand: brandFontFamily,
|
||||
plain: plainFontFamily,
|
||||
);
|
||||
|
||||
// 3) Provide emphasized deltas (weights/tracking).
|
||||
return M3ETypography(
|
||||
base: textTheme, emphasized: M3EEmphasized.forBrightness(b));
|
||||
base: baseWithFamilies,
|
||||
emphasized: M3EEmphasized.forBrightness(brightness),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lerp the full token set.
|
||||
static M3ETypography lerp(M3ETypography a, M3ETypography b, double t) =>
|
||||
M3ETypography(
|
||||
base: TextTheme.lerp(a.base, b.base, t),
|
||||
emphasized: M3EEmphasized.lerp(a.emphasized, b.emphasized, t),
|
||||
);
|
||||
|
||||
/// Apply brand/plain families: brand → display/headline/title/label,
|
||||
/// plain → body. If a family is null, keep the original.
|
||||
static TextTheme _applyFamilies(
|
||||
TextTheme t, {
|
||||
String? brand,
|
||||
String? plain,
|
||||
}) {
|
||||
TextStyle? _withFam(TextStyle? s, String? fam) =>
|
||||
fam == null ? s : s?.copyWith(fontFamily: fam);
|
||||
|
||||
return t.copyWith(
|
||||
// Brand / UI voice
|
||||
displayLarge: _withFam(t.displayLarge, brand),
|
||||
displayMedium: _withFam(t.displayMedium, brand),
|
||||
displaySmall: _withFam(t.displaySmall, brand),
|
||||
headlineLarge: _withFam(t.headlineLarge, brand),
|
||||
headlineMedium: _withFam(t.headlineMedium, brand),
|
||||
headlineSmall: _withFam(t.headlineSmall, brand),
|
||||
titleLarge: _withFam(t.titleLarge, brand),
|
||||
titleMedium: _withFam(t.titleMedium, brand),
|
||||
titleSmall: _withFam(t.titleSmall, brand),
|
||||
labelLarge: _withFam(t.labelLarge, brand),
|
||||
labelMedium: _withFam(t.labelMedium, brand),
|
||||
labelSmall: _withFam(t.labelSmall, brand),
|
||||
|
||||
// Reading voice
|
||||
bodyLarge: _withFam(t.bodyLarge, plain),
|
||||
bodyMedium: _withFam(t.bodyMedium, plain),
|
||||
bodySmall: _withFam(t.bodySmall, plain),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue