forked from mirrors/material_3_expressive
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
90
packages/app_bar_m3e/README.md
Normal file
90
packages/app_bar_m3e/README.md
Normal 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
|
||||
5
packages/app_bar_m3e/lib/app_bar_m3e.dart
Normal file
5
packages/app_bar_m3e/lib/app_bar_m3e.dart
Normal 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';
|
||||
74
packages/app_bar_m3e/lib/src/_tokens_adapter.dart
Normal file
74
packages/app_bar_m3e/lib/src/_tokens_adapter.dart
Normal 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);
|
||||
}
|
||||
3
packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart
Normal file
3
packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
enum AppBarM3EVariant { small, medium, large }
|
||||
enum AppBarM3EShapeFamily { round, square }
|
||||
enum AppBarM3EDensity { regular, compact }
|
||||
134
packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart
Normal file
134
packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
145
packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart
Normal file
145
packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
29
packages/app_bar_m3e/melos_app_bar_m3e.iml
Normal file
29
packages/app_bar_m3e/melos_app_bar_m3e.iml
Normal 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>
|
||||
18
packages/app_bar_m3e/pubspec.yaml
Normal file
18
packages/app_bar_m3e/pubspec.yaml
Normal 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
|
||||
4
packages/app_bar_m3e/pubspec_overrides.yaml
Normal file
4
packages/app_bar_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# melos_managed_dependency_overrides: m3e_design
|
||||
dependency_overrides:
|
||||
m3e_design:
|
||||
path: ..\\m3e_design
|
||||
7
packages/app_bar_m3e/test/app_bar_m3e_test.dart
Normal file
7
packages/app_bar_m3e/test/app_bar_m3e_test.dart
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('placeholder', () {
|
||||
expect(2 + 2, 4);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue