diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 3f6b649..9f405ea 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -13,6 +13,7 @@ import 'package:worker/features/account/presentation/pages/profile_edit_page.dar import 'package:worker/features/auth/domain/entities/business_unit.dart'; import 'package:worker/features/auth/presentation/pages/business_unit_selection_page.dart'; import 'package:worker/features/auth/presentation/pages/login_page.dart'; +import 'package:worker/features/auth/presentation/pages/otp_verification_page.dart'; import 'package:worker/features/auth/presentation/pages/register_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/cart/presentation/pages/checkout_page.dart'; @@ -61,6 +62,17 @@ class AppRouter { pageBuilder: (context, state) => MaterialPage(key: state.pageKey, child: const LoginPage()), ), + GoRoute( + path: RouteNames.otpVerification, + name: RouteNames.otpVerification, + pageBuilder: (context, state) { + final phoneNumber = state.extra as String? ?? ''; + return MaterialPage( + key: state.pageKey, + child: OtpVerificationPage(phoneNumber: phoneNumber), + ); + }, + ), GoRoute( path: RouteNames.register, name: RouteNames.register, diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index 3957e61..bad8406 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -191,6 +191,10 @@ class _LoginPageState extends ConsumerState { // Support Link _buildSupportLink(), + + TextButton(onPressed: () { + context.pushNamed(RouteNames.otpVerification); + }, child: Text('otp')) ], ), ), diff --git a/lib/features/auth/presentation/pages/otp_verification_page.dart b/lib/features/auth/presentation/pages/otp_verification_page.dart new file mode 100644 index 0000000..70b5151 --- /dev/null +++ b/lib/features/auth/presentation/pages/otp_verification_page.dart @@ -0,0 +1,588 @@ +/// 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), + + // Alternative Methods + 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: [ + const Text( + 'Phương thức xác thực khác', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Chức năng đang phát triển'), + duration: Duration(seconds: 1), + ), + ); + }, + icon: const Icon(Icons.message, size: 18), + label: const Text( + 'SMS', + style: TextStyle(fontSize: 12), + ), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.grey900, + side: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Chức năng đang phát triển'), + duration: Duration(seconds: 1), + ), + ); + }, + icon: const Icon(Icons.phone, size: 18), + label: const Text( + 'Gọi điện', + style: TextStyle(fontSize: 12), + ), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.grey900, + side: const BorderSide( + color: AppColors.primaryBlue, + width: 2, + ), + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + /// 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(); + } + }, + ), + ); + } +}