Prepare for publishing button_group_m3e 0.3.1

This commit is contained in:
Bolin 2026-01-24 15:17:10 +08:00
commit 27a5aa5d70
4 changed files with 443 additions and 64 deletions

View file

@ -3,6 +3,7 @@
Material 3 Expressive grouped button layout and overflow management.
## Current API (0.3.0)
`children` has been removed. Provide `actions: List<ButtonGroupM3EAction>`.
```dart
@ -17,6 +18,7 @@ ButtonGroupM3E(
```
## Actions
```dart
class ButtonGroupM3EAction {
const ButtonGroupM3EAction({
@ -34,7 +36,9 @@ class ButtonGroupM3EAction {
```
## Group selection
Enable segmented styling:
```dart
int selectedIndex = 0;
ButtonGroupM3E(
@ -47,17 +51,21 @@ ButtonGroupM3E(
],
)
```
Shape rules when `groupSelection` is true:
- Selected button: fully round.
- First & last (unselected): round.
- Middle unselected buttons: square.
## Overflow
- `menu` (default): shows what fits + overflow trigger with remaining actions in a bottom sheet.
- `scroll`: scrolls along main axis when constrained.
- `none`: no handling (may overflow if parent allows).
## Other parameters
- `type`: standard | connected (divider seams, zero spacing)
- `shape`: square | round (base shape family)
- `size`: xs | sm | md | lg | xl
@ -66,7 +74,9 @@ Shape rules when `groupSelection` is true:
- `equalizeWidths`: enforce min widths per size for even visual rhythm.
## Versioning
0.3.0 BREAKING: removed `children`. Use `actions`.
## License
See LICENSE.

View file

@ -0,0 +1,304 @@
import 'package:flutter/material.dart';
import 'package:button_group_m3e/button_group_m3e.dart';
import 'package:button_m3e/button_m3e.dart';
void main() {
runApp(const SelectionDemoApp());
}
class SelectionDemoApp extends StatelessWidget {
const SelectionDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ButtonGroupM3E Selection Demo',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const SelectionDemoScreen(),
);
}
}
class SelectionDemoScreen extends StatefulWidget {
const SelectionDemoScreen({super.key});
@override
State<SelectionDemoScreen> createState() => _SelectionDemoScreenState();
}
class _SelectionDemoScreenState extends State<SelectionDemoScreen> {
// For external selection control (using selectedIndex)
int _selectedConnectedIndex = 0;
int _selectedNonConnectedIndex = 1;
// For toggle selection control (using action.selected and action.onSelectedChange)
List<bool> _toggleStates = [false, true, false, true];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ButtonGroupM3E Selection Demo'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Demo 1: Connected + Selection with external control
const Text(
'Connected + Selection (External Control):',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ButtonGroupM3E(
type: ButtonGroupM3EType.connected,
selection: true,
selectedIndex: _selectedConnectedIndex,
style: ButtonM3EStyle.filled,
actions: [
ButtonGroupM3EAction(
label: const Text('Option 1'),
onPressed: () {
setState(() {
_selectedConnectedIndex = 0;
print('Selected: Option 1 (Index 0)');
});
},
),
ButtonGroupM3EAction(
label: const Text('Option 2'),
onPressed: () {
setState(() {
_selectedConnectedIndex = 1;
print('Selected: Option 2 (Index 1)');
});
},
),
ButtonGroupM3EAction(
label: const Text('Option 3'),
onPressed: () {
setState(() {
_selectedConnectedIndex = 2;
print('Selected: Option 3 (Index 2)');
});
},
),
],
),
),
const SizedBox(height: 24),
// Demo 2: Non-Connected + Selection with external control
const Text(
'Non-Connected + Selection (External Control):',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ButtonGroupM3E(
type: ButtonGroupM3EType.standard, // Non-connected
selection: true,
selectedIndex: _selectedNonConnectedIndex,
style: ButtonM3EStyle.tonal,
actions: [
ButtonGroupM3EAction(
label: const Text('Left'),
onPressed: () {
setState(() {
_selectedNonConnectedIndex = 0;
print('Selected: Left (Index 0)');
});
},
),
ButtonGroupM3EAction(
label: const Text('Center'),
onPressed: () {
setState(() {
_selectedNonConnectedIndex = 1;
print('Selected: Center (Index 1)');
});
},
),
ButtonGroupM3EAction(
label: const Text('Right'),
onPressed: () {
setState(() {
_selectedNonConnectedIndex = 2;
print('Selected: Right (Index 2)');
});
},
),
],
),
),
const SizedBox(height: 24),
// Demo 3: Connected + Toggle Selection (internal control)
const Text(
'Connected + Toggle Selection (Internal Control):',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ButtonGroupM3E(
type: ButtonGroupM3EType.connected,
selection: true, // This enables the selection styling
style: ButtonM3EStyle.outlined,
actions: [
ButtonGroupM3EAction(
label: const Text('Toggle 1'),
selected: _toggleStates[0],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[0] = selected;
print('Toggle 1: $selected');
});
},
),
ButtonGroupM3EAction(
label: const Text('Toggle 2'),
selected: _toggleStates[1],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[1] = selected;
print('Toggle 2: $selected');
});
},
),
ButtonGroupM3EAction(
label: const Text('Toggle 3'),
selected: _toggleStates[2],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[2] = selected;
print('Toggle 3: $selected');
});
},
),
ButtonGroupM3EAction(
label: const Text('Toggle 4'),
selected: _toggleStates[3],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[3] = selected;
print('Toggle 4: $selected');
});
},
),
],
),
),
const SizedBox(height: 24),
// Demo 4: Non-Connected + Toggle Selection (internal control)
const Text(
'Non-Connected + Toggle Selection (Internal Control):',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ButtonGroupM3E(
type: ButtonGroupM3EType.standard, // Non-connected
selection: true, // This enables the selection styling
style: ButtonM3EStyle.text,
actions: [
ButtonGroupM3EAction(
label: const Text('A'),
selected: _toggleStates[0],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[0] = selected;
print('A selected: $selected');
});
},
),
ButtonGroupM3EAction(
label: const Text('B'),
selected: _toggleStates[1],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[1] = selected;
print('B selected: $selected');
});
},
),
ButtonGroupM3EAction(
label: const Text('C'),
selected: _toggleStates[2],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[2] = selected;
print('C selected: $selected');
});
},
),
ButtonGroupM3EAction(
label: const Text('D'),
selected: _toggleStates[3],
onSelectedChange: (bool selected) {
setState(() {
_toggleStates[3] = selected;
print('D selected: $selected');
});
},
),
],
),
),
const SizedBox(height: 24),
// State Display
const Text(
'Current Selection State:',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connected Selected Index: $_selectedConnectedIndex'),
Text(
'Non-Connected Selected Index: $_selectedNonConnectedIndex'),
Text('Toggle States: ${_toggleStates.join(", ")}'),
],
),
),
),
],
),
),
),
);
}
}

View file

@ -690,71 +690,13 @@ class _ButtonGroupM3EState extends State<ButtonGroupM3E> {
if (selected) {
shapeOut = ButtonM3EShape.round; // selected always fully round
} else {
if (widget._connected) {
// Connected + selection: interior corners should have square token radius; outer ends rounded.
shapeOut = ButtonM3EShape.square;
// Obtain square base radius from button tokens for inner corners.
final squareRadiusVal = ButtonTokensAdapter(context)
.squareRadius(_mapGroupSize(widget.size));
final innerRadius = Radius.circular(squareRadiusVal);
// Obtain round radius set for outer corners.
final roundSet =
radiusFor(context, ButtonGroupM3EShape.round, widget.size);
if (widget.direction == Axis.horizontal) {
if (isFirst) {
perCorner = BorderRadius.only(
topLeft: roundSet.topLeft,
bottomLeft: roundSet.bottomLeft,
topRight: innerRadius,
bottomRight: innerRadius,
);
} else if (isLast) {
perCorner = BorderRadius.only(
topLeft: innerRadius,
bottomLeft: innerRadius,
topRight: roundSet.topRight,
bottomRight: roundSet.bottomRight,
);
} else {
// Middle buttons: all corners square token radius
perCorner = BorderRadius.all(innerRadius);
}
} else {
// Vertical direction
if (isFirst) {
perCorner = BorderRadius.only(
topLeft: roundSet.topLeft,
topRight: roundSet.topRight,
bottomLeft: innerRadius,
bottomRight: innerRadius,
);
} else if (isLast) {
perCorner = BorderRadius.only(
topLeft: innerRadius,
topRight: innerRadius,
bottomLeft: roundSet.bottomLeft,
bottomRight: roundSet.bottomRight,
);
} else {
perCorner = BorderRadius.all(innerRadius);
}
}
} else {
// Non-connected selection: ends fully round, inner square token radius.
if (isFirst || isLast) {
shapeOut = ButtonM3EShape.round;
perCorner = null; // round shape handles corners
} else {
shapeOut = ButtonM3EShape.square;
final squareRadiusVal = ButtonTokensAdapter(context)
.squareRadius(_mapGroupSize(widget.size));
perCorner = BorderRadius.all(Radius.circular(squareRadiusVal));
}
}
// All unselected buttons should be round regardless of connected mode
shapeOut = ButtonM3EShape.round;
perCorner = null; // round shape handles corners
}
}
return ButtonM3E(
final buttonWidget = ButtonM3E(
onPressed: action.onPressed,
label: action.label,
icon: action.icon,
@ -776,6 +718,30 @@ class _ButtonGroupM3EState extends State<ButtonGroupM3E> {
enabled: action.enabled,
cornerRadiusOverride: perCorner,
);
// Apply color inversion based on selection state and add jelly effect
Widget resultWidget = buttonWidget;
if (widget.selection) {
if (selected) {
// Selected button: use normal color state, internal widgets use inverted color
// For now, just return the button as is, since we don't have a mechanism to invert internal widgets
resultWidget = buttonWidget;
} else {
// Unselected button: use inverted color state, internal widgets use normal color
// We'll wrap the button in a theme that potentially inverts colors
// This is a simplified implementation - full color inversion would require more work
resultWidget = buttonWidget;
}
} else {
resultWidget = buttonWidget;
}
// Add jelly effect animation
return _JellyEffect(
child: resultWidget,
enabled: action.enabled,
);
}
ButtonM3ESize _mapGroupSize(ButtonGroupM3ESize s) => switch (s) {
@ -1065,3 +1031,102 @@ class _ButtonGroupM3EState extends State<ButtonGroupM3E> {
constraints: BoxConstraints(minWidth: minW), child: child);
}
}
class _JellyEffect extends StatefulWidget {
const _JellyEffect({
required this.child,
required this.enabled,
});
final Widget child;
final bool enabled;
@override
State<_JellyEffect> createState() => _JellyEffectState();
}
class _JellyEffectState extends State<_JellyEffect>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _squashAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration:
const Duration(milliseconds: 300), // Longer duration for jelly effect
vsync: this,
);
// Create a spring-like animation for jelly effect
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.3,
curve: Curves.elasticOut), // Elastic curve for jelly effect
),
);
_squashAnimation = Tween<double>(
begin: 1.0,
end: 1.05,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.3,
curve: Curves.elasticOut), // Elastic curve for jelly effect
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return widget.child;
}
return GestureDetector(
onTapDown: (_) {
_controller.forward();
},
onTapUp: (_) {
_controller.reverse();
},
onTapCancel: () {
_controller.reverse();
},
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final scaleValue = _scaleAnimation.value;
final squashValue = _squashAnimation.value;
// Apply both vertical squash and horizontal stretch for jelly effect
return Transform.scale(
scale: scaleValue,
child: Transform(
transform: Matrix4.identity()
..scale(
1.0 + (squashValue - 1.0) * 0.5,
1.0 -
(squashValue - 1.0) *
0.5), // Horizontal stretch, vertical squash
child: widget.child,
),
);
},
),
);
}
}

View file

@ -1,6 +1,6 @@
# melos_managed_dependency_overrides: m3e_design
dependency_overrides:
m3e_design:
path: ..\\m3e_design
path: ../m3e_design
button_m3e:
path: ..\\button_m3e
path: ../button_m3e