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,90 @@
# app_bar_m3e
Expressive **App Bar** for Flutter (Material 3 Expressive).
Small (standard) bar for `Scaffold.appBar`, plus **Medium** & **Large** collapsing variants via a sliver.
> Uses `m3e_design` tokens for color, typography, spacing, and shapes.
## Monorepo Setup
Place alongside `m3e_design` in your repo:
```
packages/
m3e_design/
app_bar_m3e/
```
`pubspec.yaml` references `../m3e_design` by default.
## API
### Small App Bar
```dart
AppBarM3E(
leading: IconButton(icon: const BackButtonIcon(), onPressed: () {}),
titleText: 'Inbox',
actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
centerTitle: false,
shapeFamily: AppBarM3EShapeFamily.round,
density: AppBarM3EDensity.regular,
);
```
Use in a `Scaffold`:
```dart
Scaffold(
appBar: const AppBarM3E(titleText: 'Inbox'),
body: ...
);
```
### Sliver App Bar (Medium / Large)
```dart
CustomScrollView(
slivers: [
SliverAppBarM3E(
variant: AppBarM3EVariant.large,
titleText: 'Gallery',
pinned: true,
),
// ... content slivers
],
);
```
- `variant: medium` uses expanded height ≈112dp (collapses to ~64dp).
- `variant: large` uses expanded height ≈152dp (collapses to ~64dp).
- Colors, shapes, and typography come from `m3e_design`'s `M3ETheme` extension.
## Theme Integration
`app_bar_m3e` reads the `M3ETheme` extension from your `ThemeData`:
```dart
final m3e = Theme.of(context).extension<M3ETheme>() ??
M3ETheme.defaults(Theme.of(context).colorScheme);
```
It uses:
- `m3e.colors.surfaceContainerHigh` for background
- `m3e.type.titleLarge` for collapsed titles
- `m3e.type.headlineSmallEmphasized` for expanded titles
- `m3e.shapes.round|square` for container shape
- `m3e.spacing.md` for horizontal padding
Override by supplying `backgroundColor`, `foregroundColor`, `toolbarHeight`, etc.
## Notes
- For collapsing behavior, use the sliver variant inside a `CustomScrollView`.
- `AppBarM3E` (small) is a `PreferredSizeWidget` suitable for `Scaffold.appBar`.
- Medium/Large variants rely on `FlexibleSpaceBar` for expanded titles.
- When `density: compact`, default heights reduce by ~8dp.
## License
MIT

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;
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.idea" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/example/build" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Flutter Plugins" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

View file

@ -0,0 +1,18 @@
name: app_bar_m3e
description: Expressive App Bar (Material 3 Expressive) with small/medium/large variants and Sliver integration.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
sdk: flutter
m3e_design:
path: ../m3e_design
dev_dependencies:
flutter_test:
sdk: flutter

View file

@ -0,0 +1,4 @@
# melos_managed_dependency_overrides: m3e_design
dependency_overrides:
m3e_design:
path: ..\\m3e_design

View file

@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () {
expect(2 + 2, 4);
});
}