import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; enum PinEntryMode { unlock, create } const _pinLength = 4; /// Full-screen PIN entry. In [PinEntryMode.unlock] it verifies a PIN /// against the one already saved. In [PinEntryMode.create] it walks the /// person through entering a new PIN twice before saving it. class PinEntryView extends StatefulWidget { final PinEntryMode mode; final VoidCallback? onSuccess; const PinEntryView({super.key, required this.mode, this.onSuccess}); @override State createState() => _PinEntryViewState(); } class _PinEntryViewState extends State { String _digits = ''; String? _firstEntry; bool _isConfirmStep = false; bool _isSubmitting = false; String? _localError; bool _shake = false; bool get _isCreateFlow => widget.mode == PinEntryMode.create; String _title(AppLocalizations l10n) { if (!_isCreateFlow) return l10n.enterPin; return _isConfirmStep ? l10n.confirmPin : l10n.createPin; } String? _subtitle(AppLocalizations l10n) { if (!_isCreateFlow) return null; return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.enterPinSubtitle; } void _onDigitPressed(String digit) { if (_isSubmitting || _digits.length >= _pinLength) return; setState(() { _digits += digit; _localError = null; }); if (_digits.length == _pinLength) { _handleComplete(); } } void _onBackspacePressed() { if (_isSubmitting || _digits.isEmpty) return; setState(() { _digits = _digits.substring(0, _digits.length - 1); }); } Future _handleComplete() async { final viewModel = context.read(); final l10n = AppLocalizations.of(context); if (_isCreateFlow && !_isConfirmStep) { // First entry of a new PIN — stash it, then ask for confirmation. final entered = _digits; setState(() { _firstEntry = entered; _isConfirmStep = true; _digits = ''; }); return; } if (_isCreateFlow && _isConfirmStep) { if (_digits != _firstEntry) { setState(() { _firstEntry = null; _isConfirmStep = false; }); _fail(l10n.pinsDidNotMatch); return; } setState(() => _isSubmitting = true); final ok = await viewModel.setPin(_digits); if (!mounted) return; setState(() => _isSubmitting = false); if (ok) { widget.onSuccess?.call(); } else { setState(() { _firstEntry = null; _isConfirmStep = false; }); _fail(l10n.localizedError(viewModel.errorMessage)); } return; } // Unlock flow. setState(() => _isSubmitting = true); final ok = await viewModel.verify(_digits); if (!mounted) return; setState(() => _isSubmitting = false); if (ok) { widget.onSuccess?.call(); } else { _fail(l10n.localizedError(viewModel.errorMessage)); } } void _fail(String message) { setState(() { _localError = message; _digits = ''; _shake = true; }); Future.delayed(const Duration(milliseconds: 350), () { if (mounted) setState(() => _shake = false); }); } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context); return Scaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( children: [ const Spacer(flex: 2), Icon(Icons.lock_outline_rounded, size: 36, color: scheme.primary), const SizedBox(height: 16), Text( _title(l10n), style: Theme.of(context).textTheme.headlineMedium, ), if (_subtitle(l10n) != null) ...[ const SizedBox(height: 6), Text( _subtitle(l10n)!, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ], const SizedBox(height: 28), AnimatedContainer( duration: const Duration(milliseconds: 60), transform: Matrix4.translationValues(_shake ? 8 : 0, 0, 0), child: _PinDots(filled: _digits.length, total: _pinLength), ), const SizedBox(height: 16), SizedBox( height: 20, child: _localError != null ? Text( _localError!, style: TextStyle(color: scheme.error, fontSize: 13), ) : const SizedBox.shrink(), ), const Spacer(flex: 2), _PinKeypad( enabled: !_isSubmitting, onDigit: _onDigitPressed, onBackspace: _onBackspacePressed, ), const Spacer(), ], ), ), ), ); } } class _PinDots extends StatelessWidget { final int filled; final int total; const _PinDots({required this.filled, required this.total}); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Row( mainAxisSize: MainAxisSize.min, children: List.generate(total, (index) { final isFilled = index < filled; return Container( margin: const EdgeInsets.symmetric(horizontal: 8), width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, color: isFilled ? scheme.primary : Colors.transparent, border: Border.all( color: isFilled ? scheme.primary : scheme.onSurface.withValues(alpha: 0.3), width: 1.4, ), ), ); }), ); } } class _PinKeypad extends StatelessWidget { final bool enabled; final ValueChanged onDigit; final VoidCallback onBackspace; const _PinKeypad({ required this.enabled, required this.onDigit, required this.onBackspace, }); static const _rows = [ ['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ]; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ for (final row in _rows) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (final digit in row) ...[ _KeypadButton( label: digit, onTap: enabled ? () => onDigit(digit) : null, ), const SizedBox(width: 20), ], ], ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const _KeypadButton(isSpacer: true, onTap: null), const SizedBox(width: 20), _KeypadButton( label: '0', onTap: enabled ? () => onDigit('0') : null, ), const SizedBox(width: 20), _KeypadButton( icon: Icons.backspace_outlined, onTap: enabled ? onBackspace : null, ), ], ), ), ], ); } } class _KeypadButton extends StatelessWidget { final String? label; final IconData? icon; final VoidCallback? onTap; final bool isSpacer; const _KeypadButton({ this.label, this.icon, required this.onTap, this.isSpacer = false, }); @override Widget build(BuildContext context) { if (isSpacer) { return const SizedBox(width: 80, height: 80); } final scheme = Theme.of(context).colorScheme; return SizedBox( width: 80, height: 80, child: Material( color: scheme.onSurface.withValues(alpha: 0.04), shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: onTap, child: Center( child: icon != null ? Icon(icon, color: scheme.onSurface.withValues(alpha: 0.8)) : Text( label!, style: TextStyle( fontSize: 24, fontWeight: FontWeight.w600, color: onTap == null ? scheme.onSurface.withValues(alpha: 0.3) : scheme.onSurface, ), ), ), ), ), ); } }