skip to content

On a Flutter desktop point-of-sale screen, how would you bind F2, Ctrl+P and Escape with Shortcuts, Actions and Intents, and why might they not fire?

level: seniorimportance: should knowfreq 38%

answer

  1. key combo to Intent to Action
  2. SingleActivator(key, control: true)
  3. Shortcuts hears keys bubbling from focus
  4. Action looked up from the focused context
  5. disabled Action lets the key pass

basics

~20 s

Shortcuts maps SingleActivator key combinations to Intent objects, and an Actions widget above the focused widget maps each Intent type to an Action. They fire only when primary focus is inside the Shortcuts subtree and an enabled Action is found.

solid answer

~40 s

Define one `Intent` subclass per command (`NewSaleIntent`, `PayIntent`, `VoidSaleIntent`), map keys with `Shortcuts(shortcuts: {SingleActivator(LogicalKeyboardKey.f2): NewSaleIntent(), SingleActivator(LogicalKeyboardKey.keyP, control: true): PayIntent(), ...})`, and bind behaviour with `Actions(actions: {PayIntent: CallbackAction<PayIntent>(onInvoke: (_) => pay()), ...})`. `Shortcuts` is a non-focusable `Focus` with an `onKeyEvent` handler, so it only hears keys that bubble up from the primary focus: if nothing inside it is focused, nothing fires — add `Focus(autofocus: true)` under it. The `Action` is looked up from the focused widget's context upward, so `Actions` must enclose the focused widget. An `Action` whose `isEnabled` returns false is skipped and the key keeps bubbling. `SingleActivator.includeRepeats` defaults to true, so a held key repeats the command. `CallbackShortcuts` is the lighter choice when you don't need the indirection.

code

dart · 50 lines
dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class NewSaleIntent extends Intent {
  const NewSaleIntent();
}

class PayIntent extends Intent {
  const PayIntent();
}

class VoidSaleIntent extends Intent {
  const VoidSaleIntent();
}

class PosShortcuts extends StatelessWidget {
  const PosShortcuts({
    super.key,
    required this.onNewSale,
    required this.onPay,
    required this.onVoid,
    required this.child,
  });

  final VoidCallback onNewSale;
  final VoidCallback onPay;
  final VoidCallback onVoid;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Shortcuts(
      shortcuts: const <ShortcutActivator, Intent>{
        SingleActivator(LogicalKeyboardKey.f2): NewSaleIntent(),
        SingleActivator(LogicalKeyboardKey.keyP, control: true, includeRepeats: false):
            PayIntent(),
        SingleActivator(LogicalKeyboardKey.escape): VoidSaleIntent(),
      },
      child: Actions(
        actions: <Type, Action<Intent>>{
          NewSaleIntent: CallbackAction<NewSaleIntent>(onInvoke: (_) => onNewSale()),
          PayIntent: CallbackAction<PayIntent>(onInvoke: (_) => onPay()),
          VoidSaleIntent: CallbackAction<VoidSaleIntent>(onInvoke: (_) => onVoid()),
        },
        // Without a focused node inside, Shortcuts never sees a key.
        child: Focus(autofocus: true, child: child),
      ),
    );
  }
}

go deeper

for a junior

Recall the chain: Shortcuts turns a key into an Intent, Actions turns the Intent into code, and something inside must have focus.

for a middle

Explain that key events bubble from the primary focus, that Action lookup starts at the focused context, and what isEnabled false does.

for a senior

Design a keyboard-first desktop screen: autofocus, includeRepeats on money actions, Command versus Control per platform, and shared intents for buttons and keys.

for a principal

Decide where an app's shortcut map lives, which commands are global versus screen-local, and how conflicts with text editing keys are avoided.

## The three pieces Flutter separates **which keys** from **what happens**: - An **`Intent`** is a plain, usually `const`, object naming a command: `class PayIntent extends Intent { const PayIntent(); }`. - A **`Shortcuts`** widget maps `ShortcutActivator`s to intents. `SingleActivator(LogicalKeyboardKey.keyP, control: true)` means Ctrl+P; `CharacterActivator('?')` matches a typed character regardless of which keys produced it. - An **`Actions`** widget maps intent *types* to **`Action`** objects that do the work: `CallbackAction<PayIntent>(onInvoke: ...)` for a closure, or a subclass of `Action<PayIntent>` that overrides `invoke` and `isEnabled`. The indirection lets the same key mean different things depending on what is focused, and lets a toolbar button call `Actions.invoke(context, const PayIntent())` so the button and the key share one implementation and one enabled state. ## How a key press reaches an Action 1. The key event goes to `FocusManager.instance.primaryFocus` and bubbles up through its ancestors' `onKeyEvent` handlers. If **no node has primary focus**, the focus system ignores the event. 2. `Shortcuts` builds a `Focus(canRequestFocus: false, onKeyEvent: ...)` around its child, so it is one stop on that bubble path — it only hears keys when the primary focus is **inside its subtree**. 3. Its `ShortcutManager` matches the event against the map. On a match it looks up the `Action` with `Actions.maybeFind`, starting from **the focused widget's context** and walking up. 4. If the action is found and enabled, it is invoked and the event is reported as handled (or `skipRemainingHandlers` if the action's `consumesKey` returns false). Otherwise the `Shortcuts` returns `ignored` and the event keeps bubbling. ## The point-of-sale screen | Key | Activator | Intent | Notes | |---|---|---|---| | F2 | `SingleActivator(LogicalKeyboardKey.f2)` | `NewSaleIntent` | function keys never collide with typing | | Ctrl+P | `SingleActivator(LogicalKeyboardKey.keyP, control: true)` | `PayIntent` | set `includeRepeats: false` so a held key cannot pay twice | | Escape | `SingleActivator(LogicalKeyboardKey.escape)` | `VoidSaleIntent` | `WidgetsApp.defaultShortcuts` already maps Escape to `DismissIntent`; the nearer `Shortcuts` wins | `PayIntent`'s action should report `isEnabled` false while the cart is empty. A subclass of `Action<PayIntent>` can override `isEnabled` and call `notifyActionListeners()` when the cart changes, so widgets listening to the action rebuild. ## Why shortcuts silently do nothing - **Nothing is focused inside `Shortcuts`.** On a screen with no text field, no widget may hold focus. Put `Focus(autofocus: true, child: ...)` under the `Shortcuts` so the screen itself takes focus. - **`Actions` is in the wrong place.** Lookup runs from the *focused* widget up, not from the `Shortcuts` down. An `Actions` placed as a sibling, or below a different branch, is never found. - **The action is disabled.** `isEnabled` false means the key is ignored at that level — correct, but confusing while debugging. - **A nearer handler took the key.** Events bubble from the focused widget, so a descendant `Focus.onKeyEvent` or `Shortcuts` that returns `handled` stops the event before yours. - **Wrong modifier on macOS.** `control: true` is the Control key; Mac users expect Command, which is `meta: true`. Choose per `defaultTargetPlatform`. - **Replacing the app defaults.** Passing `MaterialApp(shortcuts: {...})` *replaces* `WidgetsApp.defaultShortcuts` (Tab, Enter, Escape); spread `...WidgetsApp.defaultShortcuts` into the map, or add a `Shortcuts` widget lower in the tree instead. ## CallbackShortcuts and legacy names `CallbackShortcuts(bindings: {SingleActivator(...): () => ...})` maps activators straight to callbacks. It is simpler for a handful of screen-local keys but loses the per-focus overriding, the enabled state and the reuse from buttons. `LogicalKeySet` still exists, but `SingleActivator` states the trigger key and each modifier explicitly; the static `ShortcutActivator.isActivatedBy` helper is deprecated in favour of `activator.accepts(event, HardwareKeyboard.instance)`.

  • Why use Shortcuts plus Actions instead of CallbackShortcuts on the point-of-sale screen?
    `CallbackShortcuts` binds keys straight to closures, which is fine for a few screen-local keys. `Shortcuts` plus `Actions` lets a toolbar button call `Actions.invoke` with the same intent, lets a nested widget override what an intent does while it is focused, and gives each command an `isEnabled` state that both the key and the button respect.
  • In Flutter, what does passing shortcuts to MaterialApp do to the default Tab and Enter bindings?
    The `shortcuts` map replaces `WidgetsApp.defaultShortcuts`, which is where Tab, Shift+Tab, Enter, Space and Escape are bound, so focus traversal and button activation stop working unless you spread `...WidgetsApp.defaultShortcuts` into your map. It does not replace `DefaultTextEditingShortcuts`. Adding a `Shortcuts` widget lower in the tree avoids the problem.

Shortcuts is a switchboard operator who turns a caller's key press into a named request slip, and the slip is handed to the nearest department that handles it, starting from the desk where the caller stands. If nobody is standing at any desk inside the operator's building (no focus inside Shortcuts), the operator never hears the call at all.

saying these in an interview costs you the question

  • Shortcuts listens to every key press in the app regardless of focus
  • The Actions widget must sit directly under its Shortcuts widget
  • A matched shortcut runs its Action even when isEnabled returns false
  • control: true gives Mac users the Command key
  • Passing shortcuts to MaterialApp adds to the default Tab and Enter bindings