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
122
packages/icon_button_m3e/lib/src/_tokens_adapter.dart
Normal file
122
packages/icon_button_m3e/lib/src/_tokens_adapter.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
part of 'enums.dart';
|
||||
|
||||
/// All numeric tokens & constants for M3 Expressive IconButton.
|
||||
/// No business logic here—just data.
|
||||
class IconButtonM3ETokens {
|
||||
const IconButtonM3ETokens._();
|
||||
|
||||
// ----------------------------
|
||||
// Icon glyph sizes (dp)
|
||||
// ----------------------------
|
||||
static const Map<IconButtonM3ESize, double> icon = {
|
||||
IconButtonM3ESize.xs: 20.0, // A
|
||||
IconButtonM3ESize.sm: 24.0, // B
|
||||
IconButtonM3ESize.md: 24.0, // C
|
||||
IconButtonM3ESize.lg: 32.0, // D
|
||||
IconButtonM3ESize.xl: 40.0, // E
|
||||
};
|
||||
|
||||
// ----------------------------
|
||||
// Visual container sizes (dp)
|
||||
// width × height
|
||||
// ----------------------------
|
||||
static const Map<IconButtonM3ESize, Map<IconButtonM3EWidth, Size>> visual = {
|
||||
IconButtonM3ESize.xs: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(32, 32),
|
||||
IconButtonM3EWidth.narrow: Size(28, 32),
|
||||
IconButtonM3EWidth.wide: Size(40, 32),
|
||||
},
|
||||
IconButtonM3ESize.sm: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(40, 40),
|
||||
IconButtonM3EWidth.narrow: Size(32, 40),
|
||||
IconButtonM3EWidth.wide: Size(52, 40),
|
||||
},
|
||||
IconButtonM3ESize.md: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(56, 56),
|
||||
IconButtonM3EWidth.narrow: Size(48, 56),
|
||||
IconButtonM3EWidth.wide: Size(72, 56),
|
||||
},
|
||||
IconButtonM3ESize.lg: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(96, 96),
|
||||
IconButtonM3EWidth.narrow: Size(64, 96),
|
||||
IconButtonM3EWidth.wide: Size(128, 96),
|
||||
},
|
||||
IconButtonM3ESize.xl: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(136, 136),
|
||||
IconButtonM3EWidth.narrow: Size(104, 136),
|
||||
IconButtonM3EWidth.wide: Size(184, 136),
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------
|
||||
// Minimum interactive target sizes (dp)
|
||||
// XS/SM must be ≥48×48 (SM wide = 52×48); others equal visual.
|
||||
// ----------------------------
|
||||
static const Map<IconButtonM3ESize, Map<IconButtonM3EWidth, Size>> target = {
|
||||
IconButtonM3ESize.xs: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(48, 48),
|
||||
IconButtonM3EWidth.narrow: Size(48, 48),
|
||||
IconButtonM3EWidth.wide: Size(48, 48),
|
||||
},
|
||||
IconButtonM3ESize.sm: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(48, 48),
|
||||
IconButtonM3EWidth.narrow: Size(48, 48),
|
||||
IconButtonM3EWidth.wide: Size(52, 48),
|
||||
},
|
||||
// MD/LG/XL already meet or exceed 48×48 – use visual sizes as targets.
|
||||
IconButtonM3ESize.md: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(56, 56),
|
||||
IconButtonM3EWidth.narrow: Size(48, 56),
|
||||
IconButtonM3EWidth.wide: Size(72, 56),
|
||||
},
|
||||
IconButtonM3ESize.lg: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(96, 96),
|
||||
IconButtonM3EWidth.narrow: Size(64, 96),
|
||||
IconButtonM3EWidth.wide: Size(128, 96),
|
||||
},
|
||||
IconButtonM3ESize.xl: {
|
||||
IconButtonM3EWidth.defaultWidth: Size(136, 136),
|
||||
IconButtonM3EWidth.narrow: Size(104, 136),
|
||||
IconButtonM3EWidth.wide: Size(184, 136),
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------
|
||||
// Corner radii (dp)
|
||||
// Pressed radius is shared by both variants at the same size and
|
||||
// is more square than the square resting radius.
|
||||
// Values are consistent, scalable defaults; tune to match your spec.
|
||||
// ----------------------------
|
||||
static const Map<IconButtonM3ESize, double> radiusRestRound = {
|
||||
// Half of the default height → circular/pill look
|
||||
IconButtonM3ESize.xs: 16.0, // 32/2
|
||||
IconButtonM3ESize.sm: 20.0, // 40/2
|
||||
IconButtonM3ESize.md: 28.0, // 56/2
|
||||
IconButtonM3ESize.lg: 48.0, // 96/2
|
||||
IconButtonM3ESize.xl: 68.0, // 136/2
|
||||
};
|
||||
|
||||
static const Map<IconButtonM3ESize, double> radiusRestSquare = {
|
||||
// Rounded-square feel (~25% of height)
|
||||
IconButtonM3ESize.xs: 8.0, // ≈32*0.25
|
||||
IconButtonM3ESize.sm: 10.0, // ≈40*0.25
|
||||
IconButtonM3ESize.md: 14.0, // ≈56*0.25
|
||||
IconButtonM3ESize.lg: 24.0, // ≈96*0.25
|
||||
IconButtonM3ESize.xl: 34.0, // ≈136*0.25
|
||||
};
|
||||
|
||||
static const Map<IconButtonM3ESize, double> radiusPressed = {
|
||||
// More square than the square resting radius (~20% of height)
|
||||
IconButtonM3ESize.xs: 6.0, // ≈32*0.20
|
||||
IconButtonM3ESize.sm: 8.0, // ≈40*0.20
|
||||
IconButtonM3ESize.md: 11.0, // ≈56*0.20
|
||||
IconButtonM3ESize.lg: 19.0, // ≈96*0.20
|
||||
IconButtonM3ESize.xl: 27.0, // ≈136*0.20
|
||||
};
|
||||
|
||||
// ----------------------------
|
||||
// Motion tokens for shape morph (optional, but handy)
|
||||
// ----------------------------
|
||||
static const Duration morphDuration = Duration(milliseconds: 120);
|
||||
static const Curve morphCurve = Curves.easeOut;
|
||||
}
|
||||
86
packages/icon_button_m3e/lib/src/enums.dart
Normal file
86
packages/icon_button_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
library m3e_iconbutton;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
part '_tokens_adapter.dart';
|
||||
|
||||
/// Visual scale labels (A–E in the spec).
|
||||
enum IconButtonM3ESize { xs, sm, md, lg, xl }
|
||||
|
||||
/// Width variants of the button’s container (not the icon glyph).
|
||||
enum IconButtonM3EWidth { defaultWidth, narrow, wide }
|
||||
|
||||
/// The two resting shape variants.
|
||||
enum IconButtonM3EShapeVariant { round, square }
|
||||
|
||||
/// Visual variants (kept from previous API).
|
||||
enum IconButtonM3EVariant { standard, filled, tonal, outlined }
|
||||
|
||||
/// Icon glyph size inside the button (reads tokens).
|
||||
extension IconM3EGlyph on IconButtonM3ESize {
|
||||
double get icon => IconButtonM3ETokens.icon[this]!;
|
||||
}
|
||||
|
||||
/// Visual (painted) size & target size helpers (read tokens).
|
||||
extension IconButtonM3ESizes on IconButtonM3ESize {
|
||||
Size visual(IconButtonM3EWidth width) =>
|
||||
IconButtonM3ETokens.visual[this]![width]!;
|
||||
|
||||
Size target(IconButtonM3EWidth width) =>
|
||||
IconButtonM3ETokens.target[this]![width]!;
|
||||
|
||||
Size get defaultSize => visual(IconButtonM3EWidth.defaultWidth);
|
||||
Size get narrowSize => visual(IconButtonM3EWidth.narrow);
|
||||
Size get wideSize => visual(IconButtonM3EWidth.wide);
|
||||
}
|
||||
|
||||
/// Shape resolution helpers: resting/pressed radii and toggle behavior.
|
||||
class IconButtonM3EShapes {
|
||||
const IconButtonM3EShapes._();
|
||||
|
||||
static IconButtonM3EShapeVariant restVariant({
|
||||
required bool isToggle,
|
||||
required bool isSelected,
|
||||
required IconButtonM3EShapeVariant baseVariant,
|
||||
}) {
|
||||
if (isToggle && isSelected) {
|
||||
return baseVariant == IconButtonM3EShapeVariant.round
|
||||
? IconButtonM3EShapeVariant.square
|
||||
: IconButtonM3EShapeVariant.round;
|
||||
}
|
||||
return baseVariant;
|
||||
}
|
||||
|
||||
static double restingRadius({
|
||||
required IconButtonM3ESize size,
|
||||
required IconButtonM3EShapeVariant variant,
|
||||
}) {
|
||||
return switch (variant) {
|
||||
IconButtonM3EShapeVariant.round =>
|
||||
IconButtonM3ETokens.radiusRestRound[size]!,
|
||||
IconButtonM3EShapeVariant.square =>
|
||||
IconButtonM3ETokens.radiusRestSquare[size]!,
|
||||
};
|
||||
}
|
||||
|
||||
/// Effective corner radius for the given material states.
|
||||
/// Hover does not change the radius; Pressed uses the shared pressed radius.
|
||||
static double effectiveRadius({
|
||||
required IconButtonM3ESize size,
|
||||
required IconButtonM3EShapeVariant baseVariant,
|
||||
required bool isToggle,
|
||||
required bool isSelected,
|
||||
required Set<WidgetState> states,
|
||||
}) {
|
||||
final variant = restVariant(
|
||||
isToggle: isToggle,
|
||||
isSelected: isSelected,
|
||||
baseVariant: baseVariant,
|
||||
);
|
||||
|
||||
if (states.contains(WidgetState.pressed)) {
|
||||
return IconButtonM3ETokens.radiusPressed[size]!;
|
||||
}
|
||||
return restingRadius(size: size, variant: variant);
|
||||
}
|
||||
}
|
||||
139
packages/icon_button_m3e/lib/src/icon_button_m3e.dart
Normal file
139
packages/icon_button_m3e/lib/src/icon_button_m3e.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
/// Material 3 Expressive Icon Button
|
||||
///
|
||||
/// - Visual sizes are defined by [IconButtonM3ETokens.visual] (per size × width)
|
||||
/// - Tap target respects [IconButtonM3ETokens.target] with a minimum of 48×48 on XS/SM
|
||||
/// - Variants: standard, filled, tonal, outlined
|
||||
/// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected.
|
||||
/// - Widths: default, narrow, wide
|
||||
/// - Toggle: [isSelected] + [selectedIcon]
|
||||
class IconButtonM3E extends StatelessWidget {
|
||||
const IconButtonM3E({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
this.tooltip,
|
||||
this.semanticLabel,
|
||||
this.variant = IconButtonM3EVariant.standard,
|
||||
this.size = IconButtonM3ESize.sm,
|
||||
this.shape = IconButtonM3EShapeVariant.round,
|
||||
this.width = IconButtonM3EWidth.defaultWidth,
|
||||
this.isSelected,
|
||||
this.selectedIcon,
|
||||
this.enableFeedback,
|
||||
});
|
||||
|
||||
final Widget icon;
|
||||
final VoidCallback? onPressed;
|
||||
final String? tooltip;
|
||||
final String? semanticLabel;
|
||||
final IconButtonM3EVariant variant;
|
||||
final IconButtonM3ESize size;
|
||||
final IconButtonM3EShapeVariant shape;
|
||||
final IconButtonM3EWidth width;
|
||||
final bool? isSelected;
|
||||
final Widget? selectedIcon;
|
||||
final bool? enableFeedback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
final Size visual = size.visual(width);
|
||||
final Size target = size.target(width);
|
||||
final double iconPx = size.icon;
|
||||
|
||||
final bool selected = isSelected ?? false;
|
||||
// Consider it a toggle control if selection can be represented.
|
||||
final bool isToggle = isSelected != null || selectedIcon != null;
|
||||
|
||||
// Colors per variant (selected tint for standard).
|
||||
Color bg;
|
||||
Color fg;
|
||||
BorderSide? side;
|
||||
switch (variant) {
|
||||
case IconButtonM3EVariant.standard:
|
||||
bg = Colors.transparent;
|
||||
fg = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||
side = null;
|
||||
break;
|
||||
case IconButtonM3EVariant.filled:
|
||||
bg = scheme.primary;
|
||||
fg = scheme.onPrimary;
|
||||
side = null;
|
||||
break;
|
||||
case IconButtonM3EVariant.tonal:
|
||||
bg = scheme.secondaryContainer;
|
||||
fg = scheme.onSecondaryContainer;
|
||||
side = null;
|
||||
break;
|
||||
case IconButtonM3EVariant.outlined:
|
||||
bg = Colors.transparent;
|
||||
fg = scheme.primary;
|
||||
side = BorderSide(color: scheme.outline, width: 1);
|
||||
break;
|
||||
}
|
||||
|
||||
// Resolve shape radius based on states (pressed) and toggle/selection.
|
||||
OutlinedBorder shapeFor(Set<WidgetState> states) {
|
||||
final r = IconButtonM3EShapes.effectiveRadius(
|
||||
size: size,
|
||||
baseVariant: shape,
|
||||
isToggle: isToggle,
|
||||
isSelected: selected,
|
||||
states: states,
|
||||
);
|
||||
return RoundedRectangleBorder(borderRadius: BorderRadius.circular(r));
|
||||
}
|
||||
|
||||
final Widget innerIcon = IconTheme.merge(
|
||||
data: IconThemeData(size: iconPx, color: fg),
|
||||
child: (selected && selectedIcon != null) ? selectedIcon! : icon,
|
||||
);
|
||||
|
||||
final Widget button = IconButton(
|
||||
onPressed: onPressed,
|
||||
isSelected: isSelected,
|
||||
selectedIcon: selectedIcon,
|
||||
icon: innerIcon,
|
||||
tooltip: tooltip,
|
||||
enableFeedback: enableFeedback,
|
||||
style: ButtonStyle(
|
||||
// Visual (painted) size
|
||||
fixedSize: WidgetStateProperty.all(visual),
|
||||
padding: WidgetStateProperty.all(EdgeInsets.zero),
|
||||
shape: WidgetStateProperty.resolveWith(shapeFor),
|
||||
backgroundColor: WidgetStateProperty.all(bg),
|
||||
foregroundColor: WidgetStateProperty.resolveWith((_) => fg),
|
||||
side: WidgetStateProperty.resolveWith((_) => side),
|
||||
// Animate pressed shape morph a bit.
|
||||
animationDuration: IconButtonM3ETokens.morphDuration,
|
||||
visualDensity: VisualDensity.standard,
|
||||
),
|
||||
);
|
||||
|
||||
// Compose into an outer box sized to the minimum interactive target.
|
||||
final Widget core = SizedBox(
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: visual.width,
|
||||
height: visual.height,
|
||||
child: button,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final semanticsText = semanticLabel ?? tooltip;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: semanticsText,
|
||||
child: core,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue