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,5 @@
library app_bar_m3e;
export 'src/app_bar_m3e_enums.dart';
export 'src/app_bar_m3e_widget.dart';
export 'src/sliver_app_bar_m3e.dart';

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'app_bar_m3e_enums.dart';
@immutable
class _AppBarMetrics {
final double smallHeight;
final double collapsedHeight;
final double mediumExpanded;
final double largeExpanded;
final EdgeInsetsGeometry horizontalPadding;
final double iconSize;
final double elevation;
const _AppBarMetrics({
required this.smallHeight,
required this.collapsedHeight,
required this.mediumExpanded,
required this.largeExpanded,
required this.horizontalPadding,
required this.iconSize,
required this.elevation,
});
}
_AppBarMetrics metricsFor(BuildContext context, AppBarM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
// Heights (approx per M3 specs; can be tuned via Theme extension in m3e_design if desired)
double small = 64;
double collapsed = 64;
double medium = 112;
double large = 152;
// Density tweaks
if (density == AppBarM3EDensity.compact) {
small -= 8;
collapsed -= 8;
medium -= 8;
large -= 8;
}
return _AppBarMetrics(
smallHeight: small,
collapsedHeight: collapsed,
mediumExpanded: medium,
largeExpanded: large,
horizontalPadding: EdgeInsets.symmetric(horizontal: sp.md),
iconSize: 24,
elevation: 0.0,
);
}
Color backgroundFor(BuildContext context) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
// Prefer container surfaces for bars
return m3e.colors.surfaceContainerHigh;
}
TextStyle titleStyleFor(BuildContext context, {bool collapsed = true}) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
return collapsed ? m3e.type.titleLarge : m3e.type.headlineSmallEmphasized;
}
ShapeBorder shapeFor(BuildContext context, AppBarM3EShapeFamily family) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final set = family == AppBarM3EShapeFamily.round ? m3e.shapes.round : m3e.shapes.square;
// Use medium size radius for the bar container by default
return RoundedRectangleBorder(borderRadius: set.md);
}

View file

@ -0,0 +1,3 @@
enum AppBarM3EVariant { small, medium, large }
enum AppBarM3EShapeFamily { round, square }
enum AppBarM3EDensity { regular, compact }

View file

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '_tokens_adapter.dart';
import 'app_bar_m3e_enums.dart';
class AppBarM3E extends StatelessWidget implements PreferredSizeWidget {
const AppBarM3E({
super.key,
this.leading,
this.title,
this.titleText,
this.actions,
this.centerTitle = false,
this.backgroundColor,
this.foregroundColor,
this.elevation,
this.shapeFamily = AppBarM3EShapeFamily.round,
this.density = AppBarM3EDensity.regular,
this.toolbarHeight,
this.automaticallyImplyLeading = true,
this.clipBehavior = Clip.none,
this.semanticLabel,
});
final Widget? leading;
final Widget? title;
final String? titleText;
final List<Widget>? actions;
final bool centerTitle;
final Color? backgroundColor;
final Color? foregroundColor;
final double? elevation;
final AppBarM3EShapeFamily shapeFamily;
final AppBarM3EDensity density;
final double? toolbarHeight;
final bool automaticallyImplyLeading;
final Clip clipBehavior;
final String? semanticLabel;
@override
Size get preferredSize {
// Provide a reasonable non-null size; actual height applied in build.
return Size.fromHeight(toolbarHeight ?? 64);
}
@override
Widget build(BuildContext context) {
final metrics = metricsFor(context, density);
final bg = backgroundColor ?? backgroundFor(context);
final fg = foregroundColor ?? Theme.of(context).colorScheme.onSurface;
final shape = shapeFor(context, shapeFamily);
final height = toolbarHeight ?? metrics.smallHeight;
final tStyle = titleStyleFor(context, collapsed: true);
final resolvedLeading = leading ?? (automaticallyImplyLeading
? _maybeBackButton(context, fg)
: null);
final resolvedTitle = title ??
(titleText != null
? Text(titleText!, style: tStyle, overflow: TextOverflow.ellipsis)
: null);
final bar = Material(
color: bg,
elevation: elevation ?? metrics.elevation,
shape: shape,
clipBehavior: clipBehavior,
child: SafeArea(
bottom: false,
child: SizedBox(
height: height,
child: Padding(
padding: metrics.horizontalPadding,
child: IconTheme.merge(
data: IconThemeData(size: metrics.iconSize, color: fg),
child: DefaultTextStyle(
style: tStyle.copyWith(color: fg),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (resolvedLeading != null) resolvedLeading,
if (resolvedLeading != null) const SizedBox(width: 8),
if (resolvedTitle != null)
Expanded(
child: Align(
alignment: centerTitle ? Alignment.center : Alignment.centerLeft,
child: resolvedTitle,
),
)
else
const Spacer(),
if (actions != null) ...[
const SizedBox(width: 8),
..._withSpacers(actions!),
],
],
),
),
),
),
),
),
);
if (semanticLabel == null) return bar;
return Semantics(
container: true,
label: semanticLabel,
child: bar,
);
}
List<Widget> _withSpacers(List<Widget> items) {
final out = <Widget>[];
for (var i = 0; i < items.length; i++) {
out.add(items[i]);
if (i < items.length - 1) out.add(const SizedBox(width: 4));
}
return out;
}
Widget? _maybeBackButton(BuildContext context, Color fg) {
final canPop = Navigator.maybeOf(context)?.canPop() ?? false;
if (!canPop) return null;
return IconButton(
icon: const BackButtonIcon(),
color: fg,
onPressed: () => Navigator.maybeOf(context)?.maybePop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
}

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderObject, RenderProxySliver;
import 'package:flutter/semantics.dart' show SemanticsConfiguration;
import '_tokens_adapter.dart';
import 'app_bar_m3e_enums.dart';
class SliverAppBarM3E extends StatelessWidget {
const SliverAppBarM3E({
super.key,
this.leading,
this.title,
this.titleText,
this.actions,
this.centerTitle = false,
this.backgroundColor,
this.foregroundColor,
this.pinned = true,
this.floating = false,
this.snap = false,
this.shapeFamily = AppBarM3EShapeFamily.round,
this.density = AppBarM3EDensity.regular,
this.variant = AppBarM3EVariant.medium,
this.semanticLabel,
});
final Widget? leading;
final Widget? title;
final String? titleText;
final List<Widget>? actions;
final bool centerTitle;
final Color? backgroundColor;
final Color? foregroundColor;
final bool pinned;
final bool floating;
final bool snap;
final AppBarM3EShapeFamily shapeFamily;
final AppBarM3EDensity density;
final AppBarM3EVariant variant;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final metrics = metricsFor(context, density);
final bg = backgroundColor ?? backgroundFor(context);
final fg = foregroundColor ?? Theme.of(context).colorScheme.onSurface;
final shape = shapeFor(context, shapeFamily);
final collapsedStyle = titleStyleFor(context, collapsed: true);
final expandedStyle = titleStyleFor(context, collapsed: false);
final collapsed = metrics.collapsedHeight;
final expanded = switch (variant) {
AppBarM3EVariant.medium => metrics.mediumExpanded,
AppBarM3EVariant.large => metrics.largeExpanded,
AppBarM3EVariant.small => metrics.smallHeight,
};
final resolvedTitleWidget = title ??
(titleText != null
? Text(titleText!,
style: collapsedStyle, overflow: TextOverflow.ellipsis)
: null);
final bar = SliverAppBar(
pinned: pinned,
floating: floating,
snap: snap && floating,
backgroundColor: bg,
foregroundColor: fg,
collapsedHeight: collapsed,
expandedHeight: expanded,
centerTitle: centerTitle,
leading: leading,
title: resolvedTitleWidget,
actions: actions,
shape: shape,
flexibleSpace: _buildFlexibleSpace(context, expandedStyle),
);
if (semanticLabel == null) return bar;
return SliverSemantic(
label: semanticLabel!,
child: bar,
);
}
Widget? _buildFlexibleSpace(BuildContext context, TextStyle expandedStyle) {
switch (variant) {
case AppBarM3EVariant.small:
return null;
case AppBarM3EVariant.medium:
case AppBarM3EVariant.large:
final t = title ??
(titleText != null ? Text(titleText!, style: expandedStyle) : null);
if (t == null) return null;
return FlexibleSpaceBar(
titlePadding:
const EdgeInsetsDirectional.only(start: 16, bottom: 16, end: 16),
title: DefaultTextStyle(
style: expandedStyle.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
child: t,
),
collapseMode: CollapseMode.pin,
expandedTitleScale:
1.0, // Typography already larger; avoid scale morph
);
}
}
}
/// A helper to wrap a sliver with semantics label.
class SliverSemantic extends SingleChildRenderObjectWidget {
const SliverSemantic({super.key, required this.label, required Widget child})
: super(child: child);
final String label;
@override
RenderObject createRenderObject(BuildContext context) =>
_SliverSemanticRender(label);
@override
void updateRenderObject(
BuildContext context, covariant _SliverSemanticRender renderObject) {
renderObject.label = label;
}
}
class _SliverSemanticRender extends RenderProxySliver {
_SliverSemanticRender(this._label);
String _label;
set label(String v) {
if (v == _label) return;
_label = v;
markNeedsSemanticsUpdate();
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.label = _label;
config.isSemanticBoundary = true;
}
}