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

23
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,23 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install Melos
run: dart pub global activate melos
- name: Bootstrap
run: melos bootstrap
- name: Format
run: melos run format
- name: Analyze
run: melos run analyze
- name: Test
run: melos run test

18
.gitignore vendored
View file

@ -7,6 +7,24 @@
.buildlog/
.history
# Other
.dart_tool/
.packages
.melos_tool
build/
ios/
android/
macos/
windows/
linux/
web/
.idea/
.vscode/
.flutter-plugins
.flutter-plugins-dependencies
pubspec.lock
coverage/
# Flutter repo-specific

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# Material 3 Expressive Flutter Monorepo (Starter)
This is a starter monorepo for **Material 3 Expressive (M3E)** Flutter packages.
- `packages/m3e_design` design language core (tokens, ThemeExtension, motion)
- `packages/m3e_collection` re-exports all component packages
- `packages/icon_button_m3e` example component (uses `m3e_design`)
- `packages/split_button_m3e` example split button component
- `apps/gallery` showcase app that consumes `m3e_collection`
## Quick start
```bash
dart pub global activate melos
melos bootstrap
# run the gallery
cd apps/gallery
flutter run
```
## Structure
See `melos.yaml`, `analysis_options.yaml`, and the package-level READMEs.

11
analysis_options.yaml Normal file
View file

@ -0,0 +1,11 @@
linter:
rules:
always_declare_return_types: true
directives_ordering: true
prefer_single_quotes: true
sort_pub_dependencies: true
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"

45
apps/gallery/.gitignore vendored Normal file
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

45
apps/gallery/.metadata Normal file
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'

16
apps/gallery/README.md Normal file
View file

@ -0,0 +1,16 @@
# m3e_gallery
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

560
apps/gallery/lib/main.dart Normal file
View file

@ -0,0 +1,560 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
void main() => runApp(const GalleryApp());
class GalleryApp extends StatelessWidget {
const GalleryApp({super.key});
@override
Widget build(BuildContext context) {
final base = ThemeData(useMaterial3: true, colorSchemeSeed: Colors.purple);
return MaterialApp(
title: 'M3E Gallery',
theme: withM3ETheme(base),
home: const GalleryHome(),
debugShowCheckedModeBanner: false,
);
}
}
class GalleryHome extends StatefulWidget {
const GalleryHome({super.key});
@override
State<GalleryHome> createState() => _GalleryHomeState();
}
class _GalleryHomeState extends State<GalleryHome> {
// Navigation examples
int _navBarIndex = 0;
int _railIndex = 0;
// Slider examples
double _sliderValue = 0.4;
RangeValues _rangeValues = const RangeValues(0.25, 0.75);
// Progress examples
double _progressValue = 0.6;
void onPressed() {
// Placeholder function for button onPressed
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final m3e =
Theme.of(context).extension<M3ETheme>() ?? M3ETheme.defaults(cs);
return Scaffold(
appBar: AppBarM3E(
titleText: 'M3E Gallery',
actions: const [
Icon(Icons.search),
SizedBox(width: 8),
Icon(Icons.more_vert),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Icon buttons
Text('IconButtonM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
IconButtonM3E(
icon: Icon(Icons.favorite),
variant: IconButtonM3EVariant.filled,
onPressed: onPressed),
IconButtonM3E(
icon: Icon(Icons.favorite),
variant: IconButtonM3EVariant.tonal,
onPressed: onPressed),
IconButtonM3E(
icon: Icon(Icons.favorite),
variant: IconButtonM3EVariant.outlined,
onPressed: onPressed),
IconButtonM3E(
icon: Icon(Icons.favorite),
variant: IconButtonM3EVariant.standard,
onPressed: onPressed),
],
),
const SizedBox(height: 24),
// Split Button
Text('SplitButtonM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SplitButtonM3E<String>(
label: 'Primary',
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
],
),
SplitButtonM3E<String>(
label: 'Tonal',
emphasis: SplitButtonM3EEmphasis.tonal,
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
],
),
SplitButtonM3E<String>(
label: 'Outlined',
emphasis: SplitButtonM3EEmphasis.outlined,
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
],
),
SplitButtonM3E<String>(
label: 'Text',
emphasis: SplitButtonM3EEmphasis.text,
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
],
),
],
),
const SizedBox(height: 24),
// Buttons
Text('ButtonM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ButtonM3E(
labelText: 'Filled',
variant: ButtonM3EVariant.filled,
onPressed: onPressed),
ButtonM3E(
labelText: 'Tonal',
variant: ButtonM3EVariant.tonal,
onPressed: onPressed),
ButtonM3E(
labelText: 'Outlined',
variant: ButtonM3EVariant.outlined,
onPressed: onPressed),
ButtonM3E(
labelText: 'Text',
variant: ButtonM3EVariant.text,
onPressed: onPressed),
ButtonM3E(
labelText: 'Elevated',
variant: ButtonM3EVariant.elevated,
onPressed: onPressed),
],
),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ButtonM3E(
labelText: 'Filled',
variant: ButtonM3EVariant.filled,
shapeFamily: ButtonM3EShapeFamily.square,
onPressed: onPressed),
ButtonM3E(
labelText: 'Tonal',
variant: ButtonM3EVariant.tonal,
shapeFamily: ButtonM3EShapeFamily.square,
onPressed: onPressed),
ButtonM3E(
labelText: 'Outlined',
variant: ButtonM3EVariant.outlined,
shapeFamily: ButtonM3EShapeFamily.square,
onPressed: onPressed),
ButtonM3E(
labelText: 'Text',
variant: ButtonM3EVariant.text,
shapeFamily: ButtonM3EShapeFamily.square,
onPressed: onPressed),
ButtonM3E(
labelText: 'Elevated',
variant: ButtonM3EVariant.elevated,
shapeFamily: ButtonM3EShapeFamily.square,
onPressed: onPressed),
],
),
]),
const SizedBox(height: 24),
// Button groups
Text('ButtonGroupM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
// Standard group, round, md
ButtonGroupM3E(
type: ButtonGroupM3EType.standard,
shape: ButtonGroupM3EShape.round,
size: ButtonGroupM3ESize.md,
children: const [
IconButtonM3E(icon: Icon(Icons.skip_previous)),
IconButtonM3E(icon: Icon(Icons.play_arrow)),
IconButtonM3E(icon: Icon(Icons.skip_next)),
],
),
const SizedBox(height: 12),
// Connected group with divider and clipping for outer corners
ButtonGroupM3E(
type: ButtonGroupM3EType.connected,
shape: ButtonGroupM3EShape.round,
size: ButtonGroupM3ESize.lg,
showDividers: true,
clipBehavior: Clip.hardEdge,
equalizeWidths: true,
semanticLabel: 'Actions',
children: [
SplitButtonM3E<String>(
label: 'Primary',
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
],
),
SplitButtonM3E<String>(
label: 'Tonal',
emphasis: SplitButtonM3EEmphasis.tonal,
onPressed: () {},
items: const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
],
),
],
),
const SizedBox(height: 12),
// Square, compact, vertical wrap
ButtonGroupM3E(
type: ButtonGroupM3EType.standard,
shape: ButtonGroupM3EShape.square,
size: ButtonGroupM3ESize.sm,
density: ButtonGroupM3EDensity.compact,
direction: Axis.vertical,
children: const [
IconButtonM3E(icon: Icon(Icons.view_agenda_outlined)),
IconButtonM3E(icon: Icon(Icons.table_rows_outlined)),
IconButtonM3E(icon: Icon(Icons.grid_view_outlined)),
],
),
const SizedBox(height: 24),
// Toolbar
Text('ToolbarM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
ToolbarM3E(
titleText: 'Toolbar Title',
subtitleText: 'Subtitle',
actions: [
ToolbarActionM3E(icon: Icons.search, onPressed: () {}),
ToolbarActionM3E(icon: Icons.share, onPressed: () {}),
ToolbarActionM3E(
icon: Icons.delete,
onPressed: () {},
isDestructive: true,
label: 'Delete'),
ToolbarActionM3E(
icon: Icons.settings, onPressed: () {}, label: 'Settings'),
],
maxInlineActions: 2,
),
const SizedBox(height: 24),
// FABs
Text('FabM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: const [
FabM3E(icon: Icon(Icons.add)),
FabM3E(icon: Icon(Icons.edit), kind: FabM3EKind.secondary),
FabM3E(icon: Icon(Icons.share), kind: FabM3EKind.tertiary),
FabM3E(icon: Icon(Icons.more_horiz), kind: FabM3EKind.surface),
FabM3E(icon: Icon(Icons.add), size: FabM3ESize.small),
FabM3E(icon: Icon(Icons.add), size: FabM3ESize.large),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: const [
ExtendedFabM3E(label: Text('Create'), icon: Icon(Icons.add)),
ExtendedFabM3E(
label: Text('Edit'),
icon: Icon(Icons.edit),
kind: FabM3EKind.secondary),
ExtendedFabM3E(
label: Text('Share'),
icon: Icon(Icons.share),
kind: FabM3EKind.tertiary),
],
),
const SizedBox(height: 12),
SizedBox(
height: 180,
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: m3e.shapes.round.lg,
),
),
),
Align(
alignment: Alignment.bottomRight,
child: FabMenuM3E(
primaryFab: const FabM3E(icon: Icon(Icons.menu)),
items: [
FabMenuItem(
icon: const Icon(Icons.image),
label: const Text('Image'),
onPressed: () {}),
FabMenuItem(
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'),
onPressed: () {}),
FabMenuItem(
icon: const Icon(Icons.file_upload),
label: const Text('Upload'),
onPressed: () {}),
],
),
),
],
),
),
const SizedBox(height: 24),
// Loading indicators
Text('LoadingIndicatorM3E',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: const [
LoadingIndicatorM3E(
variant: LoadingIndicatorM3EVariant.defaultStyle),
LoadingIndicatorM3E(
variant: LoadingIndicatorM3EVariant.contained),
],
),
const SizedBox(height: 24),
// Progress indicators
Text('ProgressIndicatorM3E',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const CircularProgressM3E(),
const CircularProgressM3E(size: ProgressM3ESize.small),
CircularProgressM3E(
size: ProgressM3ESize.large,
value: _progressValue,
showCenterLabel: true),
const LinearProgressM3E(minWidth: 200),
LinearProgressM3E(minWidth: 200, value: _progressValue),
const LinearProgressM3E(
minWidth: 200,
variant: LinearProgressM3EVariant.indeterminate),
const LinearProgressM3E(
minWidth: 200, variant: LinearProgressM3EVariant.query),
const LinearProgressM3E(
minWidth: 200,
variant: LinearProgressM3EVariant.buffer,
bufferValue: 0.8,
value: 0.4),
],
),
const SizedBox(height: 24),
// Sliders
Text('SliderM3E', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
SliderM3E(
value: _sliderValue,
onChanged: (v) => setState(() => _sliderValue = v),
min: 0,
max: 100,
label: '${_sliderValue.toStringAsFixed(0)}',
startIcon: const Icon(Icons.volume_mute),
endIcon: const Icon(Icons.volume_up),
),
const SizedBox(height: 8),
RangeSliderM3E(
values: _rangeValues,
onChanged: (v) => setState(() => _rangeValues = v),
min: 0,
max: 100,
labels: RangeLabels(
_rangeValues.start.toStringAsFixed(0),
_rangeValues.end.toStringAsFixed(0),
),
),
const SizedBox(height: 24),
// Navigation Bar
Text('NavigationBarM3E',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
NavigationBarM3E(
selectedIndex: _navBarIndex,
onDestinationSelected: (i) => setState(() => _navBarIndex = i),
indicatorStyle: NavBarM3EIndicatorStyle.pill,
destinations: const [
NavigationDestinationM3E(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home'),
NavigationDestinationM3E(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
badgeDot: true),
NavigationDestinationM3E(
icon: Icon(Icons.favorite_outline),
selectedIcon: Icon(Icons.favorite),
label: 'Favorites',
badgeCount: 3),
NavigationDestinationM3E(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile'),
],
),
const SizedBox(height: 24),
// Navigation Rail
Text('NavigationRailM3E',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: m3e.shapes.round.lg,
),
height: 180,
child: Row(
children: [
NavigationRailM3E(
selectedIndex: _railIndex,
onDestinationSelected: (i) => setState(() => _railIndex = i),
indicatorStyle: RailIndicatorStyle.pill,
destinations: const [
RailDestinationM3E(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dashboard'),
RailDestinationM3E(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Reports'),
RailDestinationM3E(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings'),
],
),
const VerticalDivider(width: 1),
Expanded(
child: Center(
child: Text('Selected: $_railIndex'),
),
),
],
),
),
const SizedBox(height: 24),
// Sliver App Bar demo route
Text('App bars', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 12,
children: [
const ButtonM3E(labelText: 'Open SliverAppBarM3E Demo'),
// Use GestureDetector to navigate when tapping the button label area
]
.map((w) => GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const _SliverAppBarDemoPage()),
),
child: w,
))
.toList(),
),
const SizedBox(height: 48),
// Theming surface example remains
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: m3e.colors.surfaceStrong,
borderRadius: m3e.shapes.square.lg,
),
child: Text('Surface strong example',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: cs.onSurface)),
),
],
),
);
}
}
class _SliverAppBarDemoPage extends StatelessWidget {
const _SliverAppBarDemoPage();
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
const SliverAppBarM3E(
titleText: 'SliverAppBarM3E',
pinned: true,
floating: false,
variant: AppBarM3EVariant.large,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text('Item #$index'),
),
childCount: 30,
),
),
],
),
);
}
}

16
apps/gallery/pubspec.yaml Normal file
View file

@ -0,0 +1,16 @@
name: m3e_gallery
description: Gallery app for Material 3 Expressive packages.
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
m3e_collection:
path: ../../packages/m3e_collection
material_color_utilities: ^0.11.0
flutter:
uses-material-design: true

View file

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

19
melos.yaml Normal file
View file

@ -0,0 +1,19 @@
name: m3e
packages:
- "packages/*"
- "apps/*"
command:
bootstrap:
usePubspecOverrides: true
scripts:
bootstrap: melos bootstrap
clean: melos exec -- flutter clean
get: melos exec -- flutter pub get
format: melos exec -- dart format --set-exit-if-changed .
analyze: melos exec -- dart analyze --fatal-infos --fatal-warnings
test: melos exec -- flutter test --coverage
create:
run: dart run tool/create_component.dart
description: Scaffold a new [component]_m3e package (melos run create -- name=badge)

18
melos_m3e.iml Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_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$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/packages/split_button_m3e/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/packages/split_button_m3e/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/packages/split_button_m3e/example/build" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

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.

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