diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index eb804a7..a3fcad2 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ import 'package:worker/features/account/presentation/pages/change_password_page. import 'package:worker/features/account/presentation/pages/profile_edit_page.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/forgot_password_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'; @@ -58,12 +59,13 @@ final routerProvider = Provider((ref) { final isLoggedIn = authState.value != null; final isOnSplashPage = state.matchedLocation == RouteNames.splash; final isOnLoginPage = state.matchedLocation == RouteNames.login; + final isOnForgotPasswordPage = state.matchedLocation == RouteNames.forgotPassword; final isOnRegisterPage = state.matchedLocation == RouteNames.register; final isOnBusinessUnitPage = state.matchedLocation == RouteNames.businessUnitSelection; final isOnOtpPage = state.matchedLocation == RouteNames.otpVerification; final isOnAuthPage = - isOnLoginPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage; + isOnLoginPage || isOnForgotPasswordPage || isOnRegisterPage || isOnBusinessUnitPage || isOnOtpPage; // While loading auth state, show splash screen if (isLoading) { @@ -106,6 +108,12 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => MaterialPage(key: state.pageKey, child: const LoginPage()), ), + GoRoute( + path: RouteNames.forgotPassword, + name: RouteNames.forgotPassword, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const ForgotPasswordPage()), + ), GoRoute( path: RouteNames.otpVerification, name: RouteNames.otpVerification, @@ -510,6 +518,7 @@ class RouteNames { // Authentication Routes static const String splash = '/splash'; static const String login = '/login'; + static const String forgotPassword = '/forgot-password'; static const String otpVerification = '/otp-verification'; static const String register = '/register'; static const String businessUnitSelection = '/business-unit-selection'; diff --git a/lib/features/account/presentation/pages/account_page.dart b/lib/features/account/presentation/pages/account_page.dart index 6034ed0..f620f65 100644 --- a/lib/features/account/presentation/pages/account_page.dart +++ b/lib/features/account/presentation/pages/account_page.dart @@ -9,20 +9,23 @@ library; import 'package:flutter/material.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/database/hive_initializer.dart'; import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/colors.dart'; import 'package:worker/features/account/presentation/widgets/account_menu_item.dart'; +import 'package:worker/features/auth/presentation/providers/auth_provider.dart'; /// Account Page /// /// Main account/settings page accessible from the bottom navigation bar. -class AccountPage extends StatelessWidget { +class AccountPage extends ConsumerWidget { const AccountPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Scaffold( backgroundColor: const Color(0xFFF4F6F8), body: SafeArea( @@ -43,7 +46,7 @@ class AccountPage extends StatelessWidget { _buildSupportSection(context), // Logout Button - _buildLogoutButton(context), + _buildLogoutButton(context, ref), const SizedBox(height: AppSpacing.lg), ], @@ -299,13 +302,13 @@ class AccountPage extends StatelessWidget { } /// Build logout button - Widget _buildLogoutButton(BuildContext context) { + Widget _buildLogoutButton(BuildContext context, WidgetRef ref) { return Container( margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), width: double.infinity, child: OutlinedButton.icon( onPressed: () { - _showLogoutConfirmation(context); + _showLogoutConfirmation(context, ref); }, icon: const Icon(Icons.logout), label: const Text('Đăng xuất'), @@ -333,7 +336,7 @@ class AccountPage extends StatelessWidget { /// Show about dialog void _showAboutDialog(BuildContext context) { - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Về ứng dụng'), @@ -365,8 +368,8 @@ class AccountPage extends StatelessWidget { } /// Show logout confirmation dialog - void _showLogoutConfirmation(BuildContext context) { - showDialog( + void _showLogoutConfirmation(BuildContext context, WidgetRef ref) { + showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Đăng xuất'), @@ -377,15 +380,7 @@ class AccountPage extends StatelessWidget { child: const Text('Hủy'), ), TextButton( - onPressed: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Đã đăng xuất'), - duration: Duration(seconds: 1), - ), - ); - }, + onPressed: () => _performLogout(context, ref), style: TextButton.styleFrom(foregroundColor: AppColors.danger), child: const Text('Đăng xuất'), ), @@ -393,4 +388,87 @@ class AccountPage extends StatelessWidget { ), ); } + + /// Perform logout operation + /// + /// Handles the complete logout process: + /// 1. Close confirmation dialog + /// 2. Show loading indicator + /// 3. Clear Hive local data + /// 4. Call auth provider logout (clears session, gets new public session) + /// 5. Navigate to login screen (handled by router redirect) + /// 6. Show success message + Future _performLogout(BuildContext context, WidgetRef ref) async { + // Close confirmation dialog + Navigator.of(context).pop(); + + // Show loading dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: Card( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Đang đăng xuất...'), + ], + ), + ), + ), + ), + ); + + try { + // Clear Hive local data (cart, favorites, cached data) + await HiveInitializer.logout(); + + // Call auth provider logout + // This will: + // - Clear FlutterSecureStorage session + // - Clear FrappeAuthService session + // - Get new public session for login/registration + // - Update auth state to null (logged out) + await ref.read(authProvider.notifier).logout(); + + // Close loading dialog + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show success message + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đã đăng xuất thành công'), + duration: Duration(seconds: 2), + backgroundColor: AppColors.success, + ), + ); + } + + // Navigation to login screen is handled automatically by GoRouter redirect + // when auth state becomes null + } catch (e) { + // Close loading dialog + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show error message + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Lỗi đăng xuất: ${e.toString()}'), + duration: const Duration(seconds: 3), + backgroundColor: AppColors.danger, + ), + ); + } + } + } } diff --git a/lib/features/auth/presentation/pages/forgot_password_page.dart b/lib/features/auth/presentation/pages/forgot_password_page.dart new file mode 100644 index 0000000..02edf8e --- /dev/null +++ b/lib/features/auth/presentation/pages/forgot_password_page.dart @@ -0,0 +1,367 @@ +/// Forgot Password Page +/// +/// Allows users to reset their password by entering their phone number. +/// Sends OTP to the provided phone number for verification. +library; + +import 'package:flutter/material.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/theme/colors.dart'; +import 'package:worker/core/utils/validators.dart'; +import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart'; + +/// Forgot Password Page +/// +/// Simple page for password recovery flow. +/// User enters phone number and receives OTP for verification. +/// +/// Features: +/// - Phone number input with validation +/// - Submit button to request OTP +/// - Back to login link +/// - Customer support link +class ForgotPasswordPage extends ConsumerStatefulWidget { + const ForgotPasswordPage({super.key}); + + @override + ConsumerState createState() => + _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends ConsumerState { + // Form key for validation + final _formKey = GlobalKey(); + + // Controllers + final _phoneController = TextEditingController(); + + // Focus nodes + final _phoneFocusNode = FocusNode(); + + // Loading state + bool _isLoading = false; + + @override + void dispose() { + _phoneController.dispose(); + _phoneFocusNode.dispose(); + super.dispose(); + } + + /// Handle submit button press + Future _handleSubmit() async { + // Validate form + if (!_formKey.currentState!.validate()) { + return; + } + + // Unfocus keyboard + FocusScope.of(context).unfocus(); + + setState(() { + _isLoading = true; + }); + + try { + // TODO: Implement forgot password API call + // For now, just show success message after delay + await Future.delayed(const Duration(seconds: 2)); + + if (mounted) { + // Show success message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mã OTP đã được gửi đến số điện thoại của bạn'), + backgroundColor: AppColors.success, + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 3), + ), + ); + + // Navigate back to login after 1 second + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + context.pop(); + } + } + } catch (e) { + // Show error message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Có lỗi xảy ra: ${e.toString()}'), + backgroundColor: AppColors.danger, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + /// Show support dialog + void _showSupport() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Hỗ trợ khách hàng'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Hotline: 1900 xxxx'), + SizedBox(height: AppSpacing.sm), + Text('Email: support@eurotile.vn'), + SizedBox(height: AppSpacing.sm), + Text('Giờ làm việc: 8:00 - 17:00 (T2-T6)'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Đóng'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F6F8), + appBar: AppBar( + backgroundColor: AppColors.white, + elevation: AppBarSpecs.elevation, + title: const Text( + 'Quên mật khẩu', + style: TextStyle(color: Colors.black), + ), + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => context.pop(), + ), + actions: const [ + SizedBox(width: AppSpacing.sm), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.xl), + + // Icon + _buildIcon(), + + const SizedBox(height: AppSpacing.xl), + + // Title & Instructions + _buildInstructions(), + + const SizedBox(height: AppSpacing.xl), + + // Form Card + _buildFormCard(), + + const SizedBox(height: AppSpacing.lg), + + // Back to Login Link + _buildBackToLoginLink(), + + const SizedBox(height: AppSpacing.xl), + + // Support Link + _buildSupportLink(), + ], + ), + ), + ), + ), + ); + } + + /// Build icon + Widget _buildIcon() { + return Center( + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.lock_reset, + size: 50, + color: AppColors.primaryBlue, + ), + ), + ); + } + + /// Build instructions + Widget _buildInstructions() { + return const Column( + children: [ + Text( + 'Đặt lại mật khẩu', + style: TextStyle( + fontSize: 28.0, + fontWeight: FontWeight.bold, + color: AppColors.grey900, + ), + ), + SizedBox(height: AppSpacing.sm), + Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Text( + 'Nhập số điện thoại đã đăng ký. Chúng tôi sẽ gửi mã OTP để xác nhận và đặt lại mật khẩu của bạn.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15.0, + color: AppColors.grey500, + height: 1.5, + ), + ), + ), + ], + ); + } + + /// Build form card + Widget _buildFormCard() { + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.card), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10.0, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Phone Input + PhoneInputField( + controller: _phoneController, + focusNode: _phoneFocusNode, + validator: Validators.phone, + enabled: !_isLoading, + onFieldSubmitted: (_) { + if (!_isLoading) { + _handleSubmit(); + } + }, + ), + + const SizedBox(height: AppSpacing.lg), + + // Submit Button + SizedBox( + height: ButtonSpecs.height, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + disabledBackgroundColor: AppColors.grey100, + disabledForegroundColor: AppColors.grey500, + elevation: ButtonSpecs.elevation, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20.0, + width: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: AlwaysStoppedAnimation( + AppColors.white, + ), + ), + ) + : const Text( + 'Gửi mã OTP', + style: TextStyle( + fontSize: ButtonSpecs.fontSize, + fontWeight: ButtonSpecs.fontWeight, + ), + ), + ), + ), + ], + ), + ); + } + + /// Build back to login link + Widget _buildBackToLoginLink() { + return Center( + child: RichText( + text: TextSpan( + text: 'Nhớ mật khẩu? ', + style: const TextStyle(fontSize: 14.0, color: AppColors.grey500), + children: [ + WidgetSpan( + child: GestureDetector( + onTap: () => context.pop(), + child: const Text( + 'Đăng nhập', + style: TextStyle( + fontSize: 14.0, + color: AppColors.primaryBlue, + fontWeight: FontWeight.w500, + decoration: TextDecoration.none, + ), + ), + ), + ), + ], + ), + ), + ); + } + + /// Build support link + Widget _buildSupportLink() { + return Center( + child: TextButton.icon( + onPressed: _showSupport, + icon: const Icon( + Icons.headset_mic, + size: AppIconSize.sm, + color: AppColors.primaryBlue, + ), + label: const Text( + 'Hỗ trợ khách hàng', + style: TextStyle( + fontSize: 14.0, + color: AppColors.primaryBlue, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index e898569..22de62c 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -128,6 +128,11 @@ class _LoginPageState extends ConsumerState { ); } + /// Navigate to forgot password page + void _navigateToForgotPassword() { + context.pushNamed(RouteNames.forgotPassword); + } + /// Show support dialog void _showSupport() { showDialog( @@ -196,12 +201,6 @@ class _LoginPageState extends ConsumerState { // Support Link _buildSupportLink(), - TextButton(onPressed: () { - context.pushNamed(RouteNames.otpVerification); - }, child: Text('otp')), - TextButton(onPressed: () { - context.pushReplacementNamed(RouteNames.home); - }, child: Text('home')) ], ), ), @@ -408,36 +407,55 @@ class _LoginPageState extends ConsumerState { const SizedBox(height: AppSpacing.sm), - // Remember Me Checkbox + // Remember Me Checkbox & Forgot Password Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Checkbox( - value: _rememberMe, - onChanged: isLoading - ? null - : (value) { - setState(() { - _rememberMe = value ?? false; - }); - }, - activeColor: AppColors.primaryBlue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4.0), - ), + // Remember Me Checkbox + Row( + children: [ + Checkbox( + value: _rememberMe, + onChanged: isLoading + ? null + : (value) { + setState(() { + _rememberMe = value ?? false; + }); + }, + activeColor: AppColors.primaryBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.0), + ), + ), + GestureDetector( + onTap: isLoading + ? null + : () { + setState(() { + _rememberMe = !_rememberMe; + }); + }, + child: const Text( + 'Ghi nhớ đăng nhập', + style: TextStyle( + fontSize: 14.0, + color: AppColors.grey500, + ), + ), + ), + ], ), + + // Forgot Password Link GestureDetector( - onTap: isLoading - ? null - : () { - setState(() { - _rememberMe = !_rememberMe; - }); - }, - child: const Text( - 'Ghi nhớ đăng nhập', + onTap: isLoading ? null : _navigateToForgotPassword, + child: Text( + 'Quên mật khẩu?', style: TextStyle( fontSize: 14.0, - color: AppColors.grey500, + color: isLoading ? AppColors.grey500 : AppColors.primaryBlue, + fontWeight: FontWeight.w500, ), ), ),