Skip to content

Add autovalidateMode support (Fix #232)#233

Open
mem-5514-tahara wants to merge 1 commit into
Tkko:masterfrom
mem-5514-tahara:feat/expose-autovalidate-mode
Open

Add autovalidateMode support (Fix #232)#233
mem-5514-tahara wants to merge 1 commit into
Tkko:masterfrom
mem-5514-tahara:feat/expose-autovalidate-mode

Conversation

@mem-5514-tahara
Copy link
Copy Markdown

Hello @Tkko, thank you for your continuous work on pinput.
I've submitted this PR to fix #232 and introduce the requested autovalidateMode support.

Fix #232

Problem

Pinput's internal _PinputFormField subclasses Flutter's FormField but hardcoded
autovalidateMode: AutovalidateMode.disabled, making it impossible for callers to
opt into keystroke-level validation.

// Before
class _PinputFormField extends FormField<String> {
  const _PinputFormField({ ... }) : super(
    autovalidateMode: AutovalidateMode.disabled,  // hardcoded — no way to override
  );
}

As a result, validator only fired when all digits were entered (_maybeValidateForm)
or on explicit form submission. Partial-input errors were silently swallowed.

Root cause & fix

Data flow with onUserInteraction

User keystroke
  → EditableText.onChanged → field.didChange(value)
  → FormField sets _hasInteractedByUser = true, schedules rebuild
  → On rebuild, FormField detects onUserInteraction + interacted → calls _validate()
  → _validate() → _validator() → user validator + setState(_validatorErrorText)
  → Error theme + error text rendered immediately

_PinputFormField — forward instead of hardcode

// After
class _PinputFormField extends FormField<String> {
  const _PinputFormField({
    ...
    super.autovalidateMode = AutovalidateMode.disabled,  // forwarded, default preserved
  });
}

setState-during-build hazard

When AutovalidateMode.onUserInteraction is active, FormField.build() calls
_validate()_validator() during the build phase. Since _validator() calls
setState, this triggers a setState-during-build violation.

Fix: skip the user validator call entirely during SchedulerPhase.persistentCallbacks
and defer the full validation to the next frame. A res != _validatorErrorText guard
prevents infinite postFrameCallback → setState → rebuild → postFrameCallback loops.

String? _validator([String? _]) {
  if (SchedulerBinding.instance.schedulerPhase ==
      SchedulerPhase.persistentCallbacks) {
    SchedulerBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        final res = widget.validator?.call(pin);
        if (res != _validatorErrorText) {
          setState(() => _validatorErrorText = res);
        }
      }
    });
    return _validatorErrorText;  // return current error; UI updates next frame
  }
  final res = widget.validator?.call(pin);
  setState(() => _validatorErrorText = res);
  return res;
}

Changed files

File Change
lib/src/pinput.dart Add autovalidateMode field + parameter to both constructors + debugFillProperties entry; add scheduler.dart import
lib/src/widgets/widgets.dart Replace hardcoded autovalidateMode with a forwarded named parameter
lib/src/pinput_state.dart Pass autovalidateMode to _PinputFormField; update _validator() with build-phase defer logic
test/pinput_test.dart Add 6 test cases for the new behavior

Usage

// Existing code — no change required
Pinput(
  validator: (value) => value?.length != 4 ? 'Invalid OTP' : null,
)

// New: real-time validation on every keystroke
Pinput(
  autovalidateMode: AutovalidateMode.onUserInteraction,
  showErrorWhenFocused: true,
  validator: (value) => value?.length != 4 ? 'Invalid OTP' : null,
)

Tests

Six new cases in group('autovalidateMode should work properly', ...):

Test What it guards
disabled — validator not called while typing Regression: existing callers unaffected
onUserInteraction — validator called per keystroke Core feature
Error text displayed on partial entry _validatorErrorText + showErrorState UI flow
Error text disappears when PIN becomes valid Round-trip: error clears on valid input
Omitting autovalidateMode preserves legacy behavior API contract: default is disabled
Pinput.builder also respects the parameter Both constructors covered
flutter test    # → All tests passed! (18 tests)
flutter analyze # → No issues found!

Backward compatibility

  • Default is AutovalidateMode.disabled — existing code requires no changes
  • PinputAutovalidateMode and pinputAutovalidateMode are untouched

Demo

Simulator.Screen.Recording.-.iPhone.16e.-.2026-06-05.at.17.55.39.mov
Verification code (before/after comparison page)
import 'package:flutter/material.dart';
import 'package:pinput/pinput.dart';

class AutovalidateComparisonPage extends StatelessWidget {
  const AutovalidateComparisonPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF5F7FA),
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        centerTitle: true,
        title: const Text(
          'autovalidateMode comparison',
          style: TextStyle(
            fontSize: 18,
            fontWeight: FontWeight.w600,
            color: Color(0xFF1E3C57),
          ),
        ),
      ),
      body: const SingleChildScrollView(
        padding: EdgeInsets.symmetric(horizontal: 24, vertical: 32),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _SectionCard(
              tag: 'Before',
              tagColor: Color(0xFF9E9E9E),
              title: 'Default (disabled)',
              description: 'Validation runs only when all digits are entered.\n'
                  'No error is shown while typing partial input.',
              child: _LegacyExample(),
            ),
            SizedBox(height: 28),
            _SectionCard(
              tag: 'After',
              tagColor: Color(0xFF17AB90),
              title: 'AutovalidateMode.onUserInteraction',
              description: 'Validation runs on every keystroke.\n'
                  'Errors appear in real time as the user types.',
              child: _NewExample(),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Before ──────────────────────────────────────────────────
class _LegacyExample extends StatefulWidget {
  const _LegacyExample();

  @override
  State<_LegacyExample> createState() => _LegacyExampleState();
}

class _LegacyExampleState extends State<_LegacyExample> {
  final _controller = TextEditingController();
  final _callCount = ValueNotifier<int>(0);

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Pinput(
          controller: _controller,
          length: 4,
          defaultPinTheme: _defaultTheme,
          focusedPinTheme: _focusedTheme,
          submittedPinTheme: _submittedTheme,
          errorPinTheme: _errorTheme,
          validator: (value) {
            _callCount.value++;
            return value != '1234' ? 'Correct PIN is 1234' : null;
          },
          errorTextStyle: const TextStyle(color: Colors.redAccent, fontSize: 13),
        ),
        const SizedBox(height: 16),
        ValueListenableBuilder<int>(
          valueListenable: _callCount,
          builder: (_, count, __) => _CallCounter(count: count),
        ),
        const SizedBox(height: 8),
        TextButton.icon(
          onPressed: () {
            _controller.clear();
            _callCount.value = 0;
          },
          icon: const Icon(Icons.refresh, size: 16),
          label: const Text('Reset'),
        ),
      ],
    );
  }
}

// ── After ───────────────────────────────────────────────────
class _NewExample extends StatefulWidget {
  const _NewExample();

  @override
  State<_NewExample> createState() => _NewExampleState();
}

class _NewExampleState extends State<_NewExample> {
  final _controller = TextEditingController();
  final _callCount = ValueNotifier<int>(0);

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Pinput(
          controller: _controller,
          length: 4,
          autovalidateMode: AutovalidateMode.onUserInteraction,
          showErrorWhenFocused: true,
          defaultPinTheme: _defaultTheme,
          focusedPinTheme: _focusedTheme,
          submittedPinTheme: _submittedTheme,
          errorPinTheme: _errorTheme,
          validator: (value) {
            _callCount.value++;
            return value != '1234' ? 'Correct PIN is 1234' : null;
          },
          errorTextStyle: const TextStyle(color: Colors.redAccent, fontSize: 13),
        ),
        const SizedBox(height: 16),
        ValueListenableBuilder<int>(
          valueListenable: _callCount,
          builder: (_, count, __) => _CallCounter(count: count),
        ),
        const SizedBox(height: 8),
        TextButton.icon(
          onPressed: () {
            _controller.clear();
            _callCount.value = 0;
          },
          icon: const Icon(Icons.refresh, size: 16),
          label: const Text('Reset'),
        ),
      ],
    );
  }
}

// ── Shared themes ────────────────────────────────────────────
const _borderColor = Color(0x6617AB90);
const _focusedBorderColor = Color(0xFF17AB90);

final _defaultTheme = PinTheme(
  width: 56,
  height: 56,
  textStyle: const TextStyle(fontSize: 20, color: Color(0xFF1E3C57)),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    border: Border.all(color: _borderColor),
  ),
);

final _focusedTheme = _defaultTheme.copyDecorationWith(
  border: Border.all(color: _focusedBorderColor, width: 2),
);

final _submittedTheme = _defaultTheme.copyDecorationWith(
  border: Border.all(color: _focusedBorderColor),
);

final _errorTheme = _defaultTheme.copyDecorationWith(
  border: Border.all(color: Colors.redAccent),
);

// ── Widgets ──────────────────────────────────────────────────
class _CallCounter extends StatelessWidget {
  final int count;

  const _CallCounter({required this.count});

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(Icons.functions, size: 14, color: Color(0xFF888888)),
        const SizedBox(width: 4),
        Text(
          'Validator calls: $count',
          style: const TextStyle(fontSize: 13, color: Color(0xFF888888)),
        ),
      ],
    );
  }
}

class _SectionCard extends StatelessWidget {
  final String tag;
  final Color tagColor;
  final String title;
  final String description;
  final Widget child;

  const _SectionCard({
    required this.tag,
    required this.tagColor,
    required this.title,
    required this.description,
    required this.child,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        boxShadow: const [
          BoxShadow(
            color: Color(0x0F000000),
            blurRadius: 12,
            offset: Offset(0, 4),
          ),
        ],
      ),
      padding: const EdgeInsets.all(24),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
                decoration: BoxDecoration(
                  color: tagColor.withValues(alpha: 0.12),
                  borderRadius: BorderRadius.circular(6),
                ),
                child: Text(
                  tag,
                  style: TextStyle(
                    fontSize: 11,
                    fontWeight: FontWeight.w700,
                    color: tagColor,
                    letterSpacing: 0.5,
                  ),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: Text(
                  title,
                  style: const TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w600,
                    color: Color(0xFF1E3C57),
                    fontFamily: 'monospace',
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 10),
          Text(
            description,
            style: const TextStyle(
              fontSize: 13,
              color: Color(0xFF666666),
              height: 1.5,
            ),
          ),
          const SizedBox(height: 20),
          Center(child: child),
        ],
      ),
    );
  }
}

Expose Flutter's AutovalidateMode on Pinput so callers can opt into
onUserInteraction validation without waiting for full PIN entry.

- Add autovalidateMode field (default: AutovalidateMode.disabled) to
  both Pinput() and Pinput.builder() constructors
- Pass it through to _PinputFormField instead of hardcoding disabled
- Defer setState in _validator() via addPostFrameCallback when called
  during FormField's build phase to avoid setState-during-build violation
- Add 6 test cases covering disabled, onUserInteraction, error display,
  error clearing, default backward-compat, and Pinput.builder

Closes Tkko#232
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pinput should expose FormField's autovalidateMode property

1 participant