clean_pin

A tiny, UI-free PIN code logic controller for Flutter/Dart apps. clean_pin ships only the business logic (validation, attempts, lockout, optional biometrics), so you don’t pull in any widgets, animations, or theming just to handle a PIN. That keeps your app lean—perfect for size-sensitive scenarios.

If you want a drop-in screen, check the examples that pair this controller with ready-made UIs.


Why this package?

  • Logic only – no UI, no animations, no theming.
  • Lightweight – minimal code & deps, great for small APK/IPA sizes.
  • Flexible – plug into any state management (Riverpod, Bloc, Provider, plain setState).
  • Production features – async validator, auto-submit, attempt counters, lockout window, optional biometrics gateway.

What’s inside

The package is split into 4 simple files:

  • clean_pin.dart – single entry point (exports everything).
  • pin_controller.dart – the main PinController.
  • pin_state.dart – immutable PinState with derived flags.
  • biometric.dart – an interface for plugging in local auth (Face/Touch ID, etc.).

Installation

dependencies:
  clean_pin:
    git: https://github.com/your-org/clean_pin.git

Then:

import 'package:clean_pin/clean_pin.dart';

Quick start (plain Flutter)

import 'package:flutter/material.dart';
import 'package:clean_pin/clean_pin.dart';

Future<bool> serverValidator(String pin) async {
  await Future.delayed(const Duration(milliseconds: 700));
  return pin == '1234';
}

class PinExample extends StatefulWidget {
  const PinExample({super.key});
  @override
  State<PinExample> createState() => _PinExampleState();
}

class _PinExampleState extends State<PinExample> {
  late final PinController _pin = PinController(
    length: 4,
    autoSubmit: true,                 // calls validator when length reached
    maxAttempts: 5,                   // optional
    lockout: const Duration(seconds: 30),
    validator: serverValidator,       // Future<bool> Function(String)
    biometric: LocalAuthGateway(),    // optional; see "Biometrics"
  );

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

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<PinState>(
      stream: _pin.stream,               // emits a new PinState on any change
      initialData: _pin.state,
      builder: (context, snap) {
        final s = snap.data!;
        return Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text('Digits: ${s.currentLength}/${s.length}'),
            if (s.isValidating) const Text('Validating...'),
            if (s.error != null) Text('Error: ${s.error}', style: const TextStyle(color: Colors.red)),
            if (s.isLocked) Text('Locked for ${s.lockLeft?.inSeconds ?? 0}s'),

            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              children: List.generate(10, (d) => ElevatedButton(
                onPressed: s.isInputEnabled ? () => _pin.addDigit(d) : null,
                child: Text('$d'),
              )),
            ),
            Row(
              children: [
                TextButton(onPressed: _pin.removeDigit, child: const Text('Backspace')),
                TextButton(onPressed: _pin.clear, child: const Text('Clear')),
                TextButton(onPressed: _pin.submit, child: const Text('Submit')),
              ],
            ),
          ],
        );
      },
    );
  }
}

Riverpod example (like iOS lock screen)


final pinControllerProvider = Provider<PinController>((ref) {
  final c = PinController(
    length: 4,
    autoSubmit: true,
    maxAttempts: 3,
    lockout: const Duration(seconds: 10),
    validator: fakeServerValidator,
    biometric: LocalAuthGateway(),
  );
  ref.onDispose(c.dispose);
  return c;
});

final pinStateProvider = StreamProvider<PinState>((ref) {
  final c = ref.watch(pinControllerProvider);
  return c.stream;
});

class RiverpodScreen extends ConsumerWidget {
  const RiverpodScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncState = ref.watch(pinStateProvider);
    final controller = ref.watch(pinControllerProvider);
    return Center(
      child: asyncState.when(
        loading: () => const CircularProgressIndicator(),
        error: (e, st) => Text('Error: $e'),
        data: (state) => ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 360),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const SizedBox(height: 24),
              Text('Riverpod: StreamProvider',
                  style: Theme.of(context).textTheme.titleLarge),
              const SizedBox(height: 12),
              PinDots(
                  valueLength: state.value.length,
                  total: state.length,
                  isError: state.isValid == false),
              const SizedBox(height: 8),
              if (state.isValidating) const LoadingRow(label: 'Checking PIN…'),
              if (state.isBiometricInProgress)
                const LoadingRow(label: 'Biometrics…'),
              if (state.error != null)
                Padding(
                  padding: const EdgeInsets.only(top: 6),
                  child: Text(state.error!,
                      style: TextStyle(
                          color: Theme.of(context).colorScheme.error)),
                ),
              if (state.biometricError != null)
                Padding(
                  padding: const EdgeInsets.only(top: 6),
                  child: Text(state.biometricError!,
                      style: TextStyle(
                          color: Theme.of(context).colorScheme.error)),
                ),
              const SizedBox(height: 8),
              Keypad(
                onDigit: controller.inputDigit,
                onBackspace: controller.backspace,
                onClear: controller.clear,
                enabled: !state.isLocked &&
                    !state.isValidating &&
                    !state.isBiometricInProgress,
              ),
              const SizedBox(height: 8),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  FilledButton.tonal(
                    onPressed: (!state.isLocked &&
                            !state.isValidating &&
                            !state.isBiometricInProgress)
                        ? controller.clear
                        : null,
                    child: const Text('Clear'),
                  ),
                  const SizedBox(width: 12),
                  FilledButton(
                    onPressed: (!state.isLocked &&
                            !state.isValidating &&
                            !state.isBiometricInProgress)
                        ? () => controller.submit()
                        : null,
                    child: const Text('Sumbit'),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              FilledButton.icon(
                onPressed: (state.biometricAvailable &&
                        !state.isLocked &&
                        !state.isBiometricInProgress)
                    ? () => controller.tryBiometric(reason: 'Enter pin-code')
                    : null,
                icon: const Icon(Icons.fingerprint),
                label: const Text('Login using biometrics'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

PinState (read-only snapshot)

Typical fields & derived flags you’ll use:

  • length – required PIN length.
  • current – current input as a string (or access currentLength).
  • isValidatingtrue while validator runs.
  • isValid – last validation result (true/false/null if unknown).
  • error – optional message, e.g. “Invalid PIN” or lockout hint.
  • attemptsLeft – remaining attempts (if maxAttempts set).
  • isLockedtrue when lockout timer is active; lockLeft shows remaining time.
  • isInputEnabled – convenience flag (not locked, not validating, etc.).

The exact shape is intentionally small and stable; treat it as immutable.


PinController API (essentials)

class PinController {
  // Construction
  PinController({
    required int length,
    required Future<bool> Function(String) validator,
    bool autoSubmit = false,
    int? maxAttempts,
    Duration? lockout,
    BiometricGateway? biometric,
  });

  // Reactive state
  PinState get state;
  Stream<PinState> get stream;

  // Input
  void addDigit(int digit);  // 0..9
  void removeDigit();        // backspace
  void clear();              // reset input
  Future<void> submit();     // triggers validator

  // Lifecycle
  void dispose();
}

Behavior notes

  • Auto-submit: when autoSubmit: true, hitting the target length immediately calls submit().
  • Validator: runs asynchronously, sets isValidating while pending, updates isValid and error.
  • Attempts & lockout: after maxAttempts failed validations, input is locked for lockout duration; state exposes isLocked and countdown.
  • Errors: you can show state.error to drive UI (shake, color, etc.). The controller does not animate—your UI decides how.

Biometrics (optional)

biometric.dart defines a small gateway interface so you can wire platform auth without coupling:

abstract class BiometricGateway {
  Future<bool> canCheck();
  Future<bool> authenticate({String reason});
}

class LocalAuthGateway implements BiometricGateway {
  // Implement via local_auth or your platform channel.
  @override
  Future<bool> canCheck() async => true;

  @override
  Future<bool> authenticate({String reason = 'Unlock'}) async {
    // Call the real API here
    return true;
  }
}

Typical flow:

  • On screen appear, call biometric?.canCheck() and, if desired, authenticate().
  • If authenticate() returns true, you can treat it as a successful unlock (e.g., skip PIN UI or call your success handler). The controller stays UI-agnostic: you decide how biometric success maps to your app.

Patterns & tips

  • Keep UI reactive: subscribe to controller.stream and render from PinState.
  • Drive animations from state: e.g., start a ripple when isValidating == true; play a shake when isValid == false.
  • Cycle completion: if you need a validation animation to finish even after isValidating flips to false, run that animation in your UI layer using timers/queues keyed by validation tick (don’t burden the controller with visuals).
  • Thread safety: guard against multiple concurrent submit() calls in your UI (disable keypad while isValidating).

Examples

The repo includes:

  • Minimal Flutter sample (vanilla StatefulWidget)
  • Riverpod sample (iOS-style lock screen)
  • Custom UI hooks showing success/error/validating animations driven by PinState

Use them as copy-paste starters and style to your brand.


FAQ

Q: Does this package store secrets or compare pins locally? A: No. You provide the validator (local or server). The controller only orchestrates input and state.

Q: Can I use a non-numeric keypad? A: Yes—addDigit(int) expects digits 0–9, but you control the UI. For alphanumeric pins, wrap/extend the controller.

Q: How do I reset lockout? A: Either wait for lockout to elapse or recreate the controller to hard-reset (e.g., on logout).


License

MIT © 2025 АО “НВП БОЛИД” See LICENSE for details.


Contributing

Issues and PRs welcome. Keep changes UI-agnostic and size-friendly. Add tests where practical.

Libraries

clean_pin