This commit is contained in:
Phuoc Nguyen
2025-11-07 17:09:34 +07:00
parent 9e55983d82
commit c0df1687a0
3 changed files with 604 additions and 0 deletions

View File

@@ -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/domain/entities/business_unit.dart';
import 'package:worker/features/auth/presentation/pages/business_unit_selection_page.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/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/auth/presentation/pages/register_page.dart';
import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart';
import 'package:worker/features/cart/presentation/pages/checkout_page.dart'; import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
@@ -61,6 +62,17 @@ class AppRouter {
pageBuilder: (context, state) => pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const LoginPage()), 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( GoRoute(
path: RouteNames.register, path: RouteNames.register,
name: RouteNames.register, name: RouteNames.register,

View File

@@ -191,6 +191,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
// Support Link // Support Link
_buildSupportLink(), _buildSupportLink(),
TextButton(onPressed: () {
context.pushNamed(RouteNames.otpVerification);
}, child: Text('otp'))
], ],
), ),
), ),

View File

@@ -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<OtpVerificationPage> createState() =>
_OtpVerificationPageState();
}
class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
// Text controllers for 6 OTP inputs
final List<TextEditingController> _controllers = List.generate(
6,
(_) => TextEditingController(),
);
// Focus nodes for 6 OTP inputs
final List<FocusNode> _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<void> _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<void>.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<void>.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<void> _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<void>.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<Color>(
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();
}
},
),
);
}
}