Add initial configuration, tokens, and widgets for M3E components

- Introduced `.gitignore` and `.metadata` for apps and examples.
- Added Flutter/Dart analysis configurations (`analysis_options.yaml`).
- Implemented foundational tokens and themes for M3E (colors, shapes).
- Created base implementations for `IconButtonM3E` and `SplitButtonM3E`.
- Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
Emily Pauli 2025-10-21 22:15:15 +02:00
commit 62ecb86b76
184 changed files with 9872 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.idea" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/example/build" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Flutter Plugins" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

View file

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

View file

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

View file

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