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 @@
enum ToolbarM3ESize { small, medium, large }
enum ToolbarM3EShapeFamily { round, square }
enum ToolbarM3EDensity { regular, compact }
enum ToolbarM3EVariant { surface, tonal, primary }

View file

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class ToolbarActionM3E {
const ToolbarActionM3E({
required this.icon,
required this.onPressed,
this.tooltip,
this.semanticLabel,
this.enabled = true,
this.label, // used in overflow menu
this.isDestructive = false,
});
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
final String? semanticLabel;
final bool enabled;
/// Optional label used in the overflow menu; if null, tooltip or semanticLabel will be used.
final String? label;
/// If true, the action is styled as destructive in overflow (e.g., error color).
final bool isDestructive;
}
class ToolbarIconButtonM3E extends StatelessWidget {
const ToolbarIconButtonM3E({
super.key,
required this.action,
this.color,
this.iconSize,
});
final ToolbarActionM3E action;
final Color? color;
final double? iconSize;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: action.enabled ? action.onPressed : null,
tooltip: action.tooltip ?? action.label,
icon: Icon(action.icon),
color: color,
iconSize: iconSize,
);
}
}

View file

@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'toolbar_tokens_adapter.dart';
import 'toolbar_action_m3e.dart';
class ToolbarM3E extends StatelessWidget implements PreferredSizeWidget {
const ToolbarM3E({
super.key,
this.leading,
this.title,
this.titleText,
this.subtitle,
this.subtitleText,
this.actions = const <ToolbarActionM3E>[],
this.maxInlineActions = 4,
this.overflowIcon = const Icon(Icons.more_vert),
this.centerTitle = false,
this.variant = ToolbarM3EVariant.surface,
this.size = ToolbarM3ESize.medium,
this.density = ToolbarM3EDensity.regular,
this.shapeFamily = ToolbarM3EShapeFamily.round,
this.backgroundColor,
this.foregroundColor,
this.elevation,
this.padding,
this.safeArea = true,
this.clipBehavior = Clip.none,
this.semanticLabel,
});
final Widget? leading;
final Widget? title;
final String? titleText;
final Widget? subtitle;
final String? subtitleText;
final List<ToolbarActionM3E> actions;
final int maxInlineActions;
final Widget overflowIcon;
final bool centerTitle;
final ToolbarM3EVariant variant;
final ToolbarM3ESize size;
final ToolbarM3EDensity density;
final ToolbarM3EShapeFamily shapeFamily;
final Color? backgroundColor;
final Color? foregroundColor;
final double? elevation;
final EdgeInsetsGeometry? padding;
final bool safeArea;
final Clip clipBehavior;
final String? semanticLabel;
@override
Size get preferredSize {
// A rough default; actual height is resolved at build based on size/density.
switch (size) {
case ToolbarM3ESize.small: return const Size.fromHeight(40);
case ToolbarM3ESize.medium: return const Size.fromHeight(48);
case ToolbarM3ESize.large: return const Size.fromHeight(56);
}
}
@override
Widget build(BuildContext context) {
final tokens = ToolbarTokensAdapter(context);
final metrics = tokens.metrics(density);
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final height = switch (size) {
ToolbarM3ESize.small => metrics.heightSmall,
ToolbarM3ESize.medium => metrics.heightMedium,
ToolbarM3ESize.large => metrics.heightLarge,
};
final bg = backgroundColor ?? tokens.containerColor(variant);
final fg = foregroundColor ?? tokens.foregroundOn(variant);
final shape = tokens.shape(shapeFamily);
final pad = padding ?? metrics.horizontalPadding;
final resolvedTitle = title ??
(titleText != null
? Text(titleText!, style: tokens.titleStyle().copyWith(color: fg), overflow: TextOverflow.ellipsis)
: null);
final resolvedSubtitle = subtitle ??
(subtitleText != null
? Text(subtitleText!, style: tokens.subtitleStyle().copyWith(color: fg.withValues(alpha: 0.8)), overflow: TextOverflow.ellipsis)
: null);
final toolbarRow = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading != null) leading!,
if (leading != null) SizedBox(width: metrics.gap),
Expanded(
child: _TitleBlock(
title: resolvedTitle,
subtitle: resolvedSubtitle,
center: centerTitle,
),
),
SizedBox(width: metrics.gap),
_ActionsRow(
actions: actions,
maxInline: maxInlineActions,
overflowIcon: overflowIcon,
iconColor: fg,
iconSize: metrics.iconSize,
m3e: m3e,
),
],
);
final bar = Material(
color: bg,
elevation: elevation ?? (variant == ToolbarM3EVariant.surface ? metrics.elevationSurface : metrics.elevationProminent),
shape: shape,
clipBehavior: clipBehavior,
child: SizedBox(
height: height,
child: Padding(
padding: pad,
child: IconTheme.merge(
data: IconThemeData(color: fg, size: metrics.iconSize),
child: DefaultTextStyle.merge(style: TextStyle(color: fg), child: toolbarRow),
),
),
),
);
final content = safeArea ? SafeArea(top: false, left: false, right: false, bottom: false, child: bar) : bar;
if (semanticLabel == null) return content;
return Semantics(container: true, label: semanticLabel!, child: content);
}
}
class _TitleBlock extends StatelessWidget {
const _TitleBlock({required this.title, required this.subtitle, required this.center});
final Widget? title;
final Widget? subtitle;
final bool center;
@override
Widget build(BuildContext context) {
if (title == null && subtitle == null) return const SizedBox.shrink();
final col = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: center ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
if (title != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.titleSmall!, child: title!),
if (subtitle != null) DefaultTextStyle.merge(style: Theme.of(context).textTheme.bodySmall!, child: subtitle!),
],
);
if (center) {
return Align(alignment: Alignment.center, child: col);
}
return col;
}
}
class _ActionsRow extends StatelessWidget {
const _ActionsRow({
required this.actions,
required this.maxInline,
required this.overflowIcon,
required this.iconColor,
required this.iconSize,
required this.m3e,
});
final List<ToolbarActionM3E> actions;
final int maxInline;
final Widget overflowIcon;
final Color iconColor;
final double iconSize;
final M3ETheme m3e;
@override
Widget build(BuildContext context) {
if (actions.isEmpty) return const SizedBox.shrink();
final inline = actions.take(maxInline).toList(growable: false);
final overflow = actions.length > maxInline ? actions.sublist(maxInline) : const <ToolbarActionM3E>[];
final row = Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final a in inline) ToolbarIconButtonM3E(action: a, color: iconColor, iconSize: iconSize),
if (overflow.isNotEmpty)
_OverflowMenu(
actions: overflow,
icon: overflowIcon,
textStyle: Theme.of(context).textTheme.labelLarge?.copyWith(color: m3e.colors.onSurface),
destructiveColor: m3e.colors.error,
),
],
);
return row;
}
}
class _OverflowMenu extends StatelessWidget {
const _OverflowMenu({
required this.actions,
required this.icon,
this.textStyle,
this.destructiveColor,
});
final List<ToolbarActionM3E> actions;
final Widget icon;
final TextStyle? textStyle;
final Color? destructiveColor;
@override
Widget build(BuildContext context) {
return PopupMenuButton<int>(
tooltip: 'More options',
itemBuilder: (context) => [
for (var i = 0; i < actions.length; i++)
PopupMenuItem<int>(
value: i,
enabled: actions[i].enabled,
child: DefaultTextStyle.merge(
style: (actions[i].isDestructive
? (textStyle?.copyWith(color: destructiveColor) ?? TextStyle(color: destructiveColor))
: textStyle) ??
const TextStyle(),
child: Text(actions[i].label ?? actions[i].tooltip ?? actions[i].semanticLabel ?? 'Action ${i + 1}'),
),
),
],
onSelected: (index) => actions[index].onPressed(),
child: icon,
);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class ToolbarM3EWidget extends StatelessWidget {
const ToolbarM3EWidget({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('Toolbar placeholder', style: m3e.typography.base.titleMedium),
);
}
}
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _ToolbarMetrics {
final double heightSmall;
final double heightMedium;
final double heightLarge;
final EdgeInsetsGeometry horizontalPadding;
final double gap;
final double iconSize;
final double elevationSurface;
final double elevationProminent;
const _ToolbarMetrics({
required this.heightSmall,
required this.heightMedium,
required this.heightLarge,
required this.horizontalPadding,
required this.gap,
required this.iconSize,
required this.elevationSurface,
required this.elevationProminent,
});
}
_ToolbarMetrics _metricsFor(BuildContext context, ToolbarM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double hS = 40;
double hM = 48;
double hL = 56;
double icon = 24;
double gap = sp.sm;
if (density == ToolbarM3EDensity.compact) {
hS -= 4; hM -= 4; hL -= 4;
}
return _ToolbarMetrics(
heightSmall: hS,
heightMedium: hM,
heightLarge: hL,
horizontalPadding: EdgeInsets.symmetric(horizontal: sp.md),
gap: gap,
iconSize: icon,
elevationSurface: 0.0,
elevationProminent: 2.0,
);
}
class ToolbarTokensAdapter {
ToolbarTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_ToolbarMetrics metrics(ToolbarM3EDensity density) => _metricsFor(context, density);
// Container/background color by variant
Color containerColor(ToolbarM3EVariant variant) {
switch (variant) {
case ToolbarM3EVariant.surface: return _m3e.colors.surfaceContainerHigh;
case ToolbarM3EVariant.tonal: return _m3e.colors.secondaryContainer;
case ToolbarM3EVariant.primary: return _m3e.colors.primaryContainer;
}
}
Color foregroundOn(ToolbarM3EVariant variant) {
switch (variant) {
case ToolbarM3EVariant.surface: return _m3e.colors.onSurface;
case ToolbarM3EVariant.tonal: return _m3e.colors.onSecondaryContainer;
case ToolbarM3EVariant.primary: return _m3e.colors.onPrimaryContainer;
}
}
// Shapes
ShapeBorder shape(ToolbarM3EShapeFamily family) {
final set = family == ToolbarM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
return RoundedRectangleBorder(borderRadius: set.md);
}
// Typography
TextStyle titleStyle() => _m3e.type.titleSmall;
TextStyle subtitleStyle() => _m3e.type.bodySmall;
}

View file

@ -0,0 +1,6 @@
library toolbar_m3e;
export 'src/enums.dart';
export 'src/toolbar_tokens_adapter.dart' show ToolbarTokensAdapter;
export 'src/toolbar_action_m3e.dart';
export 'src/toolbar_m3e.dart';