> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/ui-media/custom-trading-keyboard.md).

# Building a Custom Trading Keyboard in Flutter

Build a custom numeric keyboard in Flutter with a controller, active binding, formatters, focus, and accessibility instead of only drawing a key grid

## Outcome

When I first built a custom numeric keyboard, I thought the main task was laying out the 1–9, 0, and backspace keys. In real application source, the key grid turned out to be the smallest part.

The hard part is knowing which field has focus, editing the correct selection, running formatters, sending suggestions through the same callback, moving focus with Next, and closing the keyboard before confirmation. That is when I started treating the keyboard as an input engine rather than a decorative widget.

The architecture used in this article looks like this:

```
User focuses a field
        │
        ▼
TradingKeyboardField.activate(binding)
        │
        ▼
TradingKeyboardController
active controller · focus · delegate · callback
        │
        ├── digit / backspace / clear
        │         └── TextEditingValue → formatter chain → commit
        │
        ├── suggestion
        │         └── same commit path + source=suggestion
        │
        └── next / done
                  └── focus traversal / unfocus + action event
```

Each layer has one clear responsibility:

| Layer      | Responsibility                                                                    |
| ---------- | --------------------------------------------------------------------------------- |
| Scope      | Owns the controller, keyboard shell, visibility, and theme.                       |
| Controller | Routes an action to the currently active binding.                                 |
| Binding    | Holds the controller, focus, delegate, and callback for one field.                |
| Delegate   | Describes the layout, formatters, and suggestions for an input type.              |
| Feature    | Receives the committed value and its input source, then runs business validation. |

The most important result is not that the keyboard has a `00` key. It is that every input path converges on one commit path:

* Numeric keys edit the `TextEditingValue` at the caret or selected range.
* Backspace deletes a selection before deleting the character immediately before the caret.
* The active field's formatters run before a value is committed.
* Suggestions use the same controller instead of writing directly to a store.
* Next and Done still emit action events even when the text does not change.
* Theme, semantics, safe area, and tests belong to the keyboard shell rather than to each form.

I choose a custom keyboard only when the feature genuinely needs the capability:

| Need                                          | System numeric keyboard     | Custom trading keyboard              |
| --------------------------------------------- | --------------------------- | ------------------------------------ |
| Numeric/decimal input only                    | Preferred                   | Usually unnecessary                  |
| Fixed `00`, `000`, Clear, Next, and Done keys | Depends on the OS           | Good fit                             |
| Suggestions that change with the active field | Requires UI outside the IME | Can be attached to the delegate      |
| Identical layout on Android and iOS           | Not guaranteed              | Fully controllable                   |
| Native autofill and accessibility             | Advantageous                | Must be implemented and QA'd         |
| Hardware keyboard, paste, and selection menu  | Better supported            | Needs a fallback and dedicated tests |
| Maintenance cost                              | Lower                       | Significantly higher                 |

If a form only needs `TextInputType.numberWithOptions(decimal: true)`, a few `TextInputFormatter`s, and a submit button, the system keyboard is the simpler choice.

## Problem

### Assigning `controller.text` breaks the editing contract

The first implementation often looks like this:

```dart
void onNumberPressed(String digit) {
  controller.text += digit;
}
```

This code is only correct when the caret is always at the end, there is no selection, and there are no formatters. In a real form, a user can select two digits in the middle and then press another key. Assigning `controller.text` also resets selection to a new state that the caller must repair manually.

The anti-pattern produces a UI that looks updated while the surrounding contracts may be broken:

```
key tap ──► controller.text += digit ──► UI looks updated
                    │
                    ├── selection lost
                    ├── formatter skipped
                    ├── onChanged is not called automatically
                    ├── wrong field may still be active
                    └── Next/Done have no value change
```

Flutter represents an edit as a `TextEditingValue`: text, selection, and the composing range travel together. A custom keyboard must work at the same abstraction level.

### Suggestions easily create two sources of truth

Price or quantity suggestions often update both the field and application state:

```dart
controller.text = suggestion;
store.update(suggestion);
```

These two lines create two commit paths. One path may run formatters, validators, and analytics while the other skips them. When a callback becomes asynchronous or a field is reused in another form, the UI and draft can easily drift apart.

A suggestion should send an action to the keyboard controller. The controller edits the active field and calls that binding's callback. The feature has only one place where it receives a value.

### `onChanged` cannot observe Next and Done

`onChanged` is meaningful only when the text changes. Next, Done, or a key rejected by a formatter are still real interactions, but they do not create a new value.

I separate two contracts:

* `onCommitted(value, source)` reports that a new value was accepted.
* `onAction(action)` reports that the user triggered a key or action.

This separation also prevents one backspace event from being incorrectly attributed to the next digit entry in telemetry.

### A custom keyboard is not a system IME

`TextInputType.none` prevents the OS from showing its virtual keyboard. It does not create a bottom inset for a keyboard drawn by the app.

If the shell keeps using `MediaQuery.viewInsets.bottom` to infer visibility, the custom keyboard may cover a field, remain open after an outside tap, or stay above a confirmation dialog.

The custom keyboard must own its state:

```
hidden → field focused → visible(activeBinding)
visible → another field focused → visible(newBinding)
visible → done/outside tap/route change → hidden
```

### A formatter does not replace validation

A formatter protects syntax while the user types: decimal places, a minus sign, or the allowed characters. It is not authoritative for minimum or maximum values, session state, trading permissions, or realtime data that has just changed.

I separate three layers:

| Layer               | Question                                       | Example                                              |
| ------------------- | ---------------------------------------------- | ---------------------------------------------------- |
| Formatter           | Is the text being entered syntactically valid? | One decimal point and at most two fractional digits. |
| Form validator      | Is the current value valid for the UI state?   | Required, minimum/maximum, and step.                 |
| Submit/server guard | Is the action still allowed?                   | Session, capacity, permissions, and the latest data. |

A polished keyboard does not make a financial action safer if submission skips the last two layers.

### Accessibility is a real cost of a custom control

The system keyboard already has semantics, feedback, locale-aware layouts, and native behavior. When an app replaces it, the app takes responsibility for all of those concerns.

Every key and suggestion needs a readable label, an enabled/disabled/selected state, a large enough target, correct contrast, and predictable focus order. Haptics are supplementary feedback; they do not replace visual or semantic state.

## Solution

### Define neutral actions and input sources

I start with contracts that know nothing about price, quantity, or a business store:

```dart
enum TradingInputSource {
  key,
  suggestion,
  stepper,
  hardware,
}

sealed class TradingKeyboardAction {
  const TradingKeyboardAction();
}

final class InsertText extends TradingKeyboardAction {
  const InsertText(this.text);

  final String text;
}

final class Backspace extends TradingKeyboardAction {
  const Backspace();
}

final class ClearValue extends TradingKeyboardAction {
  const ClearValue();
}

final class NextField extends TradingKeyboardAction {
  const NextField();
}

final class FinishEditing extends TradingKeyboardAction {
  const FinishEditing();
}
```

`TradingInputSource` travels with the value instead of being inferred from a global marker. This remains correct if suggestion commits become nested or the implementation later becomes asynchronous.

### Describe the layout with key specs

A key spec keeps the action separate from the visible text and semantic label:

```dart
final class TradingKeySpec {
  const TradingKeySpec({
    required this.label,
    required this.semanticLabel,
    required this.action,
  });

  final String label;
  final String semanticLabel;
  final TradingKeyboardAction action;
}

final class TradingKeyboardLayout {
  const TradingKeyboardLayout(this.rows);

  final List<List<TradingKeySpec>> rows;
}
```

The label can be a backspace icon while `semanticLabel` remains localized text. Do not use an icon name or an ambiguous character as the accessible name.

### Let the delegate describe field variants

The delegate decides the layout, formatters, and suggestions; the controller does not contain price or quantity rules:

```dart
abstract interface class TradingKeyboardDelegate {
  TradingKeyboardLayout get layout;
  List<TextInputFormatter> get formatters;

  Widget? buildSuggestions(
    BuildContext context,
    TradingKeyboardController controller,
  );
}
```

Here is a decimal delegate:

```dart
final class DecimalKeyboardDelegate implements TradingKeyboardDelegate {
  const DecimalKeyboardDelegate({this.suggestions = const []});

  final List<String> suggestions;

  @override
  TradingKeyboardLayout get layout => TradingKeyboardLayout([
        [
          const TradingKeySpec(
            label: '1',
            semanticLabel: 'One',
            action: InsertText('1'),
          ),
          const TradingKeySpec(
            label: '2',
            semanticLabel: 'Two',
            action: InsertText('2'),
          ),
          const TradingKeySpec(
            label: '3',
            semanticLabel: 'Three',
            action: InsertText('3'),
          ),
          const TradingKeySpec(
            label: '⌫',
            semanticLabel: 'Delete one character',
            action: Backspace(),
          ),
        ],
        [
          const TradingKeySpec(
            label: '4',
            semanticLabel: 'Four',
            action: InsertText('4'),
          ),
          const TradingKeySpec(
            label: '5',
            semanticLabel: 'Five',
            action: InsertText('5'),
          ),
          const TradingKeySpec(
            label: '6',
            semanticLabel: 'Six',
            action: InsertText('6'),
          ),
          const TradingKeySpec(
            label: 'Clear',
            semanticLabel: 'Clear the value',
            action: ClearValue(),
          ),
        ],
        [
          const TradingKeySpec(
            label: '7',
            semanticLabel: 'Seven',
            action: InsertText('7'),
          ),
          const TradingKeySpec(
            label: '8',
            semanticLabel: 'Eight',
            action: InsertText('8'),
          ),
          const TradingKeySpec(
            label: '9',
            semanticLabel: 'Nine',
            action: InsertText('9'),
          ),
          const TradingKeySpec(
            label: 'Next',
            semanticLabel: 'Move to the next field',
            action: NextField(),
          ),
        ],
        [
          const TradingKeySpec(
            label: '.',
            semanticLabel: 'Decimal separator',
            action: InsertText('.'),
          ),
          const TradingKeySpec(
            label: '0',
            semanticLabel: 'Zero',
            action: InsertText('0'),
          ),
          const TradingKeySpec(
            label: '00',
            semanticLabel: 'Two zeroes',
            action: InsertText('00'),
          ),
          const TradingKeySpec(
            label: 'Done',
            semanticLabel: 'Finish editing',
            action: FinishEditing(),
          ),
        ],
      ]);

  @override
  List<TextInputFormatter> get formatters => [
        const DecimalInputFormatter(),
      ];

  @override
  Widget? buildSuggestions(
    BuildContext context,
    TradingKeyboardController controller,
  ) {
    if (suggestions.isEmpty) return null;

    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: [
        for (final value in suggestions)
          ActionChip(
            label: Text(value),
            onPressed: () => controller.replaceAll(
              value,
              source: TradingInputSource.suggestion,
            ),
          ),
      ],
    );
  }
}
```

The sample hard-codes English labels for readability. In a real app, visible labels and semantics must come from localization.

### Use formatters only to protect decimal syntax

The formatter allows intermediate states such as an empty string or a decimal point without a fractional part yet:

```dart
final class DecimalInputFormatter extends TextInputFormatter {
  const DecimalInputFormatter();

  static final _pattern = RegExp(r'^\d*(?:\.\d{0,2})?$');

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    return _pattern.hasMatch(newValue.text) ? newValue : oldValue;
  }
}
```

This is not a minimum/maximum validator. If a field needs signed decimals, use a separate formatter and test the minus sign, caret, and backspace instead of inserting `-` like a normal character.

Flutter recommends changing text only after the composing range has collapsed for input coming from an IME. The keyboard in this article creates only numeric ASCII actions and uses `TextInputType.none`; the hardware and paste paths are still handled by the `TextField` with the same formatters.

### Keep the active field's editing contract in a binding

The binding contains everything the controller needs to commit to the correct field:

```dart
typedef TradingValueCommitted = void Function(
  String value,
  TradingInputSource source,
);

final class TradingKeyboardBinding {
  const TradingKeyboardBinding({
    required this.textController,
    required this.focusNode,
    required this.delegate,
    required this.onCommitted,
    required this.onNext,
    required this.onDone,
    this.onAction,
  });

  final TextEditingController textController;
  final FocusNode focusNode;
  final TradingKeyboardDelegate delegate;
  final TradingValueCommitted onCommitted;
  final VoidCallback onNext;
  final VoidCallback onDone;
  final ValueChanged<TradingKeyboardAction>? onAction;
}
```

The controller holds at most one active binding. Focusing a new field replaces the old binding. When a field loses focus or is disposed, it can deactivate only if the active binding is still that exact object.

### Edit selections and run the formatter chain in the controller

This is the core implementation:

```dart
final class TradingKeyboardController extends ChangeNotifier {
  TradingKeyboardBinding? _activeBinding;

  TradingKeyboardBinding? get activeBinding => _activeBinding;
  bool get isVisible => _activeBinding != null;

  void activate(TradingKeyboardBinding binding) {
    if (identical(_activeBinding, binding)) return;
    _activeBinding = binding;
    notifyListeners();
  }

  void deactivate(TradingKeyboardBinding binding) {
    if (!identical(_activeBinding, binding)) return;
    _activeBinding = null;
    notifyListeners();
  }

  void perform(
    TradingKeyboardAction action, {
    TradingInputSource source = TradingInputSource.key,
  }) {
    final binding = _activeBinding;
    if (binding == null) return;

    binding.onAction?.call(action);

    switch (action) {
      case InsertText(:final text):
        _insert(binding, text, source);
        return;
      case Backspace():
        _backspace(binding, source);
        return;
      case ClearValue():
        _commit(
          binding,
          const TextEditingValue(
            selection: TextSelection.collapsed(offset: 0),
          ),
          source,
        );
        return;
      case NextField():
        binding.onNext();
        return;
      case FinishEditing():
        binding.onDone();
        return;
    }
  }

  void replaceAll(
    String value, {
    required TradingInputSource source,
  }) {
    final binding = _activeBinding;
    if (binding == null) return;

    _commit(
      binding,
      TextEditingValue(
        text: value,
        selection: TextSelection.collapsed(offset: value.length),
      ),
      source,
    );
  }

  void _insert(
    TradingKeyboardBinding binding,
    String inserted,
    TradingInputSource source,
  ) {
    final oldValue = binding.textController.value;
    final selection = _normalizedSelection(oldValue);
    final nextText = oldValue.text.replaceRange(
      selection.start,
      selection.end,
      inserted,
    );
    final nextOffset = selection.start + inserted.length;

    _commit(
      binding,
      TextEditingValue(
        text: nextText,
        selection: TextSelection.collapsed(offset: nextOffset),
      ),
      source,
    );
  }

  void _backspace(
    TradingKeyboardBinding binding,
    TradingInputSource source,
  ) {
    final oldValue = binding.textController.value;
    final selection = _normalizedSelection(oldValue);

    if (!selection.isCollapsed) {
      _replaceRange(binding, selection.start, selection.end, source);
      return;
    }

    if (selection.start == 0) return;
    _replaceRange(binding, selection.start - 1, selection.start, source);
  }

  void _replaceRange(
    TradingKeyboardBinding binding,
    int start,
    int end,
    TradingInputSource source,
  ) {
    final oldValue = binding.textController.value;
    final nextText = oldValue.text.replaceRange(start, end, '');

    _commit(
      binding,
      TextEditingValue(
        text: nextText,
        selection: TextSelection.collapsed(offset: start),
      ),
      source,
    );
  }

  TextSelection _normalizedSelection(TextEditingValue value) {
    final selection = value.selection;
    if (!selection.isValid) {
      return TextSelection.collapsed(offset: value.text.length);
    }

    return TextSelection(
      baseOffset: selection.start.clamp(0, value.text.length).toInt(),
      extentOffset: selection.end.clamp(0, value.text.length).toInt(),
    );
  }

  void _commit(
    TradingKeyboardBinding binding,
    TextEditingValue proposed,
    TradingInputSource source,
  ) {
    final oldValue = binding.textController.value;
    var nextValue = proposed;

    for (final formatter in binding.delegate.formatters) {
      nextValue = formatter.formatEditUpdate(oldValue, nextValue);
    }

    nextValue = nextValue.copyWith(composing: TextRange.empty);
    if (nextValue == oldValue) return;

    binding.textController.value = nextValue;
    binding.onCommitted(nextValue.text, source);
  }
}
```

Backspace in this example deletes one code unit because the key set is limited to ASCII digits, a decimal point, and a minus sign. If the abstraction is expanded to text or emoji, delete by grapheme cluster with the `characters` package.

`binding.onAction` runs before the formatter. The app therefore still knows that a key was pressed even when the formatter returns `oldValue`; `onCommitted` runs only when the value actually changes.

### Activate the binding from a field bridge

The field remains a normal `TextField`, but it uses `TextInputType.none` to suppress the OS virtual keyboard:

```dart
final class TradingKeyboardField extends StatefulWidget {
  const TradingKeyboardField({
    super.key,
    required this.controller,
    required this.focusNode,
    required this.delegate,
    required this.label,
    required this.onCommitted,
    this.onAction,
    this.validator,
  });

  final TextEditingController controller;
  final FocusNode focusNode;
  final TradingKeyboardDelegate delegate;
  final String label;
  final TradingValueCommitted onCommitted;
  final ValueChanged<TradingKeyboardAction>? onAction;
  final FormFieldValidator<String>? validator;

  @override
  State<TradingKeyboardField> createState() =>
      _TradingKeyboardFieldState();
}

final class _TradingKeyboardFieldState
    extends State<TradingKeyboardField> {
  TradingKeyboardController? _keyboard;
  TradingKeyboardBinding? _binding;

  @override
  void initState() {
    super.initState();
    widget.focusNode.addListener(_handleFocus);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    final nextKeyboard = TradingKeyboardScope.of(context);
    if (identical(_keyboard, nextKeyboard)) return;

    final binding = _binding;
    if (binding != null) {
      _keyboard?.deactivate(binding);
      _binding = null;
    }

    _keyboard = nextKeyboard;
    if (widget.focusNode.hasFocus) _activate();
  }

  @override
  void didUpdateWidget(TradingKeyboardField oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.focusNode != widget.focusNode) {
      oldWidget.focusNode.removeListener(_handleFocus);
      _deactivate();
      widget.focusNode.addListener(_handleFocus);
    }

    if (widget.focusNode.hasFocus) {
      _deactivate();
      _activate();
    }
  }

  void _handleFocus() {
    if (!mounted) return;
    widget.focusNode.hasFocus ? _activate() : _deactivate();
  }

  void _activate() {
    if (_binding != null) return;
    final keyboard = _keyboard;
    if (keyboard == null) return;

    final binding = TradingKeyboardBinding(
      textController: widget.controller,
      focusNode: widget.focusNode,
      delegate: widget.delegate,
      onCommitted: widget.onCommitted,
      onAction: widget.onAction,
      onNext: () => FocusScope.of(context).nextFocus(),
      onDone: () => FocusManager.instance.primaryFocus?.unfocus(),
    );

    _binding = binding;
    keyboard.activate(binding);
  }

  void _deactivate() {
    final binding = _binding;
    if (binding == null) return;

    _keyboard?.deactivate(binding);
    _binding = null;
  }

  @override
  void dispose() {
    final binding = _binding;
    if (binding != null) {
      _keyboard?.deactivate(binding);
    }
    widget.focusNode.removeListener(_handleFocus);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: widget.controller,
      focusNode: widget.focusNode,
      keyboardType: TextInputType.none,
      inputFormatters: widget.delegate.formatters,
      decoration: InputDecoration(labelText: widget.label),
      validator: widget.validator,
      onTap: _activate,
      onChanged: (value) => widget.onCommitted(
        value,
        TradingInputSource.hardware,
      ),
    );
  }
}
```

The state caches the scope controller in `didChangeDependencies()`, so `dispose()` uses an existing reference instead of looking up an `InheritedWidget` while the element is being removed from the tree.

The field's controller and `FocusNode` must be owned and disposed by the form's `State`; do not create them in `build()`.

### Let the scope own the controller and reserved space

An `InheritedNotifier` provider lets the fields and keyboard panel use the same controller:

```dart
final class TradingKeyboardScope
    extends InheritedNotifier<TradingKeyboardController> {
  const TradingKeyboardScope({
    super.key,
    required TradingKeyboardController controller,
    required super.child,
  }) : super(notifier: controller);

  static TradingKeyboardController of(BuildContext context) {
    final scope = context
        .dependOnInheritedWidgetOfExactType<TradingKeyboardScope>();
    assert(scope != null, 'TradingKeyboardScope is missing');
    return scope!.notifier!;
  }
}
```

The shell creates the controller once and places the keyboard in the layout instead of deriving it from the system inset:

```dart
final class TradingKeyboardShell extends StatefulWidget {
  const TradingKeyboardShell({
    super.key,
    required this.child,
  });

  final Widget child;

  @override
  State<TradingKeyboardShell> createState() =>
      _TradingKeyboardShellState();
}

final class _TradingKeyboardShellState
    extends State<TradingKeyboardShell> {
  late final TradingKeyboardController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TradingKeyboardController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TradingKeyboardScope(
      controller: _controller,
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, _) {
          return Column(
            children: [
              Expanded(child: widget.child),
              if (_controller.isVisible)
                SafeArea(
                  top: false,
                  child: TradingKeyboardPanel(
                    controller: _controller,
                  ),
                ),
            ],
          );
        },
      ),
    );
  }
}
```

Placing the keyboard in a `Column` makes the content reserve space automatically. If the product requires an overlay, the shell must add explicit bottom padding and call `Scrollable.ensureVisible` for the active field.

Do not copy a fixed height from another project. Test the keyboard in landscape, split screen, safe areas, and with large text scaling. General constraint principles are covered in:

{% content-ref url="/pages/CTKWji67N7nUi8hjGxHc" %}
[Responsive](/flutter/my-flutter/ui-media/responsive.md)
{% endcontent-ref %}

### Render keys with semantics and optional haptics

The panel reads the active binding's layout:

```dart
final class TradingKeyboardPanel extends StatelessWidget {
  const TradingKeyboardPanel({
    super.key,
    required this.controller,
    this.enableHaptics = true,
  });

  final TradingKeyboardController controller;
  final bool enableHaptics;

  @override
  Widget build(BuildContext context) {
    final binding = controller.activeBinding;
    if (binding == null) return const SizedBox.shrink();

    final suggestions = binding.delegate.buildSuggestions(
      context,
      controller,
    );

    return Material(
      color: Theme.of(context).colorScheme.surfaceContainer,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (suggestions != null) ...[
              suggestions,
              const SizedBox(height: 8),
            ],
            for (final row in binding.delegate.layout.rows)
              Row(
                children: [
                  for (final key in row)
                    Expanded(
                      child: Padding(
                        padding: const EdgeInsets.all(4),
                        child: _TradingKey(
                          spec: key,
                          onPressed: () {
                            if (enableHaptics) {
                              HapticFeedback.lightImpact();
                            }
                            controller.perform(key.action);
                          },
                        ),
                      ),
                    ),
                ],
              ),
          ],
        ),
      ),
    );
  }
}

final class _TradingKey extends StatelessWidget {
  const _TradingKey({
    required this.spec,
    required this.onPressed,
  });

  final TradingKeySpec spec;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return Semantics(
      button: true,
      label: spec.semanticLabel,
      excludeSemantics: true,
      child: ConstrainedBox(
        constraints: const BoxConstraints(minHeight: 48),
        child: Material(
          color: Theme.of(context).colorScheme.surface,
          borderRadius: BorderRadius.circular(8),
          child: InkWell(
            borderRadius: BorderRadius.circular(8),
            onTap: onPressed,
            child: Center(child: Text(spec.label)),
          ),
        ),
      ),
    );
  }
}
```

A production component also needs enabled, disabled, and selected states. Do not trigger haptics for a disabled key, and do not make haptics the only feedback. `HapticFeedback` invokes the platform's default behavior, so a widget test proves only that the app requested feedback, not that the actuator physically vibrated.

Flutter resolves the theme first, and the keyboard then reads `ColorScheme` or a specific token. Building light and dark themes is covered separately in:

{% content-ref url="/pages/DDwBamhA1zlGZaY4h0sH" %}
[Dark Mode](/flutter/my-flutter/ui-media/dark-mode.md)
{% endcontent-ref %}

### Deduplicate suggestions after rounding

Small percentages can produce zero or duplicate values. I normalize them before rendering:

```dart
List<int> buildPercentageSuggestions(int maximum) {
  final used = <int>{};

  return const [25, 50, 75, 100]
      .map((percentage) => maximum * percentage ~/ 100)
      .where((value) => value > 0 && used.add(value))
      .toList(growable: false);
}
```

An invalid suggestion must be hidden or clearly disabled. When tapped, it calls `replaceAll(..., source: suggestion)`; it does not write business state directly.

### Run validators and submit guards after formatters

This example validator intentionally uses placeholder rules:

```dart
String? validateDecimalRange(
  String? text, {
  required double min,
  required double max,
}) {
  final value = double.tryParse(text ?? '');
  if (value == null) return 'Enter a valid number';
  if (value < min || value > max) return 'Value is outside the allowed range';
  return null;
}
```

Submission must still read the latest domain state and validate again. The server is the final authoritative source. A custom keyboard must not be used to bypass confirmation or provider-call guards.

### Use the focus tree for Next, Done, and dismiss

A `FocusNode` is a persistent object. The form owns and disposes it:

```dart
final class ExampleFormState extends State<ExampleForm> {
  late final priceController = TextEditingController();
  late final quantityController = TextEditingController();
  late final priceFocus = FocusNode();
  late final quantityFocus = FocusNode();

  @override
  void dispose() {
    priceController.dispose();
    quantityController.dispose();
    priceFocus.dispose();
    quantityFocus.dispose();
    super.dispose();
  }
}
```

Next calls `FocusScope.of(context).nextFocus()`. Done calls `unfocus()`. Before opening a confirmation dialog or changing routes, the form proactively hides the keyboard:

```dart
void openConfirmation() {
  FocusManager.instance.primaryFocus?.unfocus();
  // Open the confirmation only after the field has deactivated.
}
```

When switching between two forms on the same screen, request focus for the first field after the frame so the binding of a subtree about to unmount is not activated again.

An outside tap also invokes Done or hide through the keyboard controller. Do not infer custom keyboard visibility from `MediaQuery.viewInsets.bottom`, because that inset belongs to the system IME.

### Localize labels, semantics, and the decimal separator

Clear, Next, Done, and their accessible labels must come from localization. Translation organization is covered in:

{% content-ref url="/pages/4HPxogOT3HRVQ8Hwj6mG" %}
[Multi-Language](/flutter/my-flutter/ui-media/multi-language.md)
{% endcontent-ref %}

The decimal separator needs two contracts:

* A locale-specific UI label, such as `,` or `.`.
* A canonical value accepted by the domain parser, usually using `.`.

Do not send a locale-formatted string directly to a service when its parser uses a different contract.

### Treat accessibility as a release gate

Checklist before release:

* TalkBack and VoiceOver can read every key's label.
* Disabled and selected states are exposed through semantics.
* After a commit, the screen reader can read the field's new value.
* Primary targets are at least 48×48 logical pixels.
* Contrast does not use increase/decrease colors as the only signal.
* Focus traversal among fields, suggestions, and the keyboard is predictable.
* The UI remains usable with large text scaling.
* The app does not change context while the user is typing without a confirming action.

Hardware keyboards, paste, and the selection menu also need dedicated tests. This article handles custom numeric actions only; it does not assume that every input type travels through the panel.

### Keep telemetry from becoming a keylogger

If adoption needs to be measured, I log only coarse categories:

* Keyboard variant.
* Input source: `key`, `suggestion`, `stepper`, or `hardware`.
* Action category such as Next, Done, or Backspace.
* Aggregated duration or count.

I do not log raw values, full key sequences, accounts, symbols, or order payloads. Analytics must not become a keylogger.

### Test each layer

| Layer              | What to verify                                                                     |
| ------------------ | ---------------------------------------------------------------------------------- |
| Controller unit    | Insert at the caret, replace selection, backspace, clear, and formatter rejection. |
| Delegate unit      | Integer/decimal/signed layouts, suggestion deduplication, and disabled values.     |
| Scope widget       | Controller reuse, theme updates, visibility, and disposal.                         |
| Field widget       | Focus activation/deactivation, `TextInputType.none`, and source callbacks.         |
| Form widget        | Next/Done, validation errors, and exactly one suggestion commit.                   |
| Accessibility      | Semantic labels/states, traversal, and text scaling.                               |
| Device integration | OS keyboard stays hidden, outside tap, safe area, dialog, route, and haptics.      |
| Visual             | Light/dark themes, suggestions, small screens, and landscape.                      |

The most important unit test protects selection-aware editing:

```dart
test('replaces the selected range instead of appending', () {
  final textController = TextEditingController(text: '1234')
    ..selection = const TextSelection(
      baseOffset: 1,
      extentOffset: 3,
    );
  final keyboard = TradingKeyboardController();

  final binding = TradingKeyboardBinding(
    textController: textController,
    focusNode: FocusNode(),
    delegate: const DecimalKeyboardDelegate(),
    onCommitted: (_, _) {},
    onNext: () {},
    onDone: () {},
  );

  keyboard
    ..activate(binding)
    ..perform(const InsertText('9'));

  expect(textController.text, '194');
  expect(textController.selection.baseOffset, 2);

  binding.focusNode.dispose();
  textController.dispose();
  keyboard.dispose();
});
```

Golden tests fit the Flutter-native shell once the viewport, fonts, theme, and animation are fixed. They do not replace focus, screen-reader, or device tests. This boundary is explained in more detail in:

{% content-ref url="/pages/OXzEEJ7gphdUcYxkfusW" %}
[Widget Tests and Golden Regression](/flutter/my-flutter/quality-delivery/widget-test-golden-regression.md)
{% endcontent-ref %}

In the source I reviewed, tests covered the intent of scope and theme behavior, percentage suggestions, price suggestions, inline validation, and the input-source marker. The focused suite did not reach assertions in the research environment because the cache was missing a private package and several other dependencies. This is an environment blocker, not evidence that the tests passed or failed.

### Troubleshooting common failures

| Symptom                                      | First diagnosis                           | Fix                                                              |
| -------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------- |
| Tapping a key edits another field            | The active binding was not deactivated    | Activate from focus and use an identity guard when deactivating. |
| Caret always jumps to the end                | Code assigns `controller.text`            | Edit the `TextEditingValue` and selection.                       |
| Suggestion updates the UI but state is stale | There are two commit paths                | Fill through the keyboard controller.                            |
| Next/Done are not reported                   | Only `onChanged` is observed              | Add a separate action callback.                                  |
| Keyboard covers confirmation                 | Focus remains active before the overlay   | Hide or unfocus before a dialog or navigation.                   |
| Outside tap does not close it                | Visibility depends on system `viewInsets` | Use custom controller visibility.                                |
| Formatter accepts a business-invalid value   | Formatter and validator are conflated     | Keep form and submit/server guards.                              |
| Signed input moves the caret incorrectly     | Minus is inserted like a normal character | Use a signed formatter and test caret behavior.                  |
| Suggestions are duplicated or zero           | Percentages round down                    | Filter positive values and deduplicate.                          |
| Keyboard stays light in dark mode            | The widget has its own fallback theme     | Resolve tokens from the app before rendering.                    |
| Screen reader says only “button”             | Semantic label is missing                 | Expose the label, state, and action clearly.                     |
| Landscape overflows                          | Height or width is fixed                  | Use adaptive constraints, scrolling, and safe areas.             |
| Widget test passes but there is no vibration | Haptics are platform behavior             | Mock the request and QA on a device.                             |

### Evidence and official documentation

The source was verified with Flutter `3.41.2` and Dart `3.11.0`. The app uses a private Git dependency for its keyboard. Because that package has no public installation contract and its source was not present in the research cache, this article implements an independent abstraction with public Flutter APIs.

Source evidence confirms:

* An app-level scope creates and disposes the controller.
* Numeric fields use `TextInputType.none` with a controller and focus node.
* Price, quantity, and signed inputs have separate delegates and formatters.
* Suggestions go through the keyboard controller.
* An action callback exists separately from the value callback.
* The form unfocuses before confirmation and refocuses after a form switch.

Not verified on a device during research:

* Outside tap on every form.
* Next/Done traversal on Android and iOS.
* Safe area and landscape behavior with the source's fixed keyboard height.
* TalkBack, VoiceOver, hardware keyboard, and paste.
* Listener and overlay cleanup after repeated route changes.

Official Flutter documentation:

* [`TextInputType.none`](https://api.flutter.dev/flutter/services/TextInputType/none-constant.html) — prevents the OS from showing a virtual keyboard.
* [`TextInputFormatter`](https://api.flutter.dev/flutter/services/TextInputFormatter-class.html) — as-you-type formatting and `formatEditUpdate`.
* [`FocusNode`](https://api.flutter.dev/flutter/widgets/FocusNode-class.html) — the focus tree and persistent-node lifecycle.
* [`HapticFeedback`](https://api.flutter.dev/flutter/services/HapticFeedback-class.html) — feedback using platform behavior.
* [Flutter Accessibility](https://docs.flutter.dev/ui/accessibility) — a checklist for screen readers, contrast, targets, errors, and text scaling.

## Conclusion

A production custom trading keyboard is not just a `GridView` of digits. It is an input engine with an active binding, selection-aware editing, a formatter chain, a focus lifecycle, and an accessible shell.

I use the system keyboard when a form needs only ordinary numeric input. I accept the cost of a custom keyboard only when the feature needs a fixed layout, shortcut keys, field-specific suggestions, and deliberate traversal.

The architecture stays reliable when every key, stepper, and suggestion uses one commit path; formatters protect only syntax; and the form and server still enforce business rules. Next, Done, dismiss, theme, localization, haptics, and accessibility are separate contracts that need their own tests.

If a screenshot shows a polished keyboard but tests do not cover selection, focus, screen readers, and device lifecycle, the implementation is not complete.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
