diff --git a/lib/common/widgets/dyn/button.dart b/lib/common/widgets/dyn/button.dart new file mode 100644 index 00000000..dc8e1bbd --- /dev/null +++ b/lib/common/widgets/dyn/button.dart @@ -0,0 +1,789 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'elevated_button_theme.dart'; +/// @docImport 'menu_anchor.dart'; +/// @docImport 'text_button_theme.dart'; +/// @docImport 'text_theme.dart'; +/// @docImport 'theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:PiliPlus/common/widgets/dyn/ink_well.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide InkWell; +import 'package:flutter/rendering.dart'; + +/// The base [StatefulWidget] class for buttons whose style is defined by a [ButtonStyle] object. +/// +/// Concrete subclasses must override [defaultStyleOf] and [themeStyleOf]. +/// +/// See also: +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [OutlinedButton], a button with an outlined border and no fill color. +/// * [TextButton], a button with no outline or fill color. +/// * , an overview of each of +/// the Material Design button types and how they should be used in designs. +abstract class ButtonStyleButton extends StatefulWidget { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const ButtonStyleButton({ + super.key, + required this.onPressed, + required this.onLongPress, + required this.onHover, + required this.onFocusChange, + required this.style, + required this.focusNode, + required this.autofocus, + required this.clipBehavior, + this.statesController, + this.isSemanticButton = true, + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'Use ButtonStyle.iconAlignment instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + this.iconAlignment, + this.tooltip, + required this.child, + }); + + /// Called when the button is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onPressed; + + /// Called when the button is long-pressed. + /// + /// If this callback and [onPressed] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onLongPress; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the material and false if a pointer has exited this part of the + /// material. + final ValueChanged? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding + /// properties in [themeStyleOf] and [defaultStyleOf]. [WidgetStateProperty]s + /// that resolve to non-null values will similarly override the corresponding + /// [WidgetStateProperty]s in [themeStyleOf] and [defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none] unless [ButtonStyle.backgroundBuilder] or + /// [ButtonStyle.foregroundBuilder] is specified. In those + /// cases the default is [Clip.antiAlias]. + final Clip? clipBehavior; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.inkwell.statesController} + final WidgetStatesController? statesController; + + /// Determine whether this subtree represents a button. + /// + /// If this is null, the screen reader will not announce "button" when this + /// is focused. This is useful for [MenuItemButton] and [SubmenuButton] when we + /// traverse the menu system. + /// + /// Defaults to true. + final bool? isSemanticButton; + + /// {@macro flutter.material.ButtonStyleButton.iconAlignment} + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'Use ButtonStyle.iconAlignment instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + final IconAlignment? iconAlignment; + + /// Text that describes the action that will occur when the button is pressed or + /// hovered over. + /// + /// This text is displayed when the user long-presses or hovers over the button + /// in a tooltip. This string is also used for accessibility. + /// + /// If null, the button will not display a tooltip. + final String? tooltip; + + /// Typically the button's label. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Returns a [ButtonStyle] that's based primarily on the [Theme]'s + /// [ThemeData.textTheme] and [ThemeData.colorScheme], but has most values + /// filled out (non-null). + /// + /// The returned style can be overridden by the [style] parameter and by the + /// style returned by [themeStyleOf] that some button-specific themes like + /// [TextButtonTheme] or [ElevatedButtonTheme] override. For example the + /// default style of the [TextButton] subclass can be overridden with its + /// [TextButton.style] constructor parameter, or with a [TextButtonTheme]. + /// + /// Concrete button subclasses should return a [ButtonStyle] with as many + /// non-null properties as possible, where all of the non-null + /// [WidgetStateProperty] properties resolve to non-null values. + /// + /// ## Properties that can be null + /// + /// Some properties, like [ButtonStyle.fixedSize] would override other values + /// in the same [ButtonStyle] if set, so they are allowed to be null. Here is + /// a summary of properties that are allowed to be null when returned in the + /// [ButtonStyle] returned by this function, an why: + /// + /// - [ButtonStyle.fixedSize] because it would override other values in the + /// same [ButtonStyle], like [ButtonStyle.maximumSize]. + /// - [ButtonStyle.side] because null is a valid value for a button that has + /// no side. [OutlinedButton] returns a non-null default for this, however. + /// - [ButtonStyle.backgroundBuilder] and [ButtonStyle.foregroundBuilder] + /// because they would override the [ButtonStyle.foregroundColor] and + /// [ButtonStyle.backgroundColor] of the same [ButtonStyle]. + /// + /// See also: + /// + /// * [themeStyleOf], returns the ButtonStyle of this button's component + /// theme. + @protected + ButtonStyle defaultStyleOf(BuildContext context); + + /// Returns the ButtonStyle that belongs to the button's component theme. + /// + /// The returned style can be overridden by the [style] parameter. + /// + /// Concrete button subclasses should return the ButtonStyle for the + /// nearest subclass-specific inherited theme, and if no such theme + /// exists, then the same value from the overall [Theme]. + /// + /// See also: + /// + /// * [defaultStyleOf], Returns the default [ButtonStyle] for this button. + @protected + ButtonStyle? themeStyleOf(BuildContext context); + + /// Whether the button is enabled or disabled. + /// + /// Buttons are disabled by default. To enable a button, set its [onPressed] + /// or [onLongPress] properties to a non-null value. + bool get enabled => onPressed != null || onLongPress != null; + + @override + State createState() => _ButtonStyleState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + FlagProperty('enabled', value: enabled, ifFalse: 'disabled'), + ) + ..add( + DiagnosticsProperty('style', style, defaultValue: null), + ) + ..add( + DiagnosticsProperty( + 'focusNode', + focusNode, + defaultValue: null, + ), + ); + } + + /// Returns null if [value] is null, otherwise `WidgetStatePropertyAll(value)`. + /// + /// A convenience method for subclasses. + static WidgetStateProperty? allOrNull(T? value) => + value == null ? null : WidgetStatePropertyAll(value); + + /// Returns null if [enabled] and [disabled] are null. + /// Otherwise, returns a [WidgetStateProperty] that resolves to [disabled] + /// when [WidgetState.disabled] is active, and [enabled] otherwise. + /// + /// A convenience method for subclasses. + static WidgetStateProperty? defaultColor( + Color? enabled, + Color? disabled, + ) { + if ((enabled ?? disabled) == null) { + return null; + } + return WidgetStateProperty.fromMap({ + WidgetState.disabled: disabled, + WidgetState.any: enabled, + }); + } + + /// A convenience method used by subclasses in the framework, that returns an + /// interpolated value based on the [fontSizeMultiplier] parameter: + /// + /// * 0 - 1 [geometry1x] + /// * 1 - 2 lerp([geometry1x], [geometry2x], [fontSizeMultiplier] - 1) + /// * 2 - 3 lerp([geometry2x], [geometry3x], [fontSizeMultiplier] - 2) + /// * otherwise [geometry3x] + /// + /// This method is used by the framework for estimating the default paddings to + /// use on a button with a text label, when the system text scaling setting + /// changes. It's usually supplied with empirical [geometry1x], [geometry2x], + /// [geometry3x] values adjusted for different system text scaling values, when + /// the unscaled font size is set to 14.0 (the default [TextTheme.labelLarge] + /// value). + /// + /// The `fontSizeMultiplier` argument, for historical reasons, is the default + /// font size specified in the [ButtonStyle], scaled by the ambient font + /// scaler, then divided by 14.0 (the default font size used in buttons). + static EdgeInsetsGeometry scaledPadding( + EdgeInsetsGeometry geometry1x, + EdgeInsetsGeometry geometry2x, + EdgeInsetsGeometry geometry3x, + double fontSizeMultiplier, + ) { + return switch (fontSizeMultiplier) { + <= 1 => geometry1x, + < 2 => EdgeInsetsGeometry.lerp( + geometry1x, + geometry2x, + fontSizeMultiplier - 1, + )!, + < 3 => EdgeInsetsGeometry.lerp( + geometry2x, + geometry3x, + fontSizeMultiplier - 2, + )!, + _ => geometry3x, + }; + } +} + +/// The base [State] class for buttons whose style is defined by a [ButtonStyle] object. +/// +/// See also: +/// +/// * [ButtonStyleButton], the [StatefulWidget] subclass for which this class is the [State]. +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled ButtonStyleButton that doesn't elevate when pressed. +/// * [OutlinedButton], similar to [TextButton], but with an outline. +/// * [TextButton], a simple button without a shadow. +class _ButtonStyleState extends State + with TickerProviderStateMixin { + AnimationController? controller; + double? elevation; + Color? backgroundColor; + WidgetStatesController? internalStatesController; + + void handleStatesControllerChange() { + // Force a rebuild to resolve WidgetStateProperty properties + setState(() {}); + } + + WidgetStatesController get statesController => + widget.statesController ?? internalStatesController!; + + void initStatesController() { + if (widget.statesController == null) { + internalStatesController = WidgetStatesController(); + } + statesController + ..update(WidgetState.disabled, !widget.enabled) + ..addListener(handleStatesControllerChange); + } + + @override + void initState() { + super.initState(); + initStatesController(); + } + + @override + void didUpdateWidget(ButtonStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.statesController != oldWidget.statesController) { + oldWidget.statesController?.removeListener(handleStatesControllerChange); + if (widget.statesController != null) { + internalStatesController?.dispose(); + internalStatesController = null; + } + initStatesController(); + } + if (widget.enabled != oldWidget.enabled) { + statesController.update(WidgetState.disabled, !widget.enabled); + if (!widget.enabled) { + // The button may have been disabled while a press gesture is currently underway. + statesController.update(WidgetState.pressed, false); + } + } + } + + @override + void dispose() { + statesController.removeListener(handleStatesControllerChange); + internalStatesController?.dispose(); + controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final IconThemeData iconTheme = IconTheme.of(context); + final ButtonStyle? widgetStyle = widget.style; + final ButtonStyle? themeStyle = widget.themeStyleOf(context); + final ButtonStyle defaultStyle = widget.defaultStyleOf(context); + + T? effectiveValue(T? Function(ButtonStyle? style) getProperty) { + final T? widgetValue = getProperty(widgetStyle); + final T? themeValue = getProperty(themeStyle); + final T? defaultValue = getProperty(defaultStyle); + return widgetValue ?? themeValue ?? defaultValue; + } + + T? resolve( + WidgetStateProperty? Function(ButtonStyle? style) getProperty, + ) { + return effectiveValue((ButtonStyle? style) { + return getProperty(style)?.resolve(statesController.value); + }); + } + + Color? effectiveIconColor() { + return widgetStyle?.iconColor?.resolve(statesController.value) ?? + themeStyle?.iconColor?.resolve(statesController.value) ?? + widgetStyle?.foregroundColor?.resolve(statesController.value) ?? + themeStyle?.foregroundColor?.resolve(statesController.value) ?? + defaultStyle.iconColor?.resolve(statesController.value) ?? + // Fallback to foregroundColor if iconColor is null. + defaultStyle.foregroundColor?.resolve(statesController.value); + } + + final double? resolvedElevation = resolve( + (ButtonStyle? style) => style?.elevation, + ); + final TextStyle? resolvedTextStyle = resolve( + (ButtonStyle? style) => style?.textStyle, + ); + Color? resolvedBackgroundColor = resolve( + (ButtonStyle? style) => style?.backgroundColor, + ); + final Color? resolvedForegroundColor = resolve( + (ButtonStyle? style) => style?.foregroundColor, + ); + final Color? resolvedShadowColor = resolve( + (ButtonStyle? style) => style?.shadowColor, + ); + final Color? resolvedSurfaceTintColor = resolve( + (ButtonStyle? style) => style?.surfaceTintColor, + ); + final EdgeInsetsGeometry? resolvedPadding = resolve( + (ButtonStyle? style) => style?.padding, + ); + final Size? resolvedMinimumSize = resolve( + (ButtonStyle? style) => style?.minimumSize, + ); + final Size? resolvedFixedSize = resolve( + (ButtonStyle? style) => style?.fixedSize, + ); + final Size? resolvedMaximumSize = resolve( + (ButtonStyle? style) => style?.maximumSize, + ); + final Color? resolvedIconColor = effectiveIconColor(); + final double? resolvedIconSize = resolve( + (ButtonStyle? style) => style?.iconSize, + ); + final BorderSide? resolvedSide = resolve( + (ButtonStyle? style) => style?.side, + ); + final OutlinedBorder? resolvedShape = resolve( + (ButtonStyle? style) => style?.shape, + ); + + final WidgetStateMouseCursor mouseCursor = _MouseCursor( + (Set states) => effectiveValue( + (ButtonStyle? style) => style?.mouseCursor?.resolve(states), + ), + ); + + final WidgetStateProperty overlayColor = + WidgetStateProperty.resolveWith( + (Set states) => effectiveValue( + (ButtonStyle? style) => style?.overlayColor?.resolve(states), + ), + ); + + final VisualDensity? resolvedVisualDensity = effectiveValue( + (ButtonStyle? style) => style?.visualDensity, + ); + final MaterialTapTargetSize? resolvedTapTargetSize = effectiveValue( + (ButtonStyle? style) => style?.tapTargetSize, + ); + final Duration? resolvedAnimationDuration = effectiveValue( + (ButtonStyle? style) => style?.animationDuration, + ); + final bool resolvedEnableFeedback = + effectiveValue((ButtonStyle? style) => style?.enableFeedback) ?? true; + final AlignmentGeometry? resolvedAlignment = effectiveValue( + (ButtonStyle? style) => style?.alignment, + ); + final Offset densityAdjustment = resolvedVisualDensity!.baseSizeAdjustment; + final InteractiveInkFeatureFactory? resolvedSplashFactory = effectiveValue( + (ButtonStyle? style) => style?.splashFactory, + ); + final ButtonLayerBuilder? resolvedBackgroundBuilder = effectiveValue( + (ButtonStyle? style) => style?.backgroundBuilder, + ); + final ButtonLayerBuilder? resolvedForegroundBuilder = effectiveValue( + (ButtonStyle? style) => style?.foregroundBuilder, + ); + + final Clip effectiveClipBehavior = + widget.clipBehavior ?? + ((resolvedBackgroundBuilder ?? resolvedForegroundBuilder) != null + ? Clip.antiAlias + : Clip.none); + + BoxConstraints effectiveConstraints = resolvedVisualDensity + .effectiveConstraints( + BoxConstraints( + minWidth: resolvedMinimumSize!.width, + minHeight: resolvedMinimumSize.height, + maxWidth: resolvedMaximumSize!.width, + maxHeight: resolvedMaximumSize.height, + ), + ); + if (resolvedFixedSize != null) { + final Size size = effectiveConstraints.constrain(resolvedFixedSize); + if (size.width.isFinite) { + effectiveConstraints = effectiveConstraints.copyWith( + minWidth: size.width, + maxWidth: size.width, + ); + } + if (size.height.isFinite) { + effectiveConstraints = effectiveConstraints.copyWith( + minHeight: size.height, + maxHeight: size.height, + ); + } + } + + // Per the Material Design team: don't allow the VisualDensity + // adjustment to reduce the width of the left/right padding. If we + // did, VisualDensity.compact, the default for desktop/web, would + // reduce the horizontal padding to zero. + final double dy = densityAdjustment.dy; + final double dx = math.max(0, densityAdjustment.dx); + final EdgeInsetsGeometry padding = resolvedPadding! + .add(EdgeInsets.fromLTRB(dx, dy, dx, dy)) + .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + + // If an opaque button's background is becoming translucent while its + // elevation is changing, change the elevation first. Material implicitly + // animates its elevation but not its color. SKIA renders non-zero + // elevations as a shadow colored fill behind the Material's background. + if (resolvedAnimationDuration! > Duration.zero && + elevation != null && + backgroundColor != null && + elevation != resolvedElevation && + backgroundColor!.value != resolvedBackgroundColor!.value && + backgroundColor!.opacity == 1 && + resolvedBackgroundColor.opacity < 1 && + resolvedElevation == 0) { + if (controller?.duration != resolvedAnimationDuration) { + controller?.dispose(); + controller = + AnimationController( + duration: resolvedAnimationDuration, + vsync: this, + )..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + setState(() {}); // Rebuild with the final background color. + } + }); + } + resolvedBackgroundColor = + backgroundColor; // Defer changing the background color. + controller!.value = 0; + controller!.forward(); + } + elevation = resolvedElevation; + backgroundColor = resolvedBackgroundColor; + + Widget result = Padding( + padding: padding, + child: Align( + alignment: resolvedAlignment!, + widthFactor: 1.0, + heightFactor: 1.0, + child: resolvedForegroundBuilder != null + ? resolvedForegroundBuilder( + context, + statesController.value, + widget.child, + ) + : widget.child, + ), + ); + if (resolvedBackgroundBuilder != null) { + result = resolvedBackgroundBuilder( + context, + statesController.value, + result, + ); + } + + result = AnimatedTheme( + duration: resolvedAnimationDuration, + data: theme.copyWith( + iconTheme: iconTheme.merge( + IconThemeData(color: resolvedIconColor, size: resolvedIconSize), + ), + ), + child: InkWell( + onTap: widget.onPressed, + onLongPress: widget.onLongPress, + onHover: widget.onHover, + mouseCursor: mouseCursor, + enableFeedback: resolvedEnableFeedback, + focusNode: widget.focusNode, + canRequestFocus: widget.enabled, + onFocusChange: widget.onFocusChange, + autofocus: widget.autofocus, + splashFactory: resolvedSplashFactory, + overlayColor: overlayColor, + highlightColor: Colors.transparent, + customBorder: resolvedShape!.copyWith(side: resolvedSide), + statesController: statesController, + child: result, + ), + ); + + if (widget.tooltip != null) { + result = Tooltip(message: widget.tooltip, child: result); + } + + final Size minSize; + switch (resolvedTapTargetSize!) { + case MaterialTapTargetSize.padded: + minSize = Size( + kMinInteractiveDimension + densityAdjustment.dx, + kMinInteractiveDimension + densityAdjustment.dy, + ); + assert(minSize.width >= 0.0); + assert(minSize.height >= 0.0); + case MaterialTapTargetSize.shrinkWrap: + minSize = Size.zero; + } + + return Semantics( + container: true, + button: widget.isSemanticButton, + enabled: widget.enabled, + child: _InputPadding( + minSize: minSize, + child: ConstrainedBox( + constraints: effectiveConstraints, + child: Material( + elevation: resolvedElevation!, + textStyle: resolvedTextStyle?.copyWith( + color: resolvedForegroundColor, + ), + shape: resolvedShape.copyWith(side: resolvedSide), + color: resolvedBackgroundColor, + shadowColor: resolvedShadowColor, + surfaceTintColor: resolvedSurfaceTintColor, + type: resolvedBackgroundColor == null + ? MaterialType.transparency + : MaterialType.button, + animationDuration: resolvedAnimationDuration, + clipBehavior: effectiveClipBehavior, + borderOnForeground: false, + child: result, + ), + ), + ), + ); + } +} + +class _MouseCursor extends WidgetStateMouseCursor { + const _MouseCursor(this.resolveCallback); + + final WidgetPropertyResolver resolveCallback; + + @override + MouseCursor resolve(Set states) => resolveCallback(states)!; + + @override + String get debugDescription => 'ButtonStyleButton_MouseCursor'; +} + +/// A widget to pad the area around a [ButtonStyleButton]'s inner [Material]. +/// +/// Redirect taps that occur in the padded area around the child to the center +/// of the child. This increases the size of the button and the button's +/// "tap target", but not its material or its ink splashes. +class _InputPadding extends SingleChildRenderObjectWidget { + const _InputPadding({super.child, required this.minSize}); + + final Size minSize; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderInputPadding renderObject, + ) { + renderObject.minSize = minSize; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + Size _computeSize({ + required BoxConstraints constraints, + required ChildLayouter layoutChild, + }) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double height = math.max(childSize.width, minSize.width); + final double width = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(height, width)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + ); + } + + @override + double? computeDryBaseline( + covariant BoxConstraints constraints, + TextBaseline baseline, + ) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final double? result = child.getDryBaseline(constraints, baseline); + if (result == null) { + return null; + } + final Size childSize = child.getDryLayout(constraints); + return result + + Alignment.center + .alongOffset(getDryLayout(constraints) - childSize as Offset) + .dy; + } + + @override + void performLayout() { + size = _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.layoutChild, + ); + if (child != null) { + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset( + size - child!.size as Offset, + ); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (super.hitTest(result, position: position)) { + return true; + } + final Offset center = child!.size.center(Offset.zero); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return child!.hitTest(result, position: center); + }, + ); + } +} diff --git a/lib/common/widgets/dyn/ink_well.dart b/lib/common/widgets/dyn/ink_well.dart new file mode 100644 index 00000000..6723548e --- /dev/null +++ b/lib/common/widgets/dyn/ink_well.dart @@ -0,0 +1,1307 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'data_table.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'ink_decoration.dart'; +/// @docImport 'ink_ripple.dart'; +/// @docImport 'ink_splash.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +// Examples can assume: +// late BuildContext context; + +abstract class _ParentInkResponseState { + void markChildInkResponsePressed( + _ParentInkResponseState childState, + bool value, + ); +} + +class _ParentInkResponseProvider extends InheritedWidget { + const _ParentInkResponseProvider({required this.state, required super.child}); + + final _ParentInkResponseState state; + + @override + bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => + state != oldWidget.state; + + static _ParentInkResponseState? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>() + ?.state; + } +} + +typedef _GetRectCallback = RectCallback? Function(RenderBox referenceBox); +typedef _CheckContext = bool Function(BuildContext context); + +/// An area of a [Material] that responds to touch. Has a configurable shape and +/// can be configured to clip splashes that extend outside its bounds or not. +/// +/// For a variant of this widget that is specialized for rectangular areas that +/// always clip splashes, see [InkWell]. +/// +/// An [InkResponse] widget does two things when responding to a tap: +/// +/// * It starts to animate a _highlight_. The shape of the highlight is +/// determined by [highlightShape]. If it is a [BoxShape.circle], the +/// default, then the highlight is a circle of fixed size centered in the +/// [InkResponse]. If it is [BoxShape.rectangle], then the highlight is a box +/// the size of the [InkResponse] itself, unless [getRectCallback] is +/// provided, in which case that callback defines the rectangle. The color of +/// the highlight is set by [highlightColor]. +/// +/// * Simultaneously, it starts to animate a _splash_. This is a growing circle +/// initially centered on the tap location. If this is a [containedInkWell], +/// the splash grows to the [radius] while remaining centered at the tap +/// location. Otherwise, the splash migrates to the center of the box as it +/// grows. +/// +/// The following two diagrams show how [InkResponse] looks when tapped if the +/// [highlightShape] is [BoxShape.circle] (the default) and [containedInkWell] +/// is false (also the default). +/// +/// The first diagram shows how it looks if the [InkResponse] is relatively +/// large: +/// +/// ![The highlight is a disc centered in the box, smaller than the child widget.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_large.png) +/// +/// The second diagram shows how it looks if the [InkResponse] is small: +/// +/// ![The highlight is a disc overflowing the box, centered on the child.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_small.png) +/// +/// The main thing to notice from these diagrams is that the splashes happily +/// exceed the bounds of the widget (because [containedInkWell] is false). +/// +/// The following diagram shows the effect when the [InkResponse] has a +/// [highlightShape] of [BoxShape.rectangle] with [containedInkWell] set to +/// true. These are the values used by [InkWell]. +/// +/// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png) +/// +/// The [InkResponse] widget must have a [Material] widget as an ancestor. The +/// [Material] widget is where the ink reactions are actually painted. This +/// matches the Material Design premise wherein the [Material] is what is +/// actually reacting to touches by spreading ink. +/// +/// If a Widget uses this class directly, it should include the following line +/// at the top of its build function to call [debugCheckHasMaterial]: +/// +/// ```dart +/// assert(debugCheckHasMaterial(context)); +/// ``` +/// +/// ## Troubleshooting +/// +/// ### The ink splashes aren't visible! +/// +/// If there is an opaque graphic, e.g. painted using a [Container], [Image], or +/// [DecoratedBox], between the [Material] widget and the [InkResponse] widget, +/// then the splash won't be visible because it will be under the opaque graphic. +/// This is because ink splashes draw on the underlying [Material] itself, as +/// if the ink was spreading inside the material. +/// +/// The [Ink] widget can be used as a replacement for [Image], [Container], or +/// [DecoratedBox] to ensure that the image or decoration also paints in the +/// [Material] itself, below the ink. +/// +/// If this is not possible for some reason, e.g. because you are using an +/// opaque [CustomPaint] widget, alternatively consider using a second +/// [Material] above the opaque widget but below the [InkResponse] (as an +/// ancestor to the ink response). The [MaterialType.transparency] material +/// kind can be used for this purpose. +/// +/// See also: +/// +/// * [GestureDetector], for listening for gestures without ink splashes. +/// * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design. +/// * [IconButton], which combines [InkResponse] with an [Icon]. +class InkResponse extends StatelessWidget { + /// Creates an area of a [Material] that responds to touch. + /// + /// Must have an ancestor [Material] widget in which to cause ink reactions. + const InkResponse({ + super.key, + this.child, + this.onTap, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onDoubleTap, + this.onLongPress, + this.onSecondaryTap, + this.onSecondaryTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapCancel, + this.onHighlightChanged, + this.onHover, + this.mouseCursor, + this.containedInkWell = false, + this.highlightShape = BoxShape.circle, + this.radius, + this.borderRadius, + this.customBorder, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.overlayColor, + this.splashColor, + this.splashFactory, + this.enableFeedback = true, + this.excludeFromSemantics = false, + this.focusNode, + this.canRequestFocus = true, + this.onFocusChange, + this.autofocus = false, + this.statesController, + this.hoverDuration, + }); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Called when the user taps this part of the material. + final GestureTapCallback? onTap; + + /// Called when the user taps down this part of the material. + final GestureTapDownCallback? onTapDown; + + /// Called when the user releases a tap that was started on this part of the + /// material. [onTap] is called immediately after. + final GestureTapUpCallback? onTapUp; + + /// Called when the user cancels a tap that was started on this part of the + /// material. + final GestureTapCallback? onTapCancel; + + /// Called when the user double taps this part of the material. + final GestureTapCallback? onDoubleTap; + + /// Called when the user long-presses on this part of the material. + final GestureLongPressCallback? onLongPress; + + /// Called when the user taps this part of the material with a secondary button. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapCallback? onSecondaryTap; + + /// Called when the user taps down on this part of the material with a + /// secondary button. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapDownCallback? onSecondaryTapDown; + + /// Called when the user releases a secondary button tap that was started on + /// this part of the material. [onSecondaryTap] is called immediately after. + /// + /// See also: + /// + /// * [onSecondaryTap], a handler triggered right after this one that doesn't + /// pass any details about the tap. + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapUpCallback? onSecondaryTapUp; + + /// Called when the user cancels a secondary button tap that was started on + /// this part of the material. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapCallback? onSecondaryTapCancel; + + /// Called when this part of the material either becomes highlighted or stops + /// being highlighted. + /// + /// The value passed to the callback is true if this part of the material has + /// become highlighted and false if this part of the material has stopped + /// being highlighted. + /// + /// If all of [onTap], [onDoubleTap], and [onLongPress] become null while a + /// gesture is ongoing, then [onTapCancel] will be fired and + /// [onHighlightChanged] will be fired with the value false _during the + /// build_. This means, for instance, that in that scenario [State.setState] + /// cannot be called. + final ValueChanged? onHighlightChanged; + + /// Called when a pointer enters or exits the ink response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the material and false if a pointer has exited this part of the + /// material. + final ValueChanged? onHover; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If this property is null, [WidgetStateMouseCursor.clickable] will be used. + final MouseCursor? mouseCursor; + + /// Whether this ink response should be clipped its bounds. + /// + /// This flag also controls whether the splash migrates to the center of the + /// [InkResponse] or not. If [containedInkWell] is true, the splash remains + /// centered around the tap location. If it is false, the splash migrates to + /// the center of the [InkResponse] as it grows. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [borderRadius], which controls the corners when the box is a rectangle. + /// * [getRectCallback], which controls the size and position of the box when + /// it is a rectangle. + final bool containedInkWell; + + /// The shape (e.g., circle, rectangle) to use for the highlight drawn around + /// this part of the material when pressed, hovered over, or focused. + /// + /// The same shape is used for the pressed highlight (see [highlightColor]), + /// the focus highlight (see [focusColor]), and the hover highlight (see + /// [hoverColor]). + /// + /// If the shape is [BoxShape.circle], then the highlight is centered on the + /// [InkResponse]. If the shape is [BoxShape.rectangle], then the highlight + /// fills the [InkResponse], or the rectangle provided by [getRectCallback] if + /// the callback is specified. + /// + /// See also: + /// + /// * [containedInkWell], which controls clipping behavior. + /// * [borderRadius], which controls the corners when the box is a rectangle. + /// * [highlightColor], the color of the highlight. + /// * [getRectCallback], which controls the size and position of the box when + /// it is a rectangle. + final BoxShape highlightShape; + + /// The radius of the ink splash. + /// + /// Splashes grow up to this size. By default, this size is determined from + /// the size of the rectangle provided by [getRectCallback], or the size of + /// the [InkResponse] itself. + /// + /// See also: + /// + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final double? radius; + + /// The border radius of the containing rectangle. This is effective only if + /// [highlightShape] is [BoxShape.rectangle]. + /// + /// If this is null, it is interpreted as [BorderRadius.zero]. + final BorderRadius? borderRadius; + + /// The custom clip border. + /// + /// If this is null, the ink response will not clip its content. + final ShapeBorder? customBorder; + + /// The color of the ink response when the parent widget is focused. If this + /// property is null then the focus color of the theme, + /// [ThemeData.focusColor], will be used. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [hoverColor], the color of the hover highlight. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? focusColor; + + /// The color of the ink response when a pointer is hovering over it. If this + /// property is null then the hover color of the theme, + /// [ThemeData.hoverColor], will be used. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [highlightColor], the color of the pressed highlight. + /// * [focusColor], the color of the focus highlight. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? hoverColor; + + /// The highlight color of the ink response when pressed. If this property is + /// null then the highlight color of the theme, [ThemeData.highlightColor], + /// will be used. + /// + /// See also: + /// + /// * [hoverColor], the color of the hover highlight. + /// * [focusColor], the color of the focus highlight. + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? highlightColor; + + /// Defines the ink response focus, hover, and splash colors. + /// + /// This default null property can be used as an alternative to + /// [focusColor], [hoverColor], [highlightColor], and + /// [splashColor]. If non-null, it is resolved against one of + /// [WidgetState.focused], [WidgetState.hovered], and + /// [WidgetState.pressed]. It's convenient to use when the parent + /// widget can pass along its own WidgetStateProperty value for + /// the overlay color. + /// + /// [WidgetState.pressed] triggers a ripple (an ink splash), per + /// the current Material Design spec. The [overlayColor] doesn't map + /// a state to [highlightColor] because a separate highlight is not + /// used by the current design guidelines. See + /// https://material.io/design/interaction/states.html#pressed + /// + /// If the overlay color is null or resolves to null, then [focusColor], + /// [hoverColor], [splashColor] and their defaults are used instead. + /// + /// See also: + /// + /// * The Material Design specification for overlay colors and how they + /// match a component's state: + /// . + final WidgetStateProperty? overlayColor; + + /// The splash color of the ink response. If this property is null then the + /// splash color of the theme, [ThemeData.splashColor], will be used. + /// + /// See also: + /// + /// * [splashFactory], which defines the appearance of the splash. + /// * [radius], the (maximum) size of the ink splash. + /// * [highlightColor], the color of the highlight. + final Color? splashColor; + + /// Defines the appearance of the splash. + /// + /// Defaults to the value of the theme's splash factory: [ThemeData.splashFactory]. + /// + /// See also: + /// + /// * [radius], the (maximum) size of the ink splash. + /// * [splashColor], the color of the splash. + /// * [highlightColor], the color of the highlight. + /// * [InkSplash.splashFactory], which defines the default splash. + /// * [InkRipple.splashFactory], which defines a splash that spreads out + /// more aggressively than the default. + final InteractiveInkFeatureFactory? splashFactory; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + /// Whether to exclude the gestures introduced by this widget from the + /// semantics tree. + /// + /// For example, a long-press gesture for showing a tooltip is usually + /// excluded because the tooltip itself is included in the semantics + /// tree directly and so having a gesture to show it would result in + /// duplication of information. + final bool excludeFromSemantics; + + /// {@template flutter.material.inkwell.onFocusChange} + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + /// {@endtemplate} + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.canRequestFocus} + final bool canRequestFocus; + + /// The rectangle to use for the highlight effect and for clipping + /// the splash effects if [containedInkWell] is true. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [TableRowInkWell] implements this method to return the rectangle + /// corresponding to the row that the widget is in. + /// + /// The default behavior returns null, which is equivalent to + /// returning the referenceBox argument's bounding box (though + /// slightly more efficient). + RectCallback? getRectCallback(RenderBox referenceBox) => null; + + /// {@template flutter.material.inkwell.statesController} + /// Represents the interactive "state" of this widget in terms of + /// a set of [WidgetState]s, like [WidgetState.pressed] and + /// [WidgetState.focused]. + /// + /// Classes based on this one can provide their own + /// [WidgetStatesController] to which they've added listeners. + /// They can also update the controller's [WidgetStatesController.value] + /// however, this may only be done when it's safe to call + /// [State.setState], like in an event handler. + /// {@endtemplate} + final WidgetStatesController? statesController; + + /// The duration of the animation that animates the hover effect. + /// + /// The default is 50ms. + final Duration? hoverDuration; + + @override + Widget build(BuildContext context) { + final _ParentInkResponseState? parentState = + _ParentInkResponseProvider.maybeOf(context); + return _InkResponseStateWidget( + onTap: onTap, + onTapDown: onTapDown, + onTapUp: onTapUp, + onTapCancel: onTapCancel, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, + onSecondaryTapUp: onSecondaryTapUp, + onSecondaryTapDown: onSecondaryTapDown, + onSecondaryTapCancel: onSecondaryTapCancel, + onHighlightChanged: onHighlightChanged, + onHover: onHover, + mouseCursor: mouseCursor, + containedInkWell: containedInkWell, + highlightShape: highlightShape, + radius: radius, + borderRadius: borderRadius, + customBorder: customBorder, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + overlayColor: overlayColor, + splashColor: splashColor, + splashFactory: splashFactory, + enableFeedback: enableFeedback, + excludeFromSemantics: excludeFromSemantics, + focusNode: focusNode, + canRequestFocus: canRequestFocus, + onFocusChange: onFocusChange, + autofocus: autofocus, + parentState: parentState, + getRectCallback: getRectCallback, + debugCheckContext: debugCheckContext, + statesController: statesController, + hoverDuration: hoverDuration, + child: child, + ); + } + + /// Asserts that the given context satisfies the prerequisites for + /// this class. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [TableRowInkWell] implements this method to verify that the widget is + /// in a table. + @mustCallSuper + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasDirectionality(context)); + return true; + } +} + +class _InkResponseStateWidget extends StatefulWidget { + const _InkResponseStateWidget({ + this.child, + this.onTap, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onDoubleTap, + this.onLongPress, + this.onSecondaryTap, + this.onSecondaryTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapCancel, + this.onHighlightChanged, + this.onHover, + this.mouseCursor, + this.containedInkWell = false, + this.highlightShape = BoxShape.circle, + this.radius, + this.borderRadius, + this.customBorder, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.overlayColor, + this.splashColor, + this.splashFactory, + this.enableFeedback = true, + this.excludeFromSemantics = false, + this.focusNode, + this.canRequestFocus = true, + this.onFocusChange, + this.autofocus = false, + this.parentState, + this.getRectCallback, + required this.debugCheckContext, + this.statesController, + this.hoverDuration, + }); + + final Widget? child; + final GestureTapCallback? onTap; + final GestureTapDownCallback? onTapDown; + final GestureTapUpCallback? onTapUp; + final GestureTapCallback? onTapCancel; + final GestureTapCallback? onDoubleTap; + final GestureLongPressCallback? onLongPress; + final GestureTapCallback? onSecondaryTap; + final GestureTapUpCallback? onSecondaryTapUp; + final GestureTapDownCallback? onSecondaryTapDown; + final GestureTapCallback? onSecondaryTapCancel; + final ValueChanged? onHighlightChanged; + final ValueChanged? onHover; + final MouseCursor? mouseCursor; + final bool containedInkWell; + final BoxShape highlightShape; + final double? radius; + final BorderRadius? borderRadius; + final ShapeBorder? customBorder; + final Color? focusColor; + final Color? hoverColor; + final Color? highlightColor; + final WidgetStateProperty? overlayColor; + final Color? splashColor; + final InteractiveInkFeatureFactory? splashFactory; + final bool enableFeedback; + final bool excludeFromSemantics; + final ValueChanged? onFocusChange; + final bool autofocus; + final FocusNode? focusNode; + final bool canRequestFocus; + final _ParentInkResponseState? parentState; + final _GetRectCallback? getRectCallback; + final _CheckContext debugCheckContext; + final WidgetStatesController? statesController; + final Duration? hoverDuration; + + @override + _InkResponseState createState() => _InkResponseState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final List gestures = [ + if (onTap != null) 'tap', + if (onDoubleTap != null) 'double tap', + if (onLongPress != null) 'long press', + if (onTapDown != null) 'tap down', + if (onTapUp != null) 'tap up', + if (onTapCancel != null) 'tap cancel', + if (onSecondaryTap != null) 'secondary tap', + if (onSecondaryTapUp != null) 'secondary tap up', + if (onSecondaryTapDown != null) 'secondary tap down', + if (onSecondaryTapCancel != null) 'secondary tap cancel', + ]; + properties + ..add( + IterableProperty('gestures', gestures, ifEmpty: ''), + ) + ..add( + DiagnosticsProperty('mouseCursor', mouseCursor), + ) + ..add( + DiagnosticsProperty( + 'containedInkWell', + containedInkWell, + level: DiagnosticLevel.fine, + ), + ) + ..add( + DiagnosticsProperty( + 'highlightShape', + highlightShape, + description: + '${containedInkWell ? "clipped to " : ""}$highlightShape', + showName: false, + ), + ); + } +} + +/// Used to index the allocated highlights for the different types of highlights +/// in [_InkResponseState]. +enum _HighlightType { pressed, hover, focus } + +class _InkResponseState extends State<_InkResponseStateWidget> + implements _ParentInkResponseState { + Set? _splashes; + InteractiveInkFeature? _currentSplash; + bool _hovering = false; + final Map<_HighlightType, InkHighlight?> _highlights = + <_HighlightType, InkHighlight?>{}; + WidgetStatesController? internalStatesController; + + bool get highlightsExist => _highlights.values + .where((InkHighlight? highlight) => highlight != null) + .isNotEmpty; + + final ObserverList<_ParentInkResponseState> _activeChildren = + ObserverList<_ParentInkResponseState>(); + + static const Duration _activationDuration = Duration(milliseconds: 100); + Timer? _activationTimer; + + @override + void markChildInkResponsePressed( + _ParentInkResponseState childState, + bool value, + ) { + final bool lastAnyPressed = _anyChildInkResponsePressed; + if (value) { + _activeChildren.add(childState); + } else { + _activeChildren.remove(childState); + } + final bool nowAnyPressed = _anyChildInkResponsePressed; + if (nowAnyPressed != lastAnyPressed) { + widget.parentState?.markChildInkResponsePressed(this, nowAnyPressed); + } + } + + bool get _anyChildInkResponsePressed => _activeChildren.isNotEmpty; + + void activateOnIntent(Intent? intent) { + _activationTimer?.cancel(); + _activationTimer = null; + _startNewSplash(context: context); + _currentSplash?.confirm(); + _currentSplash = null; + if (widget.onTap != null) { + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onTap?.call(); + } + // Delay the call to `updateHighlight` to simulate a pressed delay + // and give WidgetStatesController listeners a chance to react. + _activationTimer = Timer(_activationDuration, () { + updateHighlight(_HighlightType.pressed, value: false); + }); + } + + void simulateTap([Intent? intent]) { + _startNewSplash(context: context); + handleTap(); + } + + void simulateLongPress() { + _startNewSplash(context: context); + handleLongPress(); + } + + void handleStatesControllerChange() { + // Force a rebuild to resolve widget.overlayColor, widget.mouseCursor + setState(() {}); + } + + WidgetStatesController get statesController => + widget.statesController ?? internalStatesController!; + + void initStatesController() { + if (widget.statesController == null) { + internalStatesController = WidgetStatesController(); + } + statesController + ..update(WidgetState.disabled, !enabled) + ..addListener(handleStatesControllerChange); + } + + @override + void initState() { + super.initState(); + initStatesController(); + FocusManager.instance.addHighlightModeListener( + handleFocusHighlightModeChange, + ); + } + + @override + void didUpdateWidget(_InkResponseStateWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.statesController != oldWidget.statesController) { + oldWidget.statesController?.removeListener(handleStatesControllerChange); + if (widget.statesController != null) { + internalStatesController?.dispose(); + internalStatesController = null; + } + initStatesController(); + } + if (widget.radius != oldWidget.radius || + widget.highlightShape != oldWidget.highlightShape || + widget.borderRadius != oldWidget.borderRadius) { + final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; + if (hoverHighlight != null) { + hoverHighlight.dispose(); + updateHighlight( + _HighlightType.hover, + value: _hovering, + callOnHover: false, + ); + } + final InkHighlight? focusHighlight = _highlights[_HighlightType.focus]; + if (focusHighlight != null) { + focusHighlight.dispose(); + // Do not call updateFocusHighlights() here because it is called below + } + } + if (widget.customBorder != oldWidget.customBorder) { + _updateHighlightsAndSplashes(); + } + if (enabled != isWidgetEnabled(oldWidget)) { + statesController.update(WidgetState.disabled, !enabled); + if (!enabled) { + statesController.update(WidgetState.pressed, false); + // Remove the existing hover highlight immediately when enabled is false. + // Do not rely on updateHighlight or InkHighlight.deactivate to not break + // the expected lifecycle which is updating _hovering when the mouse exit. + // Manually updating _hovering here or calling InkHighlight.deactivate + // will lead to onHover not being called or call when it is not allowed. + final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; + hoverHighlight?.dispose(); + } + // Don't call widget.onHover because many widgets, including the button + // widgets, apply setState to an ancestor context from onHover. + updateHighlight( + _HighlightType.hover, + value: _hovering, + callOnHover: false, + ); + } + updateFocusHighlights(); + } + + @override + void dispose() { + FocusManager.instance.removeHighlightModeListener( + handleFocusHighlightModeChange, + ); + statesController.removeListener(handleStatesControllerChange); + internalStatesController?.dispose(); + _activationTimer?.cancel(); + _activationTimer = null; + super.dispose(); + } + + Duration getFadeDurationForType(_HighlightType type) { + switch (type) { + case _HighlightType.pressed: + return const Duration(milliseconds: 200); + case _HighlightType.hover: + case _HighlightType.focus: + return widget.hoverDuration ?? const Duration(milliseconds: 50); + } + } + + void updateHighlight( + _HighlightType type, { + required bool value, + bool callOnHover = true, + }) { + final InkHighlight? highlight = _highlights[type]; + void handleInkRemoval() { + assert(_highlights[type] != null); + _highlights[type] = null; + } + + switch (type) { + case _HighlightType.pressed: + statesController.update(WidgetState.pressed, value); + case _HighlightType.hover: + if (callOnHover) { + statesController.update(WidgetState.hovered, value); + } + case _HighlightType.focus: + // see handleFocusUpdate() + break; + } + + if (type == _HighlightType.pressed) { + widget.parentState?.markChildInkResponsePressed(this, value); + } + if (value == (highlight != null && highlight.active)) { + return; + } + + if (value) { + if (highlight == null) { + final Color resolvedOverlayColor = + widget.overlayColor?.resolve(statesController.value) ?? + switch (type) { + // Use the backwards compatible defaults + _HighlightType.pressed => + widget.highlightColor ?? Theme.of(context).highlightColor, + _HighlightType.focus => + widget.focusColor ?? Theme.of(context).focusColor, + _HighlightType.hover => + widget.hoverColor ?? Theme.of(context).hoverColor, + }; + final RenderBox referenceBox = context.findRenderObject()! as RenderBox; + _highlights[type] = InkHighlight( + controller: Material.of(context), + referenceBox: referenceBox, + color: enabled + ? resolvedOverlayColor + : resolvedOverlayColor.withAlpha(0), + shape: widget.highlightShape, + radius: widget.radius, + borderRadius: widget.borderRadius, + customBorder: widget.customBorder, + rectCallback: widget.getRectCallback!(referenceBox), + onRemoved: handleInkRemoval, + textDirection: Directionality.of(context), + fadeDuration: getFadeDurationForType(type), + ); + } else { + highlight.activate(); + } + } else { + highlight!.deactivate(); + } + assert(value == (_highlights[type] != null && _highlights[type]!.active)); + + switch (type) { + case _HighlightType.pressed: + widget.onHighlightChanged?.call(value); + case _HighlightType.hover: + if (callOnHover) { + widget.onHover?.call(value); + } + case _HighlightType.focus: + break; + } + } + + void _updateHighlightsAndSplashes() { + for (final InkHighlight? highlight in _highlights.values) { + highlight?.customBorder = widget.customBorder; + } + _currentSplash?.customBorder = widget.customBorder; + + if (_splashes != null && _splashes!.isNotEmpty) { + for (final InteractiveInkFeature inkFeature in _splashes!) { + inkFeature.customBorder = widget.customBorder; + } + } + } + + InteractiveInkFeature _createSplash(Offset globalPosition) { + final MaterialInkController inkController = Material.of(context); + final RenderBox referenceBox = context.findRenderObject()! as RenderBox; + final Offset position = referenceBox.globalToLocal(globalPosition); + final Color color = + widget.overlayColor?.resolve(statesController.value) ?? + widget.splashColor ?? + Theme.of(context).splashColor; + final RectCallback? rectCallback = widget.containedInkWell + ? widget.getRectCallback!(referenceBox) + : null; + final BorderRadius? borderRadius = widget.borderRadius; + final ShapeBorder? customBorder = widget.customBorder; + + InteractiveInkFeature? splash; + void onRemoved() { + if (_splashes != null) { + assert(_splashes!.contains(splash)); + _splashes!.remove(splash); + if (_currentSplash == splash) { + _currentSplash = null; + } + } // else we're probably in deactivate() + } + + splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create( + controller: inkController, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: widget.containedInkWell, + rectCallback: rectCallback, + radius: widget.radius, + borderRadius: borderRadius, + customBorder: customBorder, + onRemoved: onRemoved, + textDirection: Directionality.of(context), + ); + + return splash; + } + + void handleFocusHighlightModeChange(FocusHighlightMode mode) { + if (!mounted) { + return; + } + setState(() { + updateFocusHighlights(); + }); + } + + bool get _shouldShowFocus => + switch (MediaQuery.maybeNavigationModeOf(context)) { + NavigationMode.traditional || null => enabled && _hasFocus, + NavigationMode.directional => _hasFocus, + }; + + void updateFocusHighlights() { + final bool showFocus = switch (FocusManager.instance.highlightMode) { + FocusHighlightMode.touch => false, + FocusHighlightMode.traditional => _shouldShowFocus, + }; + updateHighlight(_HighlightType.focus, value: showFocus); + } + + bool _hasFocus = false; + void handleFocusUpdate(bool hasFocus) { + _hasFocus = hasFocus; + // Set here rather than updateHighlight because this widget's + // (WidgetState) states include WidgetState.focused if + // the InkWell _has_ the focus, rather than if it's showing + // the focus per FocusManager.instance.highlightMode. + statesController.update(WidgetState.focused, hasFocus); + updateFocusHighlights(); + widget.onFocusChange?.call(hasFocus); + } + + void handleAnyTapDown(TapDownDetails details) { + if (_anyChildInkResponsePressed) { + return; + } + _startNewSplash(details: details); + } + + void handleTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onTapDown?.call(details); + } + + void handleTapUp(TapUpDetails details) { + widget.onTapUp?.call(details); + } + + void handleSecondaryTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onSecondaryTapDown?.call(details); + } + + void handleSecondaryTapUp(TapUpDetails details) { + widget.onSecondaryTapUp?.call(details); + } + + void _startNewSplash({TapDownDetails? details, BuildContext? context}) { + assert(details != null || context != null); + + final Offset globalPosition; + if (context != null) { + final RenderBox referenceBox = context.findRenderObject()! as RenderBox; + assert( + referenceBox.hasSize, + 'InkResponse must be done with layout before starting a splash.', + ); + globalPosition = referenceBox.localToGlobal( + referenceBox.paintBounds.center, + ); + } else { + globalPosition = details!.globalPosition; + } + statesController.update( + WidgetState.pressed, + true, + ); // ... before creating the splash + final InteractiveInkFeature splash = _createSplash(globalPosition); + _splashes ??= HashSet(); + _splashes!.add(splash); + _currentSplash?.cancel(); + _currentSplash = splash; + updateHighlight(_HighlightType.pressed, value: true); + } + + void handleTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + if (widget.onTap != null) { + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onTap?.call(); + } + } + + void handleTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + + void handleDoubleTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onDoubleTap?.call(); + } + + void handleLongPress() { + _currentSplash?.confirm(); + _currentSplash = null; + if (widget.onLongPress != null) { + if (widget.enableFeedback) { + Feedback.forLongPress(context); + } + widget.onLongPress!(); + } + } + + void handleSecondaryTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onSecondaryTap?.call(); + } + + void handleSecondaryTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onSecondaryTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + + @override + void deactivate() { + if (_splashes != null) { + final Set splashes = _splashes!; + _splashes = null; + for (final InteractiveInkFeature splash in splashes) { + splash.dispose(); + } + _currentSplash = null; + } + assert(_currentSplash == null); + for (final _HighlightType highlight in _highlights.keys) { + _highlights[highlight]?.dispose(); + _highlights[highlight] = null; + } + widget.parentState?.markChildInkResponsePressed(this, false); + super.deactivate(); + } + + bool isWidgetEnabled(_InkResponseStateWidget widget) { + return _primaryButtonEnabled(widget) || _secondaryButtonEnabled(widget); + } + + bool _primaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onTap != null || + widget.onDoubleTap != null || + widget.onLongPress != null || + widget.onTapUp != null || + widget.onTapDown != null; + } + + bool _secondaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onSecondaryTap != null || + widget.onSecondaryTapUp != null || + widget.onSecondaryTapDown != null; + } + + bool get enabled => isWidgetEnabled(widget); + bool get _primaryEnabled => _primaryButtonEnabled(widget); + bool get _secondaryEnabled => _secondaryButtonEnabled(widget); + + void handleMouseEnter(PointerEnterEvent event) { + _hovering = true; + if (enabled) { + handleHoverChange(); + } + } + + void handleMouseExit(PointerExitEvent event) { + _hovering = false; + // If the exit occurs after we've been disabled, we still + // want to take down the highlights and run widget.onHover. + handleHoverChange(); + } + + void handleHoverChange() { + updateHighlight(_HighlightType.hover, value: _hovering); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: _primaryEnabled ? handleTapDown : null, + onTapUp: _primaryEnabled ? handleTapUp : null, + onTap: _primaryEnabled ? handleTap : null, + onTapCancel: _primaryEnabled ? handleTapCancel : null, + onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null, + onLongPress: widget.onLongPress != null ? handleLongPress : null, + onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null, + onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp : null, + onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null, + onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null, + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + child: widget.child, + ); + } +} + +/// A rectangular area of a [Material] that responds to touch. +/// +/// For a variant of this widget that does not clip splashes, see [InkResponse]. +/// +/// The following diagram shows how an [InkWell] looks when tapped, when using +/// default values. +/// +/// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png) +/// +/// The [InkWell] widget must have a [Material] widget as an ancestor. The +/// [Material] widget is where the ink reactions are actually painted. This +/// matches the Material Design premise wherein the [Material] is what is +/// actually reacting to touches by spreading ink. +/// +/// If a Widget uses this class directly, it should include the following line +/// at the top of its build function to call [debugCheckHasMaterial]: +/// +/// ```dart +/// assert(debugCheckHasMaterial(context)); +/// ``` +/// +/// ## Troubleshooting +/// +/// ### The ink splashes aren't visible! +/// +/// If there is an opaque graphic, e.g. painted using a [Container], [Image], or +/// [DecoratedBox], between the [Material] widget and the [InkWell] widget, then +/// the splash won't be visible because it will be under the opaque graphic. +/// This is because ink splashes draw on the underlying [Material] itself, as +/// if the ink was spreading inside the material. +/// +/// The [Ink] widget can be used as a replacement for [Image], [Container], or +/// [DecoratedBox] to ensure that the image or decoration also paints in the +/// [Material] itself, below the ink. +/// +/// If this is not possible for some reason, e.g. because you are using an +/// opaque [CustomPaint] widget, alternatively consider using a second +/// [Material] above the opaque widget but below the [InkWell] (as an +/// ancestor to the ink well). The [MaterialType.transparency] material +/// kind can be used for this purpose. +/// +/// ### InkWell isn't clipping properly +/// +/// If you want to clip an InkWell or any [Ink] widgets you need to keep in mind +/// that the [Material] that the Ink will be printed on is responsible for clipping. +/// This means you can't wrap the [Ink] widget in a clipping widget directly, +/// since this will leave the [Material] not clipped (and by extension the printed +/// [Ink] widgets as well). +/// +/// An easy solution is to deliberately wrap the [Ink] widgets you want to clip +/// in a [Material], and wrap that in a clipping widget instead. See [Ink] for +/// an example. +/// +/// ### The ink splashes don't track the size of an animated container +/// If the size of an InkWell's [Material] ancestor changes while the InkWell's +/// splashes are expanding, you may notice that the splashes aren't clipped +/// correctly. This can't be avoided. +/// +/// An example of this situation is as follows: +/// +/// {@tool dartpad} +/// Tap the container to cause it to grow. Then, tap it again and hold before +/// the widget reaches its maximum size to observe the clipped ink splash. +/// +/// ** See code in examples/api/lib/material/ink_well/ink_well.0.dart ** +/// {@end-tool} +/// +/// An InkWell's splashes will not properly update to conform to changes if the +/// size of its underlying [Material], where the splashes are rendered, changes +/// during animation. You should avoid using InkWells within [Material] widgets +/// that are changing size. +/// +/// See also: +/// +/// * [GestureDetector], for listening for gestures without ink splashes. +/// * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design. +/// * [InkResponse], a variant of [InkWell] that doesn't force a rectangular +/// shape on the ink reaction. +class InkWell extends InkResponse { + /// Creates an ink well. + /// + /// Must have an ancestor [Material] widget in which to cause ink reactions. + const InkWell({ + super.key, + super.child, + super.onTap, + super.onDoubleTap, + super.onLongPress, + super.onTapDown, + super.onTapUp, + super.onTapCancel, + super.onSecondaryTap, + super.onSecondaryTapUp, + super.onSecondaryTapDown, + super.onSecondaryTapCancel, + super.onHighlightChanged, + super.onHover, + super.mouseCursor, + super.focusColor, + super.hoverColor, + super.highlightColor, + super.overlayColor, + super.splashColor, + super.splashFactory, + super.radius, + super.borderRadius, + super.customBorder, + super.enableFeedback, + super.excludeFromSemantics, + super.focusNode, + super.canRequestFocus, + super.onFocusChange, + super.autofocus, + super.statesController, + super.hoverDuration, + }) : super(containedInkWell: true, highlightShape: BoxShape.rectangle); +} diff --git a/lib/common/widgets/dyn/text_button.dart b/lib/common/widgets/dyn/text_button.dart new file mode 100644 index 00000000..b238fb68 --- /dev/null +++ b/lib/common/widgets/dyn/text_button.dart @@ -0,0 +1,676 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'elevated_button.dart'; +/// @docImport 'filled_button.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:PiliPlus/common/widgets/dyn/button.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide InkWell, ButtonStyleButton; + +/// A Material Design "Text Button". +/// +/// Use text buttons on toolbars, in dialogs, or inline with other +/// content but offset from that content with padding so that the +/// button's presence is obvious. Text buttons do not have visible +/// borders and must therefore rely on their position relative to +/// other content for context. In dialogs and cards, they should be +/// grouped together in one of the bottom corners. Avoid using text +/// buttons where they would blend in with other content, for example +/// in the middle of lists. +/// +/// A text button is a label [child] displayed on a (zero elevation) +/// [Material] widget. The label's [Text] and [Icon] widgets are +/// displayed in the [style]'s [ButtonStyle.foregroundColor]. The +/// button reacts to touches by filling with the [style]'s +/// [ButtonStyle.backgroundColor]. +/// +/// The text button's default style is defined by [defaultStyleOf]. +/// The style of this text button can be overridden with its [style] +/// parameter. The style of all text buttons in a subtree can be +/// overridden with the [TextButtonTheme] and the style of all of the +/// text buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.textButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// text button [ButtonStyle] from simple values. +/// +/// If the [onPressed] and [onLongPress] callbacks are null, then this +/// button will be disabled, it will not react to touch. +/// +/// {@tool dartpad} +/// This sample shows various ways to configure TextButtons, from the +/// simplest default appearance to versions that don't resemble +/// Material Design at all. +/// +/// ** See code in examples/api/lib/material/text_button/text_button.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample demonstrates using the [statesController] parameter to create a button +/// that adds support for [WidgetState.selected]. +/// +/// ** See code in examples/api/lib/material/text_button/text_button.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [OutlinedButton], a button with an outlined border and no fill color. +/// * +/// * +class TextButton extends ButtonStyleButton { + /// Create a [TextButton]. + const TextButton({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior, + super.statesController, + super.isSemanticButton, + required Widget super.child, + }); + + /// Create a text button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 8 logical pixels + /// at the ends, with an 8 pixel gap in between. + /// + /// If [icon] is null, will create a [TextButton] instead. + /// + /// {@macro flutter.material.ButtonStyleButton.iconAlignment} + /// + factory TextButton.icon({ + Key? key, + required VoidCallback? onPressed, + VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, + ButtonStyle? style, + FocusNode? focusNode, + bool? autofocus, + Clip? clipBehavior, + WidgetStatesController? statesController, + Widget? icon, + required Widget label, + IconAlignment? iconAlignment, + }) { + if (icon == null) { + return TextButton( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + statesController: statesController, + child: label, + ); + } + return _TextButtonWithIcon( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + statesController: statesController, + icon: icon, + label: label, + iconAlignment: iconAlignment, + ); + } + + /// A static convenience method that constructs a text button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] and [disabledForegroundColor] colors are used + /// to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], and + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// The [backgroundColor] and [disabledBackgroundColor] colors are + /// used to create a [WidgetStateProperty] [ButtonStyle.backgroundColor]. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// If [iconColor] is null, the button icon will use [foregroundColor]. If [foregroundColor] is also + /// null, the button icon will use the default icon color. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// All of the other parameters are either used directly or used to + /// create a [WidgetStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null. By default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// For example, to override the default text and icon colors for a + /// [TextButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// TextButton( + /// style: TextButton.styleFrom(foregroundColor: Colors.green), + /// child: const Text('Give Kate a mix tape'), + /// onPressed: () { + /// // ... + /// }, + /// ), + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + IconAlignment? iconAlignment, + Color? disabledIconColor, + Color? overlayColor, + double? elevation, + TextStyle? textStyle, + EdgeInsetsGeometry? padding, + Size? minimumSize, + Size? fixedSize, + Size? maximumSize, + BorderSide? side, + OutlinedBorder? shape, + MouseCursor? enabledMouseCursor, + MouseCursor? disabledMouseCursor, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, + }) { + final WidgetStateProperty? backgroundColorProp = switch (( + backgroundColor, + disabledBackgroundColor, + )) { + (_?, null) => WidgetStatePropertyAll(backgroundColor), + (_, _) => ButtonStyleButton.defaultColor( + backgroundColor, + disabledBackgroundColor, + ), + }; + final WidgetStateProperty? iconColorProp = switch (( + iconColor, + disabledIconColor, + )) { + (_?, null) => WidgetStatePropertyAll(iconColor), + (_, _) => ButtonStyleButton.defaultColor(iconColor, disabledIconColor), + }; + final WidgetStateProperty? overlayColorProp = switch (( + foregroundColor, + overlayColor, + )) { + (null, null) => null, + (_, Color(a: 0.0)) => WidgetStatePropertyAll(overlayColor), + (_, final Color color) || (final Color color, _) => + WidgetStateProperty.fromMap({ + WidgetState.pressed: color.withValues(alpha: 0.1), + WidgetState.hovered: color.withValues(alpha: 0.08), + WidgetState.focused: color.withValues(alpha: 0.1), + }), + }; + + return ButtonStyle( + textStyle: ButtonStyleButton.allOrNull(textStyle), + foregroundColor: ButtonStyleButton.defaultColor( + foregroundColor, + disabledForegroundColor, + ), + backgroundColor: backgroundColorProp, + overlayColor: overlayColorProp, + shadowColor: ButtonStyleButton.allOrNull(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull(surfaceTintColor), + iconColor: iconColorProp, + iconSize: ButtonStyleButton.allOrNull(iconSize), + iconAlignment: iconAlignment, + elevation: ButtonStyleButton.allOrNull(elevation), + padding: ButtonStyleButton.allOrNull(padding), + minimumSize: ButtonStyleButton.allOrNull(minimumSize), + fixedSize: ButtonStyleButton.allOrNull(fixedSize), + maximumSize: ButtonStyleButton.allOrNull(maximumSize), + side: ButtonStyleButton.allOrNull(side), + shape: ButtonStyleButton.allOrNull(shape), + mouseCursor: WidgetStateProperty.fromMap( + { + WidgetState.disabled: disabledMouseCursor, + WidgetState.any: enabledMouseCursor, + }, + ), + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, + ); + } + + /// Defines the button's default appearance. + /// + /// {@template flutter.material.text_button.default_style_of} + /// The button [child]'s [Text] and [Icon] widgets are rendered with + /// the [ButtonStyle]'s foreground color. The button's [InkWell] adds + /// the style's overlay color when the button is focused, hovered + /// or pressed. The button's background color becomes its [Material] + /// color and is transparent by default. + /// + /// All of the [ButtonStyle]'s defaults appear below. + /// + /// In this list "Theme.foo" is shorthand for + /// `Theme.of(context).foo`. Color scheme values like + /// "onSurface(0.38)" are shorthand for + /// `onSurface.withValues(alpha: 0.38)`. [WidgetStateProperty] valued + /// properties that are not followed by a sublist have the same + /// value for all states, otherwise the values are as specified for + /// each state and "others" means all other states. + /// + /// The "default font size" below refers to the font size specified in the + /// [defaultStyleOf] method (or 14.0 if unspecified), scaled by the + /// `MediaQuery.textScalerOf(context).scale` method. And the names of the + /// EdgeInsets constructors and `EdgeInsetsGeometry.lerp` have been abbreviated + /// for readability. + /// + /// The color of the [ButtonStyle.textStyle] is not used, the + /// [ButtonStyle.foregroundColor] color is used instead. + /// {@endtemplate} + /// + /// ## Material 2 defaults + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.12) + /// * `shadowColor` - Theme.shadowColor + /// * `elevation` - 0 + /// * `padding` + /// * `default font size <= 14` - (horizontal(12), vertical(8)) + /// * `14 < default font size <= 28` - lerp(all(8), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` + /// * disabled - SystemMouseCursors.basic + /// * others - SystemMouseCursors.click + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - InkRipple.splashFactory + /// + /// The default padding values for the [TextButton.icon] factory are slightly different: + /// + /// * `padding` + /// * `default font size <= 14` - all(8) + /// * `14 < default font size <= 28 `- lerp(all(8), horizontal(4)) + /// * `28 < default font size` - horizontal(4) + /// + /// The default value for `side`, which defines the appearance of the button's + /// outline, is null. That means that the outline is defined by the button + /// shape's [OutlinedBorder.side]. Typically the default value of an + /// [OutlinedBorder]'s side is [BorderSide.none], so an outline is not drawn. + /// + /// ## Material 3 defaults + /// + /// If [ThemeData.useMaterial3] is set to true the following defaults will + /// be used: + /// + /// {@template flutter.material.text_button.material3_defaults} + /// * `textStyle` - Theme.textTheme.labelLarge + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.1) + /// * others - null + /// * `shadowColor` - Colors.transparent, + /// * `surfaceTintColor` - null + /// * `elevation` - 0 + /// * `padding` + /// * `default font size <= 14` - lerp(horizontal(12), horizontal(4)) + /// * `14 < default font size <= 28` - lerp(all(8), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` + /// * disabled - SystemMouseCursors.basic + /// * others - SystemMouseCursors.click + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + /// + /// For the [TextButton.icon] factory, the end (generally the right) value of + /// `padding` is increased from 12 to 16. + /// {@endtemplate} + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + return Theme.of(context).useMaterial3 + ? _TextButtonDefaultsM3(context) + : styleFrom( + foregroundColor: colorScheme.primary, + disabledForegroundColor: colorScheme.onSurface.withValues( + alpha: 0.38, + ), + backgroundColor: Colors.transparent, + disabledBackgroundColor: Colors.transparent, + shadowColor: theme.shadowColor, + elevation: 0, + textStyle: theme.textTheme.labelLarge, + padding: _scaledPadding(context), + minimumSize: const Size(64, 36), + maximumSize: Size.infinite, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.basic, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + splashFactory: InkRipple.splashFactory, + ); + } + + /// Returns the [TextButtonThemeData.style] of the closest + /// [TextButtonTheme] ancestor. + @override + ButtonStyle? themeStyleOf(BuildContext context) { + return TextButtonTheme.of(context).style; + } +} + +EdgeInsetsGeometry _scaledPadding(BuildContext context) { + final ThemeData theme = Theme.of(context); + final double defaultFontSize = theme.textTheme.labelLarge?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + return ButtonStyleButton.scaledPadding( + theme.useMaterial3 + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 8) + : const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsets.symmetric(horizontal: 4), + effectiveTextScale, + ); +} + +class _TextButtonWithIcon extends TextButton { + _TextButtonWithIcon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + bool? autofocus, + super.clipBehavior, + super.statesController, + required Widget icon, + required Widget label, + IconAlignment? iconAlignment, + }) : super( + autofocus: autofocus ?? false, + child: _TextButtonWithIconChild( + icon: icon, + label: label, + buttonStyle: style, + iconAlignment: iconAlignment, + ), + ); + + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final bool useMaterial3 = Theme.of(context).useMaterial3; + final ButtonStyle buttonStyle = super.defaultStyleOf(context); + final double defaultFontSize = + buttonStyle.textStyle?.resolve(const {})?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + useMaterial3 + ? const EdgeInsetsDirectional.fromSTEB(12, 8, 16, 8) + : const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 4), + const EdgeInsets.symmetric(horizontal: 4), + effectiveTextScale, + ); + return buttonStyle.copyWith( + padding: WidgetStatePropertyAll(scaledPadding), + ); + } +} + +class _TextButtonWithIconChild extends StatelessWidget { + const _TextButtonWithIconChild({ + required this.label, + required this.icon, + required this.buttonStyle, + required this.iconAlignment, + }); + + final Widget label; + final Widget icon; + final ButtonStyle? buttonStyle; + final IconAlignment? iconAlignment; + + @override + Widget build(BuildContext context) { + final double defaultFontSize = + buttonStyle?.textStyle?.resolve(const {})?.fontSize ?? + 14.0; + final double scale = + clampDouble( + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0, + 1.0, + 2.0, + ) - + 1.0; + final TextButtonThemeData textButtonTheme = TextButtonTheme.of(context); + final IconAlignment effectiveIconAlignment = + iconAlignment ?? + textButtonTheme.style?.iconAlignment ?? + buttonStyle?.iconAlignment ?? + IconAlignment.start; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: lerpDouble(8, 4, scale)!, + children: effectiveIconAlignment == IconAlignment.start + ? [icon, Flexible(child: label)] + : [Flexible(child: label), icon], + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - TextButton + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// dart format off +class _TextButtonDefaultsM3 extends ButtonStyle { + _TextButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty get textStyle => + WidgetStatePropertyAll(Theme.of(context).textTheme.labelLarge); + + @override + WidgetStateProperty? get backgroundColor => + const WidgetStatePropertyAll(Colors.transparent); + + @override + WidgetStateProperty? get foregroundColor => + WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withValues(alpha: 0.38); + } + return _colors.primary; + }); + + @override + WidgetStateProperty? get overlayColor => + WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withValues(alpha: 0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withValues(alpha: 0.1); + } + return null; + }); + + @override + WidgetStateProperty? get shadowColor => + const WidgetStatePropertyAll(Colors.transparent); + + @override + WidgetStateProperty? get surfaceTintColor => + const WidgetStatePropertyAll(Colors.transparent); + + @override + WidgetStateProperty? get elevation => + const WidgetStatePropertyAll(0.0); + + @override + WidgetStateProperty? get padding => + WidgetStatePropertyAll(_scaledPadding(context)); + + @override + WidgetStateProperty? get minimumSize => + const WidgetStatePropertyAll(Size(64.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty? get iconSize => + const WidgetStatePropertyAll(18.0); + + @override + WidgetStateProperty? get iconColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withValues(alpha: 0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary; + } + if (states.contains(WidgetState.focused)) { + return _colors.primary; + } + return _colors.primary; + }); + } + + @override + WidgetStateProperty? get maximumSize => + const WidgetStatePropertyAll(Size.infinite); + + // No default side + + @override + WidgetStateProperty? get shape => + const WidgetStatePropertyAll(StadiumBorder()); + + @override + WidgetStateProperty? get mouseCursor => + WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.basic; + } + return SystemMouseCursors.click; + }); + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - TextButton diff --git a/lib/pages/dynamics/widgets/action_panel.dart b/lib/pages/dynamics/widgets/action_panel.dart index b8215b08..fde13c07 100644 --- a/lib/pages/dynamics/widgets/action_panel.dart +++ b/lib/pages/dynamics/widgets/action_panel.dart @@ -1,9 +1,10 @@ +import 'package:PiliPlus/common/widgets/dyn/text_button.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide TextButton; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class ActionPanel extends StatelessWidget { diff --git a/lib/pages/dynamics/widgets/additional_panel.dart b/lib/pages/dynamics/widgets/additional_panel.dart index 9de88902..efe543fb 100644 --- a/lib/pages/dynamics/widgets/additional_panel.dart +++ b/lib/pages/dynamics/widgets/additional_panel.dart @@ -1,4 +1,5 @@ import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/dyn/ink_well.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; @@ -8,7 +9,7 @@ import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide InkWell; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; diff --git a/lib/pages/dynamics/widgets/author_panel.dart b/lib/pages/dynamics/widgets/author_panel.dart index 9ccf7229..310be923 100644 --- a/lib/pages/dynamics/widgets/author_panel.dart +++ b/lib/pages/dynamics/widgets/author_panel.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/dialog/report.dart'; +import 'package:PiliPlus/common/widgets/dyn/ink_well.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/user.dart'; @@ -18,7 +19,7 @@ import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide InkWell; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide ContextExtensionss; diff --git a/lib/pages/dynamics/widgets/dynamic_panel.dart b/lib/pages/dynamics/widgets/dynamic_panel.dart index 2723539f..a08df399 100644 --- a/lib/pages/dynamics/widgets/dynamic_panel.dart +++ b/lib/pages/dynamics/widgets/dynamic_panel.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/common/widgets/dyn/ink_well.dart'; import 'package:PiliPlus/common/widgets/image/image_save.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/dynamics/widgets/action_panel.dart'; @@ -8,7 +9,7 @@ import 'package:PiliPlus/pages/dynamics/widgets/content_panel.dart'; import 'package:PiliPlus/pages/dynamics/widgets/module_panel.dart'; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/page_utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide InkWell; class DynamicPanel extends StatelessWidget { final DynamicItemModel item; diff --git a/lib/pages/dynamics/widgets/module_panel.dart b/lib/pages/dynamics/widgets/module_panel.dart index 1359d61d..6d9f50cc 100644 --- a/lib/pages/dynamics/widgets/module_panel.dart +++ b/lib/pages/dynamics/widgets/module_panel.dart @@ -1,6 +1,7 @@ // 转发 import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/dyn/ink_well.dart'; import 'package:PiliPlus/common/widgets/image/image_save.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; @@ -15,7 +16,7 @@ import 'package:PiliPlus/utils/date_util.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide InkWell; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart';