TKK_E32230211/lib/app/widgets/numpad_pin_widget.dart

340 lines
9.4 KiB
Dart

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<NumpadPinInputWidget> createState() => NumpadPinInputWidgetState();
}
class NumpadPinInputWidgetState extends State<NumpadPinInputWidget>
with SingleTickerProviderStateMixin {
String _errorMessage = '';
bool _hasError = false;
bool _showVerifikasiPassword = false;
late AnimationController _shakeController;
late Animation<Offset> _shakeAnimation;
static const _primaryColor = Color(0xFF5B9BD5);
@override
void initState() {
super.initState();
_shakeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_shakeAnimation =
TweenSequence<Offset>([
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<TextEditingValue>(
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<TextEditingValue>(
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,
),
),
),
),
);
},
);
}
}