Refactor button component and add new sections for loading indicators, icons, and navigation; update enums and pubspec description

This commit is contained in:
Emily Pauli 2025-10-21 23:40:25 +02:00
commit 020db0ac38
23 changed files with 1033 additions and 828 deletions

View file

@ -1,5 +1,15 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:m3e_gallery/sections/button_section.dart';
import 'package:m3e_gallery/sections/fab_section.dart';
import 'package:m3e_gallery/sections/icon_button_section.dart';
import 'package:m3e_gallery/sections/loading_indicator_section.dart';
import 'package:m3e_gallery/sections/navigation_section.dart';
import 'package:m3e_gallery/sections/progress_section.dart';
import 'package:m3e_gallery/sections/section_card.dart';
import 'package:m3e_gallery/sections/slider_section.dart';
import 'package:m3e_gallery/sections/split_button_section.dart';
import 'package:m3e_gallery/sections/toolbar_section.dart';
void main() => runApp(const GalleryApp());
@ -26,19 +36,8 @@ class GalleryHome extends StatefulWidget {
}
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
// Placeholder function onPressed
}
@override
@ -55,478 +54,7 @@ class _GalleryHomeState extends State<GalleryHome> {
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)),
),
],
),
body: const SectionedGallery(),
);
}
}
@ -558,3 +86,38 @@ class _SliverAppBarDemoPage extends StatelessWidget {
);
}
}
class SectionedGallery extends StatelessWidget {
const SectionedGallery({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const IconButtonSection(),
const SplitButtonSection(),
const ButtonSection(),
const ToolbarSection(),
const FabSection(),
const LoadingIndicatorSection(),
const ProgressSection(),
const SliderSection(),
const NavigationSection(),
SectionCard(
title: 'App bars',
child: Align(
alignment: Alignment.centerLeft,
child: ButtonM3E(
label: Text('Open SliverAppBarM3E Demo'),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const _SliverAppBarDemoPage()),
),
),
),
),
],
);
}
}

View file

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class ButtonSection extends StatelessWidget {
const ButtonSection({super.key});
@override
Widget build(BuildContext context) {
return SectionCard(
title: 'ButtonM3E',
subtitle:
'Generated from enums: variant × size; grouped by shape family.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final shape in ButtonM3EShape.values) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('${shape.name} shape',
style: Theme.of(context).textTheme.titleMedium),
),
for (final size in ButtonM3ESize.values) ...[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text('size: ${size.name}',
style: Theme.of(context).textTheme.labelLarge),
),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final variant in ButtonM3EStyle.values)
ButtonM3E(
label: Text(variant.name),
style: variant,
size: size,
shape: shape,
onPressed: () {},
),
],
),
],
],
],
),
);
}
}

View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class FabSection extends StatelessWidget {
const FabSection({super.key});
@override
Widget build(BuildContext context) {
void onPressed() {
// Placeholder function onPressed
}
return SectionCard(
title: 'FabM3E',
subtitle:
'Generated from enums: kind × size. Includes Extended FAB examples.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final kind in FabM3EKind.values)
for (final size in FabM3ESize.values)
FabM3E(
icon: const Icon(Icons.add),
kind: kind,
size: size,
onPressed: onPressed),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ExtendedFabM3E(
label: Text('Create'),
icon: Icon(Icons.add),
onPressed: onPressed),
ExtendedFabM3E(
label: Text('Edit'),
icon: Icon(Icons.edit),
kind: FabM3EKind.secondary,
onPressed: onPressed),
ExtendedFabM3E(
label: Text('Share'),
icon: Icon(Icons.share),
kind: FabM3EKind.tertiary,
onPressed: onPressed),
],
),
],
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class IconButtonSection extends StatelessWidget {
const IconButtonSection({super.key});
@override
Widget build(BuildContext context) {
return SectionCard(
title: 'IconButtonM3E',
subtitle: 'Generated from enums: variant × size (round shape, default width).',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final variant in IconButtonM3EVariant.values) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(variant.name, style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final size in IconButtonM3ESize.values)
IconButtonM3E(
icon: const Icon(Icons.favorite),
variant: variant,
size: size,
onPressed: () {},
),
],
),
],
],
),
);
}
}

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class LoadingIndicatorSection extends StatelessWidget {
const LoadingIndicatorSection({super.key});
@override
Widget build(BuildContext context) {
return SectionCard(
title: 'LoadingIndicatorM3E',
subtitle: 'Generated from enums: variant values.',
child: Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final v in LoadingIndicatorM3EVariant.values)
LoadingIndicatorM3E(variant: v),
],
),
);
}
}

View file

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class NavigationSection extends StatefulWidget {
const NavigationSection({super.key});
@override
State<NavigationSection> createState() => _NavigationSectionState();
}
class _NavigationSectionState extends State<NavigationSection> {
int _barIndex = 0;
int _railIndex = 0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
return SectionCard(
title: 'Navigation',
subtitle: 'Generated from enums: NavBar indicator styles and Rail indicator styles.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Navigation Bar', style: theme.textTheme.titleMedium),
),
Wrap(
runSpacing: 12,
children: [
for (final style in NavBarM3EIndicatorStyle.values)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('indicator: ${style.name}', style: theme.textTheme.labelLarge),
),
NavigationBarM3E(
selectedIndex: _barIndex,
onDestinationSelected: (i) => setState(() => _barIndex = i),
indicatorStyle: style,
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: 2),
NavigationDestinationM3E(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'),
],
),
const SizedBox(height: 8),
],
),
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Navigation Rail', style: theme.textTheme.titleMedium),
),
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: m3e.shapes.round.lg,
),
height: 220,
child: Row(
children: [
for (final style in RailIndicatorStyle.values) ...[
NavigationRailM3E(
selectedIndex: _railIndex,
onDestinationSelected: (i) => setState(() => _railIndex = i),
indicatorStyle: style,
destinations: const [
RailDestinationM3E(icon: Icon(Icons.dashboard_outlined), selectedIcon: Icon(Icons.dashboard), label: 'Dash'),
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')),
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class ProgressSection extends StatelessWidget {
const ProgressSection({super.key});
@override
Widget build(BuildContext context) {
return SectionCard(
title: 'ProgressIndicatorM3E',
subtitle: 'Generated from enums: circular sizes and linear variants.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Circular - Wavy',
style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (final s in ProgressM3ESize.values) ...[
CircularProgressM3E(
size: s,
value: 0.4,
),
CircularProgressM3E(
size: s,
value: 0.6,
showCenterLabel: s != ProgressM3ESize.small),
],
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Circular - Flat',
style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (final s in ProgressM3ESize.values) ...[
CircularProgressM3E(
size: s,
value: 0.4,
shape: CircularBarShapeM3E.flat,
),
CircularProgressM3E(
size: s,
shape: CircularBarShapeM3E.flat,
value: 0.6,
showCenterLabel: s != ProgressM3ESize.small),
],
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Linear - Wavy',
style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (final v in LinearProgressM3EVariant.values)
LinearProgressM3E(
minWidth: 220,
variant: v,
value: v == LinearProgressM3EVariant.determinate ? 0.6 : null,
bufferValue:
v == LinearProgressM3EVariant.buffer ? 0.8 : null,
),
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Linear - Flat',
style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (final v in LinearProgressM3EVariant.values)
LinearProgressM3E(
minWidth: 220,
variant: v,
shape: LinearBarShapeM3E.flat,
value: v == LinearProgressM3EVariant.determinate ? 0.6 : null,
bufferValue:
v == LinearProgressM3EVariant.buffer ? 0.8 : null,
),
],
),
],
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
class SectionCard extends StatelessWidget {
const SectionCard(
{super.key, required this.title, this.subtitle, required this.child});
final String title;
final String? subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(title, style: theme.textTheme.titleLarge),
subtitle: subtitle == null ? null : Text(subtitle!),
),
Material(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: m3e.shapes.round.lg,
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
],
),
);
}
}

View file

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class SliderSection extends StatefulWidget {
const SliderSection({super.key});
@override
State<SliderSection> createState() => _SliderSectionState();
}
class _SliderSectionState extends State<SliderSection> {
double _value = 40;
RangeValues _range = const RangeValues(25, 75);
@override
Widget build(BuildContext context) {
return SectionCard(
title: 'SliderM3E',
subtitle: 'Generated from enums: size × emphasis (round shape).',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final size in SliderM3ESize.values) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('size: ${size.name}', style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
runSpacing: 12,
children: [
for (final emp in SliderM3EEmphasis.values)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('emphasis: ${emp.name}', style: Theme.of(context).textTheme.labelLarge),
SliderM3E(
value: _value,
onChanged: (v) => setState(() => _value = v),
min: 0,
max: 100,
label: _value.toStringAsFixed(0),
size: size,
emphasis: emp,
startIcon: const Icon(Icons.volume_mute),
endIcon: const Icon(Icons.volume_up),
),
RangeSliderM3E(
values: _range,
onChanged: (v) => setState(() => _range = v),
min: 0,
max: 100,
size: size,
emphasis: emp,
labels: RangeLabels(
_range.start.toStringAsFixed(0),
_range.end.toStringAsFixed(0),
),
),
],
),
),
],
),
],
],
),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class SplitButtonSection extends StatelessWidget {
const SplitButtonSection({super.key});
@override
Widget build(BuildContext context) {
final items = const [
SplitButtonM3EItem<String>(value: 'one', child: 'One'),
SplitButtonM3EItem<String>(value: 'two', child: 'Two'),
SplitButtonM3EItem<String>(value: 'three', child: 'Three'),
];
return SectionCard(
title: 'SplitButtonM3E',
subtitle: 'Generated from enums: emphasis × size (round shape).',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final emphasis in SplitButtonM3EEmphasis.values) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(emphasis.name, style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final size in SplitButtonM3ESize.values)
SplitButtonM3E<String>(
label: emphasis.name,
size: size,
emphasis: emphasis,
onPressed: () {},
items: items,
),
],
),
],
],
),
);
}
}

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart';
class ToolbarSection extends StatelessWidget {
const ToolbarSection({super.key});
@override
Widget build(BuildContext context) {
final 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'),
];
return SectionCard(
title: 'ToolbarM3E',
subtitle: 'Generated from enums: variant × size (round shape).',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final variant in ToolbarM3EVariant.values) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(variant.name, style: Theme.of(context).textTheme.titleMedium),
),
Wrap(
runSpacing: 12,
children: [
for (final size in ToolbarM3ESize.values)
ToolbarM3E(
titleText: 'Toolbar',
subtitleText: 'size: ${size.name}',
actions: actions,
maxInlineActions: 2,
variant: variant,
size: size,
),
],
),
],
],
),
);
}
}

View file

@ -1,21 +1 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,70 +1,7 @@
# button_m3e
Material 3 **Expressive** Buttons for Flutter, built on top of Flutter's buttons but styled via **M3E** tokens.
Material 3 **Expressive Buttons** for Flutter — sizes XS→XL, round/square shapes,
toggle selection, and 5 styles (filled/tonal/elevated/outlined/text).
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
See `lib/src/button_tokens_adapter.dart` for measurements & color mapping.

View file

@ -1,5 +1,6 @@
library button_m3e;
export 'src/enums.dart';
export 'src/button_tokens_adapter.dart' show ButtonTokensAdapter, ButtonMeasurements;
export 'src/button_m3e.dart';
export 'src/button_theme_m3e.dart';

View file

@ -1,144 +1,212 @@
import 'package:flutter/material.dart';
import 'enums.dart';
import 'button_theme_m3e.dart';
class ButtonM3E extends StatelessWidget {
import 'button_tokens_adapter.dart';
import 'enums.dart';
class ButtonM3E extends StatefulWidget {
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');
required this.onPressed,
required this.label,
this.icon,
this.style = ButtonM3EStyle.filled,
this.size = ButtonM3ESize.sm,
this.shape = ButtonM3EShape.round,
this.selected = false,
this.toggleable = false,
this.onSelectedChange,
this.smallPaddingDeprecated24 = false,
this.enabled = true,
this.statesController,
});
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 Widget label;
final Widget? icon;
final ButtonM3EStyle style;
final ButtonM3ESize size;
final ButtonM3EShapeFamily shapeFamily;
final ButtonM3EDensity density;
final String? semanticLabel;
final ButtonM3EShape shape;
final bool selected;
final bool toggleable;
final ValueChanged<bool>? onSelectedChange;
final bool smallPaddingDeprecated24;
final bool enabled;
final WidgetStatesController? statesController;
@override
State<ButtonM3E> createState() => _ButtonM3EState();
}
class _ButtonM3EState extends State<ButtonM3E> {
late WidgetStatesController _statesController;
@override
void initState() {
super.initState();
_statesController = widget.statesController ?? WidgetStatesController();
_syncSelectedToController();
}
@override
void didUpdateWidget(covariant ButtonM3E oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected && widget.toggleable) {
_syncSelectedToController();
}
}
void _syncSelectedToController() {
_statesController.update(WidgetState.selected, widget.selected);
}
@override
Widget build(BuildContext context) {
final t = ButtonTokensAdapter(context);
final m = t.metrics(density);
final shape = t.shape(shapeFamily);
final tokens = ButtonTokensAdapter(context,
smallPaddingDeprecated24: widget.smallPaddingDeprecated24);
final m = tokens.measurements(widget.size);
final style = _resolveStyle(tokens, m);
final (minH, pad) = switch (size) {
ButtonM3ESize.small => (m.heightSmall, m.paddingSmall),
ButtonM3ESize.medium => (m.heightMedium, m.paddingMedium),
ButtonM3ESize.large => (m.heightLarge, m.paddingLarge),
};
final onPressed = widget.enabled
? () {
if (widget.toggleable) {
final newVal = !widget.selected;
widget.onSelectedChange?.call(newVal);
if (widget.onSelectedChange == null) {
_statesController.update(WidgetState.selected, newVal);
setState(() {});
}
}
widget.onPressed?.call();
}
: null;
final style = _styleFor(context, t, shape, minH, pad);
final child = _buildContent(m);
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,
switch (widget.style) {
case ButtonM3EStyle.filled:
return FilledButton(
style: style,
onPressed: onPressed,
statesController: _statesController,
child: child,
);
case ButtonM3EVariant.tonal:
return FilledButton.styleFrom(
backgroundColor: t.bgTonal(),
foregroundColor: t.fgTonal(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
case ButtonM3EStyle.tonal:
return FilledButton.tonal(
style: style,
onPressed: onPressed,
statesController: _statesController,
child: child,
);
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 ButtonM3EStyle.elevated:
return ElevatedButton(
style: style,
onPressed: onPressed,
statesController: _statesController,
child: child,
);
case ButtonM3EVariant.text:
return TextButton.styleFrom(
foregroundColor: t.fgText(),
textStyle: t.labelStyle(),
minimumSize: Size(0, minH),
padding: pad,
shape: shape,
case ButtonM3EStyle.outlined:
return OutlinedButton(
style: style.copyWith(
side: WidgetStateProperty.resolveWith((states) {
final disabled = states.contains(WidgetState.disabled);
return BorderSide(
color:
tokens.outline().withValues(alpha: disabled ? 0.12 : 1),
width: 1);
}),
),
onPressed: onPressed,
statesController: _statesController,
child: child,
);
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),
case ButtonM3EStyle.text:
return TextButton(
style: style,
onPressed: onPressed,
statesController: _statesController,
child: child,
);
}
}
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;
Widget _buildContent(ButtonMeasurements m) {
final text = DefaultTextStyle.merge(child: widget.label);
if (widget.icon == null) 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!],
IconTheme.merge(
data: IconThemeData(size: m.iconSize),
child: widget.icon!,
),
SizedBox(width: m.iconGap),
text,
],
);
}
OutlinedBorder _shapeFor(
Set<WidgetState> states, ButtonTokensAdapter tokens) {
final selected = states.contains(WidgetState.selected) || widget.selected;
final pressed = states.contains(WidgetState.pressed);
OutlinedBorder round = const StadiumBorder();
OutlinedBorder square = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.squareRadius(widget.size)),
);
OutlinedBorder pressedSquare = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.pressedRadius(widget.size)),
);
OutlinedBorder base = widget.shape == ButtonM3EShape.round ? round : square;
OutlinedBorder alt = widget.shape == ButtonM3EShape.round ? square : round;
if (selected && pressed) {
return OutlinedBorder.lerp(alt, pressedSquare, 0.5)!;
} else if (selected) {
return alt;
} else if (pressed) {
return OutlinedBorder.lerp(base, pressedSquare, 0.7)!;
}
return base;
}
ButtonStyle _resolveStyle(ButtonTokensAdapter tokens, ButtonMeasurements m) {
final fg = WidgetStateProperty.resolveWith<Color?>((states) {
final disabled = states.contains(WidgetState.disabled);
final color = tokens.foreground(widget.style);
return disabled ? color.withValues(alpha: 0.38) : color;
});
final bg = WidgetStateProperty.resolveWith<Color?>((states) {
final disabled = states.contains(WidgetState.disabled);
final color = tokens.container(widget.style);
if (widget.style == ButtonM3EStyle.outlined ||
widget.style == ButtonM3EStyle.text) {
return Colors.transparent;
}
return disabled ? color.withValues(alpha: .12) : color;
});
final elevation = WidgetStateProperty.resolveWith<double>((states) {
return tokens.elevation(widget.style, states);
});
final shape = WidgetStateProperty.resolveWith<OutlinedBorder>((states) {
return _shapeFor(states, tokens);
});
return ButtonStyle(
minimumSize: WidgetStateProperty.all(Size(48, m.height)),
padding:
WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: m.hPadding)),
foregroundColor: fg,
backgroundColor: bg,
shape: shape,
elevation: elevation,
animationDuration: const Duration(milliseconds: 140),
visualDensity: VisualDensity.standard,
splashFactory: InkRipple.splashFactory,
);
}
}

View file

@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class ButtonM3EWidget extends StatelessWidget {
const ButtonM3EWidget({super.key});
@override
Widget build(BuildContext context) {
final m3e = context.m3e;
return Container(
padding: EdgeInsets.all(m3e.spacing.md),
decoration: BoxDecoration(
color: m3e.colors.surfaceStrong,
borderRadius: m3e.shapes.square.md,
),
child: Text('Button placeholder', style: m3e.typography.base.labelLarge),
);
}
}
String _pascal(String s) => s
.split('_')
.map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1)))
.join();

View file

@ -1,94 +0,0 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _ButtonMetrics {
final double heightSmall;
final double heightMedium;
final double heightLarge;
final EdgeInsetsGeometry paddingSmall;
final EdgeInsetsGeometry paddingMedium;
final EdgeInsetsGeometry paddingLarge;
final BorderSide outlinedBorder;
final double elevation;
const _ButtonMetrics({
required this.heightSmall,
required this.heightMedium,
required this.heightLarge,
required this.paddingSmall,
required this.paddingMedium,
required this.paddingLarge,
required this.outlinedBorder,
required this.elevation,
});
}
_ButtonMetrics _metricsFor(BuildContext context, ButtonM3EDensity density) {
final theme = Theme.of(context);
final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final sp = m3e.spacing;
// Heights based on Material 3 expectations; tweakable by density.
double hS = 36;
double hM = 40;
double hL = 48;
if (density == ButtonM3EDensity.compact) {
hS -= 4;
hM -= 4;
hL -= 4;
}
return _ButtonMetrics(
heightSmall: hS,
heightMedium: hM,
heightLarge: hL,
paddingSmall: EdgeInsets.symmetric(horizontal: sp.sm),
paddingMedium: EdgeInsets.symmetric(horizontal: sp.md),
paddingLarge: EdgeInsets.symmetric(horizontal: sp.lg),
outlinedBorder: BorderSide(color: m3e.colors.outline, width: 1.0),
elevation: 1.0,
);
}
class ButtonTokensAdapter {
ButtonTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
// Colors
Color bgFilled() => _m3e.colors.primary;
Color fgFilled() => _m3e.colors.onPrimary;
Color bgTonal() => _m3e.colors.secondaryContainer;
Color fgTonal() => _m3e.colors.onSecondaryContainer;
Color bgElevated() => _m3e.colors.surfaceContainerLowest;
Color fgElevated() => _m3e.colors.primary;
Color fgText() => _m3e.colors.primary;
Color borderOutlined() => _m3e.colors.outline;
Color fgOutlined() => _m3e.colors.primary;
Color disabledFg() => _m3e.colors.onSurface.withValues(alpha: 0.38);
Color disabledBg() => _m3e.colors.onSurface.withValues(alpha: 0.12);
// Typography
TextStyle labelStyle() => _m3e.type.labelLarge;
// Shapes
OutlinedBorder shape(ButtonM3EShapeFamily family) {
if (family == ButtonM3EShapeFamily.round) {
return RoundedRectangleBorder(borderRadius: _m3e.shapes.round.lg);
}
// Square family should have sharp corners (no rounding)
return const RoundedRectangleBorder(borderRadius: BorderRadius.zero);
}
// Spacing & heights
_ButtonMetrics metrics(ButtonM3EDensity density) =>
_metricsFor(context, density);
}

View file

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class ButtonMeasurements {
const ButtonMeasurements({
required this.height,
required this.hPadding,
required this.iconSize,
required this.iconGap,
});
final double height;
final double hPadding;
final double iconSize;
final double iconGap;
}
@immutable
class ButtonTokensAdapter {
const ButtonTokensAdapter(this.context, {this.smallPaddingDeprecated24 = false});
final BuildContext context;
final bool smallPaddingDeprecated24;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
Color container(ButtonM3EStyle style) {
final c = _m3e.colors;
switch (style) {
case ButtonM3EStyle.filled:
return c.primary;
case ButtonM3EStyle.tonal:
return c.secondaryContainer;
case ButtonM3EStyle.elevated:
return c.surface;
case ButtonM3EStyle.outlined:
case ButtonM3EStyle.text:
return Colors.transparent;
}
}
Color foreground(ButtonM3EStyle style) {
final c = _m3e.colors;
switch (style) {
case ButtonM3EStyle.filled:
return c.onPrimary;
case ButtonM3EStyle.tonal:
return c.onSecondaryContainer;
case ButtonM3EStyle.elevated:
case ButtonM3EStyle.outlined:
case ButtonM3EStyle.text:
return c.primary;
}
}
Color outline() => _m3e.colors.outline;
double elevation(ButtonM3EStyle style, Set<MaterialState> states) {
final hovered = states.contains(MaterialState.hovered);
final pressed = states.contains(MaterialState.pressed);
final disabled = states.contains(MaterialState.disabled);
if (disabled) return 0;
switch (style) {
case ButtonM3EStyle.elevated:
return pressed ? 0 : hovered ? 3 : 1;
case ButtonM3EStyle.filled:
case ButtonM3EStyle.tonal:
return pressed ? 0 : hovered ? 1 : 0;
case ButtonM3EStyle.outlined:
case ButtonM3EStyle.text:
return 0;
}
}
double squareRadius(ButtonM3ESize size) {
switch (size) {
case ButtonM3ESize.xs:
return 8;
case ButtonM3ESize.sm:
return 8;
case ButtonM3ESize.md:
return 12;
case ButtonM3ESize.lg:
return 16;
case ButtonM3ESize.xl:
return 20;
}
}
double pressedRadius(ButtonM3ESize size) => (squareRadius(size) * 0.6).clamp(6, 18);
ButtonMeasurements measurements(ButtonM3ESize size) {
switch (size) {
case ButtonM3ESize.xs:
return const ButtonMeasurements(height: 32, hPadding: 12, iconSize: 20, iconGap: 4);
case ButtonM3ESize.sm:
return ButtonMeasurements(
height: 40,
hPadding: smallPaddingDeprecated24 ? 24 : 16,
iconSize: 20,
iconGap: 8,
);
case ButtonM3ESize.md:
return const ButtonMeasurements(height: 56, hPadding: 24, iconSize: 24, iconGap: 8);
case ButtonM3ESize.lg:
return const ButtonMeasurements(height: 96, hPadding: 48, iconSize: 32, iconGap: 12);
case ButtonM3ESize.xl:
return const ButtonMeasurements(height: 136, hPadding: 64, iconSize: 40, iconGap: 16);
}
}
}

View file

@ -1,4 +1,4 @@
enum ButtonM3EVariant { filled, tonal, outlined, text, elevated }
enum ButtonM3ESize { small, medium, large }
enum ButtonM3EShapeFamily { round, square }
enum ButtonM3EDensity { regular, compact }
enum ButtonM3EStyle { elevated, filled, tonal, outlined, text }
enum ButtonM3ESize { xs, sm, md, lg, xl }
enum ButtonM3EShape { round, square }

View file

@ -1,11 +1,12 @@
name: button_m3e
description: Material 3 Expressive Buttons for Flutter (filled, tonal, outlined, text, elevated) with M3E tokens.
description: Material 3 Expressive Buttons for Flutter with 5 styles, 5 sizes, round/square shapes, and toggle selection.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0"
flutter: ">=3.19.0"
dependencies:
flutter:

View file

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

View file

@ -1,7 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () {
expect(1 + 1, 2);
test('sanity', () {
expect(2 + 2, 4);
});
}

View file

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
/// Expressive emphasis tweaks layered on top of baseline M3 type.
/// Keep line-height the same; only tune weight/letter-spacing for emphasis.
@immutable
class M3EEmphasized {
final TextStyle display;
@ -14,12 +16,24 @@ class M3EEmphasized {
required this.label,
});
/// M3E guidance: slightly heavier weights and tighter tracking for large roles.
static M3EEmphasized forBrightness(Brightness b) {
// You could vary by brightness if desired; values below are neutral.
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),
display: TextStyle(
fontWeight: FontWeight.w800,
letterSpacing: -0.5, // subtle tightening on big sizes
),
headline: TextStyle(
fontWeight: FontWeight.w700,
letterSpacing: -0.25,
),
title: TextStyle(
fontWeight: FontWeight.w700,
),
label: TextStyle(
fontWeight: FontWeight.w700,
),
);
}
@ -32,6 +46,11 @@ class M3EEmphasized {
);
}
/// Material 3 Expressive typography tokens.
/// - `base` starts from **M3 (Typography.material2021)**.
/// - Optionally remaps fonts: brand (UI) for display/headline/title/label
/// and plain (reading) for body.
/// - Adds an emphasized set (weight/tracking tweaks) for expressive hierarchy.
@immutable
class M3ETypography {
final TextTheme base;
@ -39,16 +58,73 @@ class M3ETypography {
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();
/// Build default M3E typography from M3.
///
/// [brandFontFamily] is typically your UI/brand face (e.g., Roboto Flex).
/// [plainFontFamily] is typically your reading face (e.g., Roboto Serif).
/// If you pass neither, youll get pure M3 defaults (no family swap),
/// but still keep the M3E emphasized set for optional use.
factory M3ETypography.defaultFor(
Brightness brightness, {
String? brandFontFamily,
String? plainFontFamily,
TextTheme? baseOverride,
}) {
// 1) Start from Material 3 baseline type.
final m3 = Typography.material2021();
final TextTheme m3Base =
baseOverride ?? (brightness == Brightness.dark ? m3.white : m3.black);
// 2) Optionally map brand/plain families to role groups (M3E guidance).
final TextTheme baseWithFamilies = _applyFamilies(
m3Base,
brand: brandFontFamily,
plain: plainFontFamily,
);
// 3) Provide emphasized deltas (weights/tracking).
return M3ETypography(
base: textTheme, emphasized: M3EEmphasized.forBrightness(b));
base: baseWithFamilies,
emphasized: M3EEmphasized.forBrightness(brightness),
);
}
/// Lerp the full token set.
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),
);
/// Apply brand/plain families: brand display/headline/title/label,
/// plain body. If a family is null, keep the original.
static TextTheme _applyFamilies(
TextTheme t, {
String? brand,
String? plain,
}) {
TextStyle? _withFam(TextStyle? s, String? fam) =>
fam == null ? s : s?.copyWith(fontFamily: fam);
return t.copyWith(
// Brand / UI voice
displayLarge: _withFam(t.displayLarge, brand),
displayMedium: _withFam(t.displayMedium, brand),
displaySmall: _withFam(t.displaySmall, brand),
headlineLarge: _withFam(t.headlineLarge, brand),
headlineMedium: _withFam(t.headlineMedium, brand),
headlineSmall: _withFam(t.headlineSmall, brand),
titleLarge: _withFam(t.titleLarge, brand),
titleMedium: _withFam(t.titleMedium, brand),
titleSmall: _withFam(t.titleSmall, brand),
labelLarge: _withFam(t.labelLarge, brand),
labelMedium: _withFam(t.labelMedium, brand),
labelSmall: _withFam(t.labelSmall, brand),
// Reading voice
bodyLarge: _withFam(t.bodyLarge, plain),
bodyMedium: _withFam(t.bodyMedium, plain),
bodySmall: _withFam(t.bodySmall, plain),
);
}
}