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,4 @@
library icon_button_m3e;
export 'src/enums.dart';
export 'src/icon_button_m3e.dart';

View file

@ -0,0 +1,122 @@
part of 'enums.dart';
/// All numeric tokens & constants for M3 Expressive IconButton.
/// No business logic herejust 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;
}

View file

@ -0,0 +1,86 @@
library m3e_iconbutton;
import 'package:flutter/material.dart';
part '_tokens_adapter.dart';
/// Visual scale labels (AE in the spec).
enum IconButtonM3ESize { xs, sm, md, lg, xl }
/// Width variants of the buttons 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);
}
}

View 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,
);
}
}