forked from mirrors/material_3_expressive
Add initial configuration, tokens, and widgets for M3E components
- Introduced `.gitignore` and `.metadata` for apps and examples. - Added Flutter/Dart analysis configurations (`analysis_options.yaml`). - Implemented foundational tokens and themes for M3E (colors, shapes). - Created base implementations for `IconButtonM3E` and `SplitButtonM3E`. - Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
23
.github/workflows/ci.yaml
vendored
Normal file
23
.github/workflows/ci.yaml
vendored
Normal 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
18
.gitignore
vendored
|
|
@ -7,6 +7,24 @@
|
||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.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
|
# Flutter repo-specific
|
||||||
|
|
|
||||||
24
README.md
Normal file
24
README.md
Normal 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
11
analysis_options.yaml
Normal 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
45
apps/gallery/.gitignore
vendored
Normal 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
45
apps/gallery/.metadata
Normal 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
16
apps/gallery/README.md
Normal 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.
|
||||||
28
apps/gallery/analysis_options.yaml
Normal file
28
apps/gallery/analysis_options.yaml
Normal 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
560
apps/gallery/lib/main.dart
Normal 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
16
apps/gallery/pubspec.yaml
Normal 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
|
||||||
30
apps/gallery/pubspec_overrides.yaml
Normal file
30
apps/gallery/pubspec_overrides.yaml
Normal 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
19
melos.yaml
Normal 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
18
melos_m3e.iml
Normal 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>
|
||||||
90
packages/app_bar_m3e/README.md
Normal file
90
packages/app_bar_m3e/README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# app_bar_m3e
|
||||||
|
|
||||||
|
Expressive **App Bar** for Flutter (Material 3 Expressive).
|
||||||
|
Small (standard) bar for `Scaffold.appBar`, plus **Medium** & **Large** collapsing variants via a sliver.
|
||||||
|
|
||||||
|
> Uses `m3e_design` tokens for color, typography, spacing, and shapes.
|
||||||
|
|
||||||
|
## Monorepo Setup
|
||||||
|
|
||||||
|
Place alongside `m3e_design` in your repo:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
m3e_design/
|
||||||
|
app_bar_m3e/
|
||||||
|
```
|
||||||
|
|
||||||
|
`pubspec.yaml` references `../m3e_design` by default.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Small App Bar
|
||||||
|
|
||||||
|
```dart
|
||||||
|
AppBarM3E(
|
||||||
|
leading: IconButton(icon: const BackButtonIcon(), onPressed: () {}),
|
||||||
|
titleText: 'Inbox',
|
||||||
|
actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
|
||||||
|
centerTitle: false,
|
||||||
|
shapeFamily: AppBarM3EShapeFamily.round,
|
||||||
|
density: AppBarM3EDensity.regular,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Use in a `Scaffold`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Scaffold(
|
||||||
|
appBar: const AppBarM3E(titleText: 'Inbox'),
|
||||||
|
body: ...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sliver App Bar (Medium / Large)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBarM3E(
|
||||||
|
variant: AppBarM3EVariant.large,
|
||||||
|
titleText: 'Gallery',
|
||||||
|
pinned: true,
|
||||||
|
),
|
||||||
|
// ... content slivers
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `variant: medium` uses expanded height ≈112dp (collapses to ~64dp).
|
||||||
|
- `variant: large` uses expanded height ≈152dp (collapses to ~64dp).
|
||||||
|
- Colors, shapes, and typography come from `m3e_design`'s `M3ETheme` extension.
|
||||||
|
|
||||||
|
## Theme Integration
|
||||||
|
|
||||||
|
`app_bar_m3e` reads the `M3ETheme` extension from your `ThemeData`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final m3e = Theme.of(context).extension<M3ETheme>() ??
|
||||||
|
M3ETheme.defaults(Theme.of(context).colorScheme);
|
||||||
|
```
|
||||||
|
|
||||||
|
It uses:
|
||||||
|
- `m3e.colors.surfaceContainerHigh` for background
|
||||||
|
- `m3e.type.titleLarge` for collapsed titles
|
||||||
|
- `m3e.type.headlineSmallEmphasized` for expanded titles
|
||||||
|
- `m3e.shapes.round|square` for container shape
|
||||||
|
- `m3e.spacing.md` for horizontal padding
|
||||||
|
|
||||||
|
Override by supplying `backgroundColor`, `foregroundColor`, `toolbarHeight`, etc.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- For collapsing behavior, use the sliver variant inside a `CustomScrollView`.
|
||||||
|
- `AppBarM3E` (small) is a `PreferredSizeWidget` suitable for `Scaffold.appBar`.
|
||||||
|
- Medium/Large variants rely on `FlexibleSpaceBar` for expanded titles.
|
||||||
|
- When `density: compact`, default heights reduce by ~8dp.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
5
packages/app_bar_m3e/lib/app_bar_m3e.dart
Normal file
5
packages/app_bar_m3e/lib/app_bar_m3e.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
library app_bar_m3e;
|
||||||
|
|
||||||
|
export 'src/app_bar_m3e_enums.dart';
|
||||||
|
export 'src/app_bar_m3e_widget.dart';
|
||||||
|
export 'src/sliver_app_bar_m3e.dart';
|
||||||
74
packages/app_bar_m3e/lib/src/_tokens_adapter.dart
Normal file
74
packages/app_bar_m3e/lib/src/_tokens_adapter.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:m3e_design/m3e_design.dart';
|
||||||
|
import 'app_bar_m3e_enums.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class _AppBarMetrics {
|
||||||
|
final double smallHeight;
|
||||||
|
final double collapsedHeight;
|
||||||
|
final double mediumExpanded;
|
||||||
|
final double largeExpanded;
|
||||||
|
final EdgeInsetsGeometry horizontalPadding;
|
||||||
|
final double iconSize;
|
||||||
|
final double elevation;
|
||||||
|
const _AppBarMetrics({
|
||||||
|
required this.smallHeight,
|
||||||
|
required this.collapsedHeight,
|
||||||
|
required this.mediumExpanded,
|
||||||
|
required this.largeExpanded,
|
||||||
|
required this.horizontalPadding,
|
||||||
|
required this.iconSize,
|
||||||
|
required this.elevation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_AppBarMetrics metricsFor(BuildContext context, AppBarM3EDensity density) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||||
|
final sp = m3e.spacing;
|
||||||
|
|
||||||
|
// Heights (approx per M3 specs; can be tuned via Theme extension in m3e_design if desired)
|
||||||
|
double small = 64;
|
||||||
|
double collapsed = 64;
|
||||||
|
double medium = 112;
|
||||||
|
double large = 152;
|
||||||
|
|
||||||
|
// Density tweaks
|
||||||
|
if (density == AppBarM3EDensity.compact) {
|
||||||
|
small -= 8;
|
||||||
|
collapsed -= 8;
|
||||||
|
medium -= 8;
|
||||||
|
large -= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _AppBarMetrics(
|
||||||
|
smallHeight: small,
|
||||||
|
collapsedHeight: collapsed,
|
||||||
|
mediumExpanded: medium,
|
||||||
|
largeExpanded: large,
|
||||||
|
horizontalPadding: EdgeInsets.symmetric(horizontal: sp.md),
|
||||||
|
iconSize: 24,
|
||||||
|
elevation: 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color backgroundFor(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||||
|
// Prefer container surfaces for bars
|
||||||
|
return m3e.colors.surfaceContainerHigh;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle titleStyleFor(BuildContext context, {bool collapsed = true}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||||
|
return collapsed ? m3e.type.titleLarge : m3e.type.headlineSmallEmphasized;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShapeBorder shapeFor(BuildContext context, AppBarM3EShapeFamily family) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
|
||||||
|
final set = family == AppBarM3EShapeFamily.round ? m3e.shapes.round : m3e.shapes.square;
|
||||||
|
// Use medium size radius for the bar container by default
|
||||||
|
return RoundedRectangleBorder(borderRadius: set.md);
|
||||||
|
}
|
||||||
3
packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart
Normal file
3
packages/app_bar_m3e/lib/src/app_bar_m3e_enums.dart
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
enum AppBarM3EVariant { small, medium, large }
|
||||||
|
enum AppBarM3EShapeFamily { round, square }
|
||||||
|
enum AppBarM3EDensity { regular, compact }
|
||||||
134
packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart
Normal file
134
packages/app_bar_m3e/lib/src/app_bar_m3e_widget.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '_tokens_adapter.dart';
|
||||||
|
import 'app_bar_m3e_enums.dart';
|
||||||
|
|
||||||
|
class AppBarM3E extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
const AppBarM3E({
|
||||||
|
super.key,
|
||||||
|
this.leading,
|
||||||
|
this.title,
|
||||||
|
this.titleText,
|
||||||
|
this.actions,
|
||||||
|
this.centerTitle = false,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.elevation,
|
||||||
|
this.shapeFamily = AppBarM3EShapeFamily.round,
|
||||||
|
this.density = AppBarM3EDensity.regular,
|
||||||
|
this.toolbarHeight,
|
||||||
|
this.automaticallyImplyLeading = true,
|
||||||
|
this.clipBehavior = Clip.none,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget? leading;
|
||||||
|
final Widget? title;
|
||||||
|
final String? titleText;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final bool centerTitle;
|
||||||
|
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? foregroundColor;
|
||||||
|
final double? elevation;
|
||||||
|
final AppBarM3EShapeFamily shapeFamily;
|
||||||
|
final AppBarM3EDensity density;
|
||||||
|
final double? toolbarHeight;
|
||||||
|
final bool automaticallyImplyLeading;
|
||||||
|
final Clip clipBehavior;
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize {
|
||||||
|
// Provide a reasonable non-null size; actual height applied in build.
|
||||||
|
return Size.fromHeight(toolbarHeight ?? 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final metrics = metricsFor(context, density);
|
||||||
|
final bg = backgroundColor ?? backgroundFor(context);
|
||||||
|
final fg = foregroundColor ?? Theme.of(context).colorScheme.onSurface;
|
||||||
|
final shape = shapeFor(context, shapeFamily);
|
||||||
|
final height = toolbarHeight ?? metrics.smallHeight;
|
||||||
|
final tStyle = titleStyleFor(context, collapsed: true);
|
||||||
|
|
||||||
|
final resolvedLeading = leading ?? (automaticallyImplyLeading
|
||||||
|
? _maybeBackButton(context, fg)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
final resolvedTitle = title ??
|
||||||
|
(titleText != null
|
||||||
|
? Text(titleText!, style: tStyle, overflow: TextOverflow.ellipsis)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
final bar = Material(
|
||||||
|
color: bg,
|
||||||
|
elevation: elevation ?? metrics.elevation,
|
||||||
|
shape: shape,
|
||||||
|
clipBehavior: clipBehavior,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: Padding(
|
||||||
|
padding: metrics.horizontalPadding,
|
||||||
|
child: IconTheme.merge(
|
||||||
|
data: IconThemeData(size: metrics.iconSize, color: fg),
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: tStyle.copyWith(color: fg),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (resolvedLeading != null) resolvedLeading,
|
||||||
|
if (resolvedLeading != null) const SizedBox(width: 8),
|
||||||
|
if (resolvedTitle != null)
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: centerTitle ? Alignment.center : Alignment.centerLeft,
|
||||||
|
child: resolvedTitle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Spacer(),
|
||||||
|
if (actions != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
..._withSpacers(actions!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (semanticLabel == null) return bar;
|
||||||
|
return Semantics(
|
||||||
|
container: true,
|
||||||
|
label: semanticLabel,
|
||||||
|
child: bar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _withSpacers(List<Widget> items) {
|
||||||
|
final out = <Widget>[];
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
out.add(items[i]);
|
||||||
|
if (i < items.length - 1) out.add(const SizedBox(width: 4));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _maybeBackButton(BuildContext context, Color fg) {
|
||||||
|
final canPop = Navigator.maybeOf(context)?.canPop() ?? false;
|
||||||
|
if (!canPop) return null;
|
||||||
|
return IconButton(
|
||||||
|
icon: const BackButtonIcon(),
|
||||||
|
color: fg,
|
||||||
|
onPressed: () => Navigator.maybeOf(context)?.maybePop(),
|
||||||
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart
Normal file
145
packages/app_bar_m3e/lib/src/sliver_app_bar_m3e.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart' show RenderObject, RenderProxySliver;
|
||||||
|
import 'package:flutter/semantics.dart' show SemanticsConfiguration;
|
||||||
|
|
||||||
|
import '_tokens_adapter.dart';
|
||||||
|
import 'app_bar_m3e_enums.dart';
|
||||||
|
|
||||||
|
class SliverAppBarM3E extends StatelessWidget {
|
||||||
|
const SliverAppBarM3E({
|
||||||
|
super.key,
|
||||||
|
this.leading,
|
||||||
|
this.title,
|
||||||
|
this.titleText,
|
||||||
|
this.actions,
|
||||||
|
this.centerTitle = false,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.pinned = true,
|
||||||
|
this.floating = false,
|
||||||
|
this.snap = false,
|
||||||
|
this.shapeFamily = AppBarM3EShapeFamily.round,
|
||||||
|
this.density = AppBarM3EDensity.regular,
|
||||||
|
this.variant = AppBarM3EVariant.medium,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget? leading;
|
||||||
|
final Widget? title;
|
||||||
|
final String? titleText;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final bool centerTitle;
|
||||||
|
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? foregroundColor;
|
||||||
|
final bool pinned;
|
||||||
|
final bool floating;
|
||||||
|
final bool snap;
|
||||||
|
final AppBarM3EShapeFamily shapeFamily;
|
||||||
|
final AppBarM3EDensity density;
|
||||||
|
final AppBarM3EVariant variant;
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final metrics = metricsFor(context, density);
|
||||||
|
final bg = backgroundColor ?? backgroundFor(context);
|
||||||
|
final fg = foregroundColor ?? Theme.of(context).colorScheme.onSurface;
|
||||||
|
final shape = shapeFor(context, shapeFamily);
|
||||||
|
|
||||||
|
final collapsedStyle = titleStyleFor(context, collapsed: true);
|
||||||
|
final expandedStyle = titleStyleFor(context, collapsed: false);
|
||||||
|
|
||||||
|
final collapsed = metrics.collapsedHeight;
|
||||||
|
final expanded = switch (variant) {
|
||||||
|
AppBarM3EVariant.medium => metrics.mediumExpanded,
|
||||||
|
AppBarM3EVariant.large => metrics.largeExpanded,
|
||||||
|
AppBarM3EVariant.small => metrics.smallHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
final resolvedTitleWidget = title ??
|
||||||
|
(titleText != null
|
||||||
|
? Text(titleText!,
|
||||||
|
style: collapsedStyle, overflow: TextOverflow.ellipsis)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
final bar = SliverAppBar(
|
||||||
|
pinned: pinned,
|
||||||
|
floating: floating,
|
||||||
|
snap: snap && floating,
|
||||||
|
backgroundColor: bg,
|
||||||
|
foregroundColor: fg,
|
||||||
|
collapsedHeight: collapsed,
|
||||||
|
expandedHeight: expanded,
|
||||||
|
centerTitle: centerTitle,
|
||||||
|
leading: leading,
|
||||||
|
title: resolvedTitleWidget,
|
||||||
|
actions: actions,
|
||||||
|
shape: shape,
|
||||||
|
flexibleSpace: _buildFlexibleSpace(context, expandedStyle),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (semanticLabel == null) return bar;
|
||||||
|
return SliverSemantic(
|
||||||
|
label: semanticLabel!,
|
||||||
|
child: bar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _buildFlexibleSpace(BuildContext context, TextStyle expandedStyle) {
|
||||||
|
switch (variant) {
|
||||||
|
case AppBarM3EVariant.small:
|
||||||
|
return null;
|
||||||
|
case AppBarM3EVariant.medium:
|
||||||
|
case AppBarM3EVariant.large:
|
||||||
|
final t = title ??
|
||||||
|
(titleText != null ? Text(titleText!, style: expandedStyle) : null);
|
||||||
|
if (t == null) return null;
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
titlePadding:
|
||||||
|
const EdgeInsetsDirectional.only(start: 16, bottom: 16, end: 16),
|
||||||
|
title: DefaultTextStyle(
|
||||||
|
style: expandedStyle.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
child: t,
|
||||||
|
),
|
||||||
|
collapseMode: CollapseMode.pin,
|
||||||
|
expandedTitleScale:
|
||||||
|
1.0, // Typography already larger; avoid scale morph
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A helper to wrap a sliver with semantics label.
|
||||||
|
class SliverSemantic extends SingleChildRenderObjectWidget {
|
||||||
|
const SliverSemantic({super.key, required this.label, required Widget child})
|
||||||
|
: super(child: child);
|
||||||
|
final String label;
|
||||||
|
@override
|
||||||
|
RenderObject createRenderObject(BuildContext context) =>
|
||||||
|
_SliverSemanticRender(label);
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant _SliverSemanticRender renderObject) {
|
||||||
|
renderObject.label = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SliverSemanticRender extends RenderProxySliver {
|
||||||
|
_SliverSemanticRender(this._label);
|
||||||
|
String _label;
|
||||||
|
set label(String v) {
|
||||||
|
if (v == _label) return;
|
||||||
|
_label = v;
|
||||||
|
markNeedsSemanticsUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||||
|
super.describeSemanticsConfiguration(config);
|
||||||
|
config.label = _label;
|
||||||
|
config.isSemanticBoundary = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/app_bar_m3e/melos_app_bar_m3e.iml
Normal file
29
packages/app_bar_m3e/melos_app_bar_m3e.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
18
packages/app_bar_m3e/pubspec.yaml
Normal file
18
packages/app_bar_m3e/pubspec.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: app_bar_m3e
|
||||||
|
description: Expressive App Bar (Material 3 Expressive) with small/medium/large variants and Sliver integration.
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: none
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=3.5.0 <4.0.0"
|
||||||
|
flutter: ">=3.22.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
m3e_design:
|
||||||
|
path: ../m3e_design
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
4
packages/app_bar_m3e/pubspec_overrides.yaml
Normal file
4
packages/app_bar_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# melos_managed_dependency_overrides: m3e_design
|
||||||
|
dependency_overrides:
|
||||||
|
m3e_design:
|
||||||
|
path: ..\\m3e_design
|
||||||
7
packages/app_bar_m3e/test/app_bar_m3e_test.dart
Normal file
7
packages/app_bar_m3e/test/app_bar_m3e_test.dart
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('placeholder', () {
|
||||||
|
expect(2 + 2, 4);
|
||||||
|
});
|
||||||
|
}
|
||||||
100
packages/button_group_m3e/README.md
Normal file
100
packages/button_group_m3e/README.md
Normal 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 (XS–XL), 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...
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
5
packages/button_group_m3e/lib/button_group_m3e.dart
Normal file
5
packages/button_group_m3e/lib/button_group_m3e.dart
Normal 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;
|
||||||
44
packages/button_group_m3e/lib/src/_tokens_adapter.dart
Normal file
44
packages/button_group_m3e/lib/src/_tokens_adapter.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
enum ButtonGroupM3EType { standard, connected }
|
||||||
|
enum ButtonGroupM3EShape { round, square }
|
||||||
|
enum ButtonGroupM3ESize { xs, sm, md, lg, xl }
|
||||||
|
enum ButtonGroupM3EDensity { regular, compact }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
186
packages/button_group_m3e/lib/src/button_group_m3e_widget.dart
Normal file
186
packages/button_group_m3e/lib/src/button_group_m3e_widget.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/button_group_m3e/melos_button_group_m3e.iml
Normal file
29
packages/button_group_m3e/melos_button_group_m3e.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
18
packages/button_group_m3e/pubspec.yaml
Normal file
18
packages/button_group_m3e/pubspec.yaml
Normal 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
|
||||||
4
packages/button_group_m3e/pubspec_overrides.yaml
Normal file
4
packages/button_group_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# melos_managed_dependency_overrides: m3e_design
|
||||||
|
dependency_overrides:
|
||||||
|
m3e_design:
|
||||||
|
path: ..\\m3e_design
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('placeholder', () {
|
||||||
|
expect(1 + 2, 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
21
packages/button_m3e/LICENSE
Normal file
21
packages/button_m3e/LICENSE
Normal 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.
|
||||||
70
packages/button_m3e/README.md
Normal file
70
packages/button_m3e/README.md
Normal 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
|
||||||
5
packages/button_m3e/lib/button_m3e.dart
Normal file
5
packages/button_m3e/lib/button_m3e.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
library button_m3e;
|
||||||
|
|
||||||
|
export 'src/enums.dart';
|
||||||
|
export 'src/button_m3e.dart';
|
||||||
|
export 'src/button_theme_m3e.dart';
|
||||||
144
packages/button_m3e/lib/src/button_m3e.dart
Normal file
144
packages/button_m3e/lib/src/button_m3e.dart
Normal 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!],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/button_m3e/lib/src/button_m3e_widget.dart
Normal file
24
packages/button_m3e/lib/src/button_m3e_widget.dart
Normal 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();
|
||||||
94
packages/button_m3e/lib/src/button_theme_m3e.dart
Normal file
94
packages/button_m3e/lib/src/button_theme_m3e.dart
Normal 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);
|
||||||
|
}
|
||||||
4
packages/button_m3e/lib/src/enums.dart
Normal file
4
packages/button_m3e/lib/src/enums.dart
Normal 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 }
|
||||||
29
packages/button_m3e/melos_button_m3e.iml
Normal file
29
packages/button_m3e/melos_button_m3e.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
18
packages/button_m3e/pubspec.yaml
Normal file
18
packages/button_m3e/pubspec.yaml
Normal 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
|
||||||
4
packages/button_m3e/pubspec_overrides.yaml
Normal file
4
packages/button_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# melos_managed_dependency_overrides: m3e_design
|
||||||
|
dependency_overrides:
|
||||||
|
m3e_design:
|
||||||
|
path: ..\\m3e_design
|
||||||
7
packages/button_m3e/test/button_m3e_test.dart
Normal file
7
packages/button_m3e/test/button_m3e_test.dart
Normal 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
21
packages/fab_m3e/LICENSE
Normal 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.
|
||||||
93
packages/fab_m3e/README.md
Normal file
93
packages/fab_m3e/README.md
Normal 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
|
||||||
7
packages/fab_m3e/lib/fab_m3e.dart
Normal file
7
packages/fab_m3e/lib/fab_m3e.dart
Normal 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';
|
||||||
8
packages/fab_m3e/lib/src/enums.dart
Normal file
8
packages/fab_m3e/lib/src/enums.dart
Normal 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 }
|
||||||
93
packages/fab_m3e/lib/src/extended_fab_m3e.dart
Normal file
93
packages/fab_m3e/lib/src/extended_fab_m3e.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
packages/fab_m3e/lib/src/fab_m3e.dart
Normal file
88
packages/fab_m3e/lib/src/fab_m3e.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/fab_m3e/lib/src/fab_m3e_widget.dart
Normal file
21
packages/fab_m3e/lib/src/fab_m3e_widget.dart
Normal 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();
|
||||||
247
packages/fab_m3e/lib/src/fab_menu_m3e.dart
Normal file
247
packages/fab_m3e/lib/src/fab_menu_m3e.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
packages/fab_m3e/lib/src/fab_theme_m3e.dart
Normal file
106
packages/fab_m3e/lib/src/fab_theme_m3e.dart
Normal 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;
|
||||||
|
}
|
||||||
29
packages/fab_m3e/melos_fab_m3e.iml
Normal file
29
packages/fab_m3e/melos_fab_m3e.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
18
packages/fab_m3e/pubspec.yaml
Normal file
18
packages/fab_m3e/pubspec.yaml
Normal 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
|
||||||
4
packages/fab_m3e/pubspec_overrides.yaml
Normal file
4
packages/fab_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# melos_managed_dependency_overrides: m3e_design
|
||||||
|
dependency_overrides:
|
||||||
|
m3e_design:
|
||||||
|
path: ..\\m3e_design
|
||||||
7
packages/fab_m3e/test/fab_m3e_test.dart
Normal file
7
packages/fab_m3e/test/fab_m3e_test.dart
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('placeholder', () {
|
||||||
|
expect(3 * 3, 9);
|
||||||
|
});
|
||||||
|
}
|
||||||
92
packages/icon_button_m3e/README.md
Normal file
92
packages/icon_button_m3e/README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# icon_button_m3e
|
||||||
|
|
||||||
|
Expressive Material 3 icon button for Flutter — `IconButtonM3E` — with
|
||||||
|
five sizes (XS–XL), 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
|
||||||
45
packages/icon_button_m3e/example/.gitignore
vendored
Normal file
45
packages/icon_button_m3e/example/.gitignore
vendored
Normal 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
packages/icon_button_m3e/example/.metadata
Normal file
45
packages/icon_button_m3e/example/.metadata
Normal 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
packages/icon_button_m3e/example/README.md
Normal file
16
packages/icon_button_m3e/example/README.md
Normal 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.
|
||||||
28
packages/icon_button_m3e/example/analysis_options.yaml
Normal file
28
packages/icon_button_m3e/example/analysis_options.yaml
Normal 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
|
||||||
183
packages/icon_button_m3e/example/lib/main.dart
Normal file
183
packages/icon_button_m3e/example/lib/main.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/icon_button_m3e/example/pubspec.yaml
Normal file
21
packages/icon_button_m3e/example/pubspec.yaml
Normal 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
|
||||||
4
packages/icon_button_m3e/lib/icon_button_m3e.dart
Normal file
4
packages/icon_button_m3e/lib/icon_button_m3e.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
library icon_button_m3e;
|
||||||
|
|
||||||
|
export 'src/enums.dart';
|
||||||
|
export 'src/icon_button_m3e.dart';
|
||||||
122
packages/icon_button_m3e/lib/src/_tokens_adapter.dart
Normal file
122
packages/icon_button_m3e/lib/src/_tokens_adapter.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
part of 'enums.dart';
|
||||||
|
|
||||||
|
/// All numeric tokens & constants for M3 Expressive IconButton.
|
||||||
|
/// No business logic here—just 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;
|
||||||
|
}
|
||||||
86
packages/icon_button_m3e/lib/src/enums.dart
Normal file
86
packages/icon_button_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
library m3e_iconbutton;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
part '_tokens_adapter.dart';
|
||||||
|
|
||||||
|
/// Visual scale labels (A–E in the spec).
|
||||||
|
enum IconButtonM3ESize { xs, sm, md, lg, xl }
|
||||||
|
|
||||||
|
/// Width variants of the button’s 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
packages/icon_button_m3e/lib/src/icon_button_m3e.dart
Normal file
139
packages/icon_button_m3e/lib/src/icon_button_m3e.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
packages/icon_button_m3e/melos_icon_button_m3e.iml
Normal file
29
packages/icon_button_m3e/melos_icon_button_m3e.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
21
packages/icon_button_m3e/pubspec.yaml
Normal file
21
packages/icon_button_m3e/pubspec.yaml
Normal 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
|
||||||
4
packages/icon_button_m3e/pubspec_overrides.yaml
Normal file
4
packages/icon_button_m3e/pubspec_overrides.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# melos_managed_dependency_overrides: m3e_design
|
||||||
|
dependency_overrides:
|
||||||
|
m3e_design:
|
||||||
|
path: ..\\m3e_design
|
||||||
46
packages/icon_button_m3e/test/icon_button_m3e_test.dart
Normal file
46
packages/icon_button_m3e/test/icon_button_m3e_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
21
packages/loading_indicator_m3e/LICENSE
Normal file
21
packages/loading_indicator_m3e/LICENSE
Normal 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.
|
||||||
56
packages/loading_indicator_m3e/README.md
Normal file
56
packages/loading_indicator_m3e/README.md
Normal 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
|
||||||
|
|
@ -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';
|
||||||
1
packages/loading_indicator_m3e/lib/src/enums.dart
Normal file
1
packages/loading_indicator_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
enum LoadingIndicatorM3EVariant { defaultStyle, contained }
|
||||||
|
|
@ -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.
|
||||||
|
/// 
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
19
packages/loading_indicator_m3e/pubspec.yaml
Normal file
19
packages/loading_indicator_m3e/pubspec.yaml
Normal 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
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('placeholder', () {
|
||||||
|
expect(2 + 2, 4);
|
||||||
|
});
|
||||||
|
}
|
||||||
3
packages/m3e_collection/README.md
Normal file
3
packages/m3e_collection/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# m3e_collection
|
||||||
|
|
||||||
|
Single import that re-exports all M3E component packages plus `m3e_design`.
|
||||||
16
packages/m3e_collection/lib/m3e_collection.dart
Normal file
16
packages/m3e_collection/lib/m3e_collection.dart
Normal 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';
|
||||||
29
packages/m3e_collection/melos_m3e_collection.iml
Normal file
29
packages/m3e_collection/melos_m3e_collection.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
39
packages/m3e_collection/pubspec.yaml
Normal file
39
packages/m3e_collection/pubspec.yaml
Normal 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
|
||||||
|
|
||||||
29
packages/m3e_collection/pubspec_overrides.yaml
Normal file
29
packages/m3e_collection/pubspec_overrides.yaml
Normal 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
|
||||||
|
|
||||||
4
packages/m3e_design/README.md
Normal file
4
packages/m3e_design/README.md
Normal 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.
|
||||||
10
packages/m3e_design/lib/m3e_design.dart
Normal file
10
packages/m3e_design/lib/m3e_design.dart
Normal 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';
|
||||||
99
packages/m3e_design/lib/theme/m3e_theme.dart
Normal file
99
packages/m3e_design/lib/theme/m3e_theme.dart
Normal 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);
|
||||||
|
}
|
||||||
196
packages/m3e_design/lib/tokens/color_tokens.dart
Normal file
196
packages/m3e_design/lib/tokens/color_tokens.dart
Normal 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)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
43
packages/m3e_design/lib/tokens/motion_tokens.dart
Normal file
43
packages/m3e_design/lib/tokens/motion_tokens.dart
Normal 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});
|
||||||
|
}
|
||||||
51
packages/m3e_design/lib/tokens/shape_tokens.dart
Normal file
51
packages/m3e_design/lib/tokens/shape_tokens.dart
Normal 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)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
37
packages/m3e_design/lib/tokens/spacing_tokens.dart
Normal file
37
packages/m3e_design/lib/tokens/spacing_tokens.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
54
packages/m3e_design/lib/tokens/typography_tokens.dart
Normal file
54
packages/m3e_design/lib/tokens/typography_tokens.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
7
packages/m3e_design/lib/utils/build_context_x.dart
Normal file
7
packages/m3e_design/lib/utils/build_context_x.dart
Normal 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);
|
||||||
|
}
|
||||||
5
packages/m3e_design/lib/utils/semantics_x.dart
Normal file
5
packages/m3e_design/lib/utils/semantics_x.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
extension SemanticsX on Widget {
|
||||||
|
Widget withLabel(String label) => Semantics(label: label, child: this);
|
||||||
|
}
|
||||||
29
packages/m3e_design/melos_m3e_design.iml
Normal file
29
packages/m3e_design/melos_m3e_design.iml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/build" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
16
packages/m3e_design/pubspec.yaml
Normal file
16
packages/m3e_design/pubspec.yaml
Normal 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
|
||||||
21
packages/navigation_bar_m3e/LICENSE
Normal file
21
packages/navigation_bar_m3e/LICENSE
Normal 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
Loading…
Add table
Add a link
Reference in a new issue