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
4
packages/toolbar_m3e/lib/src/enums.dart
Normal file
4
packages/toolbar_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
enum ToolbarM3ESize { small, medium, large }
|
||||
enum ToolbarM3EShapeFamily { round, square }
|
||||
enum ToolbarM3EDensity { regular, compact }
|
||||
enum ToolbarM3EVariant { surface, tonal, primary }
|
||||
49
packages/toolbar_m3e/lib/src/toolbar_action_m3e.dart
Normal file
49
packages/toolbar_m3e/lib/src/toolbar_action_m3e.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
244
packages/toolbar_m3e/lib/src/toolbar_m3e.dart
Normal file
244
packages/toolbar_m3e/lib/src/toolbar_m3e.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
21
packages/toolbar_m3e/lib/src/toolbar_m3e_widget.dart
Normal file
21
packages/toolbar_m3e/lib/src/toolbar_m3e_widget.dart
Normal 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();
|
||||
91
packages/toolbar_m3e/lib/src/toolbar_tokens_adapter.dart
Normal file
91
packages/toolbar_m3e/lib/src/toolbar_tokens_adapter.dart
Normal 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;
|
||||
}
|
||||
6
packages/toolbar_m3e/lib/toolbar_m3e.dart
Normal file
6
packages/toolbar_m3e/lib/toolbar_m3e.dart
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue