clean_pin 1.0.0
clean_pin: ^1.0.0 copied to clipboard
A tiny, UI-free PIN code logic controller for Flutter/Dart apps.
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 mainPinController
.pin_state.dart
– immutablePinState
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 accesscurrentLength
).isValidating
–true
whilevalidator
runs.isValid
– last validation result (true
/false
/null
if unknown).error
– optional message, e.g. “Invalid PIN” or lockout hint.attemptsLeft
– remaining attempts (ifmaxAttempts
set).isLocked
–true
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 callssubmit()
. - Validator: runs asynchronously, sets
isValidating
while pending, updatesisValid
anderror
. - Attempts & lockout: after
maxAttempts
failed validations, input is locked forlockout
duration; state exposesisLocked
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()
returnstrue
, 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 fromPinState
. - Drive animations from state: e.g., start a ripple when
isValidating == true
; play a shake whenisValid == false
. - Cycle completion: if you need a validation animation to finish even after
isValidating
flips tofalse
, 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 whileisValidating
).
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.