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

View file

@ -0,0 +1,100 @@
# button_group_m3e
Wrapper-only **Button Group** for Material 3 Expressive (M3E).
Arranges arbitrary action buttons and applies **group-level presentation**: type (standard/connected), shape family (round/square), size (XSXL), density, and layout (axis, wrap).
> Buttons themselves remain independent (no selection logic). Use your own M3E buttons (`icon_button_m3e`, `split_button_m3e`, etc.).
## Install (in monorepo)
Place this folder alongside `m3e_design`:
```
packages/
m3e_design/
button_group_m3e/
```
`pubspec.yaml` already expects `m3e_design` at `../m3e_design`.
## API
```dart
class ButtonGroupM3E extends StatelessWidget {
const ButtonGroupM3E({
required List<Widget> children,
ButtonGroupM3EType type = ButtonGroupM3EType.standard,
ButtonGroupM3EShape shape = ButtonGroupM3EShape.round,
ButtonGroupM3ESize size = ButtonGroupM3ESize.md,
ButtonGroupM3EDensity density = ButtonGroupM3EDensity.regular,
Axis direction = Axis.horizontal,
bool wrap = false,
double? spacing,
double? runSpacing,
WrapAlignment alignment = WrapAlignment.start,
WrapAlignment runAlignment = WrapAlignment.start,
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
bool showDividers = false,
Color? dividerColor,
double? dividerThickness,
bool equalizeWidths = false,
String? semanticLabel,
Clip clipBehavior = Clip.none,
});
}
```
Enums:
```dart
enum ButtonGroupM3EType { standard, connected }
enum ButtonGroupM3EShape { round, square }
enum ButtonGroupM3ESize { xs, sm, md, lg, xl }
enum ButtonGroupM3EDensity { regular, compact }
```
## Scope for cooperative buttons
The group exposes: `ButtonGroupM3EScope` and `ButtonGroupM3EItemScope`:
```dart
final g = ButtonGroupM3EScope.of(context);
final i = ButtonGroupM3EItemScope.of(context);
// g.size, g.shape, g.isConnected, g.direction ...
// i.index, i.count, i.isFirst, i.isLast ...
```
Buttons can read these to adopt **height, corner radii (outer vs inner), compact paddings**, etc.
## Defaults (recommended)
- type: `standard`
- shape: `round`
- size: `md`
- density: `regular`
- direction: `Axis.horizontal`
- wrap: `false`
- standard spacing: token-based (≈8dp at md)
- connected spacing: `0`
- dividers: `false` by default (connected only)
- dividerThickness: `1dp` (hairline)
## Notes
- In **wrap** mode, connected **dividers** are not auto-drawn per run (Flutter Wrap lacks per-run hooks). Use standard type, or accept flush seams.
- `equalizeWidths` uses min-widths by size (40, 56, 72, 96, 120). For true equalization per run, implement a custom multi-pass layout if needed.
- The widget does **not** clip children unless `clipBehavior` is set. Prefer cooperative styling via scope.
## Example
```dart
ButtonGroupM3E(
type: ButtonGroupM3EType.connected,
shape: ButtonGroupM3EShape.round,
size: ButtonGroupM3ESize.lg,
showDividers: true,
semanticLabel: 'Playback controls',
children: [
// Your M3E buttons here...
],
)
```

View file

@ -0,0 +1,5 @@
library button_group_m3e;
export 'src/button_group_m3e_widget.dart';
export 'src/button_group_m3e_enums.dart';
export 'src/button_group_m3e_scope.dart' show ButtonGroupM3EScope, ButtonGroupM3EItemScope;

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'button_group_m3e_enums.dart';
class _GroupMetrics {
final double spacing;
final double runSpacing;
final double dividerThickness;
const _GroupMetrics({required this.spacing, required this.runSpacing, required this.dividerThickness});
}
_GroupMetrics metricsFor(BuildContext context, ButtonGroupM3ESize size, ButtonGroupM3EDensity density) {
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final sp = m3e.spacing;
double space;
double run;
switch (size) {
case ButtonGroupM3ESize.xs: space = 6; run = 6; break;
case ButtonGroupM3ESize.sm: space = sp.sm; run = sp.sm; break;
case ButtonGroupM3ESize.md: space = sp.sm; run = sp.md; break;
case ButtonGroupM3ESize.lg: space = sp.md; run = sp.lg; break;
case ButtonGroupM3ESize.xl: space = sp.lg; run = sp.lg; break;
}
if (density == ButtonGroupM3EDensity.compact) {
space = (space * 0.75).floorToDouble();
run = (run * 0.75).floorToDouble();
}
return _GroupMetrics(spacing: space, runSpacing: run, dividerThickness: 1);
}
BorderRadius radiusFor(BuildContext context, ButtonGroupM3EShape shape, ButtonGroupM3ESize size) {
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final set = shape == ButtonGroupM3EShape.round ? m3e.shapes.round : m3e.shapes.square;
switch (size) {
case ButtonGroupM3ESize.xs: return set.xs;
case ButtonGroupM3ESize.sm: return set.sm;
case ButtonGroupM3ESize.md: return set.md;
case ButtonGroupM3ESize.lg: return set.lg;
case ButtonGroupM3ESize.xl: return set.xl;
}
}

View file

@ -0,0 +1,4 @@
enum ButtonGroupM3EType { standard, connected }
enum ButtonGroupM3EShape { round, square }
enum ButtonGroupM3ESize { xs, sm, md, lg, xl }
enum ButtonGroupM3EDensity { regular, compact }

View file

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'button_group_m3e_enums.dart';
class ButtonGroupM3EScope extends InheritedWidget {
const ButtonGroupM3EScope({
super.key,
required super.child,
required this.type,
required this.shape,
required this.size,
required this.density,
required this.direction,
required this.isConnected,
});
final ButtonGroupM3EType type;
final ButtonGroupM3EShape shape;
final ButtonGroupM3ESize size;
final ButtonGroupM3EDensity density;
final Axis direction;
final bool isConnected;
static ButtonGroupM3EScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ButtonGroupM3EScope>();
static ButtonGroupM3EScope of(BuildContext context) =>
maybeOf(context)!;
@override
bool updateShouldNotify(covariant ButtonGroupM3EScope oldWidget) =>
type != oldWidget.type ||
shape != oldWidget.shape ||
size != oldWidget.size ||
density != oldWidget.density ||
direction != oldWidget.direction ||
isConnected != oldWidget.isConnected;
}
class ButtonGroupM3EItemScope extends InheritedWidget {
const ButtonGroupM3EItemScope({
super.key,
required super.child,
required this.index,
required this.count,
required this.isFirst,
required this.isLast,
});
final int index;
final int count;
final bool isFirst;
final bool isLast;
static ButtonGroupM3EItemScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ButtonGroupM3EItemScope>();
static ButtonGroupM3EItemScope of(BuildContext context) =>
maybeOf(context)!;
@override
bool updateShouldNotify(covariant ButtonGroupM3EItemScope oldWidget) =>
index != oldWidget.index ||
count != oldWidget.count ||
isFirst != oldWidget.isFirst ||
isLast != oldWidget.isLast;
}

View file

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'button_group_m3e_enums.dart';
import '_tokens_adapter.dart';
import 'button_group_m3e_scope.dart';
class ButtonGroupM3E extends StatelessWidget {
const ButtonGroupM3E({
super.key,
required this.children,
this.type = ButtonGroupM3EType.standard,
this.shape = ButtonGroupM3EShape.round,
this.size = ButtonGroupM3ESize.md,
this.density = ButtonGroupM3EDensity.regular,
this.direction = Axis.horizontal,
this.wrap = false,
this.spacing,
this.runSpacing,
this.alignment = WrapAlignment.start,
this.runAlignment = WrapAlignment.start,
this.crossAxisAlignment = WrapCrossAlignment.center,
this.showDividers = false,
this.dividerColor,
this.dividerThickness,
this.equalizeWidths = false,
this.semanticLabel,
this.clipBehavior = Clip.none,
});
final List<Widget> children;
final ButtonGroupM3EType type;
final ButtonGroupM3EShape shape;
final ButtonGroupM3ESize size;
final ButtonGroupM3EDensity density;
final Axis direction;
final bool wrap;
final double? spacing;
final double? runSpacing;
final WrapAlignment alignment;
final WrapAlignment runAlignment;
final WrapCrossAlignment crossAxisAlignment;
final bool showDividers;
final Color? dividerColor;
final double? dividerThickness;
final bool equalizeWidths;
final String? semanticLabel;
final Clip clipBehavior;
bool get _connected => type == ButtonGroupM3EType.connected;
@override
Widget build(BuildContext context) {
final tokens = metricsFor(context, size, density);
final cs = Theme.of(context).colorScheme;
final dividerClr = dividerColor ?? cs.outlineVariant.withValues(alpha: 0.6);
final dividerThk = (dividerThickness ?? tokens.dividerThickness).clamp(0.5, 2.0);
final effSpacing = _connected ? 0.0 : (spacing ?? tokens.spacing);
final effRunSpacing = wrap ? (runSpacing ?? tokens.runSpacing) : 0.0;
final group = ButtonGroupM3EScope(
type: type,
shape: shape,
size: size,
density: density,
direction: direction,
isConnected: _connected,
child: _buildContent(context, effSpacing, effRunSpacing, dividerClr, dividerThk),
);
final semantics = Semantics(
container: true,
label: semanticLabel,
child: group,
);
if (clipBehavior == Clip.none) return semantics;
return ClipRRect(
clipBehavior: clipBehavior,
borderRadius: radiusFor(context, shape, size),
child: semantics,
);
}
Widget _buildContent(BuildContext context, double spacing, double runSpacing,
Color dividerColor, double dividerThickness) {
if (children.isEmpty) return const SizedBox.shrink();
if (wrap) {
return _wrapLayout(context, spacing, runSpacing);
}
final list = <Widget>[];
for (var i = 0; i < children.length; i++) {
final isFirst = i == 0;
final isLast = i == children.length - 1;
final child = _wrapItemScope(
context,
index: i,
count: children.length,
isFirst: isFirst,
isLast: isLast,
child: _maybeEqualized(children[i]),
);
list.add(child);
final isBetween = i < children.length - 1;
if (!isBetween) continue;
if (_connected) {
if (showDividers) {
list.add(_buildDivider(dividerColor, dividerThickness));
}
} else {
list.add(_spacer(spacing));
}
}
return direction == Axis.horizontal
? Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list)
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: list);
}
Widget _wrapLayout(BuildContext context, double spacing, double runSpacing) {
final wrapped = List<Widget>.generate(children.length, (i) {
final isFirst = i == 0;
final isLast = i == children.length - 1;
return _wrapItemScope(
context,
index: i,
count: children.length,
isFirst: isFirst,
isLast: isLast,
child: _maybeEqualized(children[i]),
);
});
return Wrap(
direction: direction,
spacing: spacing,
runSpacing: runSpacing,
alignment: alignment,
runAlignment: runAlignment,
crossAxisAlignment: crossAxisAlignment,
children: wrapped,
);
}
Widget _wrapItemScope(BuildContext context,
{required int index, required int count, required bool isFirst, required bool isLast, required Widget child}) {
return ButtonGroupM3EItemScope(
index: index,
count: count,
isFirst: isFirst,
isLast: isLast,
child: child,
);
}
Widget _spacer(double spacing) =>
direction == Axis.horizontal ? SizedBox(width: spacing) : SizedBox(height: spacing);
Widget _buildDivider(Color color, double thickness) {
return direction == Axis.horizontal
? Container(width: thickness, height: 24, color: color)
: Container(height: thickness, width: 24, color: color);
}
Widget _maybeEqualized(Widget child) {
if (!equalizeWidths) return child;
final minW = switch (size) {
ButtonGroupM3ESize.xs => 40.0,
ButtonGroupM3ESize.sm => 56.0,
ButtonGroupM3ESize.md => 72.0,
ButtonGroupM3ESize.lg => 96.0,
ButtonGroupM3ESize.xl => 120.0,
};
return ConstrainedBox(constraints: BoxConstraints(minWidth: minW), child: child);
}
}

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: button_group_m3e
description: Wrapper-only Button Group for Material 3 Expressive (layout, shape, size propagation).
version: 0.1.0
publish_to: none
repository: https://example.com/your-repo
environment:
sdk: ">=3.5.0 <4.0.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(1 + 2, 3);
});
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,70 @@
# button_m3e
Material 3 **Expressive** Buttons for Flutter, built on top of Flutter's buttons but styled via **M3E** tokens.
Variants: **filled**, **tonal**, **outlined**, **text**, **elevated**
Sizes: **small**, **medium**, **large**
Shape families: **round**, **square**
Density: **regular**, **compact**
> Depends on `m3e_design` (ThemeExtension with colors/typography/spacing/shapes).
## Monorepo Layout
```
packages/
m3e_design/
button_m3e/
```
In `pubspec.yaml` this package references `../m3e_design`.
## Usage
```dart
import 'package:button_m3e/button_m3e.dart';
ButtonM3E(
variant: ButtonM3EVariant.filled,
size: ButtonM3ESize.medium,
labelText: 'Continue',
leading: const Icon(Icons.arrow_forward),
onPressed: () {},
);
```
Full-width button:
```dart
const ButtonM3E(
variant: ButtonM3EVariant.tonal,
size: ButtonM3ESize.large,
labelText: 'Buy now',
expand: true,
);
```
Outlined/Text/Elevated work similarly.
## Theming via `m3e_design`
`button_m3e` reads tokens from your theme:
- `m3e.colors.*` for background/foreground/border/disabled
- `m3e.type.labelLarge` for the button label
- `m3e.shapes.round|square` (uses `.lg` radius for buttons)
- `m3e.spacing` for horizontal paddings (`sm`, `md`, `lg`)
If the extension is not present, it falls back to `M3ETheme.defaults(ColorScheme)`.
You can still override `ThemeData.colorScheme` to influence defaults globally.
## Notes
- Label can be provided as `labelText` (String) or `label` (Widget).
- `leading`/`trailing` are optional helpers for icons.
- `expand: true` makes the button take full width.
- `density: compact` slightly reduces height for each size.
## License
MIT

View file

@ -0,0 +1,5 @@
library button_m3e;
export 'src/enums.dart';
export 'src/button_m3e.dart';
export 'src/button_theme_m3e.dart';

View file

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'button_theme_m3e.dart';
class ButtonM3E extends StatelessWidget {
const ButtonM3E({
super.key,
this.onPressed,
this.onLongPress,
this.leading,
this.trailing,
this.label,
this.labelText,
this.expand = false,
this.variant = ButtonM3EVariant.filled,
this.size = ButtonM3ESize.medium,
this.shapeFamily = ButtonM3EShapeFamily.round,
this.density = ButtonM3EDensity.regular,
this.semanticLabel,
}) : assert(label != null || labelText != null, 'Provide either label or labelText');
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final Widget? leading;
final Widget? trailing;
final Widget? label;
final String? labelText;
final bool expand;
final ButtonM3EVariant variant;
final ButtonM3ESize size;
final ButtonM3EShapeFamily shapeFamily;
final ButtonM3EDensity density;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final t = ButtonTokensAdapter(context);
final m = t.metrics(density);
final shape = t.shape(shapeFamily);
final (minH, pad) = switch (size) {
ButtonM3ESize.small => (m.heightSmall, m.paddingSmall),
ButtonM3ESize.medium => (m.heightMedium, m.paddingMedium),
ButtonM3ESize.large => (m.heightLarge, m.paddingLarge),
};
final style = _styleFor(context, t, shape, minH, pad);
final childLabel = label ?? Text(labelText!, overflow: TextOverflow.ellipsis);
final content = _buildContent(context, t, childLabel);
final Widget btn = switch (variant) {
ButtonM3EVariant.filled => FilledButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
ButtonM3EVariant.tonal => FilledButton.tonal(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
ButtonM3EVariant.outlined => OutlinedButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
ButtonM3EVariant.text => TextButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
ButtonM3EVariant.elevated => ElevatedButton(style: style, onPressed: onPressed, onLongPress: onLongPress, child: content),
};
if (!expand && semanticLabel == null) return btn;
final wrapped = expand ? SizedBox(width: double.infinity, child: btn) : btn;
if (semanticLabel == null) return wrapped;
return Semantics(
button: true,
label: semanticLabel,
child: wrapped,
);
}
ButtonStyle _styleFor(BuildContext context, ButtonTokensAdapter t, OutlinedBorder shape, double minH, EdgeInsetsGeometry pad) {
switch (variant) {
case ButtonM3EVariant.filled:
return FilledButton.styleFrom(
backgroundColor: t.bgFilled(),
foregroundColor: t.fgFilled(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
);
case ButtonM3EVariant.tonal:
return FilledButton.styleFrom(
backgroundColor: t.bgTonal(),
foregroundColor: t.fgTonal(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
);
case ButtonM3EVariant.outlined:
return OutlinedButton.styleFrom(
foregroundColor: t.fgOutlined(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
side: BorderSide(color: t.borderOutlined()),
);
case ButtonM3EVariant.text:
return TextButton.styleFrom(
foregroundColor: t.fgText(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
);
case ButtonM3EVariant.elevated:
return ElevatedButton.styleFrom(
backgroundColor: t.bgElevated(),
foregroundColor: t.fgElevated(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
elevation: _elevationFor(context, t),
);
}
}
double _elevationFor(BuildContext context, ButtonTokensAdapter t) {
// Simple mapping; can be themed further via tokens.
return 1.0;
}
Widget _buildContent(BuildContext context, ButtonTokensAdapter t, Widget childLabel) {
final style = t.labelStyle();
final text = DefaultTextStyle.merge(style: style, child: childLabel);
final hasLeading = leading != null;
final hasTrailing = trailing != null;
if (!hasLeading && !hasTrailing) return text;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (hasLeading) ...[leading!, const SizedBox(width: 8)],
Flexible(child: text),
if (hasTrailing) ...[const SizedBox(width: 8), trailing!],
],
);
}
}

View file

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

View file

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _ButtonMetrics {
final double heightSmall;
final double heightMedium;
final double heightLarge;
final EdgeInsetsGeometry paddingSmall;
final EdgeInsetsGeometry paddingMedium;
final EdgeInsetsGeometry paddingLarge;
final BorderSide outlinedBorder;
final double elevation;
const _ButtonMetrics({
required this.heightSmall,
required this.heightMedium,
required this.heightLarge,
required this.paddingSmall,
required this.paddingMedium,
required this.paddingLarge,
required this.outlinedBorder,
required this.elevation,
});
}
_ButtonMetrics _metricsFor(BuildContext context, ButtonM3EDensity density) {
final theme = Theme.of(context);
final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
// Heights based on Material 3 expectations; tweakable by density.
double hS = 36;
double hM = 40;
double hL = 48;
if (density == ButtonM3EDensity.compact) {
hS -= 4;
hM -= 4;
hL -= 4;
}
return _ButtonMetrics(
heightSmall: hS,
heightMedium: hM,
heightLarge: hL,
paddingSmall: EdgeInsets.symmetric(horizontal: sp.sm),
paddingMedium: EdgeInsets.symmetric(horizontal: sp.md),
paddingLarge: EdgeInsets.symmetric(horizontal: sp.lg),
outlinedBorder: BorderSide(color: m3e.colors.outline, width: 1.0),
elevation: 1.0,
);
}
class ButtonTokensAdapter {
ButtonTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
// Colors
Color bgFilled() => _m3e.colors.primary;
Color fgFilled() => _m3e.colors.onPrimary;
Color bgTonal() => _m3e.colors.secondaryContainer;
Color fgTonal() => _m3e.colors.onSecondaryContainer;
Color bgElevated() => _m3e.colors.surfaceContainerLowest;
Color fgElevated() => _m3e.colors.primary;
Color fgText() => _m3e.colors.primary;
Color borderOutlined() => _m3e.colors.outline;
Color fgOutlined() => _m3e.colors.primary;
Color disabledFg() => _m3e.colors.onSurface.withValues(alpha: 0.38);
Color disabledBg() => _m3e.colors.onSurface.withValues(alpha: 0.12);
// Typography
TextStyle labelStyle() => _m3e.type.labelLarge;
// Shapes
OutlinedBorder shape(ButtonM3EShapeFamily family) {
if (family == ButtonM3EShapeFamily.round) {
return RoundedRectangleBorder(borderRadius: _m3e.shapes.round.lg);
}
// Square family should have sharp corners (no rounding)
return const RoundedRectangleBorder(borderRadius: BorderRadius.zero);
}
// Spacing & heights
_ButtonMetrics metrics(ButtonM3EDensity density) =>
_metricsFor(context, density);
}

View file

@ -0,0 +1,4 @@
enum ButtonM3EVariant { filled, tonal, outlined, text, elevated }
enum ButtonM3ESize { small, medium, large }
enum ButtonM3EShapeFamily { round, square }
enum ButtonM3EDensity { regular, compact }

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: button_m3e
description: Material 3 Expressive Buttons for Flutter (filled, tonal, outlined, text, elevated) with M3E tokens.
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(1 + 1, 2);
});
}

21
packages/fab_m3e/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,93 @@
# fab_m3e
Material 3 **Expressive** Floating Action Buttons for Flutter:
- **FabM3E**: circular FAB (small / regular / large)
- **ExtendedFabM3E**: pill-shaped FAB with label (and optional icon)
- **FabMenuM3E**: FAB menu (speed dial) with animated items (up/down/left/right)
All components read **M3E tokens** from `m3e_design` (ThemeExtension).
## Monorepo Layout
```
packages/
m3e_design/
fab_m3e/
```
This package's `pubspec.yaml` references `../m3e_design`.
## Usage
### FAB
```dart
import 'package:fab_m3e/fab_m3e.dart';
FabM3E(
icon: const Icon(Icons.add),
kind: FabM3EKind.primary,
size: FabM3ESize.regular,
onPressed: () {},
);
```
### Extended FAB
```dart
ExtendedFabM3E(
icon: const Icon(Icons.edit),
label: const Text('Compose'),
kind: FabM3EKind.secondary,
onPressed: () {},
);
```
### FAB Menu (Speed dial)
```dart
final controller = FabMenuController();
FabMenuM3E(
controller: controller,
alignment: Alignment.bottomRight,
direction: FabMenuDirection.up,
primaryFab: FabM3E(icon: const Icon(Icons.add), onPressed: controller.toggle),
items: [
FabMenuItem(
icon: const Icon(Icons.photo),
label: const Text('Photo'),
onPressed: () {},
),
FabMenuItem(
icon: const Icon(Icons.note),
label: const Text('Note'),
onPressed: () {},
),
],
);
```
## Theming via `m3e_design`
- Background/foreground colors derive from kind:
- `primary``primaryContainer` / `onPrimaryContainer`
- `secondary``secondaryContainer` / `onSecondaryContainer`
- `tertiary``tertiaryContainer` / `onTertiaryContainer`
- `surface``surfaceContainerHigh` / `onSurface`
- Sizes: **small ≈40dp**, **regular ≈56dp**, **large ≈96dp**
- Extended FAB height ≈56dp
- Elevations: rest 6, hover 8, pressed 12 (tweak in code or via tokens)
- Shapes: `round`/`square` from `m3e_design.shapes` (extended uses StadiumBorder)
## Notes
- `FabM3E` uses `RawMaterialButton` to directly inject shape/elevation/colors with tokens.
- `ExtendedFabM3E` uses `Material` + `InkWell` with stadium shape and token paddings.
- `FabMenuM3E` stacks items near the primary FAB and animates **scale + fade**.
- Provide your own `Hero` tags if coordinating transitions across pages.
## License
MIT

View file

@ -0,0 +1,7 @@
library fab_m3e;
export 'src/enums.dart';
export 'src/fab_theme_m3e.dart' show FabTokensAdapter;
export 'src/fab_m3e.dart';
export 'src/extended_fab_m3e.dart';
export 'src/fab_menu_m3e.dart';

View file

@ -0,0 +1,8 @@
enum FabM3EKind { primary, secondary, tertiary, surface }
/// Size mapping follows Material 3: small (40), regular (56), large (96).
enum FabM3ESize { small, regular, large }
enum FabM3EShapeFamily { round, square }
enum FabM3EDensity { regular, compact }
/// Direction for the FAB menu children to expand.
enum FabMenuDirection { up, down, left, right }

View file

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'fab_theme_m3e.dart';
class ExtendedFabM3E extends StatelessWidget {
const ExtendedFabM3E({
super.key,
required this.label,
this.icon,
this.onPressed,
this.tooltip,
this.heroTag,
this.kind = FabM3EKind.primary,
this.size = FabM3ESize.regular,
this.shapeFamily = FabM3EShapeFamily.round,
this.density = FabM3EDensity.regular,
this.elevation,
this.expand = false,
this.semanticLabel,
});
final Widget label;
final Widget? icon;
final VoidCallback? onPressed;
final String? tooltip;
final Object? heroTag;
final FabM3EKind kind;
final FabM3ESize size;
final FabM3EShapeFamily shapeFamily;
final FabM3EDensity density;
final double? elevation;
final bool expand;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final tokens = FabTokensAdapter(context);
final m = tokens.metrics(density);
final bg = tokens.bg(kind);
final fg = tokens.fg(kind);
final shape = tokens.shape(shapeFamily, size, extended: true);
final minH = m.extendedHeight;
final child = DefaultTextStyle.merge(
style: tokens.labelStyle().copyWith(color: fg),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
IconTheme.merge(
data: IconThemeData(color: fg, size: m.iconSize), child: icon!),
const SizedBox(width: 12)
],
Flexible(child: label),
],
),
);
final btn = ConstrainedBox(
constraints: BoxConstraints(minHeight: minH),
child: Material(
shape: shape,
color: bg,
elevation: elevation ?? m.elevationRest,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
onHover: (_) {},
child: Padding(
padding: m.extendedPadding,
child: Align(alignment: Alignment.center, child: child),
),
),
),
);
final core = Tooltip(
message: tooltip ?? '',
preferBelow: false,
child: expand ? SizedBox(width: double.infinity, child: btn) : btn,
);
Widget wrapped = core;
if (heroTag != null &&
context.findAncestorWidgetOfExactType<Hero>() == null) {
wrapped = Hero(tag: heroTag!, child: core);
}
if (semanticLabel == null) return wrapped;
return Semantics(button: true, label: semanticLabel, child: wrapped);
}
}

View file

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'fab_theme_m3e.dart';
class FabM3E extends StatelessWidget {
const FabM3E({
super.key,
required this.icon,
this.onPressed,
this.tooltip,
this.heroTag,
this.kind = FabM3EKind.primary,
this.size = FabM3ESize.regular,
this.shapeFamily = FabM3EShapeFamily.round,
this.density = FabM3EDensity.regular,
this.elevation,
this.focusNode,
this.autofocus = false,
this.isPrimaryAction = true,
this.semanticLabel,
});
final Widget icon;
final VoidCallback? onPressed;
final String? tooltip;
final Object? heroTag;
final FabM3EKind kind;
final FabM3ESize size;
final FabM3EShapeFamily shapeFamily;
final FabM3EDensity density;
final double? elevation;
final FocusNode? focusNode;
final bool autofocus;
final bool isPrimaryAction;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final tokens = FabTokensAdapter(context);
final m = tokens.metrics(density);
final bg = tokens.bg(kind);
final fg = tokens.fg(kind);
final shape = tokens.shape(shapeFamily, size);
final double dim = switch (size) {
FabM3ESize.small => m.small,
FabM3ESize.regular => m.regular,
FabM3ESize.large => m.large,
};
final button = SizedBox(
width: dim,
height: dim,
child: RawMaterialButton(
onPressed: onPressed,
fillColor: bg,
elevation: elevation ?? m.elevationRest,
hoverElevation: m.elevationHover,
highlightElevation: m.elevationPressed,
focusElevation: m.elevationHover,
shape: shape,
focusNode: focusNode,
autofocus: autofocus,
clipBehavior: Clip.antiAlias,
child: IconTheme.merge(
data: IconThemeData(color: fg, size: m.iconSize),
child: icon,
),
),
);
final core = Tooltip(
message: tooltip ?? '',
preferBelow: false,
child: button,
);
// Only wrap with Hero when an explicit tag is provided and there is no ancestor hero.
Widget wrapped = core;
if (heroTag != null &&
context.findAncestorWidgetOfExactType<Hero>() == null) {
wrapped = Hero(tag: heroTag!, child: core);
}
if (semanticLabel == null) return wrapped;
return Semantics(button: true, label: semanticLabel, child: wrapped);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class FabM3EWidget extends StatelessWidget {
const FabM3EWidget({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('Fab 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,247 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'extended_fab_m3e.dart';
class FabMenuItem {
FabMenuItem({
required this.icon,
required this.label,
required this.onPressed,
this.semanticLabel,
});
final Widget icon;
final Widget label;
final VoidCallback onPressed;
final String? semanticLabel;
}
class FabMenuController extends ChangeNotifier {
bool _open = false;
bool get isOpen => _open;
void open() {
if (!_open) {
_open = true;
notifyListeners();
}
}
void close() {
if (_open) {
_open = false;
notifyListeners();
}
}
void toggle() {
_open = !_open;
notifyListeners();
}
}
class FabMenuM3E extends StatefulWidget {
const FabMenuM3E({
super.key,
required this.primaryFab,
required this.items,
this.direction = FabMenuDirection.up,
this.spacing,
this.overlay = true,
this.overlayColor,
this.controller,
this.alignment = Alignment.bottomRight,
this.popOnItemTap = true,
this.heroTag,
});
/// The FAB that toggles the menu (typically a primary FabM3E or ExtendedFabM3E).
final Widget primaryFab;
/// Menu items displayed when open.
final List<FabMenuItem> items;
/// Direction in which children expand.
final FabMenuDirection direction;
/// Spacing between items.
final double? spacing;
/// Show a scrim overlay behind the menu when open.
final bool overlay;
final Color? overlayColor;
/// Optional external controller; if omitted, an internal one is created.
final FabMenuController? controller;
/// Alignment within the Stack (e.g., bottomRight in a Scaffold).
final Alignment alignment;
/// Whether to automatically close the menu when an item is tapped.
final bool popOnItemTap;
final Object? heroTag;
@override
State<FabMenuM3E> createState() => _FabMenuM3EState();
}
class _FabMenuM3EState extends State<FabMenuM3E>
with SingleTickerProviderStateMixin {
late final FabMenuController _controller =
widget.controller ?? FabMenuController();
late final AnimationController _anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
reverseDuration: const Duration(milliseconds: 150),
);
late final Animation<double> _scale = CurvedAnimation(
parent: _anim,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic);
@override
void initState() {
super.initState();
_controller.addListener(_onChange);
}
@override
void dispose() {
_controller.removeListener(_onChange);
if (widget.controller == null) _controller.dispose();
_anim.dispose();
super.dispose();
}
void _onChange() {
if (_controller.isOpen) {
_anim.forward();
} else {
_anim.reverse();
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final sp = context.m3e.spacing; // use spacing scale via context extension
final gap = widget.spacing ?? sp.md;
final children = <Widget>[];
for (int i = 0; i < widget.items.length; i++) {
final item = widget.items[i];
final w = _buildMenuItem(context, item);
final animatedChild = ScaleTransition(
scale: _scale,
child: FadeTransition(
opacity: _scale,
child: w,
),
);
// Ensure Positioned is a direct child of the Stack
children.add(_positioned(animatedChild, i, gap));
}
final menu = Stack(
alignment: widget.alignment,
clipBehavior: Clip.none,
children: [
// Primary FAB
Align(
alignment: widget.alignment,
child: _wrapToggle(widget.primaryFab),
),
// Menu items
...children,
],
);
final overlay = widget.overlay && _controller.isOpen
? Positioned.fill(
child: GestureDetector(
onTap: _controller.toggle,
child: ColoredBox(
color:
widget.overlayColor ?? Colors.black.withValues(alpha: 0.25),
),
),
)
: const SizedBox.shrink();
return Stack(
children: [
overlay,
Positioned.fill(
child: IgnorePointer(
ignoring: !_controller.isOpen, child: Container())),
menu,
],
);
}
Widget _wrapToggle(Widget child) {
final core = GestureDetector(
onTap: _controller.toggle,
behavior: HitTestBehavior.opaque,
child: child,
);
if (widget.heroTag != null && context.findAncestorWidgetOfExactType<Hero>() == null) {
return Hero(tag: widget.heroTag!, child: core);
}
return core;
}
Widget _positioned(Widget child, int index, double gap) {
final offset = (index + 1) *
(gap +
56); // base step; extended affects height, but 56 is a practical default
switch (widget.direction) {
case FabMenuDirection.up:
return Positioned(
right: 0,
bottom: offset,
child: child,
);
case FabMenuDirection.down:
return Positioned(
right: 0,
top: offset,
child: child,
);
case FabMenuDirection.left:
return Positioned(
right: offset,
bottom: 0,
child: child,
);
case FabMenuDirection.right:
return Positioned(
left: offset,
bottom: 0,
child: child,
);
}
}
Widget _buildMenuItem(BuildContext context, FabMenuItem item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ExtendedFabM3E(
icon: item.icon,
label: item.label,
onPressed: () {
item.onPressed();
if (widget.popOnItemTap) _controller.close();
},
kind: FabM3EKind.surface,
size: FabM3ESize.regular,
density: FabM3EDensity.regular,
semanticLabel: item.semanticLabel,
),
);
}
}

View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _FabMetrics {
final double small;
final double regular;
final double large;
final double extendedHeight;
final EdgeInsetsGeometry extendedPadding;
final double iconSize;
final double elevationRest;
final double elevationHover;
final double elevationPressed;
const _FabMetrics({
required this.small,
required this.regular,
required this.large,
required this.extendedHeight,
required this.extendedPadding,
required this.iconSize,
required this.elevationRest,
required this.elevationHover,
required this.elevationPressed,
});
}
_FabMetrics _metricsFor(BuildContext context, FabM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double small = 40;
double regular = 56;
double large = 96;
double extH = 56;
double icon = 24;
if (density == FabM3EDensity.compact) {
small -= 4; regular -= 4; large -= 4; extH -= 4;
}
return _FabMetrics(
small: small,
regular: regular,
large: large,
extendedHeight: extH,
extendedPadding: EdgeInsets.symmetric(horizontal: sp.lg),
iconSize: icon,
elevationRest: 6.0,
elevationHover: 8.0,
elevationPressed: 12.0,
);
}
class FabTokensAdapter {
FabTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_FabMetrics metrics(FabM3EDensity density) => _metricsFor(context, density);
// Colors by kind
Color bg(FabM3EKind kind) {
switch (kind) {
case FabM3EKind.primary:
return _m3e.colors.primaryContainer;
case FabM3EKind.secondary:
return _m3e.colors.secondaryContainer;
case FabM3EKind.tertiary:
return _m3e.colors.tertiaryContainer;
case FabM3EKind.surface:
return _m3e.colors.surfaceContainerHigh;
}
}
Color fg(FabM3EKind kind) {
switch (kind) {
case FabM3EKind.primary:
return _m3e.colors.onPrimaryContainer;
case FabM3EKind.secondary:
return _m3e.colors.onSecondaryContainer;
case FabM3EKind.tertiary:
return _m3e.colors.onTertiaryContainer;
case FabM3EKind.surface:
return _m3e.colors.onSurface;
}
}
// Shapes
ShapeBorder shape(FabM3EShapeFamily family, FabM3ESize size, {bool extended = false}) {
final set = family == FabM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
if (extended) return StadiumBorder(side: BorderSide.none);
// circular-ish fab: use large radius to approach circle; actual size enforced by constraints
final radius = switch (size) { FabM3ESize.small => set.lg, FabM3ESize.regular => set.xl, FabM3ESize.large => set.xl };
return RoundedRectangleBorder(borderRadius: radius);
}
// Typography
TextStyle labelStyle() => _m3e.type.labelLarge;
}

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: fab_m3e
description: Material 3 Expressive Floating Action Button (FAB), Extended FAB, and FAB Menu for Flutter using M3E tokens.
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(3 * 3, 9);
});
}

View file

@ -0,0 +1,92 @@
# icon_button_m3e
Expressive Material 3 icon button for Flutter — `IconButtonM3E` — with
five sizes (XSXL), four variants (standard, filled, tonal, outlined),
round/square shapes, toggle support, and guaranteed 48×48dp tap targets
(even when visual size is 32/40).
## Highlights
- Sizes: `M3EIconButtonSize` = XS, SM, MD, LG, XL
- Widths: `M3EIconButtonWidth` = default, narrow, wide
- Variants: standard, filled, tonal, outlined
- Shapes: round (pill) or square (rounded rect)
- Toggle: `isSelected` + `selectedIcon`
- A11y: min 48×48dp hit target; semantics label/selected state
- Tokens: centralized static values in `M3EIconButtonTokens` (no ThemeExtension)
## Install
```yaml
dependencies:
icon_button_m3e:
path: ../icon_button_m3e # or from pub once published
```
## Quick Start
```dart
import 'package:icon_button_m3e/icon_button_m3e.dart';
IconButtonM3E(
variant: IconButtonM3EVariant.filled,
size: M3EIconButtonSize.md,
width: M3EIconButtonWidth.defaultWidth,
icon: const Icon(Icons.mic),
tooltip: 'Start recording',
onPressed: () {},
);
```
### Toggle
```dart
bool isFav = false;
IconButtonM3E(
variant: IconButtonM3EVariant.tonal,
isSelected: isFav,
icon: const Icon(Icons.favorite_border),
selectedIcon: const Icon(Icons.favorite),
tooltip: isFav ? 'Remove from favorites' : 'Add to favorites',
onPressed: () => setState(() => isFav = !isFav),
);
```
## Sizing
- Visual container sizes come from tokens: `M3EIconButtonTokens.visual[size][width]`.
- Minimum interactive target sizes come from `M3EIconButtonTokens.target[size][width]`.
- XS/SM enforce at least 48×48; others match their visual sizes.
- Icon glyph sizes are in `M3EIconButtonTokens.icon[size]`.
For example (default width):
- XS: 32×32 visual, 48×48 target
- SM: 40×40 visual, 48×48 target (SM wide: 52×48)
- MD: 56×56
- LG: 96×96
- XL: 136×136
## Colors and shapes
- Colors are derived from your `ThemeData.colorScheme`:
- standard: transparent bg, onSurfaceVariant fg (selected uses primary)
- filled: primary bg, onPrimary fg
- tonal: secondaryContainer bg, onSecondaryContainer fg
- outlined: transparent bg, primary fg, outline border
- Shapes: `M3EIconButtonShapeVariant.round` (pill) or `.square` (rounded square).
- Pressed state uses a shared, more-square radius per size.
- If used as a toggle, selected state flips round/square for expressive feel.
## Example
Run the example app:
```sh
cd example
flutter run
```
## License
MIT

View file

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View file

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: android
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: ios
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: linux
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: macos
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: web
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
- platform: windows
create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View file

@ -0,0 +1,16 @@
# icon_button_m3e_example
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:icon_button_m3e/icon_button_m3e.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'IconButtonM3E Demo',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
/* extensions: const [IconButtonM3ETokens.fallback()],*/
),
home: const DemoHome(),
);
}
}
class DemoHome extends StatefulWidget {
const DemoHome({super.key});
@override
State<DemoHome> createState() => _DemoHomeState();
}
class _DemoHomeState extends State<DemoHome> {
bool selected = false;
@override
Widget build(BuildContext context) {
const sizes = IconButtonM3ESize.values;
const variants = IconButtonM3EVariant.values;
return Scaffold(
appBar: AppBar(title: const Text('IconButtonM3E Demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Wrap(spacing: 12, runSpacing: 12, children: [
Column(
children: [
const Text('Variants × Sizes (round - width default)',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
for (final v in variants) ...[
Text(v.toString().split('.').last.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final s in sizes)
IconButtonM3E(
variant: v,
size: s,
width: IconButtonM3EWidth.defaultWidth,
icon: const Icon(Icons.mic),
tooltip: 'Mic',
onPressed: () {},
),
],
),
const SizedBox(height: 16),
],
],
),
Column(
children: [
const Text('Variants × Sizes (round - width narrow)',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
for (final v in variants) ...[
Text(v.toString().split('.').last.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final s in sizes)
IconButtonM3E(
variant: v,
size: s,
width: IconButtonM3EWidth.narrow,
icon: const Icon(Icons.mic),
tooltip: 'Mic',
onPressed: () {},
),
],
),
const SizedBox(height: 16),
],
],
),
Column(
children: [
const Text('Variants × Sizes (round - width narrow)',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
for (final v in variants) ...[
Text(v.toString().split('.').last.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final s in sizes)
IconButtonM3E(
variant: v,
size: s,
width: IconButtonM3EWidth.wide,
icon: const Icon(Icons.mic),
tooltip: 'Mic',
onPressed: () {},
),
],
),
const SizedBox(height: 16),
],
],
),
]),
const SizedBox(height: 24),
const Text('Square shape',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final v in variants)
IconButtonM3E(
variant: v,
shape: IconButtonM3EShapeVariant.square,
size: IconButtonM3ESize.md,
icon: const Icon(Icons.share),
tooltip: 'Share',
onPressed: () {},
),
],
),
const SizedBox(height: 32),
const Text('Toggle example',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
IconButtonM3E(
variant: IconButtonM3EVariant.tonal,
isSelected: selected,
icon: const Icon(Icons.favorite_border),
selectedIcon: const Icon(Icons.favorite),
tooltip: selected ? 'Unfavorite' : 'Favorite',
onPressed: () => setState(() => selected = !selected),
),
IconButtonM3E(
variant: IconButtonM3EVariant.filled,
isSelected: selected,
icon: const Icon(Icons.bookmark_add_outlined),
selectedIcon: const Icon(Icons.bookmark_added),
tooltip: selected ? 'Remove bookmark' : 'Add bookmark',
onPressed: () => setState(() => selected = !selected),
),
],
),
const SizedBox(height: 32),
],
),
);
}
}

View file

@ -0,0 +1,21 @@
name: icon_button_m3e_example
description: Example for icon_button_m3e
publish_to: "none"
environment:
sdk: ">=3.2.0 <4.0.0"
flutter: ">=3.19.0"
dependencies:
flutter:
sdk: flutter
icon_button_m3e:
path: ../
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

View file

@ -0,0 +1,4 @@
library icon_button_m3e;
export 'src/enums.dart';
export 'src/icon_button_m3e.dart';

View file

@ -0,0 +1,122 @@
part of 'enums.dart';
/// All numeric tokens & constants for M3 Expressive IconButton.
/// No business logic herejust data.
class IconButtonM3ETokens {
const IconButtonM3ETokens._();
// ----------------------------
// Icon glyph sizes (dp)
// ----------------------------
static const Map<IconButtonM3ESize, double> icon = {
IconButtonM3ESize.xs: 20.0, // A
IconButtonM3ESize.sm: 24.0, // B
IconButtonM3ESize.md: 24.0, // C
IconButtonM3ESize.lg: 32.0, // D
IconButtonM3ESize.xl: 40.0, // E
};
// ----------------------------
// Visual container sizes (dp)
// width × height
// ----------------------------
static const Map<IconButtonM3ESize, Map<IconButtonM3EWidth, Size>> visual = {
IconButtonM3ESize.xs: {
IconButtonM3EWidth.defaultWidth: Size(32, 32),
IconButtonM3EWidth.narrow: Size(28, 32),
IconButtonM3EWidth.wide: Size(40, 32),
},
IconButtonM3ESize.sm: {
IconButtonM3EWidth.defaultWidth: Size(40, 40),
IconButtonM3EWidth.narrow: Size(32, 40),
IconButtonM3EWidth.wide: Size(52, 40),
},
IconButtonM3ESize.md: {
IconButtonM3EWidth.defaultWidth: Size(56, 56),
IconButtonM3EWidth.narrow: Size(48, 56),
IconButtonM3EWidth.wide: Size(72, 56),
},
IconButtonM3ESize.lg: {
IconButtonM3EWidth.defaultWidth: Size(96, 96),
IconButtonM3EWidth.narrow: Size(64, 96),
IconButtonM3EWidth.wide: Size(128, 96),
},
IconButtonM3ESize.xl: {
IconButtonM3EWidth.defaultWidth: Size(136, 136),
IconButtonM3EWidth.narrow: Size(104, 136),
IconButtonM3EWidth.wide: Size(184, 136),
},
};
// ----------------------------
// Minimum interactive target sizes (dp)
// XS/SM must be 48×48 (SM wide = 52×48); others equal visual.
// ----------------------------
static const Map<IconButtonM3ESize, Map<IconButtonM3EWidth, Size>> target = {
IconButtonM3ESize.xs: {
IconButtonM3EWidth.defaultWidth: Size(48, 48),
IconButtonM3EWidth.narrow: Size(48, 48),
IconButtonM3EWidth.wide: Size(48, 48),
},
IconButtonM3ESize.sm: {
IconButtonM3EWidth.defaultWidth: Size(48, 48),
IconButtonM3EWidth.narrow: Size(48, 48),
IconButtonM3EWidth.wide: Size(52, 48),
},
// MD/LG/XL already meet or exceed 48×48 use visual sizes as targets.
IconButtonM3ESize.md: {
IconButtonM3EWidth.defaultWidth: Size(56, 56),
IconButtonM3EWidth.narrow: Size(48, 56),
IconButtonM3EWidth.wide: Size(72, 56),
},
IconButtonM3ESize.lg: {
IconButtonM3EWidth.defaultWidth: Size(96, 96),
IconButtonM3EWidth.narrow: Size(64, 96),
IconButtonM3EWidth.wide: Size(128, 96),
},
IconButtonM3ESize.xl: {
IconButtonM3EWidth.defaultWidth: Size(136, 136),
IconButtonM3EWidth.narrow: Size(104, 136),
IconButtonM3EWidth.wide: Size(184, 136),
},
};
// ----------------------------
// Corner radii (dp)
// Pressed radius is shared by both variants at the same size and
// is more square than the square resting radius.
// Values are consistent, scalable defaults; tune to match your spec.
// ----------------------------
static const Map<IconButtonM3ESize, double> radiusRestRound = {
// Half of the default height circular/pill look
IconButtonM3ESize.xs: 16.0, // 32/2
IconButtonM3ESize.sm: 20.0, // 40/2
IconButtonM3ESize.md: 28.0, // 56/2
IconButtonM3ESize.lg: 48.0, // 96/2
IconButtonM3ESize.xl: 68.0, // 136/2
};
static const Map<IconButtonM3ESize, double> radiusRestSquare = {
// Rounded-square feel (~25% of height)
IconButtonM3ESize.xs: 8.0, // 32*0.25
IconButtonM3ESize.sm: 10.0, // 40*0.25
IconButtonM3ESize.md: 14.0, // 56*0.25
IconButtonM3ESize.lg: 24.0, // 96*0.25
IconButtonM3ESize.xl: 34.0, // 136*0.25
};
static const Map<IconButtonM3ESize, double> radiusPressed = {
// More square than the square resting radius (~20% of height)
IconButtonM3ESize.xs: 6.0, // 32*0.20
IconButtonM3ESize.sm: 8.0, // 40*0.20
IconButtonM3ESize.md: 11.0, // 56*0.20
IconButtonM3ESize.lg: 19.0, // 96*0.20
IconButtonM3ESize.xl: 27.0, // 136*0.20
};
// ----------------------------
// Motion tokens for shape morph (optional, but handy)
// ----------------------------
static const Duration morphDuration = Duration(milliseconds: 120);
static const Curve morphCurve = Curves.easeOut;
}

View file

@ -0,0 +1,86 @@
library m3e_iconbutton;
import 'package:flutter/material.dart';
part '_tokens_adapter.dart';
/// Visual scale labels (AE in the spec).
enum IconButtonM3ESize { xs, sm, md, lg, xl }
/// Width variants of the buttons container (not the icon glyph).
enum IconButtonM3EWidth { defaultWidth, narrow, wide }
/// The two resting shape variants.
enum IconButtonM3EShapeVariant { round, square }
/// Visual variants (kept from previous API).
enum IconButtonM3EVariant { standard, filled, tonal, outlined }
/// Icon glyph size inside the button (reads tokens).
extension IconM3EGlyph on IconButtonM3ESize {
double get icon => IconButtonM3ETokens.icon[this]!;
}
/// Visual (painted) size & target size helpers (read tokens).
extension IconButtonM3ESizes on IconButtonM3ESize {
Size visual(IconButtonM3EWidth width) =>
IconButtonM3ETokens.visual[this]![width]!;
Size target(IconButtonM3EWidth width) =>
IconButtonM3ETokens.target[this]![width]!;
Size get defaultSize => visual(IconButtonM3EWidth.defaultWidth);
Size get narrowSize => visual(IconButtonM3EWidth.narrow);
Size get wideSize => visual(IconButtonM3EWidth.wide);
}
/// Shape resolution helpers: resting/pressed radii and toggle behavior.
class IconButtonM3EShapes {
const IconButtonM3EShapes._();
static IconButtonM3EShapeVariant restVariant({
required bool isToggle,
required bool isSelected,
required IconButtonM3EShapeVariant baseVariant,
}) {
if (isToggle && isSelected) {
return baseVariant == IconButtonM3EShapeVariant.round
? IconButtonM3EShapeVariant.square
: IconButtonM3EShapeVariant.round;
}
return baseVariant;
}
static double restingRadius({
required IconButtonM3ESize size,
required IconButtonM3EShapeVariant variant,
}) {
return switch (variant) {
IconButtonM3EShapeVariant.round =>
IconButtonM3ETokens.radiusRestRound[size]!,
IconButtonM3EShapeVariant.square =>
IconButtonM3ETokens.radiusRestSquare[size]!,
};
}
/// Effective corner radius for the given material states.
/// Hover does not change the radius; Pressed uses the shared pressed radius.
static double effectiveRadius({
required IconButtonM3ESize size,
required IconButtonM3EShapeVariant baseVariant,
required bool isToggle,
required bool isSelected,
required Set<WidgetState> states,
}) {
final variant = restVariant(
isToggle: isToggle,
isSelected: isSelected,
baseVariant: baseVariant,
);
if (states.contains(WidgetState.pressed)) {
return IconButtonM3ETokens.radiusPressed[size]!;
}
return restingRadius(size: size, variant: variant);
}
}

View file

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'enums.dart';
/// Material 3 Expressive Icon Button
///
/// - Visual sizes are defined by [IconButtonM3ETokens.visual] (per size × width)
/// - Tap target respects [IconButtonM3ETokens.target] with a minimum of 48×48 on XS/SM
/// - Variants: standard, filled, tonal, outlined
/// - Shapes: round (pill) or square (rounded rect). Toggle can flip shape when selected.
/// - Widths: default, narrow, wide
/// - Toggle: [isSelected] + [selectedIcon]
class IconButtonM3E extends StatelessWidget {
const IconButtonM3E({
super.key,
required this.icon,
this.onPressed,
this.tooltip,
this.semanticLabel,
this.variant = IconButtonM3EVariant.standard,
this.size = IconButtonM3ESize.sm,
this.shape = IconButtonM3EShapeVariant.round,
this.width = IconButtonM3EWidth.defaultWidth,
this.isSelected,
this.selectedIcon,
this.enableFeedback,
});
final Widget icon;
final VoidCallback? onPressed;
final String? tooltip;
final String? semanticLabel;
final IconButtonM3EVariant variant;
final IconButtonM3ESize size;
final IconButtonM3EShapeVariant shape;
final IconButtonM3EWidth width;
final bool? isSelected;
final Widget? selectedIcon;
final bool? enableFeedback;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final Size visual = size.visual(width);
final Size target = size.target(width);
final double iconPx = size.icon;
final bool selected = isSelected ?? false;
// Consider it a toggle control if selection can be represented.
final bool isToggle = isSelected != null || selectedIcon != null;
// Colors per variant (selected tint for standard).
Color bg;
Color fg;
BorderSide? side;
switch (variant) {
case IconButtonM3EVariant.standard:
bg = Colors.transparent;
fg = selected ? scheme.primary : scheme.onSurfaceVariant;
side = null;
break;
case IconButtonM3EVariant.filled:
bg = scheme.primary;
fg = scheme.onPrimary;
side = null;
break;
case IconButtonM3EVariant.tonal:
bg = scheme.secondaryContainer;
fg = scheme.onSecondaryContainer;
side = null;
break;
case IconButtonM3EVariant.outlined:
bg = Colors.transparent;
fg = scheme.primary;
side = BorderSide(color: scheme.outline, width: 1);
break;
}
// Resolve shape radius based on states (pressed) and toggle/selection.
OutlinedBorder shapeFor(Set<WidgetState> states) {
final r = IconButtonM3EShapes.effectiveRadius(
size: size,
baseVariant: shape,
isToggle: isToggle,
isSelected: selected,
states: states,
);
return RoundedRectangleBorder(borderRadius: BorderRadius.circular(r));
}
final Widget innerIcon = IconTheme.merge(
data: IconThemeData(size: iconPx, color: fg),
child: (selected && selectedIcon != null) ? selectedIcon! : icon,
);
final Widget button = IconButton(
onPressed: onPressed,
isSelected: isSelected,
selectedIcon: selectedIcon,
icon: innerIcon,
tooltip: tooltip,
enableFeedback: enableFeedback,
style: ButtonStyle(
// Visual (painted) size
fixedSize: WidgetStateProperty.all(visual),
padding: WidgetStateProperty.all(EdgeInsets.zero),
shape: WidgetStateProperty.resolveWith(shapeFor),
backgroundColor: WidgetStateProperty.all(bg),
foregroundColor: WidgetStateProperty.resolveWith((_) => fg),
side: WidgetStateProperty.resolveWith((_) => side),
// Animate pressed shape morph a bit.
animationDuration: IconButtonM3ETokens.morphDuration,
visualDensity: VisualDensity.standard,
),
);
// Compose into an outer box sized to the minimum interactive target.
final Widget core = SizedBox(
width: target.width,
height: target.height,
child: Center(
child: SizedBox(
width: visual.width,
height: visual.height,
child: button,
),
),
);
final semanticsText = semanticLabel ?? tooltip;
return Semantics(
button: true,
selected: selected,
label: semanticsText,
child: core,
);
}
}

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,21 @@
name: icon_button_m3e
description: "Material 3 Expressive IconButton with sizes, variants, shapes, toggle, and accessible hit targets."
version: 0.1.1
repository: https://github.com/EmilyMonestone/icon_button_m3e
issue_tracker: https://github.com/EmilyMonestone/icon_button_m3e/issues
environment:
sdk: ^3.9.2
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

View file

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

View file

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:icon_button_m3e/icon_button_m3e.dart';
void main() {
testWidgets('Semantics exposes label and selected state', (tester) async {
const label = 'Favorite';
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: IconButtonM3E(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
isSelected: true,
tooltip: label,
),
),
),
);
final semantics = tester.getSemantics(find.byType(IconButtonM3E));
expect(semantics.flagsCollection.hasSelectedState, true);
expect(semantics.label, label);
});
testWidgets('Hit target is at least 48x48 when visual is XS (32)', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: IconButtonM3E(
size: IconButtonM3ESize.xs,
icon: Icon(Icons.mic),
),
),
),
),
);
final size = tester.getSize(find.byType(IconButtonM3E));
expect(size.width, greaterThanOrEqualTo(48));
expect(size.height, greaterThanOrEqualTo(48));
});
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,56 @@
# loading_indicator_m3e
Material 3 **Expressive** Loading Indicator for Flutter — a morphing polygon that continuously rotates and morphs between shapes (ported from Android's Material3 `LoadingIndicator`).
Two configurations:
- **Default** — container uses `secondaryContainer`, active indicator uses `primary`
- **Contained** — container uses `primaryContainer`, active indicator uses `onPrimaryContainer`
Token-aligned sizes:
- Container: **48 × 48dp**
- Active indicator size: **38dp**
- Container shape: **full** (pill/circular) corners
## Usage
```dart
import 'package:loading_indicator_m3e/loading_indicator_m3e.dart';
// Default
const LoadingIndicatorM3E();
// Contained
const LoadingIndicatorM3E(variant: LoadingIndicatorM3EVariant.contained);
// Custom colors, custom polygon sequence
LoadingIndicatorM3E(
color: Colors.teal,
polygons: const [
MaterialShapes.sunny,
MaterialShapes.cookie9Sided,
MaterialShapes.pill,
],
);
```
## Notes
- The inner morph sequence and animation timings match the Compose implementation:
- Morph interval ~650ms, global rotation ~4666ms
- Active size is scaled to ~38dp inside the 48dp container to avoid clipping while rotating
- Requires your monorepo `m3e_design` (for tokens) and `material_new_shapes` (for `RoundedPolygon` + `Morph` + `MaterialShapes`). The `pubspec.yaml` is set up with `path: ../...`.
## Monorepo Layout
```
packages/
m3e_design/
material_new_shapes/
loading_indicator_m3e/
```
## Accessibility
Pass `semanticLabel` and `semanticValue` to announce loading status if needed.
## License
- Android/Compose implementation © Google, Apache-2.0
- This package MIT

View file

@ -0,0 +1,6 @@
library loading_indicator_m3e;
export 'src/enums.dart';
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
export 'src/expressive_loading_indicator.dart';
export 'src/loading_indicator_m3e.dart';

View file

@ -0,0 +1 @@
enum LoadingIndicatorM3EVariant { defaultStyle, contained }

View file

@ -0,0 +1,329 @@
// Port of Android's LoadingIndicator
// Source: androidx/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt
// Copyright (c) 2024 The Android Open Source Project
// Licensed under the Apache License, Version 2.0
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/semantics.dart';
import 'package:material_new_shapes/material_new_shapes.dart';
/// A Material Design loading indicator.
///
/// This version of the loading indicator morphs between its [polygons] shapes.
/// ![Loading indicator image](https://developer.android.com/images/reference/androidx/compose/material3/loading-indicator.png)
class ExpressiveLoadingIndicator extends ProgressIndicator {
/// A list of [RoundedPolygon]s for the sequence of shapes this loading indicator
/// will morph between. The loading indicator expects at least two items in that list.
final List<RoundedPolygon>? polygons;
/// Defines minimum and maximum sizes for an [ExpressiveLoadingIndicator].
/// If null, then the [ProgressIndicatorThemeData.constraints] will be used. Otherwise, defaults to a minimum width and height of 48 pixels.
final BoxConstraints? constraints;
const ExpressiveLoadingIndicator({
super.key,
super.color,
this.polygons,
this.constraints,
super.semanticsLabel,
super.semanticsValue,
}) : assert(polygons != null ? polygons.length > 1 : true);
@override
State<ExpressiveLoadingIndicator> createState() =>
_ExpressiveLoadingIndicatorState();
}
class _ExpressiveLoadingIndicatorState extends State<ExpressiveLoadingIndicator>
with TickerProviderStateMixin {
static final List<RoundedPolygon> _defaultPolygons = [
MaterialShapes.softBurst,
MaterialShapes.cookie9Sided,
MaterialShapes.pentagon,
MaterialShapes.pill,
MaterialShapes.sunny,
MaterialShapes.cookie4Sided,
MaterialShapes.oval,
];
static final BoxConstraints _defaultConstraints = BoxConstraints(
minWidth: 48.0,
minHeight: 48.0,
maxWidth: 48.0,
maxHeight: 48.0,
); // default from kotlin source
late final List<RoundedPolygon> _polygons;
static const int _globalRotationDurationMs = 4666;
static const int _morphIntervalMs = 650;
static const double _fullRotation = 360.0;
static const double _quarterRotation = _fullRotation / 4;
static const double _activeSize = 38; // based on source spec
late final List<Morph> _morphSequence;
late final AnimationController _morphController;
late final AnimationController _globalRotationController;
int _currentMorphIndex = 0;
double _morphRotationTargetAngle = _quarterRotation;
Timer? _morphTimer;
final _morphAnimationSpec = SpringSimulation(
SpringDescription.withDampingRatio(ratio: 0.6, stiffness: 200.0, mass: 1.0),
0.0,
1.0,
5.0,
snapToEnd: true,
);
late BoxConstraints _constraints;
late Color _color;
@override
Widget build(BuildContext context) {
final indicatorTheme = ProgressIndicatorTheme.of(context);
_color =
widget.color ??
indicatorTheme.color ??
Theme.of(context).colorScheme.primary;
_constraints =
widget.constraints ?? indicatorTheme.constraints ?? _defaultConstraints;
final activeIndicatorScale =
_activeSize / math.min(_constraints.maxWidth, _constraints.maxHeight);
final shapesScaleFactor =
_calculateScaleFactor(_polygons) * activeIndicatorScale;
return Semantics.fromProperties(
properties: SemanticsProperties(
label: widget.semanticsLabel,
value: widget.semanticsValue,
),
child: RepaintBoundary(
child: ConstrainedBox(
constraints: _constraints,
child: AspectRatio(
aspectRatio: 1.0,
child: AnimatedBuilder(
animation: Listenable.merge([
_morphController,
_globalRotationController,
]),
builder: (context, child) {
final morphProgress = _morphController.value.clamp(0.0, 1.0);
final globalRotationDegrees =
_globalRotationController.value * _fullRotation;
// calculate total rotation (clockwise, matching Kotlin implementation)
final totalRotationDegrees =
morphProgress * _quarterRotation +
_morphRotationTargetAngle +
globalRotationDegrees;
final totalRotationRadians =
totalRotationDegrees * (math.pi / 180.0);
return Transform.rotate(
angle: totalRotationRadians,
child: CustomPaint(
painter: _MorphPainter(
morph: _morphSequence[_currentMorphIndex],
progress: morphProgress,
color: _color,
scaleFactor: shapesScaleFactor,
repaint: Listenable.merge([
_morphController,
_globalRotationController,
]),
),
child: const SizedBox.expand(),
),
);
},
),
),
),
),
);
}
@override
void dispose() {
_morphTimer?.cancel();
_morphController.dispose();
_globalRotationController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_polygons = widget.polygons ?? _defaultPolygons;
_morphSequence = _createMorphSequence(_polygons, circularSequence: true);
_morphController = AnimationController.unbounded(vsync: this);
// continuous linear rotation
_globalRotationController = AnimationController(
duration: const Duration(milliseconds: _globalRotationDurationMs),
vsync: this,
);
_startAnimations();
}
List<Morph> _createMorphSequence(
List<RoundedPolygon> polygons, {
required bool circularSequence,
}) {
final morphs = <Morph>[];
for (int i = 0; i < polygons.length; i++) {
if (i + 1 < polygons.length) {
morphs.add(Morph(polygons[i], polygons[i + 1]));
} else if (circularSequence) {
// morph from last shape back to first shape
morphs.add(Morph(polygons[i], polygons[0]));
}
}
return morphs;
}
/// Calculates a scale factor that will be used when scaling the provided [RoundedPolygon]s into a
/// specified sized container.
///
/// Since the polygons may rotate, a simple [RoundedPolygon.calculateBounds] is not enough to
/// determine the size the polygon will occupy as it rotates. Using the simple bounds calculation may
/// result in a clipped shape.
///
/// This function calculates and returns a scale factor by utilizing the
/// [RoundedPolygon.calculateMaxBounds] and comparing its result to the
/// [RoundedPolygon.calculateBounds]. The scale factor can later be used when calling [processPath].
///
/// Port of Kotlin implementation.
double _calculateScaleFactor(List<RoundedPolygon> polygons) {
var scaleFactor = 1.0;
for (final polygon in polygons) {
final bounds = polygon.calculateBounds();
final maxBounds = polygon.calculateMaxBounds();
final boundsWidth = bounds[2] - bounds[0];
final boundsHeight = bounds[3] - bounds[1];
final maxBoundsWidth = maxBounds[2] - maxBounds[0];
final maxBoundsHeight = maxBounds[3] - maxBounds[1];
final scaleX = boundsWidth / maxBoundsWidth;
final scaleY = boundsHeight / maxBoundsHeight;
// We use max(scaleX, scaleY) to handle cases like a pill-shape that can throw off the
// entire calculation.
scaleFactor = math.min(scaleFactor, math.max(scaleX, scaleY));
}
return scaleFactor;
}
void _startAnimations() {
// infinite global rotation
_globalRotationController.repeat();
// periodic morph cycle
_morphTimer = Timer.periodic(
const Duration(milliseconds: _morphIntervalMs),
(_) => _startMorphCycle(),
);
_startMorphCycle();
}
void _startMorphCycle() {
if (!mounted) return;
// move to next morph in sequence
_currentMorphIndex = (_currentMorphIndex + 1) % _morphSequence.length;
// accumulate rotation target
_morphRotationTargetAngle =
(_morphRotationTargetAngle + _quarterRotation) % _fullRotation;
// Reset and start morph animation
_morphController
..value = 0.0
..animateWith(_morphAnimationSpec);
}
}
class _MorphPainter extends CustomPainter {
final Morph morph;
final double progress;
final Color color;
/// A scale factor that will be taken into account uniformly when the [path] is
/// scaled (i.e. the scaleX would be the [size] width x the scale factor, and the scaleY would be
/// the [size] height x the scale factor)
final double scaleFactor;
_MorphPainter({
required this.morph,
required this.progress,
required this.color,
this.scaleFactor = 1.0,
super.repaint,
});
@override
void paint(Canvas canvas, Size size) {
final path = morph.toPath(progress: progress);
final processedPath = _processPath(path, size);
canvas.drawPath(
processedPath,
Paint()
..style = PaintingStyle.fill
..color = color,
);
}
@override
bool shouldRepaint(_MorphPainter oldDelegate) {
return oldDelegate.morph != morph ||
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.scaleFactor != scaleFactor;
}
/// Process a given path to scale it and center it inside the given size.
///
/// [path] takes a [Path] that was generated by a _normalized_ [Morph] or [RoundedPolygon].
/// [size] takes a [Size] that the provided [path] is going to be scaled and centered into.
Path _processPath(Path path, Size size) {
// a [Matrix] that would be used to apply the scaling. Note that any provided
// matrix will be reset in this function.
final Matrix4 scaleMatrix = Matrix4.diagonal3Values(
size.width * scaleFactor,
size.height * scaleFactor,
1,
);
final Path scaledPath = path.transform(scaleMatrix.storage);
// Translate the path so that its center aligns with the center of the container.
final Rect bounds = scaledPath.getBounds();
final Offset translation =
Offset(size.width / 2, size.height / 2) - bounds.center;
final Path finalPath = scaledPath.shift(translation);
return finalPath;
}
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:material_new_shapes/material_new_shapes.dart';
import 'expressive_loading_indicator.dart';
import 'loading_tokens_adapter.dart';
import 'enums.dart';
/// Material 3 Expressive Loading Indicator
/// - Default: floating morphing shape on surface
/// - Contained: icon inside colored container (primary container) using onPrimaryContainer
class LoadingIndicatorM3E extends StatelessWidget {
const LoadingIndicatorM3E({
super.key,
this.variant = LoadingIndicatorM3EVariant.defaultStyle,
this.color,
this.containerColor,
this.polygons,
this.constraints,
this.padding,
this.semanticLabel,
this.semanticValue,
});
final LoadingIndicatorM3EVariant variant;
final Color? color;
final Color? containerColor;
final List<RoundedPolygon>? polygons;
final BoxConstraints? constraints;
final EdgeInsetsGeometry? padding;
final String? semanticLabel;
final String? semanticValue;
@override
Widget build(BuildContext context) {
final tokens = LoadingTokensAdapter(context);
final size = Size(tokens.containerWidth(), tokens.containerHeight());
final cons = constraints ?? BoxConstraints.tight(size);
final activeColor = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(),
};
final containerBg = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(),
LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(),
};
final indicator = ExpressiveLoadingIndicator(
color: activeColor,
polygons: polygons,
semanticsLabel: semanticLabel,
semanticsValue: semanticValue,
constraints: cons,
);
if (variant == LoadingIndicatorM3EVariant.defaultStyle) {
// Default: subtle container (secondaryContainer)
return DecoratedBox(
decoration: BoxDecoration(
color: containerBg,
borderRadius: tokens.containerRadius(),
),
child: Padding(
padding: padding ?? const EdgeInsets.all(0),
child: indicator,
),
);
}
// Contained: stronger container (primaryContainer) and contrasting active indicator
return DecoratedBox(
decoration: BoxDecoration(
color: containerBg,
borderRadius: tokens.containerRadius(),
),
child: Padding(
padding: padding ?? const EdgeInsets.all(0),
child: indicator,
),
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
@immutable
class LoadingTokensAdapter {
const LoadingTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
// Active indicator color (Default variant)
Color activeColor() => _m3e.colors.primary;
// Container color (Default variant -> transparent background)
Color containerColorDefault() => Colors.transparent;
// Contained variant colors
Color containedContainerColor() => _m3e.colors.primaryContainer;
Color containedActiveColor() => _m3e.colors.onPrimaryContainer;
// Size tokens (from spec)
double containerWidth() => 48; // container height/width
double containerHeight() => 48;
double activeIndicatorSize() => 38;
// Shape: full corners
BorderRadius containerRadius() => BorderRadius.circular(999);
}

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,19 @@
name: loading_indicator_m3e
description: Material 3 Expressive Loading Indicator (morphing polygons) for Flutter, with Default and Contained variants.
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
material_new_shapes: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter

View file

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

View file

@ -0,0 +1,3 @@
# m3e_collection
Single import that re-exports all M3E component packages plus `m3e_design`.

View file

@ -0,0 +1,16 @@
library m3e_collection;
export 'package:app_bar_m3e/app_bar_m3e.dart';
export 'package:button_group_m3e/button_group_m3e.dart';
export 'package:button_m3e/button_m3e.dart';
export 'package:fab_m3e/fab_m3e.dart';
export 'package:icon_button_m3e/icon_button_m3e.dart';
export 'package:loading_indicator_m3e/loading_indicator_m3e.dart';
export 'package:m3e_design/m3e_design.dart';
export 'package:material_new_shapes/material_new_shapes.dart';
export 'package:navigation_bar_m3e/navigation_bar_m3e.dart';
export 'package:navigation_rail_m3e/navigation_rail_m3e.dart';
export 'package:progress_indicator_m3e/progress_indicator_m3e.dart';
export 'package:slider_m3e/slider_m3e.dart';
export 'package:split_button_m3e/split_button_m3e.dart';
export 'package:toolbar_m3e/toolbar_m3e.dart';

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,39 @@
name: m3e_collection
description: Aggregated exports of all Material 3 Expressive components for Flutter.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
material_new_shapes: ^1.0.0
m3e_design:
path: ../m3e_design
icon_button_m3e:
path: ../icon_button_m3e
split_button_m3e:
path: ../split_button_m3e
button_group_m3e:
path: ../button_group_m3e
app_bar_m3e:
path: ../app_bar_m3e
button_m3e:
path: ../button_m3e
fab_m3e:
path: ../fab_m3e
loading_indicator_m3e:
path: ../loading_indicator_m3e
progress_indicator_m3e:
path: ../progress_indicator_m3e
navigation_bar_m3e:
path: ../navigation_bar_m3e
navigation_rail_m3e:
path: ../navigation_rail_m3e
slider_m3e:
path: ../slider_m3e
toolbar_m3e:
path: ../toolbar_m3e

View file

@ -0,0 +1,29 @@
# melos_managed_dependency_overrides: icon_button_m3e,m3e_design,split_button_m3e,app_bar_m3e,button_m3e,fab_m3e,loading_indicator_m3e,progress_indicator_m3e,navigation_bar_m3e,navigation_rail_m3e,slider_m3e,toolbar_m3e,button_group_m3e
dependency_overrides:
icon_button_m3e:
path: ..\\icon_button_m3e
m3e_design:
path: ..\\m3e_design
split_button_m3e:
path: ..\\split_button_m3e
app_bar_m3e:
path: ..\\app_bar_m3e
button_m3e:
path: ..\\button_m3e
fab_m3e:
path: ..\\fab_m3e
loading_indicator_m3e:
path: ..\\loading_indicator_m3e
progress_indicator_m3e:
path: ..\\progress_indicator_m3e
navigation_bar_m3e:
path: ..\\navigation_bar_m3e
navigation_rail_m3e:
path: ..\\navigation_rail_m3e
slider_m3e:
path: ..\\slider_m3e
toolbar_m3e:
path: ..\\toolbar_m3e
button_group_m3e:
path: ..\\button_group_m3e

View file

@ -0,0 +1,4 @@
# m3e_design
Design language core for Material 3 Expressive (Flutter).
Provides ThemeExtension and token accessors for color, typography, shapes, spacing, motion.

View file

@ -0,0 +1,10 @@
library m3e_design;
export 'theme/m3e_theme.dart';
export 'tokens/color_tokens.dart';
export 'tokens/motion_tokens.dart';
export 'tokens/shape_tokens.dart';
export 'tokens/spacing_tokens.dart';
export 'tokens/typography_tokens.dart';
export 'utils/build_context_x.dart';
export 'utils/semantics_x.dart';

View file

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../tokens/color_tokens.dart';
import '../tokens/motion_tokens.dart';
import '../tokens/shape_tokens.dart';
import '../tokens/spacing_tokens.dart';
import '../tokens/typography_tokens.dart';
@immutable
class M3ETheme extends ThemeExtension<M3ETheme> {
final M3EColors colors;
final M3ETypography typography;
final M3EShapes shapes;
final M3ESpacing spacing;
final M3EMotion motion;
const M3ETheme({
required this.colors,
required this.typography,
required this.shapes,
required this.spacing,
required this.motion,
});
// Convenience proxy for commonly used text styles in packages (m3e.type.*)
_M3ETypeProxy get type => _M3ETypeProxy(typography);
static M3ETheme defaults(ColorScheme scheme) => M3ETheme(
colors: M3EColors.from(scheme),
typography: M3ETypography.defaultFor(scheme.brightness),
shapes: M3EShapes.expressive(),
spacing: const M3ESpacing.regular(),
motion: const M3EMotion.expressive(),
);
@override
M3ETheme copyWith({
M3EColors? colors,
M3ETypography? typography,
M3EShapes? shapes,
M3ESpacing? spacing,
M3EMotion? motion,
}) =>
M3ETheme(
colors: colors ?? this.colors,
typography: typography ?? this.typography,
shapes: shapes ?? this.shapes,
spacing: spacing ?? this.spacing,
motion: motion ?? this.motion,
);
@override
M3ETheme lerp(covariant M3ETheme? other, double t) {
if (other == null) return this;
return M3ETheme(
colors: M3EColors.lerp(colors, other.colors, t),
typography: M3ETypography.lerp(typography, other.typography, t),
shapes: M3EShapes.lerp(shapes, other.shapes, t),
spacing: M3ESpacing.lerp(spacing, other.spacing, t),
motion: M3EMotion.lerp(motion, other.motion, t),
);
}
}
/// Inject (or replace) the M3ETheme extension on a ThemeData.
ThemeData withM3ETheme(ThemeData base, {M3ETheme? override}) {
// Use any existing M3ETheme, else the provided override, else defaults.
final current = base.extension<M3ETheme>();
final next = override ?? current ?? M3ETheme.defaults(base.colorScheme);
// Merge existing extensions (values) with our M3ETheme, replacing prior ones.
final Iterable<ThemeExtension<dynamic>> existing = base.extensions.values;
final List<ThemeExtension<dynamic>> merged = <ThemeExtension<dynamic>>[];
for (final e in existing) {
if (e is! M3ETheme) {
merged.add(e);
}
}
merged.add(next);
return base.copyWith(extensions: merged);
}
// Internal proxy for typography shortcuts used by components.
class _M3ETypeProxy {
const _M3ETypeProxy(this._t);
final M3ETypography _t;
TextStyle get _empty => const TextStyle();
TextStyle get titleLarge => _t.base.titleLarge ?? _empty;
TextStyle get titleSmall => _t.base.titleSmall ?? _empty;
TextStyle get bodySmall => _t.base.bodySmall ?? _empty;
TextStyle get labelLarge => _t.base.labelLarge ?? _empty;
TextStyle get labelMedium => _t.base.labelMedium ?? _empty;
TextStyle get labelSmall => _t.base.labelSmall ?? _empty;
TextStyle get headlineSmallEmphasized =>
(_t.base.headlineSmall ?? _empty).merge(_t.emphasized.headline);
}

View file

@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
@immutable
class M3EColors {
final Color emphasis;
final Color onEmphasis;
final Color info;
final Color success;
final Color warning;
final Color danger;
final Color surfaceStrong;
final Color onSurfaceStrong;
final Color outlineStrong;
// New: proxy common ColorScheme fields used across packages
final Color primary;
final Color onPrimary;
final Color primaryContainer;
final Color onPrimaryContainer;
final Color secondary;
final Color onSecondary;
final Color secondaryContainer;
final Color onSecondaryContainer;
final Color tertiary;
final Color onTertiary;
final Color tertiaryContainer;
final Color onTertiaryContainer;
final Color surface;
final Color onSurface;
final Color onSurfaceVariant;
final Color error;
final Color onError;
final Color errorContainer;
final Color onErrorContainer;
final Color outline;
final Color outlineVariant;
// New: container surface tokens not always present on older ColorScheme
final Color surfaceContainerHigh;
final Color surfaceContainerLowest;
const M3EColors({
required this.emphasis,
required this.onEmphasis,
required this.info,
required this.success,
required this.warning,
required this.danger,
required this.surfaceStrong,
required this.onSurfaceStrong,
required this.outlineStrong,
// New fields
required this.primary,
required this.onPrimary,
required this.primaryContainer,
required this.onPrimaryContainer,
required this.secondary,
required this.onSecondary,
required this.secondaryContainer,
required this.onSecondaryContainer,
required this.tertiary,
required this.onTertiary,
required this.tertiaryContainer,
required this.onTertiaryContainer,
required this.surface,
required this.onSurface,
required this.onSurfaceVariant,
required this.error,
required this.onError,
required this.errorContainer,
required this.onErrorContainer,
required this.outline,
required this.outlineVariant,
required this.surfaceContainerHigh,
required this.surfaceContainerLowest,
});
factory M3EColors.from(ColorScheme s) {
// Compute container surface variants if not available on the ColorScheme version in use.
// We prefer mild blends that work in both light/dark.
Color computeSurfaceContainerHigh() =>
Color.alphaBlend(s.primary.withValues(alpha: 0.12), s.surface);
Color computeSurfaceContainerLowest() =>
Color.alphaBlend(s.onSurface.withValues(alpha: 0.05), s.surface);
return M3EColors(
emphasis: s.primary,
onEmphasis: s.onPrimary,
info: s.tertiary,
success: Color.alphaBlend(
Colors.green.shade400.withValues(alpha: 0.2), s.primaryContainer),
warning: Color.alphaBlend(
Colors.orange.shade400.withValues(alpha: 0.2), s.secondaryContainer),
danger: Color.alphaBlend(
Colors.red.shade400.withValues(alpha: 0.2), s.errorContainer),
surfaceStrong:
Color.alphaBlend(s.primary.withValues(alpha: 0.06), s.surface),
onSurfaceStrong: s.onSurface,
outlineStrong:
Color.alphaBlend(s.primary.withValues(alpha: 0.40), s.outlineVariant),
// New fields mapped from ColorScheme
primary: s.primary,
onPrimary: s.onPrimary,
primaryContainer: s.primaryContainer,
onPrimaryContainer: s.onPrimaryContainer,
secondary: s.secondary,
onSecondary: s.onSecondary,
secondaryContainer: s.secondaryContainer,
onSecondaryContainer: s.onSecondaryContainer,
tertiary: s.tertiary,
onTertiary: s.onTertiary,
tertiaryContainer: s.tertiaryContainer,
onTertiaryContainer: s.onTertiaryContainer,
surface: s.surface,
onSurface: s.onSurface,
onSurfaceVariant: s.onSurfaceVariant,
error: s.error,
onError: s.onError,
errorContainer: s.errorContainer,
onErrorContainer: s.onErrorContainer,
outline: s.outline,
outlineVariant: s.outlineVariant,
surfaceContainerHigh: (() {
// If the ColorScheme already has a matching field, prefer that via dynamic access; otherwise compute.
try {
final dynamic dyn = s;
final c = dyn.surfaceContainerHigh as Color?;
return c ?? computeSurfaceContainerHigh();
} catch (_) {
return computeSurfaceContainerHigh();
}
})(),
surfaceContainerLowest: (() {
try {
final dynamic dyn = s;
final c = dyn.surfaceContainerLowest as Color?;
return c ?? computeSurfaceContainerLowest();
} catch (_) {
return computeSurfaceContainerLowest();
}
})(),
);
}
static M3EColors lerp(M3EColors a, M3EColors b, double t) => M3EColors(
emphasis: Color.lerp(a.emphasis, b.emphasis, t)!,
onEmphasis: Color.lerp(a.onEmphasis, b.onEmphasis, t)!,
info: Color.lerp(a.info, b.info, t)!,
success: Color.lerp(a.success, b.success, t)!,
warning: Color.lerp(a.warning, b.warning, t)!,
danger: Color.lerp(a.danger, b.danger, t)!,
surfaceStrong: Color.lerp(a.surfaceStrong, b.surfaceStrong, t)!,
onSurfaceStrong: Color.lerp(a.onSurfaceStrong, b.onSurfaceStrong, t)!,
outlineStrong: Color.lerp(a.outlineStrong, b.outlineStrong, t)!,
// New fields
primary: Color.lerp(a.primary, b.primary, t)!,
onPrimary: Color.lerp(a.onPrimary, b.onPrimary, t)!,
primaryContainer:
Color.lerp(a.primaryContainer, b.primaryContainer, t)!,
onPrimaryContainer:
Color.lerp(a.onPrimaryContainer, b.onPrimaryContainer, t)!,
secondary: Color.lerp(a.secondary, b.secondary, t)!,
onSecondary: Color.lerp(a.onSecondary, b.onSecondary, t)!,
secondaryContainer:
Color.lerp(a.secondaryContainer, b.secondaryContainer, t)!,
onSecondaryContainer:
Color.lerp(a.onSecondaryContainer, b.onSecondaryContainer, t)!,
tertiary: Color.lerp(a.tertiary, b.tertiary, t)!,
onTertiary: Color.lerp(a.onTertiary, b.onTertiary, t)!,
tertiaryContainer:
Color.lerp(a.tertiaryContainer, b.tertiaryContainer, t)!,
onTertiaryContainer:
Color.lerp(a.onTertiaryContainer, b.onTertiaryContainer, t)!,
surface: Color.lerp(a.surface, b.surface, t)!,
onSurface: Color.lerp(a.onSurface, b.onSurface, t)!,
onSurfaceVariant:
Color.lerp(a.onSurfaceVariant, b.onSurfaceVariant, t)!,
error: Color.lerp(a.error, b.error, t)!,
onError: Color.lerp(a.onError, b.onError, t)!,
errorContainer: Color.lerp(a.errorContainer, b.errorContainer, t)!,
onErrorContainer:
Color.lerp(a.onErrorContainer, b.onErrorContainer, t)!,
outline: Color.lerp(a.outline, b.outline, t)!,
outlineVariant: Color.lerp(a.outlineVariant, b.outlineVariant, t)!,
surfaceContainerHigh:
Color.lerp(a.surfaceContainerHigh, b.surfaceContainerHigh, t)!,
surfaceContainerLowest:
Color.lerp(a.surfaceContainerLowest, b.surfaceContainerLowest, t)!,
);
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
@immutable
class M3EMotion {
final SpringDescription spatialFast;
final SpringDescription spatialMedium;
final SpringDescription spatialGentle;
final SpringDescription effectsFast;
final SpringDescription effectsMedium;
final Duration fast;
final Duration medium;
final Duration slow;
const M3EMotion({
required this.spatialFast,
required this.spatialMedium,
required this.spatialGentle,
required this.effectsFast,
required this.effectsMedium,
required this.fast,
required this.medium,
required this.slow,
});
const M3EMotion.expressive()
: spatialFast = const SpringDescription(mass: 1, stiffness: 500, damping: 30),
spatialMedium = const SpringDescription(mass: 1, stiffness: 350, damping: 28),
spatialGentle = const SpringDescription(mass: 1, stiffness: 220, damping: 24),
effectsFast = const SpringDescription(mass: 1, stiffness: 420, damping: 32),
effectsMedium = const SpringDescription(mass: 1, stiffness: 280, damping: 28),
fast = const Duration(milliseconds: 150),
medium = const Duration(milliseconds: 250),
slow = const Duration(milliseconds: 400);
static M3EMotion lerp(M3EMotion a, M3EMotion b, double t) => a;
}
class SpringDescription {
final double mass, stiffness, damping;
const SpringDescription({required this.mass, required this.stiffness, required this.damping});
}

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
enum M3EShapeVariant { round, square }
@immutable
class M3EShapeSet {
final BorderRadius xs;
final BorderRadius sm;
final BorderRadius md;
final BorderRadius lg;
final BorderRadius xl;
const M3EShapeSet({required this.xs, required this.sm, required this.md, required this.lg, required this.xl});
}
@immutable
class M3EShapes {
final M3EShapeSet round;
final M3EShapeSet square;
const M3EShapes({required this.round, required this.square});
factory M3EShapes.expressive() => const M3EShapes(
round: M3EShapeSet(
xs: BorderRadius.all(Radius.circular(999)),
sm: BorderRadius.all(Radius.circular(20)),
md: BorderRadius.all(Radius.circular(28)),
lg: BorderRadius.all(Radius.circular(44)),
xl: BorderRadius.all(Radius.circular(64)),
),
square: M3EShapeSet(
xs: BorderRadius.all(Radius.circular(6)),
sm: BorderRadius.all(Radius.circular(8)),
md: BorderRadius.all(Radius.circular(12)),
lg: BorderRadius.all(Radius.circular(16)),
xl: BorderRadius.all(Radius.circular(20)),
),
);
static M3EShapes lerp(M3EShapes a, M3EShapes b, double t) => M3EShapes(
round: _lerpSet(a.round, b.round, t),
square: _lerpSet(a.square, b.square, t),
);
static M3EShapeSet _lerpSet(M3EShapeSet a, M3EShapeSet b, double t) => M3EShapeSet(
xs: BorderRadius.lerp(a.xs, b.xs, t)!,
sm: BorderRadius.lerp(a.sm, b.sm, t)!,
md: BorderRadius.lerp(a.md, b.md, t)!,
lg: BorderRadius.lerp(a.lg, b.lg, t)!,
xl: BorderRadius.lerp(a.xl, b.xl, t)!,
);
}

View file

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
@immutable
class M3ESpacing {
final double xs; // 4
final double sm; // 8
final double md; // 12
final double lg; // 16
final double xl; // 24
final double xxl; // 32
const M3ESpacing({
required this.xs,
required this.sm,
required this.md,
required this.lg,
required this.xl,
required this.xxl,
});
const M3ESpacing.regular()
: xs = 4,
sm = 8,
md = 12,
lg = 16,
xl = 24,
xxl = 32;
static M3ESpacing lerp(M3ESpacing a, M3ESpacing b, double t) => M3ESpacing(
xs: a.xs + (b.xs - a.xs) * t,
sm: a.sm + (b.sm - a.sm) * t,
md: a.md + (b.md - a.md) * t,
lg: a.lg + (b.lg - a.lg) * t,
xl: a.xl + (b.xl - a.xl) * t,
xxl: a.xxl + (b.xxl - a.xxl) * t,
);
}

View file

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
@immutable
class M3EEmphasized {
final TextStyle display;
final TextStyle headline;
final TextStyle title;
final TextStyle label;
const M3EEmphasized({
required this.display,
required this.headline,
required this.title,
required this.label,
});
static M3EEmphasized forBrightness(Brightness b) {
return const M3EEmphasized(
display: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
headline: TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.25),
title: TextStyle(fontWeight: FontWeight.w700),
label: TextStyle(fontWeight: FontWeight.w700),
);
}
static M3EEmphasized lerp(M3EEmphasized a, M3EEmphasized b, double t) =>
M3EEmphasized(
display: TextStyle.lerp(a.display, b.display, t)!,
headline: TextStyle.lerp(a.headline, b.headline, t)!,
title: TextStyle.lerp(a.title, b.title, t)!,
label: TextStyle.lerp(a.label, b.label, t)!,
);
}
@immutable
class M3ETypography {
final TextTheme base;
final M3EEmphasized emphasized;
const M3ETypography({required this.base, required this.emphasized});
factory M3ETypography.defaultFor(Brightness b) {
// Use a minimal baseline; app's ThemeData will provide fuller TextTheme.
const textTheme = TextTheme();
return M3ETypography(
base: textTheme, emphasized: M3EEmphasized.forBrightness(b));
}
static M3ETypography lerp(M3ETypography a, M3ETypography b, double t) =>
M3ETypography(
base: TextTheme.lerp(a.base, b.base, t),
emphasized: M3EEmphasized.lerp(a.emphasized, b.emphasized, t),
);
}

View file

@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
import '../theme/m3e_theme.dart';
extension BuildContextM3EX on BuildContext {
M3ETheme get m3e =>
Theme.of(this).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(this).colorScheme);
}

View file

@ -0,0 +1,5 @@
import 'package:flutter/material.dart';
extension SemanticsX on Widget {
Widget withLabel(String label) => Semantics(label: label, child: this);
}

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,16 @@
name: m3e_design
description: Material 3 Expressive design language for Flutter (tokens, ThemeExtension, motion).
version: 0.1.0
publish_to: none
repository: https://example.com/your-repo
environment:
sdk: ">=3.5.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,77 @@
# navigation_bar_m3e
Material 3 **Expressive** Navigation Bar for Flutter with badges, pill/underline indicators, and token-driven styling.
- `NavigationBarM3E` — wrapper around Flutter's `NavigationBar` with M3E tokens
- `NavigationDestinationM3E` — destination data (icon, selectedIcon, label, badge)
- `NavBadgeM3E` — small badge/dot utility for icons
All styling is driven by the `m3e_design` ThemeExtension (**M3ETheme**).
## Monorepo Layout
```
packages/
m3e_design/
navigation_bar_m3e/
```
`pubspec.yaml` references `../m3e_design`.
## Usage
```dart
import 'package:navigation_bar_m3e/navigation_bar_m3e.dart';
final items = [
const NavigationDestinationM3E(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
const NavigationDestinationM3E(
icon: Icon(Icons.search),
label: 'Search',
badgeCount: 3,
),
const NavigationDestinationM3E(
icon: Icon(Icons.person),
label: 'Profile',
badgeDot: true,
),
];
NavigationBarM3E(
destinations: items,
selectedIndex: 0,
onDestinationSelected: (i) {},
labelBehavior: NavBarM3ELabelBehavior.onlySelected,
indicatorStyle: NavBarM3EIndicatorStyle.pill, // pill | underline | none
size: NavBarM3ESize.medium,
density: NavBarM3EDensity.regular,
shapeFamily: NavBarM3EShapeFamily.round,
);
```
## Tokens mapping
- **Container**: `surfaceContainerHigh`
- **Indicator**: `secondaryContainer` (color), pill shape by default; `underline` style uses a bottom border
- **Selected**: `onSecondaryContainer` (icon/label)
- **Unselected**: `onSurfaceVariant`
- **Label style**: `labelMedium`
- **Heights**: `small ≈64dp`, `medium ≈80dp`
- **Icon size**: `24dp`
## Badges
Use `badgeCount` for numeric badges or `badgeDot: true` for a small dot. Colors default to `errorContainer / onErrorContainer` and can be overridden via `NavBadgeM3E`.
## Accessibility
- Provide `semanticLabel` per destination (used as tooltip) or on the bar.
- Label behavior options: **alwaysShow**, **onlySelected**, or **alwaysHide**.
## License
MIT

View file

@ -0,0 +1,7 @@
library navigation_bar_m3e;
export 'src/enums.dart';
export 'src/nav_tokens_adapter.dart' show NavTokensAdapter;
export 'src/navigation_bar_m3e.dart';
export 'src/nav_badge_m3e.dart';
export 'src/nav_destination_m3e.dart';

View file

@ -0,0 +1,5 @@
enum NavBarM3ELabelBehavior { alwaysShow, onlySelected, alwaysHide }
enum NavBarM3ESize { small, medium }
enum NavBarM3EShapeFamily { round, square }
enum NavBarM3EDensity { regular, compact }
enum NavBarM3EIndicatorStyle { pill, underline, none }

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class NavBadgeM3E extends StatelessWidget {
const NavBadgeM3E({
super.key,
required this.child,
this.count,
this.showDot = false,
this.maxCount = 99,
this.backgroundColor,
this.foregroundColor,
this.semanticLabel,
this.offset = const Offset(8, -6),
}) : assert(count == null || count >= 0);
final Widget child;
final int? count;
final bool showDot;
final int maxCount;
final Color? backgroundColor;
final Color? foregroundColor;
final String? semanticLabel;
final Offset offset;
@override
Widget build(BuildContext context) {
final t = Theme.of(context);
final m3e = t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
final bg = backgroundColor ?? m3e.colors.errorContainer;
final fg = foregroundColor ?? m3e.colors.onErrorContainer;
final badge = showDot
? _dot(bg)
: _label(bg, fg, count == null ? '' : _format(count!, maxCount));
final stack = Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
right: offset.dx,
top: offset.dy,
child: Semantics(
label: semanticLabel ?? (count != null ? 'Notifications: ${count!}' : 'Notifications'),
child: badge,
),
),
],
);
return stack;
}
Widget _dot(Color bg) {
return Container(
width: 8, height: 8,
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
);
}
Widget _label(Color bg, Color fg, String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: DefaultTextStyle(
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600),
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: fg)),
),
);
}
String _format(int c, int max) => (c > max) ? '$max+' : '$c';
}

View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'nav_badge_m3e.dart';
class NavigationDestinationM3E {
const NavigationDestinationM3E({
required this.icon,
required this.label,
this.selectedIcon,
this.badgeCount,
this.badgeDot = false,
this.semanticLabel,
});
final Widget icon;
final Widget? selectedIcon;
final String label;
/// Optional badge counter
final int? badgeCount;
/// If true, show a small dot instead of a counter.
final bool badgeDot;
final String? semanticLabel;
Widget buildIcon([bool selected = false]) {
final base = selected && selectedIcon != null ? selectedIcon! : icon;
if (badgeCount != null || badgeDot) {
return NavBadgeM3E(
child: base,
count: badgeCount,
showDot: badgeDot,
semanticLabel: semanticLabel,
);
}
return base;
}
}

View file

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _NavMetrics {
final double heightSmall;
final double heightMedium;
final double iconSize;
final EdgeInsetsGeometry padding;
final double indicatorThickness; // for underline
const _NavMetrics({
required this.heightSmall,
required this.heightMedium,
required this.iconSize,
required this.padding,
required this.indicatorThickness,
});
}
_NavMetrics _metricsFor(BuildContext context, NavBarM3EDensity density) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
double hSmall = 64; // compact/phone-tight
double hMedium = 80; // default M3 nav bar height
double icon = 24;
double underline = 3;
if (density == NavBarM3EDensity.compact) {
hSmall -= 4; hMedium -= 4; underline -= 1;
}
return _NavMetrics(
heightSmall: hSmall,
heightMedium: hMedium,
iconSize: icon,
padding: EdgeInsets.symmetric(horizontal: sp.md),
indicatorThickness: underline,
);
}
class NavTokensAdapter {
NavTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_NavMetrics metrics(NavBarM3EDensity density) => _metricsFor(context, density);
// Container/background
Color containerColor() => _m3e.colors.surfaceContainerHigh;
// Indicator
Color indicatorColor() => _m3e.colors.secondaryContainer;
// Icon/label colors
Color selectedColor() => _m3e.colors.onSecondaryContainer;
Color unselectedColor() => _m3e.colors.onSurfaceVariant;
// Typography
TextStyle labelStyle() => _m3e.type.labelMedium;
// Shapes
ShapeBorder containerShape(NavBarM3EShapeFamily family) {
final set = family == NavBarM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
return RoundedRectangleBorder(borderRadius: set.lg);
}
ShapeBorder indicatorShapePill() => const StadiumBorder();
// Underline decoration for selected.
BoxDecoration underlineDecoration(Color color, double thickness) {
return BoxDecoration(
border: Border(
bottom: BorderSide(color: color, width: thickness),
),
);
}
}

View file

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
import 'nav_tokens_adapter.dart';
import 'nav_destination_m3e.dart';
class NavigationBarM3E extends StatelessWidget {
const NavigationBarM3E({
super.key,
required this.destinations,
this.selectedIndex = 0,
this.onDestinationSelected,
this.labelBehavior = NavBarM3ELabelBehavior.onlySelected,
this.size = NavBarM3ESize.medium,
this.shapeFamily = NavBarM3EShapeFamily.round,
this.density = NavBarM3EDensity.regular,
this.backgroundColor,
this.elevation,
this.indicatorStyle = NavBarM3EIndicatorStyle.pill,
this.indicatorColor,
this.padding,
this.safeArea = true,
this.semanticLabel,
});
final List<NavigationDestinationM3E> destinations;
final int selectedIndex;
final ValueChanged<int>? onDestinationSelected;
final NavBarM3ELabelBehavior labelBehavior;
final NavBarM3ESize size;
final NavBarM3EShapeFamily shapeFamily;
final NavBarM3EDensity density;
final Color? backgroundColor;
final double? elevation;
final NavBarM3EIndicatorStyle indicatorStyle;
final Color? indicatorColor;
final EdgeInsetsGeometry? padding;
final bool safeArea;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
assert(destinations.isNotEmpty, 'Provide at least one destination');
final tokens = NavTokensAdapter(context);
final metrics = tokens.metrics(density);
final m3e = Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(Theme.of(context).colorScheme);
final height = size == NavBarM3ESize.small ? metrics.heightSmall : metrics.heightMedium;
final bg = backgroundColor ?? tokens.containerColor();
final shape = tokens.containerShape(shapeFamily);
final nav = Material(
color: bg,
elevation: elevation ?? 0,
shape: shape,
child: SizedBox(
height: height,
child: NavigationBar(
height: height,
elevation: elevation ?? 0,
indicatorColor: indicatorStyle == NavBarM3EIndicatorStyle.none
? Colors.transparent
: (indicatorColor ?? tokens.indicatorColor()),
indicatorShape: switch (indicatorStyle) {
NavBarM3EIndicatorStyle.pill => tokens.indicatorShapePill(),
NavBarM3EIndicatorStyle.underline => const StadiumBorder(), // we'll fake underline via decoration below
NavBarM3EIndicatorStyle.none => const StadiumBorder(),
},
backgroundColor: Colors.transparent, // outer Material supplies bg + shape
labelBehavior: switch (labelBehavior) {
NavBarM3ELabelBehavior.alwaysShow => NavigationDestinationLabelBehavior.alwaysShow,
NavBarM3ELabelBehavior.onlySelected => NavigationDestinationLabelBehavior.onlyShowSelected,
NavBarM3ELabelBehavior.alwaysHide => NavigationDestinationLabelBehavior.alwaysHide,
},
selectedIndex: selectedIndex,
destinations: List.generate(destinations.length, (i) {
final d = destinations[i];
return NavigationDestination(
icon: _icon(context, false, d, metrics.iconSize),
selectedIcon: _selectedIcon(context, true, d, metrics.iconSize, tokens, indicatorStyle),
label: d.label,
tooltip: d.semanticLabel,
);
}),
onDestinationSelected: onDestinationSelected,
),
),
);
final padded = Padding(
padding: padding ?? EdgeInsets.zero,
child: nav,
);
final content = DefaultTextStyle.merge(
style: tokens.labelStyle().copyWith(
color: m3e.colors.onSurfaceVariant,
),
child: IconTheme.merge(
data: IconThemeData(size: metrics.iconSize, color: m3e.colors.onSurfaceVariant),
child: padded,
),
);
if (!safeArea && semanticLabel == null) return content;
final wrapped = SafeArea(top: false, left: false, right: false, bottom: safeArea, child: content);
if (semanticLabel == null) return wrapped;
return Semantics(container: true, label: semanticLabel!, child: wrapped);
}
Widget _icon(BuildContext context, bool selected, NavigationDestinationM3E d, double iconSize) {
return SizedBox(
width: iconSize + 8, // give a little space for underline
height: iconSize + 8,
child: Center(child: d.buildIcon(selected)),
);
}
Widget _selectedIcon(
BuildContext context,
bool selected,
NavigationDestinationM3E d,
double iconSize,
NavTokensAdapter tokens,
NavBarM3EIndicatorStyle style,
) {
final w = _icon(context, selected, d, iconSize);
if (style != NavBarM3EIndicatorStyle.underline) return w;
final metrics = tokens.metrics(density);
final deco = tokens.underlineDecoration(tokens.indicatorColor(), metrics.indicatorThickness);
return DecoratedBox(
decoration: deco,
child: w,
);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class NavigationBarM3EWidget extends StatelessWidget {
const NavigationBarM3EWidget({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('NavigationBar 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,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: navigation_bar_m3e
description: Material 3 Expressive Navigation Bar for Flutter with token-driven colors, shapes, and badges.
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 * 3, 6);
});
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show more