From 27a5aa5d70b6c1ab4ab9f3f601f43e5fa1ce2085 Mon Sep 17 00:00:00 2001 From: Bolin Date: Sat, 24 Jan 2026 15:17:10 +0800 Subject: [PATCH] Prepare for publishing button_group_m3e 0.3.1 --- packages/button_group_m3e/README.md | 10 + .../example/selection_demo.dart | 304 ++++++++++++++++++ .../lib/src/button_group_m3e_widget.dart | 189 +++++++---- .../button_group_m3e/pubspec_overrides.yaml | 4 +- 4 files changed, 443 insertions(+), 64 deletions(-) create mode 100644 packages/button_group_m3e/example/selection_demo.dart diff --git a/packages/button_group_m3e/README.md b/packages/button_group_m3e/README.md index ae73675..810c7c7 100644 --- a/packages/button_group_m3e/README.md +++ b/packages/button_group_m3e/README.md @@ -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`. ```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. diff --git a/packages/button_group_m3e/example/selection_demo.dart b/packages/button_group_m3e/example/selection_demo.dart new file mode 100644 index 0000000..a4a8bf3 --- /dev/null +++ b/packages/button_group_m3e/example/selection_demo.dart @@ -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 createState() => _SelectionDemoScreenState(); +} + +class _SelectionDemoScreenState extends State { + // For external selection control (using selectedIndex) + int _selectedConnectedIndex = 0; + int _selectedNonConnectedIndex = 1; + + // For toggle selection control (using action.selected and action.onSelectedChange) + List _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(", ")}'), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart index 2a98cfe..79d9fe8 100644 --- a/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart +++ b/packages/button_group_m3e/lib/src/button_group_m3e_widget.dart @@ -690,71 +690,13 @@ class _ButtonGroupM3EState extends State { 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 { 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 { 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 _scaleAnimation; + late Animation _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( + 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( + 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, + ), + ); + }, + ), + ); + } +} diff --git a/packages/button_group_m3e/pubspec_overrides.yaml b/packages/button_group_m3e/pubspec_overrides.yaml index 6c4dda6..af0f7b7 100644 --- a/packages/button_group_m3e/pubspec_overrides.yaml +++ b/packages/button_group_m3e/pubspec_overrides.yaml @@ -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