import 'package:flutter/material.dart'; class NumpadPinInputWidget extends StatefulWidget { final void Function(String key) onKey; final VoidCallback onSimpan; final VoidCallback? onVerifikasiPassword; final TextEditingController displayController; final int maxLength; final String title; const NumpadPinInputWidget({ super.key, required this.onKey, required this.onSimpan, required this.displayController, this.onVerifikasiPassword, this.maxLength = 6, this.title = 'Masukkan PIN', }); static void show( BuildContext context, { required TextEditingController controller, required void Function(String key) onKey, required VoidCallback onSimpan, VoidCallback? onVerifikasiPassword, int maxLength = 6, String title = 'Masukkan PIN', }) { showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, isDismissible: false, enableDrag: false, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (_) => NumpadPinInputWidget( displayController: controller, onKey: onKey, onSimpan: onSimpan, onVerifikasiPassword: onVerifikasiPassword, maxLength: maxLength, title: title, ), ); } @override State createState() => NumpadPinInputWidgetState(); } class NumpadPinInputWidgetState extends State with SingleTickerProviderStateMixin { String _errorMessage = ''; bool _hasError = false; bool _showVerifikasiPassword = false; late AnimationController _shakeController; late Animation _shakeAnimation; static const _primaryColor = Color(0xFF5B9BD5); @override void initState() { super.initState(); _shakeController = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); _shakeAnimation = TweenSequence([ TweenSequenceItem( tween: Tween(begin: Offset.zero, end: const Offset(-0.05, 0)), weight: 1, ), TweenSequenceItem( tween: Tween( begin: const Offset(-0.05, 0), end: const Offset(0.05, 0), ), weight: 2, ), TweenSequenceItem( tween: Tween( begin: const Offset(0.05, 0), end: const Offset(-0.05, 0), ), weight: 2, ), TweenSequenceItem( tween: Tween( begin: const Offset(-0.05, 0), end: const Offset(0.05, 0), ), weight: 2, ), TweenSequenceItem( tween: Tween(begin: const Offset(0.05, 0), end: Offset.zero), weight: 1, ), ]).animate( CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut), ); } @override void dispose() { _shakeController.dispose(); super.dispose(); } void showError(String message, {bool showVerifikasiPassword = false}) { widget.displayController.clear(); setState(() { _errorMessage = message; _hasError = true; if (showVerifikasiPassword) _showVerifikasiPassword = true; }); _shakeController.forward(from: 0); } void _clearError() { if (!_hasError) return; setState(() { _errorMessage = ''; _hasError = false; }); } void _onKey(String key) { _clearError(); widget.onKey(key); } @override Widget build(BuildContext context) { return SingleChildScrollView( padding: EdgeInsets.fromLTRB( 16, 12, 16, MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildHandle(), _buildTitle(), const SizedBox(height: 20), _buildDotIndicator(), _buildErrorMessage(), _buildVerifikasiPasswordLink(), const SizedBox(height: 16), _buildNumpad(), const SizedBox(height: 12), _buildSimpanButton(), ], ), ); } Widget _buildHandle() { return Container( width: 40, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ); } Widget _buildTitle() { return Text( widget.title, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87, ), ); } Widget _buildDotIndicator() { return SlideTransition( position: _shakeAnimation, child: ValueListenableBuilder( valueListenable: widget.displayController, builder: (_, value, __) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(widget.maxLength, (i) { final filled = i < value.text.length; return Container( margin: const EdgeInsets.symmetric(horizontal: 8), width: 14, height: 14, decoration: BoxDecoration( shape: BoxShape.circle, color: _hasError ? Colors.red.shade400 : filled ? _primaryColor : Colors.grey.shade300, ), ); }), ); }, ), ); } Widget _buildErrorMessage() { return AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _errorMessage.isNotEmpty ? Padding( key: ValueKey(_errorMessage), padding: const EdgeInsets.only(top: 10), child: Text( _errorMessage, style: TextStyle( color: Colors.red.shade600, fontSize: 13, fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), ) : const SizedBox(height: 10, key: ValueKey('_empty')), ); } Widget _buildVerifikasiPasswordLink() { if (widget.onVerifikasiPassword == null) return const SizedBox.shrink(); return AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _showVerifikasiPassword ? Padding( key: const ValueKey('_verifikasiPassword'), padding: const EdgeInsets.only(top: 6, bottom: 4), child: GestureDetector( onTap: widget.onVerifikasiPassword, child: const Text( 'Masukkan Password Akun', style: TextStyle( color: _primaryColor, fontSize: 14, fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), ), ), ) : const SizedBox(key: ValueKey('_noVerifikasi')), ); } Widget _buildNumpad() { final keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'del']; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: keys.length, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 1.6, ), itemBuilder: (_, i) { final key = keys[i]; if (key.isEmpty) return const SizedBox(); return GestureDetector( onTap: () => _onKey(key), child: Container( decoration: const BoxDecoration( color: Color(0xFFF2F2F2), shape: BoxShape.circle, ), child: Center( child: key == 'del' ? const Icon( Icons.backspace_outlined, color: Colors.black87, size: 20, ) : Text( key, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w600, color: Colors.black87, ), ), ), ), ); }, ); } Widget _buildSimpanButton() { return ValueListenableBuilder( valueListenable: widget.displayController, builder: (_, value, __) { final isReady = value.text.length == widget.maxLength; return GestureDetector( onTap: isReady ? widget.onSimpan : null, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( color: isReady ? _primaryColor : Colors.grey.shade300, borderRadius: BorderRadius.circular(12), ), child: Center( child: Text( 'Simpan', style: TextStyle( color: isReady ? Colors.white : Colors.black45, fontWeight: FontWeight.bold, fontSize: 16, ), ), ), ), ); }, ); } }