/// OTP Verification Page /// /// User verifies their phone number with a 6-digit OTP code. /// Matches design from html/otp.html library; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/colors.dart'; /// OTP Verification Page /// /// Features: /// - 6-digit OTP input with auto-focus /// - Auto-advance to next field on input /// - Auto-submit when all 6 digits are entered /// - Countdown timer (60 seconds) /// - Resend OTP button (enabled after countdown) /// - Phone number display class OtpVerificationPage extends ConsumerStatefulWidget { /// Phone number that received the OTP final String phoneNumber; const OtpVerificationPage({ super.key, required this.phoneNumber, }); @override ConsumerState createState() => _OtpVerificationPageState(); } class _OtpVerificationPageState extends ConsumerState { // Text controllers for 6 OTP inputs final List _controllers = List.generate( 6, (_) => TextEditingController(), ); // Focus nodes for 6 OTP inputs final List _focusNodes = List.generate( 6, (_) => FocusNode(), ); // State bool _isLoading = false; int _countdown = 60; Timer? _timer; @override void initState() { super.initState(); _startCountdown(); // Auto-focus first input WidgetsBinding.instance.addPostFrameCallback((_) { _focusNodes[0].requestFocus(); }); } @override void dispose() { _timer?.cancel(); for (final controller in _controllers) { controller.dispose(); } for (final focusNode in _focusNodes) { focusNode.dispose(); } super.dispose(); } /// Start countdown timer void _startCountdown() { _countdown = 60; _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_countdown > 0) { setState(() { _countdown--; }); } else { timer.cancel(); } }); } /// Get OTP code from all inputs String _getOtpCode() { return _controllers.map((c) => c.text).join(); } /// Check if OTP is complete (all 6 digits filled) bool _isOtpComplete() { return _getOtpCode().length == 6; } /// Handle OTP input change void _onOtpChanged(int index, String value) { if (value.isNotEmpty) { // Move to next field if (index < 5) { _focusNodes[index + 1].requestFocus(); } else { // All fields filled, unfocus to hide keyboard _focusNodes[index].unfocus(); // Auto-submit if (_isOtpComplete()) { _handleVerifyOtp(); } } } } /// Handle verify OTP Future _handleVerifyOtp() async { if (!_isOtpComplete()) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Vui lòng nhập đủ 6 số OTP'), backgroundColor: AppColors.warning, ), ); return; } setState(() { _isLoading = true; }); try { // TODO: Call verify OTP API // final otpCode = _getOtpCode(); // await ref.read(authProvider.notifier).verifyOtp( // phoneNumber: widget.phoneNumber, // otpCode: otpCode, // ); // Simulate API delay await Future.delayed(const Duration(seconds: 2)); if (mounted) { // Show success message ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Xác thực thành công!'), backgroundColor: AppColors.success, duration: Duration(seconds: 1), ), ); // Navigate to home await Future.delayed(const Duration(seconds: 1)); if (mounted) { context.goHome(); } } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Xác thực thất bại: $e'), backgroundColor: AppColors.danger, ), ); } } finally { if (mounted) { setState(() { _isLoading = false; }); } } } /// Handle resend OTP Future _handleResendOtp() async { if (_countdown > 0) return; try { // TODO: Call resend OTP API // await ref.read(authProvider.notifier).resendOtp( // phoneNumber: widget.phoneNumber, // ); // Simulate API delay await Future.delayed(const Duration(seconds: 1)); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Mã OTP mới đã được gửi!'), backgroundColor: AppColors.success, ), ); // Clear inputs for (final controller in _controllers) { controller.clear(); } // Restart countdown _startCountdown(); // Focus first input _focusNodes[0].requestFocus(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Gửi lại OTP thất bại: $e'), backgroundColor: AppColors.danger, ), ); } } } /// Format phone number for display (0983 441 099) String _formatPhoneNumber(String phone) { if (phone.length >= 10) { return '${phone.substring(0, 4)} ${phone.substring(4, 7)} ${phone.substring(7)}'; } return phone; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.grey50, appBar: AppBar( backgroundColor: AppColors.white, elevation: AppBarSpecs.elevation, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => context.pop(), ), title: const Text( 'Xác thực OTP', style: TextStyle( color: Colors.black, fontSize: 18, fontWeight: FontWeight.w600, ), ), centerTitle: false, actions: const [ SizedBox(width: AppSpacing.sm), ], ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppSpacing.md), // Shield Icon Center( child: Container( width: 80, height: 80, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.primaryBlue, AppColors.lightBlue], ), shape: BoxShape.circle, ), child: const Icon( Icons.shield_outlined, size: 36, color: AppColors.white, ), ), ), const SizedBox(height: AppSpacing.lg), // Instructions const Text( 'Nhập mã xác thực', textAlign: TextAlign.center, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.grey900, ), ), const SizedBox(height: 12), const Text( 'Mã OTP đã được gửi đến số điện thoại', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: AppColors.grey500, ), ), const SizedBox(height: 4), Text( _formatPhoneNumber(widget.phoneNumber), textAlign: TextAlign.center, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.primaryBlue, ), ), const SizedBox(height: 24), // OTP Input Card Container( decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(AppRadius.card), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.07), blurRadius: 15, offset: const Offset(0, 4), ), ], ), padding: const EdgeInsets.all(AppSpacing.lg), child: Column( children: [ // OTP Input Boxes Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate( 6, (index) => Padding( padding: EdgeInsets.only( left: index > 0 ? 8 : 0, ), child: _buildOtpInput(index), ), ), ), const SizedBox(height: 12), // Verify Button SizedBox( width: double.infinity, height: ButtonSpecs.height, child: ElevatedButton( onPressed: _isLoading ? null : _handleVerifyOtp, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryBlue, foregroundColor: AppColors.white, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( ButtonSpecs.borderRadius, ), ), ), child: _isLoading ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( AppColors.white, ), ), ) : const Text( 'Xác nhận', style: TextStyle( fontSize: ButtonSpecs.fontSize, fontWeight: ButtonSpecs.fontWeight, ), ), ), ), ], ), ), const SizedBox(height: 12), // Resend OTP Center( child: Text.rich( TextSpan( text: 'Không nhận được mã? ', style: const TextStyle( fontSize: 12, color: AppColors.grey500, ), children: [ WidgetSpan( child: GestureDetector( onTap: _countdown == 0 ? _handleResendOtp : null, child: Text( _countdown > 0 ? 'Gửi lại (${_countdown}s)' : 'Gửi lại', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: _countdown > 0 ? AppColors.grey500 : AppColors.primaryBlue, decoration: _countdown == 0 ? TextDecoration.none : TextDecoration.none, ), ), ), ), ], ), ), ), const SizedBox(height: AppSpacing.lg), ], ), ), ), ); } /// Build single OTP input box Widget _buildOtpInput(int index) { return SizedBox( width: 48, height: 48, child: TextField( controller: _controllers[index], focusNode: _focusNodes[index], textAlign: TextAlign.center, keyboardType: TextInputType.number, maxLength: 1, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.grey900, ), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], decoration: InputDecoration( counterText: '', contentPadding: EdgeInsets.zero, enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide( color: AppColors.grey100, width: 2, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide( color: AppColors.primaryBlue, width: 2, ), ), filled: false, fillColor: AppColors.white, ), onChanged: (value) => _onOtpChanged(index, value), onTap: () { // Clear field on tap for easier re-entry _controllers[index].clear(); }, onSubmitted: (_) { if (_isOtpComplete()) { _handleVerifyOtp(); } }, ), ); } }