Prepare for publishing button_group_m3e 0.3.1
This commit is contained in:
parent
76daccbc71
commit
27a5aa5d70
4 changed files with 443 additions and 64 deletions
|
|
@ -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.
|
||||
|
|
|
|||
304
packages/button_group_m3e/example/selection_demo.dart
Normal file
304
packages/button_group_m3e/example/selection_demo.dart
Normal 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(", ")}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue