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/
|
||||
.history
|
||||
|
||||
# Other
|
||||
.dart_tool/
|
||||
.packages
|
||||
.melos_tool
|
||||
build/
|
||||
ios/
|
||||
android/
|
||||
macos/
|
||||
windows/
|
||||
linux/
|
||||
web/
|
||||
.idea/
|
||||
.vscode/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
pubspec.lock
|
||||
coverage/
|
||||
|
||||
|
||||
|
||||
# Flutter repo-specific
|
||||
|
|
|
|||
24
README.md
Normal file
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