Unverified Commit 1ca0333c authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Overridable action (#85743)

parent 724c0eb6
...@@ -72,6 +72,31 @@ typedef ActionListenerCallback = void Function(Action<Intent> action); ...@@ -72,6 +72,31 @@ typedef ActionListenerCallback = void Function(Action<Intent> action);
/// The [ActionDispatcher] can invoke an [Action] on the primary focus, or /// The [ActionDispatcher] can invoke an [Action] on the primary focus, or
/// without regard for focus. /// without regard for focus.
/// ///
/// ### Action Overriding
///
/// When using a leaf widget to build a more specialized widget, it's sometimes
/// desirable to change the default handling of an [Intent] defined in the leaf
/// widget. For instance, [TextField]'s [SelectAllTextIntent] by default selects
/// the text it currently contains, but in a US phone number widget that
/// consists of 3 different [TextField]s (area code, prefix and line number),
/// [SelectAllTextIntent] should instead select the text within all 3
/// [TextField]s.
///
/// An overridable [Action] is a special kind of [Action] created using the
/// [Action.overridable] constructor. It has access to a default [Action], and a
/// nullable override [Action]. It has the same behavior as its override if that
/// exists, and mirrors the behavior of its `defaultAction` otherwise.
///
/// The [Action.overridable] constructor creates overridable [Action]s that use
/// a [BuildContext] to find a suitable override in its ancestor [Actions]
/// widget. This can be used to provide a default implementation when creating a
/// general purpose leaf widget, and later override it when building a more
/// specialized widget using that leaf widget. Using the [TextField] example
/// above, the [TextField] widget uses an overridable [Action] to provide a
/// sensible default for [SelectAllTextIntent], while still allowing app
/// developers to change that if they add an ancestor [Actions] widget that maps
/// [SelectAllTextIntent] to a different [Action].
///
/// See also: /// See also:
/// ///
/// * [Shortcuts], which is a widget that contains a key map, in which it looks /// * [Shortcuts], which is a widget that contains a key map, in which it looks
...@@ -80,9 +105,241 @@ typedef ActionListenerCallback = void Function(Action<Intent> action); ...@@ -80,9 +105,241 @@ typedef ActionListenerCallback = void Function(Action<Intent> action);
/// and allows redefining of actions for its descendants. /// and allows redefining of actions for its descendants.
/// * [ActionDispatcher], a class that takes an [Action] and invokes it, passing /// * [ActionDispatcher], a class that takes an [Action] and invokes it, passing
/// a given [Intent]. /// a given [Intent].
/// * [Action.overridable] for an example on how to make an [Action]
/// overridable.
abstract class Action<T extends Intent> with Diagnosticable { abstract class Action<T extends Intent> with Diagnosticable {
/// Creates an [Action].
Action();
/// Creates an [Action] that allows itself to be overridden by the closest
/// ancestor [Action] in the given [context] that handles the same [Intent],
/// if one exists.
///
/// When invoked, the resulting [Action] tries to find the closest [Action] in
/// the given `context` that handles the same type of [Intent] as the
/// `defaultAction`, then calls its [Action.invoke] method. When no override
/// [Action]s can be found, it invokes the `defaultAction`.
///
/// An overridable action delegates everything to its override if one exists,
/// and has the same behavior as its `defaultAction` otherwise. For this
/// reason, the override has full control over whether and how an [Intent]
/// should be handled, or a key event should be consumed. An override
/// [Action]'s [callingAction] property will be set to the [Action] it
/// currently overrides, giving it access to the default behavior. See the
/// [callingAction] property for an example.
///
/// The `context` argument is the [BuildContext] to find the override with. It
/// is typically a [BuildContext] above the [Actions] widget that contains
/// this overridable [Action].
///
/// The `defaultAction` argument is the [Action] to be invoked where there's
/// no ancestor [Action]s can't be found in `context` that handle the same
/// type of [Intent].
///
/// This is useful for providing a set of default [Action]s in a leaf widget
/// to allow further overriding, or to allow the [Intent] to propagate to
/// parent widgets that also support this [Intent].
///
/// {@tool sample --template=freeform}
/// This sample implements a custom text input field that handles the
/// [DeleteTextIntent] intent, as well as a US telephone number input widget
/// that consists of multiple text fields for area code, prefix and line
/// number. When the backspace key is pressed, the phone number input widget
/// sends the focus to the preceding text field when the currently focused
/// field becomes empty.
///
/// ```dart imports
/// import 'package:flutter/material.dart';
/// import 'package:flutter/services.dart';
/// ```
///
/// ```dart main
/// void main() {
/// runApp(
/// const MaterialApp(
/// home: Scaffold(
/// body: Center(child: SimpleUSPhoneNumberEntry()),
/// ),
/// ),
/// );
/// }
/// ```
///
/// ```dart
/// // This implements a custom phone number input field that handles the
/// // [DeleteTextIntent] intent.
/// class DigitInput extends StatefulWidget {
/// const DigitInput({
/// Key? key,
/// required this.controller,
/// required this.focusNode,
/// this.maxLength,
/// this.textInputAction = TextInputAction.next,
/// }) : super(key: key);
///
/// final int? maxLength;
/// final TextEditingController controller;
/// final TextInputAction textInputAction;
/// final FocusNode focusNode;
///
/// @override
/// DigitInputState createState() => DigitInputState();
/// }
///
/// class DigitInputState extends State<DigitInput> {
/// late final Action<DeleteTextIntent> _deleteTextAction = CallbackAction<DeleteTextIntent>(
/// onInvoke: (DeleteTextIntent intent) {
/// // For simplicity we delete everything in the section.
/// widget.controller.clear();
/// },
/// );
///
/// @override
/// Widget build(BuildContext context) {
/// return Actions(
/// actions: <Type, Action<Intent>>{
/// // Make the default `DeleteTextIntent` handler overridable.
/// DeleteTextIntent: Action<DeleteTextIntent>.overridable(defaultAction: _deleteTextAction, context: context),
/// },
/// child: TextField(
/// controller: widget.controller,
/// textInputAction: TextInputAction.next,
/// keyboardType: TextInputType.phone,
/// focusNode: widget.focusNode,
/// decoration: const InputDecoration(
/// border: OutlineInputBorder(),
/// ),
/// inputFormatters: <TextInputFormatter>[
/// FilteringTextInputFormatter.digitsOnly,
/// LengthLimitingTextInputFormatter(widget.maxLength),
/// ],
/// ),
/// );
/// }
/// }
///
/// class SimpleUSPhoneNumberEntry extends StatefulWidget {
/// const SimpleUSPhoneNumberEntry({ Key? key }) : super(key: key);
///
/// @override
/// State<SimpleUSPhoneNumberEntry> createState() => _SimpleUSPhoneNumberEntryState();
/// }
///
/// class _DeleteDigit extends Action<DeleteTextIntent> {
/// _DeleteDigit(this.state);
///
/// final _SimpleUSPhoneNumberEntryState state;
/// @override
/// Object? invoke(DeleteTextIntent intent) {
/// assert(callingAction != null);
/// callingAction?.invoke(intent);
///
/// if (state.lineNumberController.text.isEmpty && state.lineNumberFocusNode.hasFocus) {
/// state.prefixFocusNode.requestFocus();
/// }
///
/// if (state.prefixController.text.isEmpty && state.prefixFocusNode.hasFocus) {
/// state.areaCodeFocusNode.requestFocus();
/// }
/// }
///
/// // This action is only enabled when the `callingAction` exists and is
/// // enabled.
/// @override
/// bool get isActionEnabled => callingAction?.isActionEnabled ?? false;
/// }
///
/// class _SimpleUSPhoneNumberEntryState extends State<SimpleUSPhoneNumberEntry> {
/// final FocusNode areaCodeFocusNode = FocusNode();
/// final TextEditingController areaCodeController = TextEditingController();
/// final FocusNode prefixFocusNode = FocusNode();
/// final TextEditingController prefixController = TextEditingController();
/// final FocusNode lineNumberFocusNode = FocusNode();
/// final TextEditingController lineNumberController = TextEditingController();
///
/// @override
/// Widget build(BuildContext context) {
/// return Actions(
/// actions: <Type, Action<Intent>>{
/// DeleteTextIntent : _DeleteDigit(this),
/// },
/// child: Row(
/// mainAxisAlignment: MainAxisAlignment.spaceBetween,
/// children: <Widget>[
/// const Expanded(child: Text('(', textAlign: TextAlign.center,), flex: 1),
/// Expanded(child: DigitInput(focusNode: areaCodeFocusNode, controller: areaCodeController, maxLength: 3), flex: 3),
/// const Expanded(child: Text(')', textAlign: TextAlign.center,), flex: 1),
/// Expanded(child: DigitInput(focusNode: prefixFocusNode, controller: prefixController, maxLength: 3), flex: 3),
/// const Expanded(child: Text('-', textAlign: TextAlign.center,), flex: 1),
/// Expanded(child: DigitInput(focusNode: lineNumberFocusNode, controller: lineNumberController, textInputAction: TextInputAction.done, maxLength: 4), flex: 4),
/// ],
/// ),
/// );
/// }
/// }
/// ```
/// {@end-tool}
factory Action.overridable({
required Action<T> defaultAction,
required BuildContext context,
}) {
return defaultAction._makeOverridableAction(context);
}
final ObserverList<ActionListenerCallback> _listeners = ObserverList<ActionListenerCallback>(); final ObserverList<ActionListenerCallback> _listeners = ObserverList<ActionListenerCallback>();
Action<T>? _currentCallingAction;
set _callingAction(Action<T>? newAction) {
if (newAction == _currentCallingAction) {
return;
}
assert(newAction == null || _currentCallingAction == null);
_currentCallingAction = newAction;
}
/// The [Action] overridden by this [Action].
///
/// The [Action.overridable] constructor creates an overridable [Action] that
/// allows itself to be overridden by the closest ancestor [Action], and falls
/// back to its own `defaultAction` when no overrides can be found. When an
/// override is present, an overridable [Action] forwards all incoming
/// method calls to the override, and allows the override to access the
/// `defaultAction` via its [callingAction] property.
///
/// Before forwarding the call to the override, the overridable [Action] is
/// responsible for setting [callingAction] to its `defaultAction`, which is
/// already taken care of by the overridable [Action] created using
/// [Action.overridable].
///
/// This property is only non-null when this [Action] is an override of the
/// [callingAction], and is currently being invoked from [callingAction].
///
/// Invoking [callingAction]'s methods, or accessing its properties, is
/// allowed and does not introduce infinite loops or infinite recursions.
///
/// {@tool snippet}
/// An example `Action` that handles [PasteTextIntent] but has mostly the same
/// behavior as the overridable action. It's OK to call
/// `callingAction?.isActionEnabled` in the implementation of this `Action`.
///
/// ```dart
/// class MyPasteAction extends Action<PasteTextIntent> {
/// @override
/// Object? invoke(PasteTextIntent intent) {
/// print(intent);
/// return callingAction?.invoke(intent);
/// }
///
/// @override
/// bool get isActionEnabled => callingAction?.isActionEnabled ?? false;
///
/// @override
/// bool consumesKey(PasteTextIntent intent) => callingAction?.consumesKey(intent) ?? false;
/// }
/// ```
/// {@end-tool}
@protected
Action<T>? get callingAction => _currentCallingAction;
/// Gets the type of intent this action responds to. /// Gets the type of intent this action responds to.
Type get intentType => T; Type get intentType => T;
...@@ -90,10 +347,19 @@ abstract class Action<T extends Intent> with Diagnosticable { ...@@ -90,10 +347,19 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// ///
/// This will be called by the [ActionDispatcher] before attempting to invoke /// This will be called by the [ActionDispatcher] before attempting to invoke
/// the action. /// the action.
bool isEnabled(T intent) => isActionEnabled;
/// Whether this [Action] is inherently enabled.
/// ///
/// If [isActionEnabled] is false, then this [Action] is disabled for any
/// given [Intent].
//
/// If the enabled state changes, overriding subclasses must call /// If the enabled state changes, overriding subclasses must call
/// [notifyActionListeners] to notify any listeners of the change. /// [notifyActionListeners] to notify any listeners of the change.
bool isEnabled(covariant T intent) => true; ///
/// In the case of an overridable `Action`, accessing this property creates
/// an dependency on the overridable `Action`s `lookupContext`.
bool get isActionEnabled => true;
/// Indicates whether this action should treat key events mapped to this /// Indicates whether this action should treat key events mapped to this
/// action as being "handled" when it is invoked via the key event. /// action as being "handled" when it is invoked via the key event.
...@@ -106,7 +372,7 @@ abstract class Action<T extends Intent> with Diagnosticable { ...@@ -106,7 +372,7 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// widgets to receive the key event. /// widgets to receive the key event.
/// ///
/// The default implementation returns true. /// The default implementation returns true.
bool consumesKey(covariant T intent) => true; bool consumesKey(T intent) => true;
/// Called when the action is to be performed. /// Called when the action is to be performed.
/// ///
...@@ -143,7 +409,7 @@ abstract class Action<T extends Intent> with Diagnosticable { ...@@ -143,7 +409,7 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// [Actions.invoke], or by invoking it using an [ActionDispatcher]. An action /// [Actions.invoke], or by invoking it using an [ActionDispatcher]. An action
/// invoked via a [Shortcuts] widget will have its return value ignored. /// invoked via a [Shortcuts] widget will have its return value ignored.
@protected @protected
Object? invoke(covariant T intent); Object? invoke(T intent);
/// Register a callback to listen for changes to the state of this action. /// Register a callback to listen for changes to the state of this action.
/// ///
...@@ -234,6 +500,10 @@ abstract class Action<T extends Intent> with Diagnosticable { ...@@ -234,6 +500,10 @@ abstract class Action<T extends Intent> with Diagnosticable {
} }
} }
} }
Action<T> _makeOverridableAction(BuildContext context) {
return _OverridableAction<T>(defaultAction: this, lookupContext: context);
}
} }
/// A helper widget for making sure that listeners on an action are removed properly. /// A helper widget for making sure that listeners on an action are removed properly.
...@@ -447,7 +717,12 @@ abstract class ContextAction<T extends Intent> extends Action<T> { ...@@ -447,7 +717,12 @@ abstract class ContextAction<T extends Intent> extends Action<T> {
/// ``` /// ```
@protected @protected
@override @override
Object? invoke(covariant T intent, [BuildContext? context]); Object? invoke(T intent, [BuildContext? context]);
@override
ContextAction<T> _makeOverridableAction(BuildContext context) {
return _OverridableContextAction<T>(defaultAction: this, lookupContext: context);
}
} }
/// The signature of a callback accepted by [CallbackAction]. /// The signature of a callback accepted by [CallbackAction].
...@@ -478,7 +753,7 @@ class CallbackAction<T extends Intent> extends Action<T> { ...@@ -478,7 +753,7 @@ class CallbackAction<T extends Intent> extends Action<T> {
final OnInvokeCallback<T> onInvoke; final OnInvokeCallback<T> onInvoke;
@override @override
Object? invoke(covariant T intent) => onInvoke(intent); Object? invoke(T intent) => onInvoke(intent);
} }
/// An action dispatcher that simply invokes the actions given to it. /// An action dispatcher that simply invokes the actions given to it.
...@@ -865,7 +1140,7 @@ class Actions extends StatefulWidget { ...@@ -865,7 +1140,7 @@ class Actions extends StatefulWidget {
_visitActionsAncestors(context, (InheritedElement element) { _visitActionsAncestors(context, (InheritedElement element) {
final _ActionsMarker actions = element.widget as _ActionsMarker; final _ActionsMarker actions = element.widget as _ActionsMarker;
final Action<T>? result = actions.actions[type] as Action<T>?; final Action<T>? result = _castAction(actions, intent: intent);
if (result != null) { if (result != null) {
context.dependOnInheritedElement(element); context.dependOnInheritedElement(element);
action = result; action = result;
...@@ -877,6 +1152,49 @@ class Actions extends StatefulWidget { ...@@ -877,6 +1152,49 @@ class Actions extends StatefulWidget {
return action; return action;
} }
static Action<T>? _maybeFindWithoutDependingOn<T extends Intent>(BuildContext context, { T? intent }) {
Action<T>? action;
// Specialize the type if a runtime example instance of the intent is given.
// This allows this function to be called by code that doesn't know the
// concrete type of the intent at compile time.
final Type type = intent?.runtimeType ?? T;
assert(
type != Intent,
'The type passed to "find" resolved to "Intent": either a non-Intent '
'generic type argument or an example intent derived from Intent must be '
'specified. Intent may be used as the generic type as long as the optional '
'"intent" argument is passed.',
);
_visitActionsAncestors(context, (InheritedElement element) {
final _ActionsMarker actions = element.widget as _ActionsMarker;
final Action<T>? result = _castAction(actions, intent: intent);
if (result != null) {
action = result;
return true;
}
return false;
});
return action;
}
// Find the [Action] that handles the given `intent` in the given
// `_ActionsMarker`, and verify it has the right type parameter.
static Action<T>? _castAction<T extends Intent>(_ActionsMarker actionsMarker, { T? intent }) {
final Action<Intent>? mappedAction = actionsMarker.actions[intent?.runtimeType ?? T];
if (mappedAction is Action<T>?) {
return mappedAction;
} else {
assert(
false,
'$T cannot be handled by an Action of runtime type ${mappedAction.runtimeType}.'
);
return null;
}
}
/// Returns the [ActionDispatcher] associated with the [Actions] widget that /// Returns the [ActionDispatcher] associated with the [Actions] widget that
/// most tightly encloses the given [BuildContext]. /// most tightly encloses the given [BuildContext].
/// ///
...@@ -896,38 +1214,33 @@ class Actions extends StatefulWidget { ...@@ -896,38 +1214,33 @@ class Actions extends StatefulWidget {
/// ///
/// The `context` and `intent` arguments must not be null. /// The `context` and `intent` arguments must not be null.
/// ///
/// If the given `intent` doesn't map to an action, or doesn't map to one that /// If the given `intent` doesn't map to an action, then it will look to the
/// returns true for [Action.isEnabled] in an [Actions.actions] map it finds, /// next ancestor [Actions] widget in the hierarchy until it reaches the root.
/// then it will look to the next ancestor [Actions] widget in the hierarchy
/// until it reaches the root.
/// ///
/// This method will throw an exception if no ambient [Actions] widget is /// This method will throw an exception if no ambient [Actions] widget is
/// found, or if the given `intent` doesn't map to an enabled action in any of /// found, or when a suitable [Action] is found but it returns false for
/// the [Actions.actions] maps that are found. /// [Action.isEnabled].
static Object? invoke<T extends Intent>( static Object? invoke<T extends Intent>(
BuildContext context, BuildContext context,
T intent, T intent,
) { ) {
assert(intent != null); assert(intent != null);
assert(context != null); assert(context != null);
Action<T>? action; Object? returnValue;
InheritedElement? actionElement;
_visitActionsAncestors(context, (InheritedElement element) { final bool actionFound = _visitActionsAncestors(context, (InheritedElement element) {
final _ActionsMarker actions = element.widget as _ActionsMarker; final _ActionsMarker actions = element.widget as _ActionsMarker;
final Action<T>? result = actions.actions[intent.runtimeType] as Action<T>?; final Action<T>? result = _castAction(actions, intent: intent);
if (result != null) { if (result != null && result.isEnabled(intent)) {
actionElement = element; // Invoke the action we found using the relevant dispatcher from the Actions
if (result.isEnabled(intent)) { // Element we found.
action = result; returnValue = _findDispatcher(element).invokeAction(result, intent, context);
return true;
}
} }
return false; return result != null;
}); });
assert(() { assert(() {
if (actionElement == null) { if (!actionFound) {
throw FlutterError( throw FlutterError(
'Unable to find an action for an Intent with type ' 'Unable to find an action for an Intent with type '
'${intent.runtimeType} in an $Actions widget in the given context.\n' '${intent.runtimeType} in an $Actions widget in the given context.\n'
...@@ -943,12 +1256,7 @@ class Actions extends StatefulWidget { ...@@ -943,12 +1256,7 @@ class Actions extends StatefulWidget {
} }
return true; return true;
}()); }());
if (actionElement == null || action == null) { return returnValue;
return null;
}
// Invoke the action we found using the relevant dispatcher from the Actions
// Element we found.
return _findDispatcher(actionElement!).invokeAction(action!, intent, context);
} }
/// Invokes the action associated with the given [Intent] using the /// Invokes the action associated with the given [Intent] using the
...@@ -956,43 +1264,34 @@ class Actions extends StatefulWidget { ...@@ -956,43 +1264,34 @@ class Actions extends StatefulWidget {
/// ///
/// This method returns the result of invoking the action's [Action.invoke] /// This method returns the result of invoking the action's [Action.invoke]
/// method. If no action mapping was found for the specified intent, or if the /// method. If no action mapping was found for the specified intent, or if the
/// actions that were found were disabled, or the action itself returns null /// first action found was disabled, or the action itself returns null
/// from [Action.invoke], then this method returns null. /// from [Action.invoke], then this method returns null.
/// ///
/// The `context` and `intent` arguments must not be null. /// The `context` and `intent` arguments must not be null.
/// ///
/// If the given `intent` doesn't map to an action, or doesn't map to one that /// If the given `intent` doesn't map to an action, then it will look to the
/// returns true for [Action.isEnabled] in an [Actions.actions] map it finds, /// next ancestor [Actions] widget in the hierarchy until it reaches the root.
/// then it will look to the next ancestor [Actions] widget in the hierarchy /// If a suitable [Action] is found but its [Action.isEnabled] returns false,
/// until it reaches the root. /// the search will stop and this method will return null.
static Object? maybeInvoke<T extends Intent>( static Object? maybeInvoke<T extends Intent>(
BuildContext context, BuildContext context,
T intent, T intent,
) { ) {
assert(intent != null); assert(intent != null);
assert(context != null); assert(context != null);
Action<T>? action; Object? returnValue;
InheritedElement? actionElement;
_visitActionsAncestors(context, (InheritedElement element) { _visitActionsAncestors(context, (InheritedElement element) {
final _ActionsMarker actions = element.widget as _ActionsMarker; final _ActionsMarker actions = element.widget as _ActionsMarker;
final Action<T>? result = actions.actions[intent.runtimeType] as Action<T>?; final Action<T>? result = _castAction(actions, intent: intent);
if (result != null) { if (result != null && result.isEnabled(intent)) {
actionElement = element; // Invoke the action we found using the relevant dispatcher from the Actions
if (result.isEnabled(intent)) { // Element we found.
action = result; returnValue = _findDispatcher(element).invokeAction(result, intent, context);
return true;
}
} }
return false; return result != null;
}); });
return returnValue;
if (actionElement == null || action == null) {
return null;
}
// Invoke the action we found using the relevant dispatcher from the Actions
// Element we found.
return _findDispatcher(actionElement!).invokeAction(action!, intent, context);
} }
@override @override
...@@ -1682,3 +1981,251 @@ class PrioritizedAction extends Action<PrioritizedIntents> { ...@@ -1682,3 +1981,251 @@ class PrioritizedAction extends Action<PrioritizedIntents> {
_selectedAction.invoke(_selectedIntent); _selectedAction.invoke(_selectedIntent);
} }
} }
mixin _OverridableActionMixin<T extends Intent> on Action<T> {
// When debugAssertMutuallyRecursive is true, this action will throw an
// assertion error when the override calls this action's "invoke" method and
// the override is already being invoked from within the "invoke" method.
bool debugAssertMutuallyRecursive = false;
bool debugAssertIsActionEnabledMutuallyRecursive = false;
bool debugAssertIsEnabledMutuallyRecursive = false;
bool debugAssertConsumeKeyMutuallyRecursive = false;
// The default action to invoke if an enabled override Action can't be found
// using [lookupContext];
Action<T> get defaultAction;
// The [BuildContext] used to find the override of this [Action].
BuildContext get lookupContext;
// How to invoke [defaultAction], given the caller [fromAction].
Object? invokeDefaultAction(T intent, Action<T>? fromAction, BuildContext? context);
Action<T>? getOverrideAction({ bool declareDependency = false }) {
final Action<T>? override = declareDependency
? Actions.maybeFind(lookupContext)
: Actions._maybeFindWithoutDependingOn(lookupContext);
assert(!identical(override, this));
return override;
}
@override
set _callingAction(Action<T>? newAction) {
super._callingAction = newAction;
defaultAction._callingAction = newAction;
}
Object? _invokeOverride(Action<T> overrideAction, T intent, BuildContext? context) {
assert(!debugAssertMutuallyRecursive);
assert(() {
debugAssertMutuallyRecursive = true;
return true;
}());
overrideAction._callingAction = defaultAction;
final Object? returnValue = overrideAction is ContextAction<T>
? overrideAction.invoke(intent, context)
: overrideAction.invoke(intent);
overrideAction._callingAction = null;
assert(() {
debugAssertMutuallyRecursive = false;
return true;
}());
return returnValue;
}
@override
Object? invoke(T intent, [BuildContext? context]) {
final Action<T>? overrideAction = getOverrideAction();
final Object? returnValue = overrideAction == null
? invokeDefaultAction(intent, callingAction, context)
: _invokeOverride(overrideAction, intent, context);
return returnValue;
}
bool isOverrideActionEnabled(Action<T> overrideAction) {
assert(!debugAssertIsActionEnabledMutuallyRecursive);
assert(() {
debugAssertIsActionEnabledMutuallyRecursive = true;
return true;
}());
overrideAction._callingAction = defaultAction;
final bool isOverrideEnabled = overrideAction.isActionEnabled;
overrideAction._callingAction = null;
assert(() {
debugAssertIsActionEnabledMutuallyRecursive = false;
return true;
}());
return isOverrideEnabled;
}
@override
bool get isActionEnabled {
final Action<T>? overrideAction = getOverrideAction(declareDependency: true);
final bool returnValue = overrideAction != null
? isOverrideActionEnabled(overrideAction)
: defaultAction.isActionEnabled;
return returnValue;
}
@override
bool isEnabled(T intent) {
assert(!debugAssertIsEnabledMutuallyRecursive);
assert(() {
debugAssertIsEnabledMutuallyRecursive = true;
return true;
}());
final Action<T>? overrideAction = getOverrideAction();
overrideAction?._callingAction = defaultAction;
final bool returnValue = (overrideAction ?? defaultAction).isEnabled(intent);
overrideAction?._callingAction = null;
assert(() {
debugAssertIsEnabledMutuallyRecursive = false;
return true;
}());
return returnValue;
}
@override
bool consumesKey(T intent) {
assert(!debugAssertConsumeKeyMutuallyRecursive);
assert(() {
debugAssertConsumeKeyMutuallyRecursive = true;
return true;
}());
final Action<T>? overrideAction = getOverrideAction();
overrideAction?._callingAction = defaultAction;
final bool isEnabled = (overrideAction ?? defaultAction).consumesKey(intent);
overrideAction?._callingAction = null;
assert(() {
debugAssertConsumeKeyMutuallyRecursive = false;
return true;
}());
return isEnabled;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Action<T>>('defaultAction', defaultAction));
}
}
class _OverridableAction<T extends Intent> extends ContextAction<T> with _OverridableActionMixin<T> {
_OverridableAction({ required this.defaultAction, required this.lookupContext }) ;
@override
final Action<T> defaultAction;
@override
final BuildContext lookupContext;
@override
Object? invokeDefaultAction(T intent, Action<T>? fromAction, BuildContext? context) {
if (fromAction == null) {
return defaultAction.invoke(intent);
} else {
final Object? returnValue = defaultAction.invoke(intent);
return returnValue;
}
}
@override
ContextAction<T> _makeOverridableAction(BuildContext context) {
return _OverridableAction<T>(defaultAction: defaultAction, lookupContext: context);
}
}
class _OverridableContextAction<T extends Intent> extends ContextAction<T> with _OverridableActionMixin<T> {
_OverridableContextAction({ required this.defaultAction, required this.lookupContext });
@override
final ContextAction<T> defaultAction;
@override
final BuildContext lookupContext;
@override
Object? _invokeOverride(Action<T> overrideAction, T intent, BuildContext? context) {
assert(context != null);
assert(!debugAssertMutuallyRecursive);
assert(() {
debugAssertMutuallyRecursive = true;
return true;
}());
// Wrap the default Action together with the calling context in case
// overrideAction is not a ContextAction and thus have no access to the
// calling BuildContext.
final Action<T> wrappedDefault = _ContextActionToActionAdapter<T>(invokeContext: context!, action: defaultAction);
overrideAction._callingAction = wrappedDefault;
final Object? returnValue = overrideAction is ContextAction<T>
? overrideAction.invoke(intent, context)
: overrideAction.invoke(intent);
overrideAction._callingAction = null;
assert(() {
debugAssertMutuallyRecursive = false;
return true;
}());
return returnValue;
}
@override
Object? invokeDefaultAction(T intent, Action<T>? fromAction, BuildContext? context) {
if (fromAction == null) {
return defaultAction.invoke(intent, context);
} else {
final Object? returnValue = defaultAction.invoke(intent, context);
return returnValue;
}
}
@override
ContextAction<T> _makeOverridableAction(BuildContext context) {
return _OverridableContextAction<T>(defaultAction: defaultAction, lookupContext: context);
}
}
class _ContextActionToActionAdapter<T extends Intent> extends Action<T> {
_ContextActionToActionAdapter({required this.invokeContext, required this.action});
final BuildContext invokeContext;
final ContextAction<T> action;
@override
set _callingAction(Action<T>? newAction) {
action._callingAction = newAction;
}
@override
Action<T>? get callingAction => action.callingAction;
@override
bool isEnabled(T intent) => action.isEnabled(intent);
@override
bool get isActionEnabled => action.isActionEnabled;
@override
bool consumesKey(T intent) => action.consumesKey(intent);
@override
void addActionListener(ActionListenerCallback listener) {
super.addActionListener(listener);
action.addActionListener(listener);
}
@override
void removeActionListener(ActionListenerCallback listener) {
super.removeActionListener(listener);
action.removeActionListener(listener);
}
@override
@protected
void notifyActionListeners() => action.notifyActionListeners();
@override
Object? invoke(T intent) => action.invoke(intent, invokeContext);
}
...@@ -549,7 +549,7 @@ void main() { ...@@ -549,7 +549,7 @@ void main() {
expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); expect(invokedDispatcher.runtimeType, equals(TestDispatcher1));
expect(testAction.capturedContexts.single, containerKey.currentContext); expect(testAction.capturedContexts.single, containerKey.currentContext);
}); });
testWidgets('Disabled actions allow propagation to an ancestor', (WidgetTester tester) async { testWidgets('Disabled actions stop propagation to an ancestor', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
bool invoked = false; bool invoked = false;
const TestIntent intent = TestIntent(); const TestIntent intent = TestIntent();
...@@ -589,11 +589,11 @@ void main() { ...@@ -589,11 +589,11 @@ void main() {
containerKey.currentContext!, containerKey.currentContext!,
intent, intent,
); );
expect(result, isTrue); expect(result, isNull);
expect(invoked, isTrue); expect(invoked, isFalse);
expect(invokedIntent, equals(intent)); expect(invokedIntent, isNull);
expect(invokedAction, equals(enabledTestAction)); expect(invokedAction, isNull);
expect(invokedDispatcher.runtimeType, equals(TestDispatcher1)); expect(invokedDispatcher, isNull);
}); });
}); });
...@@ -651,7 +651,7 @@ void main() { ...@@ -651,7 +651,7 @@ void main() {
), ),
); );
Object? result = Actions.invoke( Object? result = Actions.maybeInvoke(
containerKey.currentContext!, containerKey.currentContext!,
const TestIntent(), const TestIntent(),
); );
...@@ -689,7 +689,7 @@ void main() { ...@@ -689,7 +689,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
result = Actions.invoke<TestIntent>( result = Actions.maybeInvoke<TestIntent>(
containerKey.currentContext!, containerKey.currentContext!,
const SecondTestIntent(), const SecondTestIntent(),
); );
...@@ -1025,6 +1025,677 @@ void main() { ...@@ -1025,6 +1025,677 @@ void main() {
expect(description[1], equalsIgnoringHashCodes('actions: {TestIntent: TestAction#00000}')); expect(description[1], equalsIgnoringHashCodes('actions: {TestIntent: TestAction#00000}'));
}); });
}); });
group('Action overriding', () {
final List<String> invocations = <String>[];
BuildContext? invokingContext;
tearDown(() {
invocations.clear();
invokingContext = null;
});
testWidgets('Basic usage', (WidgetTester tester) async {
late BuildContext invokingContext2;
late BuildContext invokingContext3;
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent : Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
invokingContext2 = context2;
return Actions(
actions: <Type, Action<Intent>> {
LogIntent : Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
invokingContext3 = context3;
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
invocations.clear();
// Invoke from a different (higher) context.
Actions.invoke(invokingContext3, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invoke',
'action1.invokeAsOverride-post-super',
]);
invocations.clear();
// Invoke from a different (higher) context.
Actions.invoke(invokingContext2, LogIntent(log: invocations));
expect(invocations, <String>['action1.invoke']);
});
testWidgets('Does not break after use', (WidgetTester tester) async {
late BuildContext invokingContext2;
late BuildContext invokingContext3;
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
invokingContext2 = context2;
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
invokingContext3 = context3;
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
// Invoke a bunch of times and verify it still produces the same result.
final List<BuildContext> randomContexts = <BuildContext>[
invokingContext!,
invokingContext2,
invokingContext!,
invokingContext3,
invokingContext3,
invokingContext3,
invokingContext2,
];
for (final BuildContext randomContext in randomContexts) {
Actions.invoke(randomContext, LogIntent(log: invocations));
}
invocations.clear();
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
});
testWidgets('Does not override if not overridable', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> { LogIntent : LogInvocationAction(actionName: 'action2') },
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
]);
});
testWidgets('The final override controls isEnabled', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2', enabled: false), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
invocations.clear();
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1', enabled: false), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[]);
});
testWidgets('The override can choose to defer isActionEnabled to the overridable', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationButDeferIsEnabledAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2', enabled: false), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
// Nothing since the final override defers its isActionEnabled state to action2,
// which is disabled.
expect(invocations, <String>[]);
invocations.clear();
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1', enabled: true), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationButDeferIsEnabledAction(actionName: 'action2'), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3', enabled: false), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
// The final override (action1) is enabled so all 3 actions are enabled.
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
});
testWidgets('Throws on infinite recursions', (WidgetTester tester) async {
late StateSetter setState;
BuildContext? action2LookupContext;
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: StatefulBuilder(
builder: (BuildContext context2, StateSetter stateSetter) {
setState = stateSetter;
return Actions(
actions: <Type, Action<Intent>> {
if (action2LookupContext != null) LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: action2LookupContext!)
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
// Let action2 look up its override using a context below itself, so it
// will find action3 as its override.
expect(tester.takeException(), isNull);
setState(() {
action2LookupContext = invokingContext;
});
await tester.pump();
expect(tester.takeException(), isNull);
Object? exception;
try {
Actions.invoke(invokingContext!, LogIntent(log: invocations));
} catch (e) {
exception = e;
}
expect(exception?.toString(), contains('debugAssertIsEnabledMutuallyRecursive'));
});
testWidgets('Throws on invoking invalid override', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context) {
return Actions(
actions: <Type, Action<Intent>> { LogIntent : TestContextAction() },
child: Builder(
builder: (BuildContext context) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context),
},
child: Builder(
builder: (BuildContext context1) {
invokingContext = context1;
return const SizedBox();
},
),
);
},
),
);
},
),
);
Object? exception;
try {
Actions.invoke(invokingContext!, LogIntent(log: invocations));
} catch (e) {
exception = e;
}
expect(
exception?.toString(),
contains('cannot be handled by an Action of runtime type TestContextAction.'),
);
});
testWidgets('Make an overridable action overridable', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2'), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(
defaultAction: Action<LogIntent>.overridable(
defaultAction: Action<LogIntent>.overridable(
defaultAction: LogInvocationAction(actionName: 'action3'),
context: context1,
),
context: context2,
),
context: context3,
)
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
});
testWidgets('Overriding Actions can change the intent', (WidgetTester tester) async {
final List<String> newLogChannel = <String>[];
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: RedirectOutputAction(actionName: 'action2', newLog: newLogChannel), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action1.invokeAsOverride-post-super',
]);
expect(newLogChannel, <String>[
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
]);
});
testWidgets('Override non-context overridable Actions with a ContextAction', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
// The default Action is a ContextAction subclass.
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationContextAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action2', enabled: false), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
// Action1 is a ContextAction and action2 & action3 are not.
// They should not lose information.
expect(LogInvocationContextAction.invokeContext, isNotNull);
expect(LogInvocationContextAction.invokeContext, invokingContext);
});
testWidgets('Override a ContextAction with a regular Action', (WidgetTester tester) async {
await tester.pumpWidget(
Builder(
builder: (BuildContext context1) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action1'), context: context1),
},
child: Builder(
builder: (BuildContext context2) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationContextAction(actionName: 'action2', enabled: false), context: context2),
},
child: Builder(
builder: (BuildContext context3) {
return Actions(
actions: <Type, Action<Intent>> {
LogIntent: Action<LogIntent>.overridable(defaultAction: LogInvocationAction(actionName: 'action3'), context: context3),
},
child: Builder(
builder: (BuildContext context4) {
invokingContext = context4;
return const SizedBox();
},
),
);
},
),
);
},
),
);
},
),
);
Actions.invoke(invokingContext!, LogIntent(log: invocations));
expect(invocations, <String>[
'action1.invokeAsOverride-pre-super',
'action2.invokeAsOverride-pre-super',
'action3.invoke',
'action2.invokeAsOverride-post-super',
'action1.invokeAsOverride-post-super',
]);
// Action2 is a ContextAction and action1 & action2 are regular actions.
// Invoking action2 from action3 should still supply a non-null
// BuildContext.
expect(LogInvocationContextAction.invokeContext, isNotNull);
expect(LogInvocationContextAction.invokeContext, invokingContext);
});
});
} }
class TestContextAction extends ContextAction<TestIntent> { class TestContextAction extends ContextAction<TestIntent> {
...@@ -1036,3 +1707,91 @@ class TestContextAction extends ContextAction<TestIntent> { ...@@ -1036,3 +1707,91 @@ class TestContextAction extends ContextAction<TestIntent> {
return null; return null;
} }
} }
class LogIntent extends Intent {
const LogIntent({ required this.log });
final List<String> log;
}
class LogInvocationAction extends Action<LogIntent> {
LogInvocationAction({ required this.actionName, this.enabled = true });
final String actionName;
final bool enabled;
@override
bool get isActionEnabled => enabled;
@override
Object? invoke(LogIntent intent) {
final Action<LogIntent>? callingAction = this.callingAction;
if (callingAction == null) {
intent.log.add('$actionName.invoke');
} else {
intent.log.add('$actionName.invokeAsOverride-pre-super');
callingAction.invoke(intent);
intent.log.add('$actionName.invokeAsOverride-post-super');
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('actionName', actionName));
}
}
class LogInvocationContextAction extends ContextAction<LogIntent> {
LogInvocationContextAction({ required this.actionName, this.enabled = true });
static BuildContext? invokeContext;
final String actionName;
final bool enabled;
@override
bool get isActionEnabled => enabled;
@override
Object? invoke(LogIntent intent, [BuildContext? context]) {
invokeContext = context;
final Action<LogIntent>? callingAction = this.callingAction;
if (callingAction == null) {
intent.log.add('$actionName.invoke');
} else {
intent.log.add('$actionName.invokeAsOverride-pre-super');
callingAction.invoke(intent);
intent.log.add('$actionName.invokeAsOverride-post-super');
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('actionName', actionName));
}
}
class LogInvocationButDeferIsEnabledAction extends LogInvocationAction {
LogInvocationButDeferIsEnabledAction({ required String actionName }) : super(actionName: actionName);
// Defer `isActionEnabled` to the overridable action.
@override
bool get isActionEnabled => callingAction?.isActionEnabled ?? false;
}
class RedirectOutputAction extends LogInvocationAction {
RedirectOutputAction({
required String actionName,
bool enabled = true,
required this.newLog,
}) : super(actionName: actionName, enabled: enabled);
final List<String> newLog;
@override
Object? invoke(LogIntent intent) => super.invoke(LogIntent(log: newLog));
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment