forked from mirrors/material_3_expressive
Add initial configuration, tokens, and widgets for M3E components
- Introduced `.gitignore` and `.metadata` for apps and examples. - Added Flutter/Dart analysis configurations (`analysis_options.yaml`). - Implemented foundational tokens and themes for M3E (colors, shapes). - Created base implementations for `IconButtonM3E` and `SplitButtonM3E`. - Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
5
packages/split_button_m3e/lib/split_button_m3e.dart
Normal file
5
packages/split_button_m3e/lib/split_button_m3e.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
library split_button_m3e;
|
||||
|
||||
export 'src/enums.dart';
|
||||
export 'src/menu_items.dart';
|
||||
export 'src/split_button.dart';
|
||||
160
packages/split_button_m3e/lib/src/_tokens_adapter.dart
Normal file
160
packages/split_button_m3e/lib/src/_tokens_adapter.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
part of 'enums.dart';
|
||||
|
||||
/// Token bucket for SplitButtonM3E (dp, ms, turns, etc.).
|
||||
class SplitButtonM3ETokens {
|
||||
const SplitButtonM3ETokens._();
|
||||
|
||||
// Control heights per size
|
||||
static const Map<SplitButtonM3ESize, double> height = {
|
||||
SplitButtonM3ESize.xs: 32,
|
||||
SplitButtonM3ESize.sm: 40,
|
||||
SplitButtonM3ESize.md: 56,
|
||||
SplitButtonM3ESize.lg: 96,
|
||||
SplitButtonM3ESize.xl: 136,
|
||||
};
|
||||
|
||||
// Trailing segment width
|
||||
static const Map<SplitButtonM3ESize, double> trailingSegmentWidth = {
|
||||
SplitButtonM3ESize.xs: 22,
|
||||
SplitButtonM3ESize.sm: 22,
|
||||
SplitButtonM3ESize.md: 26,
|
||||
SplitButtonM3ESize.lg: 38,
|
||||
SplitButtonM3ESize.xl: 50,
|
||||
};
|
||||
|
||||
// Inner “gap” between segments
|
||||
static const double innerGap = 2.0;
|
||||
|
||||
// Inner corner radius (both facing edges)
|
||||
static const Map<SplitButtonM3ESize, double> innerCornerRadius = {
|
||||
SplitButtonM3ESize.xs: 4,
|
||||
SplitButtonM3ESize.sm: 4,
|
||||
SplitButtonM3ESize.md: 4,
|
||||
SplitButtonM3ESize.lg: 8,
|
||||
SplitButtonM3ESize.xl: 12,
|
||||
};
|
||||
|
||||
// Inner padding (from inner edge to content)
|
||||
static const Map<SplitButtonM3ESize, double> innerPadding = {
|
||||
SplitButtonM3ESize.xs: 4,
|
||||
SplitButtonM3ESize.sm: 4,
|
||||
SplitButtonM3ESize.md: 4,
|
||||
SplitButtonM3ESize.lg: 8,
|
||||
SplitButtonM3ESize.xl: 12,
|
||||
};
|
||||
|
||||
// Menu chevron optical offset (unselected/resting; negative = shift left)
|
||||
static const Map<SplitButtonM3ESize, double> menuIconOffsetUnselected = {
|
||||
SplitButtonM3ESize.xs: -1,
|
||||
SplitButtonM3ESize.sm: -1,
|
||||
SplitButtonM3ESize.md: -2,
|
||||
SplitButtonM3ESize.lg: -3,
|
||||
SplitButtonM3ESize.xl: -6,
|
||||
};
|
||||
|
||||
// Icon glyph size (for both segments)
|
||||
static const Map<SplitButtonM3ESize, double> icon = {
|
||||
SplitButtonM3ESize.xs: 20.0,
|
||||
SplitButtonM3ESize.sm: 24.0,
|
||||
SplitButtonM3ESize.md: 24.0,
|
||||
SplitButtonM3ESize.lg: 32.0,
|
||||
SplitButtonM3ESize.xl: 40.0,
|
||||
};
|
||||
|
||||
// Minimum touch target per segment
|
||||
static const double minTapTarget = 48.0;
|
||||
|
||||
// Shape radii (outer corners) and pressed morph
|
||||
// round = half height; square ≈ 25% height; pressed ≈ 20% height
|
||||
static const Map<SplitButtonM3ESize, double> outerRadiusRound = {
|
||||
SplitButtonM3ESize.xs: 16,
|
||||
SplitButtonM3ESize.sm: 20,
|
||||
SplitButtonM3ESize.md: 28,
|
||||
SplitButtonM3ESize.lg: 48,
|
||||
SplitButtonM3ESize.xl: 68,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> outerRadiusSquare = {
|
||||
SplitButtonM3ESize.xs: 8,
|
||||
SplitButtonM3ESize.sm: 10,
|
||||
SplitButtonM3ESize.md: 14,
|
||||
SplitButtonM3ESize.lg: 24,
|
||||
SplitButtonM3ESize.xl: 34,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> pressedRadius = {
|
||||
SplitButtonM3ESize.xs: 6,
|
||||
SplitButtonM3ESize.sm: 8,
|
||||
SplitButtonM3ESize.md: 11,
|
||||
SplitButtonM3ESize.lg: 19,
|
||||
SplitButtonM3ESize.xl: 27,
|
||||
};
|
||||
|
||||
// Layout: Asymmetrical (optically centered trailing; unselected)
|
||||
static const Map<SplitButtonM3ESize, double> leadingIconBlockWidth = {
|
||||
SplitButtonM3ESize.xs: 20,
|
||||
SplitButtonM3ESize.sm: 20,
|
||||
SplitButtonM3ESize.md: 24,
|
||||
SplitButtonM3ESize.lg: 32,
|
||||
SplitButtonM3ESize.xl: 40,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> leftOuterPadding = {
|
||||
SplitButtonM3ESize.xs: 12,
|
||||
SplitButtonM3ESize.sm: 16,
|
||||
SplitButtonM3ESize.md: 24,
|
||||
SplitButtonM3ESize.lg: 48,
|
||||
SplitButtonM3ESize.xl: 64,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> gapIconToLabel = {
|
||||
SplitButtonM3ESize.xs: 4,
|
||||
SplitButtonM3ESize.sm: 8,
|
||||
SplitButtonM3ESize.md: 8,
|
||||
SplitButtonM3ESize.lg: 12,
|
||||
SplitButtonM3ESize.xl: 16,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> labelRightPaddingBeforeDivider =
|
||||
{
|
||||
SplitButtonM3ESize.xs: 10,
|
||||
SplitButtonM3ESize.sm: 12,
|
||||
SplitButtonM3ESize.md: 24,
|
||||
SplitButtonM3ESize.lg: 48,
|
||||
SplitButtonM3ESize.xl: 64,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> trailingLeftInnerPadding = {
|
||||
SplitButtonM3ESize.xs: 12,
|
||||
SplitButtonM3ESize.sm: 12,
|
||||
SplitButtonM3ESize.md: 13,
|
||||
SplitButtonM3ESize.lg: 26,
|
||||
SplitButtonM3ESize.xl: 37,
|
||||
};
|
||||
|
||||
static const Map<SplitButtonM3ESize, double> rightOuterPadding = {
|
||||
SplitButtonM3ESize.xs: 14,
|
||||
SplitButtonM3ESize.sm: 14,
|
||||
SplitButtonM3ESize.md: 17,
|
||||
SplitButtonM3ESize.lg: 32,
|
||||
SplitButtonM3ESize.xl: 49,
|
||||
};
|
||||
|
||||
// Layout: Symmetrical (trailing centered; selected)
|
||||
static const Map<SplitButtonM3ESize, double> sidePaddingSelected = {
|
||||
SplitButtonM3ESize.xs: 13,
|
||||
SplitButtonM3ESize.sm: 13,
|
||||
SplitButtonM3ESize.md: 15,
|
||||
SplitButtonM3ESize.lg: 29,
|
||||
SplitButtonM3ESize.xl: 43,
|
||||
};
|
||||
|
||||
// Animation
|
||||
static const Duration morphDuration = Duration(milliseconds: 120);
|
||||
static const Curve morphCurve = Curves.easeOut;
|
||||
static const double chevronOpenTurns = 0.5; // 180°
|
||||
static const Duration chevronDuration = Duration(milliseconds: 120);
|
||||
|
||||
// Focus ring
|
||||
static const double focusStrokeWidth = 2.0;
|
||||
}
|
||||
46
packages/split_button_m3e/lib/src/enums.dart
Normal file
46
packages/split_button_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
part '_tokens_adapter.dart';
|
||||
|
||||
/// 5-step size scale (rows 1–5 in the spec).
|
||||
enum SplitButtonM3ESize { xs, sm, md, lg, xl }
|
||||
|
||||
/// Base silhouette for the outer corners in resting state.
|
||||
/// (Pressed state morphs using tokens regardless of the base.)
|
||||
enum SplitButtonM3EShape { round, square }
|
||||
|
||||
/// Emphasis family (choose container/elevation per theme).
|
||||
enum SplitButtonM3EEmphasis { filled, tonal, elevated, outlined, text }
|
||||
|
||||
/// Trailing icon alignment:
|
||||
/// - opticalCenter → apply per-size negative offset in resting (menu closed) state
|
||||
/// - geometricCenter → no offset, purely geometric center
|
||||
enum SplitButtonM3ETrailingAlignment { opticalCenter, geometricCenter }
|
||||
|
||||
/// Public helpers to access tokens without exposing numbers.
|
||||
extension SplitButtonM3ETokensX on SplitButtonM3ESize {
|
||||
double get height => SplitButtonM3ETokens.height[this]!;
|
||||
double get trailingWidthCentered =>
|
||||
SplitButtonM3ETokens.trailingSegmentWidth[this]!;
|
||||
double get innerCornerRadius => SplitButtonM3ETokens.innerCornerRadius[this]!;
|
||||
double get innerPadding => SplitButtonM3ETokens.innerPadding[this]!;
|
||||
double get menuIconOffsetUnselected =>
|
||||
SplitButtonM3ETokens.menuIconOffsetUnselected[this]!;
|
||||
double get iconPx => SplitButtonM3ETokens.icon[this]!;
|
||||
double get outerRoundRadius => SplitButtonM3ETokens.outerRadiusRound[this]!;
|
||||
double get outerSquareRadius => SplitButtonM3ETokens.outerRadiusSquare[this]!;
|
||||
double get pressedRadius => SplitButtonM3ETokens.pressedRadius[this]!;
|
||||
|
||||
// New layout getters (per spec tables)
|
||||
double get leadingIconBlockWidth =>
|
||||
SplitButtonM3ETokens.leadingIconBlockWidth[this]!;
|
||||
double get leftOuterPadding => SplitButtonM3ETokens.leftOuterPadding[this]!;
|
||||
double get gapIconToLabel => SplitButtonM3ETokens.gapIconToLabel[this]!;
|
||||
double get labelRightPaddingBeforeDivider =>
|
||||
SplitButtonM3ETokens.labelRightPaddingBeforeDivider[this]!;
|
||||
double get trailingLeftInnerPadding =>
|
||||
SplitButtonM3ETokens.trailingLeftInnerPadding[this]!;
|
||||
double get rightOuterPadding => SplitButtonM3ETokens.rightOuterPadding[this]!;
|
||||
double get sidePaddingSelected =>
|
||||
SplitButtonM3ETokens.sidePaddingSelected[this]!;
|
||||
}
|
||||
12
packages/split_button_m3e/lib/src/menu_items.dart
Normal file
12
packages/split_button_m3e/lib/src/menu_items.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/// Simple generic menu item model for SplitButtonM3E.
|
||||
class SplitButtonM3EItem<T> {
|
||||
const SplitButtonM3EItem({
|
||||
required this.value,
|
||||
required this.child,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final Object child; // Widget or plain string; the caller builds PopupMenuItem
|
||||
final bool enabled;
|
||||
}
|
||||
578
packages/split_button_m3e/lib/src/split_button.dart
Normal file
578
packages/split_button_m3e/lib/src/split_button.dart
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
import 'menu_items.dart';
|
||||
|
||||
/// Two-segment Material 3 Expressive split button.
|
||||
///
|
||||
/// - Leading segment: primary action (icon, label, or both)
|
||||
/// - Trailing segment: menu trigger (chevron), opens a menu of alternatives
|
||||
///
|
||||
/// All numeric values (sizes, paddings, radii, durations) are pulled from
|
||||
/// `tokens.dart` via the enums extension getters.
|
||||
class SplitButtonM3E<T> extends StatefulWidget {
|
||||
const SplitButtonM3E({
|
||||
super.key,
|
||||
this.shape = SplitButtonM3EShape.round,
|
||||
this.size = SplitButtonM3ESize.sm,
|
||||
this.emphasis = SplitButtonM3EEmphasis.filled,
|
||||
this.label,
|
||||
this.leadingIcon,
|
||||
this.onPressed,
|
||||
required this.items,
|
||||
this.onSelected,
|
||||
this.trailingAlignment = SplitButtonM3ETrailingAlignment.opticalCenter,
|
||||
this.leadingTooltip,
|
||||
this.trailingTooltip,
|
||||
this.enabled = true,
|
||||
this.menuBuilder,
|
||||
}) : assert(
|
||||
items != null || menuBuilder != null,
|
||||
'Provide either `items` or `menuBuilder`.',
|
||||
);
|
||||
|
||||
/// Size row (XS→XL).
|
||||
final SplitButtonM3ESize size;
|
||||
|
||||
/// Resting outer shape (round/square). Pressed morph uses tokens.
|
||||
final SplitButtonM3EShape shape;
|
||||
|
||||
/// Visual emphasis family.
|
||||
final SplitButtonM3EEmphasis emphasis;
|
||||
|
||||
/// Leading segment content.
|
||||
final String? label;
|
||||
final IconData? leadingIcon;
|
||||
|
||||
/// Leading action.
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Trailing menu definition. Use either a static list...
|
||||
final List<SplitButtonM3EItem<T>>? items;
|
||||
|
||||
/// ...or a builder that returns a list of PopupMenuEntries.
|
||||
final List<PopupMenuEntry<T>> Function(BuildContext)? menuBuilder;
|
||||
|
||||
/// Called when a menu item is selected.
|
||||
final ValueChanged<T>? onSelected;
|
||||
|
||||
/// Trailing chevron alignment strategy.
|
||||
final SplitButtonM3ETrailingAlignment trailingAlignment;
|
||||
|
||||
/// Optional tooltips.
|
||||
final String? leadingTooltip;
|
||||
final String? trailingTooltip;
|
||||
|
||||
/// Set to false to disable both segments.
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<SplitButtonM3E<T>> createState() => _SplitButtonM3EState<T>();
|
||||
}
|
||||
|
||||
class _SplitButtonM3EState<T> extends State<SplitButtonM3E<T>> {
|
||||
bool _leadingPressed = false;
|
||||
bool _trailingPressed = false;
|
||||
bool _menuOpen = false;
|
||||
final GlobalKey _trailingKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dir = Directionality.of(context);
|
||||
|
||||
// Container/foreground colors per emphasis family
|
||||
final (
|
||||
Color cont,
|
||||
Color onCont,
|
||||
BorderSide? outlineSide,
|
||||
double? elevation,
|
||||
) = _resolveColorsAndShapes(
|
||||
context,
|
||||
);
|
||||
|
||||
final height = widget.size.height;
|
||||
const minTap = SplitButtonM3ETokens.minTapTarget;
|
||||
final outerRadius = switch (widget.shape) {
|
||||
SplitButtonM3EShape.round => widget.size.outerRoundRadius,
|
||||
SplitButtonM3EShape.square => widget.size.outerSquareRadius,
|
||||
};
|
||||
final pressedRadius = widget.size.pressedRadius;
|
||||
final innerRadius = widget.size.innerCornerRadius;
|
||||
const innerGap = SplitButtonM3ETokens.innerGap;
|
||||
final chevronTurns = _menuOpen
|
||||
? SplitButtonM3ETokens.chevronOpenTurns
|
||||
: 0.0;
|
||||
|
||||
// Build segments
|
||||
final leading = _SegmentContainer(
|
||||
height: height,
|
||||
minTapHeight: minTap,
|
||||
color: cont,
|
||||
onColor: onCont,
|
||||
elevation: elevation,
|
||||
outlineSide: outlineSide,
|
||||
pressed: _leadingPressed,
|
||||
radius: _leadingRadii(
|
||||
dir: dir,
|
||||
outer: outerRadius,
|
||||
inner: innerRadius,
|
||||
pressed: _leadingPressed ? pressedRadius : null,
|
||||
),
|
||||
tooltip: widget.leadingTooltip,
|
||||
onHighlightChanged: (v) {
|
||||
if (!widget.enabled) return;
|
||||
setState(() => _leadingPressed = v);
|
||||
},
|
||||
onTap: widget.enabled ? widget.onPressed : null,
|
||||
child: _LeadingContent(
|
||||
size: widget.size,
|
||||
icon: widget.leadingIcon,
|
||||
label: widget.label,
|
||||
color: onCont,
|
||||
),
|
||||
);
|
||||
|
||||
final trailingIconOffsetBase =
|
||||
(widget.trailingAlignment ==
|
||||
SplitButtonM3ETrailingAlignment.opticalCenter &&
|
||||
!_menuOpen)
|
||||
? widget.size.menuIconOffsetUnselected
|
||||
: 0.0;
|
||||
|
||||
// Trailing segment total width per state (asymmetrical vs symmetrical)
|
||||
final trailingWidthUnselected =
|
||||
widget.size.trailingLeftInnerPadding +
|
||||
widget.size.trailingWidthCentered +
|
||||
widget.size.rightOuterPadding;
|
||||
final trailingWidthSelected =
|
||||
widget.size.sidePaddingSelected * 2 + widget.size.trailingWidthCentered;
|
||||
|
||||
// When round + pressed/open, morph trailing into a perfect circle
|
||||
final bool allowCircle =
|
||||
widget.size == SplitButtonM3ESize.md ||
|
||||
widget.size == SplitButtonM3ESize.lg ||
|
||||
widget.size == SplitButtonM3ESize.xl;
|
||||
final bool circleTrailing =
|
||||
widget.shape == SplitButtonM3EShape.round &&
|
||||
allowCircle &&
|
||||
(_trailingPressed || _menuOpen);
|
||||
|
||||
// XS/SM selected: fully rounded (capsule), not a circle
|
||||
final bool smallSelectedCapsule =
|
||||
widget.shape == SplitButtonM3EShape.round &&
|
||||
(widget.size == SplitButtonM3ESize.xs ||
|
||||
widget.size == SplitButtonM3ESize.sm) &&
|
||||
_menuOpen;
|
||||
|
||||
final trailingFixedWidth = circleTrailing
|
||||
? height
|
||||
: (_menuOpen ? trailingWidthSelected : trailingWidthUnselected);
|
||||
|
||||
final trailingLeftPad = circleTrailing
|
||||
? 0.0
|
||||
: (_menuOpen
|
||||
? widget.size.sidePaddingSelected
|
||||
: widget.size.trailingLeftInnerPadding);
|
||||
final trailingRightPad = circleTrailing
|
||||
? 0.0
|
||||
: (_menuOpen
|
||||
? widget.size.sidePaddingSelected
|
||||
: widget.size.rightOuterPadding);
|
||||
|
||||
final trailingChevronDx = circleTrailing ? 0.0 : trailingIconOffsetBase;
|
||||
|
||||
final trailingRadius = circleTrailing
|
||||
? _CornerRadii(
|
||||
topStart: height / 2,
|
||||
bottomStart: height / 2,
|
||||
topEnd: height / 2,
|
||||
bottomEnd: height / 2,
|
||||
)
|
||||
: smallSelectedCapsule
|
||||
? _CornerRadii(
|
||||
topStart: height / 2,
|
||||
bottomStart: height / 2,
|
||||
topEnd: height / 2,
|
||||
bottomEnd: height / 2,
|
||||
)
|
||||
: _trailingRadii(
|
||||
dir: dir,
|
||||
outer: outerRadius,
|
||||
inner: innerRadius,
|
||||
pressed: (_trailingPressed || _menuOpen) ? pressedRadius : null,
|
||||
);
|
||||
|
||||
final trailing = KeyedSubtree(
|
||||
key: _trailingKey,
|
||||
child: _SegmentContainer(
|
||||
height: height,
|
||||
minTapHeight: minTap,
|
||||
fixedWidth: trailingFixedWidth,
|
||||
color: cont,
|
||||
onColor: onCont,
|
||||
elevation: elevation,
|
||||
outlineSide: outlineSide,
|
||||
pressed: _trailingPressed || _menuOpen,
|
||||
radius: trailingRadius,
|
||||
tooltip: widget.trailingTooltip,
|
||||
onHighlightChanged: (v) {
|
||||
if (!widget.enabled) return;
|
||||
setState(() => _trailingPressed = v);
|
||||
},
|
||||
onTap: widget.enabled ? () => _openMenu(context) : null,
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: trailingLeftPad,
|
||||
end: trailingRightPad,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: circleTrailing ? height : widget.size.trailingWidthCentered,
|
||||
child: Center(
|
||||
child: _TrailingChevron(
|
||||
color: onCont,
|
||||
size: widget.size.iconPx,
|
||||
turns: chevronTurns,
|
||||
dxOffset: trailingChevronDx,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: minTap),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
textDirection: dir,
|
||||
children: [
|
||||
leading,
|
||||
const SizedBox(width: innerGap),
|
||||
trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, Color, BorderSide?, double?) _resolveColorsAndShapes(
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
switch (widget.emphasis) {
|
||||
case SplitButtonM3EEmphasis.filled:
|
||||
return (cs.primary, cs.onPrimary, null, null);
|
||||
case SplitButtonM3EEmphasis.tonal:
|
||||
return (cs.secondaryContainer, cs.onSecondaryContainer, null, null);
|
||||
case SplitButtonM3EEmphasis.elevated:
|
||||
return (
|
||||
theme.colorScheme.surfaceContainerHigh,
|
||||
cs.onSurface,
|
||||
null,
|
||||
1.0,
|
||||
);
|
||||
case SplitButtonM3EEmphasis.outlined:
|
||||
final side = BorderSide(color: cs.outline);
|
||||
return (Colors.transparent, cs.primary, side, null);
|
||||
case SplitButtonM3EEmphasis.text:
|
||||
return (Colors.transparent, cs.primary, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
_CornerRadii _leadingRadii({
|
||||
required TextDirection dir,
|
||||
required double outer,
|
||||
required double inner,
|
||||
double? pressed,
|
||||
}) {
|
||||
final o = pressed ?? outer;
|
||||
final i = pressed ?? inner;
|
||||
// Leading segment: outer = start corners, inner = end corners
|
||||
return _CornerRadii(topStart: o, bottomStart: o, topEnd: i, bottomEnd: i);
|
||||
}
|
||||
|
||||
_CornerRadii _trailingRadii({
|
||||
required TextDirection dir,
|
||||
required double outer,
|
||||
required double inner,
|
||||
double? pressed,
|
||||
}) {
|
||||
final o = pressed ?? outer;
|
||||
final i = pressed ?? inner;
|
||||
// Trailing segment: inner = start corners, outer = end corners
|
||||
return _CornerRadii(topStart: i, bottomStart: i, topEnd: o, bottomEnd: o);
|
||||
}
|
||||
|
||||
Future<void> _openMenu(BuildContext context) async {
|
||||
if (widget.menuBuilder != null) {
|
||||
setState(() => _menuOpen = true);
|
||||
final res = await showMenu<T>(
|
||||
context: context,
|
||||
position: _menuPosition(context),
|
||||
items: widget.menuBuilder!(context),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _menuOpen = false);
|
||||
if (res != null && widget.onSelected != null) widget.onSelected!(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert simple items to PopupMenuEntries
|
||||
final items = widget.items!;
|
||||
setState(() => _menuOpen = true);
|
||||
final res = await showMenu<T>(
|
||||
context: context,
|
||||
position: _menuPosition(context),
|
||||
items: items
|
||||
.map(
|
||||
(e) => PopupMenuItem<T>(
|
||||
value: e.value,
|
||||
enabled: e.enabled,
|
||||
child: e.child is Widget ? e.child as Widget : Text('${e.child}'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _menuOpen = false);
|
||||
if (res != null && widget.onSelected != null) widget.onSelected!(res);
|
||||
}
|
||||
|
||||
RelativeRect _menuPosition(BuildContext context) {
|
||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final textDir = Directionality.of(context);
|
||||
|
||||
// Default to whole control if trailing key is missing
|
||||
RenderBox? tb;
|
||||
Offset tTopLeft = Offset.zero;
|
||||
Size tSize = Size.zero;
|
||||
final tCtx = _trailingKey.currentContext;
|
||||
if (tCtx != null) {
|
||||
tb = tCtx.findRenderObject() as RenderBox?;
|
||||
}
|
||||
if (tb != null) {
|
||||
tTopLeft = tb.localToGlobal(Offset.zero);
|
||||
tSize = tb.size;
|
||||
} else {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box != null) {
|
||||
tTopLeft = box.localToGlobal(Offset.zero);
|
||||
tSize = box.size;
|
||||
}
|
||||
}
|
||||
|
||||
final top = tTopLeft.dy + tSize.height;
|
||||
|
||||
late double left;
|
||||
late double right;
|
||||
|
||||
if (textDir == TextDirection.ltr) {
|
||||
final endX = tTopLeft.dx + tSize.width; // right edge
|
||||
left = endX;
|
||||
right = overlay.size.width - endX;
|
||||
} else {
|
||||
final startX = tTopLeft.dx; // left edge is trailing in RTL
|
||||
left = startX;
|
||||
right = overlay.size.width - startX;
|
||||
}
|
||||
|
||||
return RelativeRect.fromLTRB(left, top, right, overlay.size.height - top);
|
||||
}
|
||||
}
|
||||
|
||||
/// --- Internal: segment container ------------------------------------------------
|
||||
|
||||
class _SegmentContainer extends StatelessWidget {
|
||||
const _SegmentContainer({
|
||||
required this.height,
|
||||
required this.minTapHeight,
|
||||
required this.color,
|
||||
required this.onColor,
|
||||
this.fixedWidth,
|
||||
this.elevation,
|
||||
this.outlineSide,
|
||||
required this.pressed,
|
||||
required this.radius,
|
||||
required this.child,
|
||||
required this.onHighlightChanged,
|
||||
required this.onTap,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
final double height;
|
||||
final double minTapHeight;
|
||||
final double? fixedWidth;
|
||||
final Color color;
|
||||
final Color onColor;
|
||||
final double? elevation;
|
||||
final BorderSide? outlineSide;
|
||||
final bool pressed;
|
||||
final _CornerRadii radius;
|
||||
final Widget child;
|
||||
final ValueChanged<bool> onHighlightChanged;
|
||||
final VoidCallback? onTap;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = RoundedRectangleBorder(
|
||||
borderRadius: radius.toBorderRadius(Directionality.of(context)),
|
||||
side: outlineSide ?? BorderSide.none,
|
||||
);
|
||||
|
||||
final button = Center(
|
||||
child: Material(
|
||||
color: color,
|
||||
elevation: elevation ?? 0,
|
||||
shape: shape,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onHighlightChanged: onHighlightChanged,
|
||||
customBorder: shape,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
width: fixedWidth,
|
||||
child: Center(child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final cont = ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: 0, minHeight: minTapHeight),
|
||||
child: button,
|
||||
);
|
||||
|
||||
if (tooltip == null) return cont;
|
||||
return Tooltip(message: tooltip!, child: cont);
|
||||
}
|
||||
}
|
||||
|
||||
/// --- Internal: leading content --------------------------------------------------
|
||||
|
||||
class _LeadingContent extends StatelessWidget {
|
||||
const _LeadingContent({
|
||||
required this.size,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final SplitButtonM3ESize size;
|
||||
final IconData? icon;
|
||||
final String? label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m3e = context.m3e;
|
||||
final iconSize = size.iconPx;
|
||||
final lp = size.leftOuterPadding;
|
||||
final rp = size.labelRightPaddingBeforeDivider;
|
||||
final iconBlock = size.leadingIconBlockWidth;
|
||||
final gap = size.gapIconToLabel;
|
||||
|
||||
Widget content;
|
||||
if (icon != null && (label?.isNotEmpty ?? false)) {
|
||||
content = Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: lp, end: rp),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: iconBlock,
|
||||
child: Center(
|
||||
child: Icon(icon, size: iconSize, color: color),
|
||||
),
|
||||
),
|
||||
SizedBox(width: gap),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: m3e.typography.base.labelLarge?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (icon != null) {
|
||||
content = Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: lp, end: rp),
|
||||
child: SizedBox(
|
||||
width: iconBlock,
|
||||
child: Center(
|
||||
child: Icon(icon, size: iconSize, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content = Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: lp, end: rp),
|
||||
child: Text(
|
||||
label ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DefaultTextStyle.of(context).style.copyWith(color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/// --- Internal: trailing chevron -------------------------------------------------
|
||||
|
||||
class _TrailingChevron extends StatelessWidget {
|
||||
const _TrailingChevron({
|
||||
required this.color,
|
||||
required this.size,
|
||||
required this.turns,
|
||||
required this.dxOffset,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final double size;
|
||||
final double turns;
|
||||
final double dxOffset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = Icon(Icons.keyboard_arrow_down, size: size, color: color);
|
||||
|
||||
return AnimatedRotation(
|
||||
duration: SplitButtonM3ETokens.chevronDuration,
|
||||
turns: turns,
|
||||
curve: SplitButtonM3ETokens.morphCurve,
|
||||
child: Transform.translate(offset: Offset(dxOffset, 0.0), child: icon),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// --- Internal: corner radii helper (private to this file) -----------------------
|
||||
|
||||
class _CornerRadii {
|
||||
const _CornerRadii({
|
||||
required this.topStart,
|
||||
required this.bottomStart,
|
||||
required this.topEnd,
|
||||
required this.bottomEnd,
|
||||
});
|
||||
|
||||
final double topStart, bottomStart, topEnd, bottomEnd;
|
||||
|
||||
BorderRadius toBorderRadius(TextDirection direction) {
|
||||
return BorderRadiusDirectional.only(
|
||||
topStart: Radius.circular(topStart),
|
||||
bottomStart: Radius.circular(bottomStart),
|
||||
topEnd: Radius.circular(topEnd),
|
||||
bottomEnd: Radius.circular(bottomEnd),
|
||||
).resolve(direction);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue