forked from mirrors/material_3_expressive
Add initial configuration, tokens, and widgets for M3E components
- Introduced `.gitignore` and `.metadata` for apps and examples. - Added Flutter/Dart analysis configurations (`analysis_options.yaml`). - Implemented foundational tokens and themes for M3E (colors, shapes). - Created base implementations for `IconButtonM3E` and `SplitButtonM3E`. - Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
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));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue