diff --git a/assets/login/app_icon.png b/assets/login/app_icon.png index 702f709..569864b 100644 Binary files a/assets/login/app_icon.png and b/assets/login/app_icon.png differ diff --git a/lib/all_bloc_poviders/all_bloc_providers.dart b/lib/all_bloc_poviders/all_bloc_providers.dart new file mode 100644 index 0000000..6be4eeb --- /dev/null +++ b/lib/all_bloc_poviders/all_bloc_providers.dart @@ -0,0 +1,37 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../login/blocs/login/login_bloc.dart'; +import '../login/blocs/forgot_password/forgot_password_bloc.dart'; +import '../login/blocs/verify_otp/verify_otp_bloc.dart'; +import '../login/blocs/reset_password/reset_password_bloc.dart'; +import '../splash/bloc/splash_bloc.dart'; +import '../profile/blocs/profile/profile_bloc.dart'; +import '../profile/repository/profile_repository.dart'; + +class AllBlocProviders { + AllBlocProviders._(); // Private constructor — not meant to be instantiated + + static List providers() { + return [ + // ─── Splash ────────────────────────────────────────────────────────── + BlocProvider( + create: (_) => SplashBloc(), + ), + BlocProvider( + create: (_) => LoginBloc(), + ), + BlocProvider( + create: (_) => ForgotPasswordBloc(), + ), + BlocProvider( + create: (_) => VerifyOtpBloc(), + ), + BlocProvider( + create: (_) => ResetPasswordBloc(), + ), + // ─── Profile ───────────────────────────────────────────────────────── + BlocProvider( + create: (_) => ProfileBloc(profileRepository: ProfileRepository()), + ), + ]; + } +} \ No newline at end of file diff --git a/lib/constants/app_assets.dart b/lib/constants/app_assets.dart new file mode 100644 index 0000000..76f2ad5 --- /dev/null +++ b/lib/constants/app_assets.dart @@ -0,0 +1,3 @@ +class AppAssets { + static const String appIcon = "assets/login/app_icon.png"; +} diff --git a/lib/constants/app_colors.dart b/lib/constants/app_colors.dart new file mode 100644 index 0000000..9c5b780 --- /dev/null +++ b/lib/constants/app_colors.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color primaryRed = Color(0xFFF16F6F); + static const Color backgroundWhite = Colors.white; + static const Color labelGrey = Color(0xFF4B4B4B); + static const Color textGrey = Color(0xFF7C7C7C); + static const Color hintGrey = Color(0xFFBDBDBD); + static const Color borderGrey = Color(0xFFE0E0E0); + static const Color successGreen = Color(0xFF4CAF50); + static const Color errorLight = Color(0xFFFFF1F1); + static const Color bgLightGrey = Color(0xFFF9F9F9); + static const Color black = Colors.black; +} diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 8cfa8c4..c22212f 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -12,7 +12,7 @@ import '../redemption/view/ticket_redemption_screen.dart'; import '../scan/view/qr_scan_screen.dart'; import '../scan_history/views/scan_history_detail_page.dart'; import '../scan_history/views/scan_history_page.dart'; -import '../splash/splash_view.dart'; +import '../splash/view/splash_view.dart'; import '../support/view/help_support_page.dart'; class AppRouter { @@ -45,26 +45,36 @@ class AppRouter { case forgotPassword: return MaterialPageRoute(builder: (_) => const ForgotPasswordPage()); case otpVerification: - return MaterialPageRoute(builder: (_) => const OtpVerificationPage()); + final email = settings.arguments as String? ?? ''; + return MaterialPageRoute( + builder: (_) => OtpVerificationPage(email: email), + ); case resetPassword: - return MaterialPageRoute(builder: (_) => const ResetPasswordPage()); + final email = settings.arguments as String? ?? ''; + return MaterialPageRoute( + builder: (_) => ResetPasswordPage(email: email), + ); case profileScreen: return MaterialPageRoute(builder: (_) => const ProfileScreen()); - case qrScanScreen: + case qrScanScreen: return MaterialPageRoute(builder: (_) => const QrScanScreen()); - case splashScreen: + case splashScreen: return MaterialPageRoute(builder: (_) => const SplashScreen()); - case scanHistoryDetailPage: - return MaterialPageRoute(builder: (_) => const ScanHistoryDetailPage(passId: 'P214125125',)); + case scanHistoryDetailPage: + return MaterialPageRoute( + builder: (_) => const ScanHistoryDetailPage( + passId: 'P214125125', + )); case selectedTimeSlotPage: return MaterialPageRoute(builder: (_) => const SelectedTimeSlotPage()); case bookingPage: return MaterialPageRoute(builder: (_) => const BookingPage()); case helpSupportPage: return MaterialPageRoute(builder: (_) => const HelpSupportPage()); - case ticketRedemptionScreen: - return MaterialPageRoute(builder: (_) => const TicketRedemptionScreen()); - case recurringBlockBasicInfo: + case ticketRedemptionScreen: + return MaterialPageRoute( + builder: (_) => const TicketRedemptionScreen()); + case recurringBlockBasicInfo: return MaterialPageRoute(builder: (_) => const RecurringBlockPage()); default: return MaterialPageRoute( @@ -73,4 +83,4 @@ class AppRouter { ); } } -} +} \ No newline at end of file diff --git a/lib/custome_widgets/custom_button.dart b/lib/custome_widgets/custom_button.dart new file mode 100644 index 0000000..2456170 --- /dev/null +++ b/lib/custome_widgets/custom_button.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../constants/app_colors.dart'; + +class CustomButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final Color? backgroundColor; + final Color? textColor; + final double height; + final double borderRadius; + + const CustomButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.backgroundColor, + this.textColor, + this.height = 56, + this.borderRadius = 16, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: height.h, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor ?? AppColors.primaryRed, + disabledBackgroundColor: (backgroundColor ?? AppColors.primaryRed).withOpacity(0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius.r), + ), + elevation: 0, + ), + child: isLoading + ? SizedBox( + height: 24.h, + width: 24.w, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + text, + style: GoogleFonts.poppins( + color: textColor ?? Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/custome_widgets/custom_textfield.dart b/lib/custome_widgets/custom_textfield.dart new file mode 100644 index 0000000..61bfa8e --- /dev/null +++ b/lib/custome_widgets/custom_textfield.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../constants/app_colors.dart'; + +class CustomTextField extends StatelessWidget { + final String label; + final String hintText; + final TextEditingController controller; + final FocusNode? focusNode; + final IconData? prefixIcon; + final bool hasError; + final String? errorText; + final bool isPassword; + final bool isPasswordVisible; + final VoidCallback? onTogglePasswordVisibility; + final bool readOnly; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final Color? accentColor; + + const CustomTextField({ + super.key, + required this.label, + required this.hintText, + required this.controller, + this.focusNode, + this.prefixIcon, + this.hasError = false, + this.errorText, + this.isPassword = false, + this.isPasswordVisible = false, + this.onTogglePasswordVisibility, + this.readOnly = false, + this.keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.done, + this.onChanged, + this.onSubmitted, + this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final Color primary = accentColor ?? AppColors.primaryRed; + final Color labelColor = AppColors.labelGrey; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: GoogleFonts.poppins( + color: labelColor, + fontSize: 14.sp, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 8.h), + TextField( + controller: controller, + focusNode: focusNode, + readOnly: readOnly, + obscureText: isPassword && !isPasswordVisible, + keyboardType: keyboardType, + textInputAction: textInputAction, + style: GoogleFonts.poppins(fontSize: 14.sp), + onChanged: onChanged, + onSubmitted: onSubmitted, + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.poppins(color: AppColors.hintGrey), + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, color: AppColors.hintGrey, size: 20.sp) + : null, + suffixIcon: isPassword + ? IconButton( + icon: Icon( + isPasswordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.hintGrey, + size: 20.sp, + ), + onPressed: onTogglePasswordVisibility, + ) + : null, + contentPadding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 16.w), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide( + color: hasError ? primary : AppColors.borderGrey, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide(color: primary), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide(color: AppColors.borderGrey.withOpacity(0.5)), + ), + ), + ), + if (hasError && errorText != null) + Padding( + padding: EdgeInsets.only(top: 4.h), + child: Text( + errorText!, + style: GoogleFonts.poppins(color: primary, fontSize: 12.sp), + ), + ), + ], + ); + } +} diff --git a/lib/local_peference/local_preference.dart b/lib/local_peference/local_preference.dart new file mode 100644 index 0000000..8bc55de --- /dev/null +++ b/lib/local_peference/local_preference.dart @@ -0,0 +1,77 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class LocalPreference { + // Keys + static const String _keyLogin = "is_logged_in"; + static const String _keyUserId = "user_id"; + static const String _keyAccessToken = "access_token"; + static const String _keyRefreshToken = "refresh_token"; + static const String _keyOnBoarding = "on_boarding_done"; + + // -------------------- LOGIN -------------------- + + static Future setLogin(bool value) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyLogin, value); + } + + static Future getLogin() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyLogin) ?? false; + } + + // -------------------- USER ID -------------------- + + static Future setUserId(int id) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyUserId, id); + } + + static Future getUserId() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_keyUserId) ?? 0; + } + + // -------------------- ACCESS TOKEN -------------------- + + static Future setAccessToken(String token) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyAccessToken, token); + } + + static Future getAccessToken() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyAccessToken) ?? ""; + } + + // -------------------- REFRESH TOKEN -------------------- + + static Future setRefreshToken(String token) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyRefreshToken, token); + } + + static Future getRefreshToken() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyRefreshToken) ?? ""; + } + + // -------------------- ONBOARDING -------------------- + + static Future setOnBoarding(bool value) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyOnBoarding, value); + } + + static Future getOnBoarding() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyOnBoarding) ?? true; + } + + // -------------------- CLEAR -------------------- + + static Future clearAll() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + } +} \ No newline at end of file diff --git a/lib/login/blocs/forgot_password/forgot_password_bloc.dart b/lib/login/blocs/forgot_password/forgot_password_bloc.dart new file mode 100644 index 0000000..656038c --- /dev/null +++ b/lib/login/blocs/forgot_password/forgot_password_bloc.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../repositories/forgot_password_repository.dart'; + +part 'forgot_password_event.dart'; +part 'forgot_password_state.dart'; + +class ForgotPasswordBloc extends Bloc { + final ForgotPasswordRepository _forgotPasswordRepository; + + ForgotPasswordBloc({ForgotPasswordRepository? forgotPasswordRepository}) + : _forgotPasswordRepository = forgotPasswordRepository ?? ForgotPasswordRepository(), + super(const ForgotPasswordState()) { + on(_onForgotPasswordSubmitted); + on(_onEmailErrorToggled); + } + + Future _onForgotPasswordSubmitted( + ForgotPasswordSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: ForgotPasswordStatus.loading, errorMessage: null)); + + try { + await _forgotPasswordRepository.forgotPassword( + emailAddress: event.emailAddress, + ); + + emit(state.copyWith(status: ForgotPasswordStatus.success)); + } catch (e) { + emit(state.copyWith( + status: ForgotPasswordStatus.failure, + errorMessage: e.toString().replaceAll('Exception: ', ''), + )); + } + } + + void _onEmailErrorToggled( + ForgotPasswordEmailErrorToggled event, + Emitter emit, + ) { + emit(state.copyWith(showEmailError: event.show)); + } +} \ No newline at end of file diff --git a/lib/login/blocs/forgot_password/forgot_password_event.dart b/lib/login/blocs/forgot_password/forgot_password_event.dart new file mode 100644 index 0000000..eb166c7 --- /dev/null +++ b/lib/login/blocs/forgot_password/forgot_password_event.dart @@ -0,0 +1,25 @@ +part of 'forgot_password_bloc.dart'; + +abstract class ForgotPasswordEvent extends Equatable { + const ForgotPasswordEvent(); + + @override + List get props => []; +} + +class ForgotPasswordSubmitted extends ForgotPasswordEvent { + final String emailAddress; + + const ForgotPasswordSubmitted({required this.emailAddress}); + + @override + List get props => [emailAddress]; +} + +class ForgotPasswordEmailErrorToggled extends ForgotPasswordEvent { + final bool show; + const ForgotPasswordEmailErrorToggled(this.show); + + @override + List get props => [show]; +} \ No newline at end of file diff --git a/lib/login/blocs/forgot_password/forgot_password_state.dart b/lib/login/blocs/forgot_password/forgot_password_state.dart new file mode 100644 index 0000000..efa2fbe --- /dev/null +++ b/lib/login/blocs/forgot_password/forgot_password_state.dart @@ -0,0 +1,30 @@ +part of 'forgot_password_bloc.dart'; + +enum ForgotPasswordStatus { initial, loading, success, failure } + +class ForgotPasswordState extends Equatable { + final ForgotPasswordStatus status; + final String? errorMessage; + final bool showEmailError; + + const ForgotPasswordState({ + this.status = ForgotPasswordStatus.initial, + this.errorMessage, + this.showEmailError = false, + }); + + ForgotPasswordState copyWith({ + ForgotPasswordStatus? status, + String? errorMessage, + bool? showEmailError, + }) { + return ForgotPasswordState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + showEmailError: showEmailError ?? this.showEmailError, + ); + } + + @override + List get props => [status, errorMessage, showEmailError]; +} \ No newline at end of file diff --git a/lib/login/blocs/login/login_bloc.dart b/lib/login/blocs/login/login_bloc.dart new file mode 100644 index 0000000..91a7724 --- /dev/null +++ b/lib/login/blocs/login/login_bloc.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../local_peference/local_preference.dart'; +import '../../models/login.dart'; +import '../../repositories/login_repository.dart'; +part 'login_event.dart'; +part 'login_state.dart'; + +class LoginBloc extends Bloc { + final LoginRepository _loginRepository; + + LoginBloc({LoginRepository? loginRepository}) + : _loginRepository = loginRepository ?? LoginRepository(), + super(const LoginState()) { + on(_onLoginSubmitted); + on(_onPasswordVisibilityToggled); + on(_onRememberMeToggled); + on(_onEmailErrorToggled); + on(_onPasswordErrorToggled); + } + + // ================= LOGIN SUBMITTED ================= + Future _onLoginSubmitted( + LoginSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: LoginStatus.loading, errorMessage: null)); + + try { + final LoginModel loginData = await _loginRepository.login( + emailAddress: event.emailAddress, + password: event.password, + rememberMe: event.rememberMe, + ); + + // ── Save to local preference ────────────────────────────────────── + await Future.wait([ + LocalPreference.setAccessToken(loginData.accessToken), + LocalPreference.setRefreshToken(loginData.refreshToken), + LocalPreference.setUserId(loginData.partner.id), + LocalPreference.setLogin(true), + ]); + // ───────────────────────────────────────────────────────────────── + + emit(state.copyWith( + status: LoginStatus.success, + loginData: loginData, + )); + } catch (e) { + emit(state.copyWith( + status: LoginStatus.failure, + errorMessage: e.toString().replaceAll('Exception: ', ''), + )); + } + } + + // ================= PASSWORD VISIBILITY ================= + void _onPasswordVisibilityToggled( + LoginPasswordVisibilityToggled event, + Emitter emit, + ) { + emit(state.copyWith(isPasswordVisible: !state.isPasswordVisible)); + } + + // ================= REMEMBER ME ================= + void _onRememberMeToggled( + LoginRememberMeToggled event, + Emitter emit, + ) { + emit(state.copyWith(rememberMe: event.value)); + } + + void _onEmailErrorToggled( + LoginEmailErrorToggled event, + Emitter emit, + ) { + emit(state.copyWith(showEmailError: event.show)); + } + + void _onPasswordErrorToggled( + LoginPasswordErrorToggled event, + Emitter emit, + ) { + emit(state.copyWith(showPasswordError: event.show)); + } +} \ No newline at end of file diff --git a/lib/login/blocs/login/login_event.dart b/lib/login/blocs/login/login_event.dart new file mode 100644 index 0000000..05f5356 --- /dev/null +++ b/lib/login/blocs/login/login_event.dart @@ -0,0 +1,51 @@ +part of 'login_bloc.dart'; + +abstract class LoginEvent extends Equatable { + const LoginEvent(); + + @override + List get props => []; +} + +class LoginSubmitted extends LoginEvent { + final String emailAddress; + final String password; + final bool rememberMe; + + const LoginSubmitted({ + required this.emailAddress, + required this.password, + this.rememberMe = false, + }); + + @override + List get props => [emailAddress, password, rememberMe]; +} + +class LoginPasswordVisibilityToggled extends LoginEvent { + const LoginPasswordVisibilityToggled(); +} + +class LoginRememberMeToggled extends LoginEvent { + final bool value; + const LoginRememberMeToggled(this.value); + + @override + List get props => [value]; +} + +class LoginEmailErrorToggled extends LoginEvent { + final bool show; + const LoginEmailErrorToggled(this.show); + + @override + List get props => [show]; +} + +class LoginPasswordErrorToggled extends LoginEvent { + final bool show; + const LoginPasswordErrorToggled(this.show); + + @override + List get props => [show]; +} \ No newline at end of file diff --git a/lib/login/blocs/login/login_state.dart b/lib/login/blocs/login/login_state.dart new file mode 100644 index 0000000..869b6ad --- /dev/null +++ b/lib/login/blocs/login/login_state.dart @@ -0,0 +1,54 @@ +part of 'login_bloc.dart'; + +enum LoginStatus { initial, loading, success, failure } + +class LoginState extends Equatable { + final LoginStatus status; + final bool isPasswordVisible; + final bool rememberMe; + final String? errorMessage; + final LoginModel? loginData; + final bool showEmailError; + final bool showPasswordError; + + const LoginState({ + this.status = LoginStatus.initial, + this.isPasswordVisible = false, + this.rememberMe = false, + this.errorMessage, + this.loginData, + this.showEmailError = false, + this.showPasswordError = false, + }); + + LoginState copyWith({ + LoginStatus? status, + bool? isPasswordVisible, + bool? rememberMe, + String? errorMessage, + LoginModel? loginData, + bool? showEmailError, + bool? showPasswordError, + }) { + return LoginState( + status: status ?? this.status, + isPasswordVisible: isPasswordVisible ?? this.isPasswordVisible, + rememberMe: rememberMe ?? this.rememberMe, + errorMessage: errorMessage ?? this.errorMessage, + loginData: loginData ?? this.loginData, + showEmailError: showEmailError ?? this.showEmailError, + showPasswordError: showPasswordError ?? this.showPasswordError, + ); + } + + @override + List get props => [ + status, + isPasswordVisible, + rememberMe, + errorMessage, + loginData, + showEmailError, + showPasswordError, + ]; +} \ No newline at end of file diff --git a/lib/login/blocs/reset_password/reset_password_bloc.dart b/lib/login/blocs/reset_password/reset_password_bloc.dart new file mode 100644 index 0000000..f9f20aa --- /dev/null +++ b/lib/login/blocs/reset_password/reset_password_bloc.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../repositories/reset_password_repository.dart'; + +part 'reset_password_event.dart'; +part 'reset_password_state.dart'; + +class ResetPasswordBloc extends Bloc { + final ResetPasswordRepository _resetPasswordRepository; + + ResetPasswordBloc({ResetPasswordRepository? resetPasswordRepository}) + : _resetPasswordRepository = + resetPasswordRepository ?? ResetPasswordRepository(), + super(const ResetPasswordState()) { + on(_onResetPasswordSubmitted); + on(_onResetPasswordVisibilityToggled); + on(_onConfirmPasswordVisibilityToggled); + on(_onPasswordChanged); + on(_onPasswordErrorToggled); + on(_onConfirmPasswordErrorToggled); + } + + Future _onResetPasswordSubmitted( + ResetPasswordSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith( + status: ResetPasswordStatus.loading, errorMessage: null)); + + try { + await _resetPasswordRepository.resetPassword( + emailAddress: event.emailAddress, + newPassword: event.newPassword, + ); + + emit(state.copyWith(status: ResetPasswordStatus.success)); + } catch (e) { + emit(state.copyWith( + status: ResetPasswordStatus.failure, + errorMessage: e.toString().replaceAll('Exception: ', ''), + )); + } + } + + void _onResetPasswordVisibilityToggled( + ResetPasswordVisibilityToggled event, + Emitter emit, + ) { + emit(state.copyWith(isPasswordVisible: !state.isPasswordVisible)); + } + + void _onConfirmPasswordVisibilityToggled( + ConfirmPasswordVisibilityToggled event, + Emitter emit, + ) { + emit(state.copyWith( + isConfirmPasswordVisible: !state.isConfirmPasswordVisible)); + } + + void _onPasswordChanged( + PasswordChanged event, + Emitter emit, + ) { + final password = event.password; + emit(state.copyWith( + hasMinLength: password.length >= 8, + hasUppercase: password.contains(RegExp(r'[A-Z]')), + hasLowercase: password.contains(RegExp(r'[a-z]')), + hasNumber: password.contains(RegExp(r'[0-9]')), + hasSpecial: password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')), + )); + } + + void _onPasswordErrorToggled( + ResetPasswordErrorToggled event, + Emitter emit, + ) { + emit(state.copyWith( + showPasswordError: event.show, + passwordErrorText: event.error, + )); + } + + void _onConfirmPasswordErrorToggled( + ResetConfirmPasswordErrorToggled event, + Emitter emit, + ) { + emit(state.copyWith( + showConfirmPasswordError: event.show, + confirmPasswordErrorText: event.error, + )); + } +} diff --git a/lib/login/blocs/reset_password/reset_password_event.dart b/lib/login/blocs/reset_password/reset_password_event.dart new file mode 100644 index 0000000..7b8b9ca --- /dev/null +++ b/lib/login/blocs/reset_password/reset_password_event.dart @@ -0,0 +1,55 @@ +part of 'reset_password_bloc.dart'; + +abstract class ResetPasswordEvent extends Equatable { + const ResetPasswordEvent(); + + @override + List get props => []; +} + +class ResetPasswordSubmitted extends ResetPasswordEvent { + final String emailAddress; + final String newPassword; + + const ResetPasswordSubmitted({ + required this.emailAddress, + required this.newPassword, + }); + + @override + List get props => [emailAddress, newPassword]; +} + +class ResetPasswordVisibilityToggled extends ResetPasswordEvent { + const ResetPasswordVisibilityToggled(); +} + +class ConfirmPasswordVisibilityToggled extends ResetPasswordEvent { + const ConfirmPasswordVisibilityToggled(); +} + +class PasswordChanged extends ResetPasswordEvent { + final String password; + const PasswordChanged(this.password); + + @override + List get props => [password]; +} + +class ResetPasswordErrorToggled extends ResetPasswordEvent { + final bool show; + final String? error; + const ResetPasswordErrorToggled(this.show, {this.error}); + + @override + List get props => [show, error]; +} + +class ResetConfirmPasswordErrorToggled extends ResetPasswordEvent { + final bool show; + final String? error; + const ResetConfirmPasswordErrorToggled(this.show, {this.error}); + + @override + List get props => [show, error]; +} \ No newline at end of file diff --git a/lib/login/blocs/reset_password/reset_password_state.dart b/lib/login/blocs/reset_password/reset_password_state.dart new file mode 100644 index 0000000..c135efa --- /dev/null +++ b/lib/login/blocs/reset_password/reset_password_state.dart @@ -0,0 +1,90 @@ +part of 'reset_password_bloc.dart'; + +enum ResetPasswordStatus { initial, loading, success, failure } + +class ResetPasswordState extends Equatable { + final ResetPasswordStatus status; + final bool isPasswordVisible; + final bool isConfirmPasswordVisible; + final String? errorMessage; + + // Validation flags + final bool hasMinLength; + final bool hasUppercase; + final bool hasLowercase; + final bool hasNumber; + final bool hasSpecial; + + final bool showPasswordError; + final bool showConfirmPasswordError; + final String? passwordErrorText; + final String? confirmPasswordErrorText; + + const ResetPasswordState({ + this.status = ResetPasswordStatus.initial, + this.isPasswordVisible = false, + this.isConfirmPasswordVisible = false, + this.errorMessage, + this.hasMinLength = false, + this.hasUppercase = false, + this.hasLowercase = false, + this.hasNumber = false, + this.hasSpecial = false, + this.showPasswordError = false, + this.showConfirmPasswordError = false, + this.passwordErrorText, + this.confirmPasswordErrorText, + }); + + bool get isPasswordValid => + hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecial; + + ResetPasswordState copyWith({ + ResetPasswordStatus? status, + bool? isPasswordVisible, + bool? isConfirmPasswordVisible, + String? errorMessage, + bool? hasMinLength, + bool? hasUppercase, + bool? hasLowercase, + bool? hasNumber, + bool? hasSpecial, + bool? showPasswordError, + bool? showConfirmPasswordError, + String? passwordErrorText, + String? confirmPasswordErrorText, + }) { + return ResetPasswordState( + status: status ?? this.status, + isPasswordVisible: isPasswordVisible ?? this.isPasswordVisible, + isConfirmPasswordVisible: isConfirmPasswordVisible ?? this.isConfirmPasswordVisible, + errorMessage: errorMessage ?? this.errorMessage, + hasMinLength: hasMinLength ?? this.hasMinLength, + hasUppercase: hasUppercase ?? this.hasUppercase, + hasLowercase: hasLowercase ?? this.hasLowercase, + hasNumber: hasNumber ?? this.hasNumber, + hasSpecial: hasSpecial ?? this.hasSpecial, + showPasswordError: showPasswordError ?? this.showPasswordError, + showConfirmPasswordError: showConfirmPasswordError ?? this.showConfirmPasswordError, + passwordErrorText: passwordErrorText ?? this.passwordErrorText, + confirmPasswordErrorText: confirmPasswordErrorText ?? this.confirmPasswordErrorText, + ); + } + + @override + List get props => [ + status, + isPasswordVisible, + isConfirmPasswordVisible, + errorMessage, + hasMinLength, + hasUppercase, + hasLowercase, + hasNumber, + hasSpecial, + showPasswordError, + showConfirmPasswordError, + passwordErrorText, + confirmPasswordErrorText, + ]; +} \ No newline at end of file diff --git a/lib/login/blocs/verify_otp/verify_otp_bloc.dart b/lib/login/blocs/verify_otp/verify_otp_bloc.dart new file mode 100644 index 0000000..242e871 --- /dev/null +++ b/lib/login/blocs/verify_otp/verify_otp_bloc.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../repositories/otp_repository.dart'; + +part 'verify_otp_event.dart'; +part 'verify_otp_state.dart'; + +class VerifyOtpBloc extends Bloc { + final OtpRepository _otpRepository; + + VerifyOtpBloc({OtpRepository? otpRepository}) + : _otpRepository = otpRepository ?? OtpRepository(), + super(const VerifyOtpState()) { + on(_onVerifyOtpSubmitted); + } + + Future _onVerifyOtpSubmitted( + VerifyOtpSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: VerifyOtpStatus.loading, errorMessage: null)); + + try { + await _otpRepository.verifyOtp( + emailAddress: event.emailAddress, + otp: event.otp, + ); + + emit(state.copyWith(status: VerifyOtpStatus.success)); + } catch (e) { + emit(state.copyWith( + status: VerifyOtpStatus.failure, + errorMessage: e.toString().replaceAll('Exception: ', ''), + )); + } + } +} \ No newline at end of file diff --git a/lib/login/blocs/verify_otp/verify_otp_event.dart b/lib/login/blocs/verify_otp/verify_otp_event.dart new file mode 100644 index 0000000..0abe38d --- /dev/null +++ b/lib/login/blocs/verify_otp/verify_otp_event.dart @@ -0,0 +1,21 @@ +part of 'verify_otp_bloc.dart'; + +abstract class VerifyOtpEvent extends Equatable { + const VerifyOtpEvent(); + + @override + List get props => []; +} + +class VerifyOtpSubmitted extends VerifyOtpEvent { + final String emailAddress; + final String otp; + + const VerifyOtpSubmitted({ + required this.emailAddress, + required this.otp, + }); + + @override + List get props => [emailAddress, otp]; +} \ No newline at end of file diff --git a/lib/login/blocs/verify_otp/verify_otp_state.dart b/lib/login/blocs/verify_otp/verify_otp_state.dart new file mode 100644 index 0000000..e8d2522 --- /dev/null +++ b/lib/login/blocs/verify_otp/verify_otp_state.dart @@ -0,0 +1,26 @@ +part of 'verify_otp_bloc.dart'; + +enum VerifyOtpStatus { initial, loading, success, failure } + +class VerifyOtpState extends Equatable { + final VerifyOtpStatus status; + final String? errorMessage; + + const VerifyOtpState({ + this.status = VerifyOtpStatus.initial, + this.errorMessage, + }); + + VerifyOtpState copyWith({ + VerifyOtpStatus? status, + String? errorMessage, + }) { + return VerifyOtpState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, errorMessage]; +} \ No newline at end of file diff --git a/lib/login/models/login.dart b/lib/login/models/login.dart index e69de29..eb7d6b5 100644 --- a/lib/login/models/login.dart +++ b/lib/login/models/login.dart @@ -0,0 +1,63 @@ +class LoginModel { + final String accessToken; + final String refreshToken; + final int refreshMaxAge; + final PartnerModel partner; + + const LoginModel({ + required this.accessToken, + required this.refreshToken, + required this.refreshMaxAge, + required this.partner, + }); + + factory LoginModel.fromJson(Map json) { + return LoginModel( + accessToken: json['accessToken'] ?? '', + refreshToken: json['refreshToken'] ?? '', + refreshMaxAge: json['refreshMaxAge'] ?? 0, + partner: PartnerModel.fromJson(json['partner']), + ); + } + + Map toJson() { + return { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'refreshMaxAge': refreshMaxAge, + 'partner': partner.toJson(), + }; + } +} + +class PartnerModel { + final int id; + final String email; + final String name; + final int roleXid; + + const PartnerModel({ + required this.id, + required this.email, + required this.name, + required this.roleXid, + }); + + factory PartnerModel.fromJson(Map json) { + return PartnerModel( + id: json['id'] ?? 0, + email: json['email'] ?? '', + name: json['name'] ?? '', + roleXid: json['roleXid'] ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'name': name, + 'roleXid': roleXid, + }; + } +} \ No newline at end of file diff --git a/lib/login/repositories/forgot_password_repository.dart b/lib/login/repositories/forgot_password_repository.dart index e69de29..27dbcb6 100644 --- a/lib/login/repositories/forgot_password_repository.dart +++ b/lib/login/repositories/forgot_password_repository.dart @@ -0,0 +1,17 @@ +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; + +class ForgotPasswordRepository { + final ApiService _apiService = ApiService(); + + Future forgotPassword({required String emailAddress}) async { + try { + await _apiService.post( + ApiUrls.forgotPassword, + data: {"emailAddress": emailAddress}, + ); + } catch (e) { + throw Exception('Failed to send forgot password request: $e'); + } + } +} \ No newline at end of file diff --git a/lib/login/repositories/login_repository.dart b/lib/login/repositories/login_repository.dart index e69de29..d8f9e88 100644 --- a/lib/login/repositories/login_repository.dart +++ b/lib/login/repositories/login_repository.dart @@ -0,0 +1,28 @@ +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; +import '../models/login.dart'; + +class LoginRepository { + final ApiService _apiService = ApiService(); + + Future login({ + required String emailAddress, + required String password, + bool rememberMe = false, + }) async { + try { + final response = await _apiService.post( + ApiUrls.login, + data: { + "emailAddress": emailAddress, + "password": password, + "rememberMe": rememberMe, + }, + ); + + return LoginModel.fromJson(response.data as Map); + } catch (e) { + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/login/repositories/otp_repository.dart b/lib/login/repositories/otp_repository.dart index e69de29..8dd3b4c 100644 --- a/lib/login/repositories/otp_repository.dart +++ b/lib/login/repositories/otp_repository.dart @@ -0,0 +1,23 @@ +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; + +class OtpRepository { + final ApiService _apiService = ApiService(); + + Future verifyOtp({ + required String emailAddress, + required String otp, + }) async { + try { + await _apiService.post( + ApiUrls.verifyOtp, + data: { + "emailAddress": emailAddress, + "otp": otp, + }, + ); + } catch (e) { + throw Exception('Failed to verify OTP: $e'); + } + } +} \ No newline at end of file diff --git a/lib/login/repositories/reset_password_repository.dart b/lib/login/repositories/reset_password_repository.dart index e69de29..c35e3b4 100644 --- a/lib/login/repositories/reset_password_repository.dart +++ b/lib/login/repositories/reset_password_repository.dart @@ -0,0 +1,23 @@ +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; + +class ResetPasswordRepository { + final ApiService _apiService = ApiService(); + + Future resetPassword({ + required String emailAddress, + required String newPassword, + }) async { + try { + await _apiService.post( + ApiUrls.resetPassword, + data: { + "emailAddress": emailAddress, + "newPassword": newPassword, + }, + ); + } catch (e) { + throw Exception('Failed to reset password: $e'); + } + } +} \ No newline at end of file diff --git a/lib/login/views/forgot_password_page.dart b/lib/login/views/forgot_password_page.dart index ba3703c..35c62b7 100644 --- a/lib/login/views/forgot_password_page.dart +++ b/lib/login/views/forgot_password_page.dart @@ -1,241 +1,178 @@ -import 'dart:ui'; +import 'package:citycards_partner_flutter/constants/app_assets.dart'; +import 'package:citycards_partner_flutter/constants/app_colors.dart'; import 'package:citycards_partner_flutter/core/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../blocs/forgot_password_bloc.dart'; +import '../../custome_widgets/custom_button.dart'; +import '../../custome_widgets/custom_textfield.dart'; +import '../blocs/forgot_password/forgot_password_bloc.dart'; -class ForgotPasswordPage extends StatelessWidget { +class ForgotPasswordPage extends StatefulWidget { const ForgotPasswordPage({super.key}); @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ForgotPasswordBloc(), - child: const _ForgotPasswordView(), - ); - } + State createState() => _ForgotPasswordPageState(); } -class _ForgotPasswordView extends StatelessWidget { - const _ForgotPasswordView(); +class _ForgotPasswordPageState extends State { + final _emailController = TextEditingController(); + final _emailFocusNode = FocusNode(); + + @override + void dispose() { + _emailController.dispose(); + _emailFocusNode.dispose(); + super.dispose(); + } + + bool _isEmailValid(String email) { + return RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") + .hasMatch(email); + } + + void _onSendLinkPressed(BuildContext context) { + FocusManager.instance.primaryFocus?.unfocus(); + + final email = _emailController.text.trim(); + final isEmailValid = _isEmailValid(email); + + context.read().add(ForgotPasswordEmailErrorToggled(!isEmailValid)); + + if (isEmailValid) { + context.read().add( + ForgotPasswordSubmitted(emailAddress: email), + ); + } + } @override Widget build(BuildContext context) { - final bloc = context.read(); - - return Scaffold( - body: Stack( - children: [ - // Background - Positioned.fill( - child: Image.asset( - 'assets/login/bg.png', - fit: BoxFit.cover, - ), - ), - - // Gradient Overlay - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.3), - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(1.0), - ], + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.translucent, + child: Scaffold( + backgroundColor: AppColors.backgroundWhite, + body: BlocConsumer( + listener: (context, state) { + if (state.status == ForgotPasswordStatus.success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("OTP sent successfully!"), + backgroundColor: AppColors.successGreen, ), - ), - ), - ), + ); + Navigator.pushNamed( + context, + AppRouter.otpVerification, + arguments: _emailController.text.trim(), + ); + } else if (state.status == ForgotPasswordStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? "An error occurred"), + backgroundColor: Colors.redAccent, + ), + ); + } + }, + builder: (context, state) { + final isLoading = state.status == ForgotPasswordStatus.loading; - // Foreground content - Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: BlocConsumer( - listener: (context, state) { - if (state.isSuccess) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Reset link sent successfully!"), - backgroundColor: Colors.green, - ), - ); - Navigator.pushNamed(context, AppRouter.otpVerification); - } else if (state.message.isNotEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.redAccent, - ), - ); - } - }, - builder: (context, state) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 160), - - // Glass Card - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Align( - alignment: Alignment.center, - child: Image.asset( - "assets/login/app_icon.png", - scale: 4, + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: 40.h), + // ===== LOGO SECTION ===== + Center( + child: Column( + children: [ + Image.asset( + AppAssets.appIcon, + height: 60.h, ), - ), - const SizedBox(height: 24), - Text( - "Forgot Password", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - "Forgot your password? Don’t worry — just enter your email and we’ll help you reset it.", - textAlign: TextAlign.start, - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 13, - ), - ), - const SizedBox(height: 24), - - // Email Field - TextField( - style: const TextStyle(color: Colors.white), - onChanged: (value) => - bloc.add(EmailChanged(value)), - decoration: InputDecoration( - prefixIcon: const Icon( - Icons.email_outlined, - color: Colors.white70, - ), - hintText: 'Enter your email address', - hintStyle: const TextStyle( - color: Colors.white54), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: Colors.white54), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: Colors.white), + SizedBox(height: 8.h), + Text( + "Partner’s App", + style: GoogleFonts.poppins( + color: AppColors.primaryRed, + fontSize: 20.sp, + fontWeight: FontWeight.w500, ), ), - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - - const SizedBox(height: 148), - - // SizedBox( - // width: double.infinity, - // child: ElevatedButton( - // onPressed: state.isValidEmail && !state.isLoading - // ? () => bloc.add(SendResetLink()) - // : null, - // style: ButtonStyle( - // backgroundColor: - // MaterialStateProperty.resolveWith( - // (states) { - // if (states.contains(MaterialState.disabled)) { - // return const Color(0xFF9C3F42); - // } - // return const Color(0xFFFF4C4C); - // }, - // ), - // padding: MaterialStateProperty.all( - // const EdgeInsets.symmetric(vertical: 14), - // ), - // shape: MaterialStateProperty.all( - // RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(12), - // ), - // ), - // ), - // child: Text( - // "Send Reset Link", - // style: GoogleFonts.poppins( - // color: state.isValidEmail?Colors.white:Color(0xff9D9F9F), - // fontSize: 16, - // fontWeight: FontWeight.w600, - // ), - // ), - // ), - // ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => bloc.add(SendResetLink()), - style: ButtonStyle( - - backgroundColor: - MaterialStateProperty.resolveWith( - (states) { - if (states.contains(MaterialState.disabled)) { - return const Color(0xFFFF4C4C); - } - return const Color(0xFFFF4C4C); - }, - ), - - padding: MaterialStateProperty.all( - const EdgeInsets.symmetric(vertical: 14), - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + ], ), ), - child: Text( - "Send Reset Link", + SizedBox(height: 60.h), + + // ===== HEADER TEXT ===== + Text( + "Change Your Password", + textAlign: TextAlign.center, style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 16, + color: AppColors.black, + fontSize: 24.sp, fontWeight: FontWeight.w600, ), ), - ), + SizedBox(height: 12.h), + Text( + "Enter your email to update your password\nand secure your account", + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + color: AppColors.textGrey, + fontSize: 16.sp, + height: 1.4, + ), + ), + SizedBox(height: 48.h), + + // ===== EMAIL FIELD ===== + CustomTextField( + label: 'Email Address', + hintText: 'you@example.com', + controller: _emailController, + focusNode: _emailFocusNode, + prefixIcon: Icons.email_outlined, + hasError: state.showEmailError, + errorText: 'Please enter a valid email address', + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + readOnly: isLoading, + onChanged: (val) { + if (state.showEmailError) { + context.read().add( + ForgotPasswordEmailErrorToggled(!_isEmailValid(val.trim())), + ); + } + }, + onSubmitted: (_) => _onSendLinkPressed(context), + ), + ], ), ), - ], - ); - }, + ), + + // ===== SEND LINK BUTTON ===== + CustomButton( + text: "Send OTP", + isLoading: isLoading, + onPressed: () => _onSendLinkPressed(context), + ), + SizedBox(height: 24.h), + ], + ), ), - ), - ), - ], + ); + }, + ), ), ); } diff --git a/lib/login/views/login_page.dart b/lib/login/views/login_page.dart index bf63857..26d41ab 100644 --- a/lib/login/views/login_page.dart +++ b/lib/login/views/login_page.dart @@ -1,8 +1,13 @@ -import 'dart:ui'; - import 'package:citycards_partner_flutter/core/app_router.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../constants/app_assets.dart'; +import '../../constants/app_colors.dart'; +import '../../custome_widgets/custom_button.dart'; +import '../../custome_widgets/custom_textfield.dart'; +import '../blocs/login/login_bloc.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -12,222 +17,278 @@ class LoginPage extends StatefulWidget { } class _LoginPageState extends State { - bool _isPasswordVisible = false; - bool _rememberMe = false; + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _emailFocusNode = FocusNode(); + final _passwordFocusNode = FocusNode(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _emailFocusNode.dispose(); + _passwordFocusNode.dispose(); + super.dispose(); + } + + bool _isEmailValid(String email) { + return RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") + .hasMatch(email); + } + + void _onLoginPressed(BuildContext context) { + FocusManager.instance.primaryFocus?.unfocus(); + + final email = _emailController.text.trim(); + final password = _passwordController.text.trim(); + + final isEmailValid = _isEmailValid(email); + final isPasswordValid = password.length >= 8; + + context.read().add(LoginEmailErrorToggled(!isEmailValid)); + context.read().add(LoginPasswordErrorToggled(!isPasswordValid)); + + if (isEmailValid && isPasswordValid) { + context.read().add( + LoginSubmitted( + emailAddress: email, + password: password, + rememberMe: context.read().state.rememberMe, + ), + ); + } + } @override Widget build(BuildContext context) { - return Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: Image.asset( - 'assets/login/bg.png', - fit: BoxFit.cover, - ), - ), - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.3), - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(1.0), - ], - // stops: const [0.5, 1.0], - ), - ), - ), - ), - Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: 80), - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Container( - padding: const EdgeInsets.all(24), + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.translucent, + child: Scaffold( + backgroundColor: AppColors.backgroundWhite, + resizeToAvoidBottomInset: true, + body: BlocConsumer( + listener: (context, state) { + if (state.status == LoginStatus.success) { + Navigator.pushNamedAndRemoveUntil( + context, + AppRouter.qrScanScreen, + (route) => false, // removes all previous routes + ); + } + }, + builder: (context, state) { + final isLoading = state.status == LoginStatus.loading; + + return SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 40.h), + + // ===== LOGO SECTION ===== + Center( + child: Column( + children: [ + Image.asset( + AppAssets.appIcon, + height: 60.h, + ), + SizedBox(height: 8.h), + Text( + "Partner's App", + style: GoogleFonts.poppins( + color: AppColors.primaryRed, + fontSize: 20.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + SizedBox(height: 60.h), + + // ===== WELCOME TEXT ===== + Center( + child: Column( + children: [ + Text( + "Welcome Back", + style: GoogleFonts.poppins( + color: AppColors.black, + fontSize: 24.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + Text( + "Sign in to your account", + style: GoogleFonts.poppins( + color: AppColors.textGrey, + fontSize: 16.sp, + ), + ), + ], + ), + ), + SizedBox(height: 32.h), + + // ===== ERROR BANNER ===== + if (state.status == LoginStatus.failure) + Container( + margin: EdgeInsets.only(bottom: 24.h), + padding: EdgeInsets.symmetric( + horizontal: 16.w, vertical: 12.h), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(12.r), + border: + Border.all(color: AppColors.primaryRed.withOpacity(0.5)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Align( - alignment: AlignmentGeometry.center, - child: Image.asset("assets/login/app_icon.png",scale: 4,)), - const SizedBox(height: 24), - Text( - "Enter your email", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 14, - ), - ), - const SizedBox(height: 8), - TextField( - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: '', - hintStyle: const TextStyle(color: Colors.white54), - prefixIcon: const Icon( - Icons.email_outlined, - color: Colors.white70, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - const BorderSide(color: Colors.white54), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - const BorderSide(color: Colors.white), + Icon(Icons.error_outline, + color: AppColors.primaryRed, size: 20.sp), + SizedBox(width: 12.w), + Expanded( + child: Text( + state.errorMessage ?? + "Invalid email or password. Please try again.", + style: GoogleFonts.poppins( + color: AppColors.primaryRed, + fontSize: 13.sp, + fontWeight: FontWeight.w400, ), ), ), - const SizedBox(height: 20), - - Text( - "Enter your password", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 14, - ), - ), - const SizedBox(height: 8), - TextField( - obscureText: !_isPasswordVisible, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: '', - hintStyle: const TextStyle(color: Colors.white54), - prefixIcon: const Icon( - Icons.lock_outline, - color: Colors.white70, - ), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - color: Colors.white70, - ), - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - const BorderSide(color: Colors.white54), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - const BorderSide(color: Colors.white), - ), - ), - ), - - const SizedBox(height: 12), - - // Remember me + Forgot password - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - GestureDetector( - onTap: () { - setState(() { - _rememberMe = !_rememberMe; - }); - }, - child: Container( - height: 18, - width: 18, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - border: Border.all(color: Colors.white), - color: _rememberMe - ? Colors.white - : Colors.transparent, - ), - ), - ), - const SizedBox(width: 8), - Text( - "Remember me", - style: GoogleFonts.poppins( - color: Colors.white, - fontWeight: FontWeight.w400, - fontSize: 14, - ), - ), - ], - ), - GestureDetector( - onTap: () { - Navigator.pushNamed( - context, AppRouter.forgotPassword); - }, - child: Text( - "Forgot Password?", - style: GoogleFonts.poppins( - color: const Color(0xFFFF4C4C), - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), ], ), ), - ), - ), - const SizedBox(height: 68), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - Navigator.pushNamed(context, AppRouter.forgotPassword); + + // ===== EMAIL FIELD ===== + CustomTextField( + label: 'Email Address', + hintText: 'Enter your email', + controller: _emailController, + focusNode: _emailFocusNode, + prefixIcon: Icons.email_outlined, + hasError: state.showEmailError, + errorText: 'Please enter a valid email address', + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + readOnly: isLoading, + onChanged: (val) { + if (state.showEmailError) { + context.read().add( + LoginEmailErrorToggled(!_isEmailValid(val.trim())), + ); + } }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFFF4C4C), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - "Log in", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), ), - ), - ], + SizedBox(height: 20.h), + + // ===== PASSWORD FIELD ===== + CustomTextField( + label: 'Password', + hintText: 'Enter your password', + controller: _passwordController, + focusNode: _passwordFocusNode, + prefixIcon: Icons.lock_outline, + isPassword: true, + isPasswordVisible: state.isPasswordVisible, + onTogglePasswordVisibility: () { + context.read().add( + const LoginPasswordVisibilityToggled(), + ); + }, + hasError: state.showPasswordError, + errorText: 'Password must be at least 8 characters.', + textInputAction: TextInputAction.done, + readOnly: isLoading, + onChanged: (val) { + if (state.showPasswordError) { + context.read().add( + LoginPasswordErrorToggled(val.length < 8), + ); + } + }, + onSubmitted: (_) => _onLoginPressed(context), + ), + SizedBox(height: 16.h), + + // ===== REMEMBER ME + FORGOT PASSWORD ===== + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + SizedBox( + height: 24.h, + width: 24.w, + child: Checkbox( + value: state.rememberMe, + onChanged: isLoading + ? null + : (value) { + context.read().add( + LoginRememberMeToggled( + value ?? false), + ); + }, + activeColor: AppColors.primaryRed, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.r), + ), + side: BorderSide(color: Colors.grey[300]!), + ), + ), + SizedBox(width: 8.w), + Text( + "Remember me", + style: GoogleFonts.poppins( + color: AppColors.textGrey, + fontSize: 14.sp, + ), + ), + ], + ), + GestureDetector( + onTap: isLoading + ? null + : () { + Navigator.pushNamed( + context, + AppRouter.forgotPassword, + ); + }, + child: Text( + "Forgot Password?", + style: GoogleFonts.poppins( + color: AppColors.primaryRed, + fontSize: 14.sp, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + SizedBox(height: 80.h), + + // ===== LOGIN BUTTON ===== + CustomButton( + text: "Log in", + isLoading: isLoading, + onPressed: () => _onLoginPressed(context), + ), + SizedBox(height: 24.h), + ], + ), ), - ), - ), - ], + ); + }, + ), ), ); } diff --git a/lib/login/views/otp_verification_page.dart b/lib/login/views/otp_verification_page.dart index 2f52ec5..a09316c 100644 --- a/lib/login/views/otp_verification_page.dart +++ b/lib/login/views/otp_verification_page.dart @@ -1,192 +1,165 @@ -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../constants/app_assets.dart'; +import '../../constants/app_colors.dart'; import '../../core/app_router.dart'; -import '../blocs/otp_bloc.dart'; +import '../../custome_widgets/custom_button.dart'; +import '../blocs/verify_otp/verify_otp_bloc.dart'; -class OtpVerificationPage extends StatelessWidget { - const OtpVerificationPage({super.key}); +class OtpVerificationPage extends StatefulWidget { + final String email; + + const OtpVerificationPage({super.key, required this.email}); + + @override + State createState() => _OtpVerificationPageState(); +} + +class _OtpVerificationPageState extends State { + String _otp = ""; @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => OtpBloc(), + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.translucent, child: Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: Image.asset('assets/login/bg.png', fit: BoxFit.cover), - ), - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.3), - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(1.0), - ], - ), + backgroundColor: AppColors.backgroundWhite, + body: BlocConsumer( + listener: (context, state) { + if (state.status == VerifyOtpStatus.success) { + Navigator.pushReplacementNamed( + context, + AppRouter.resetPassword, + arguments: widget.email, + ); + } else if (state.status == VerifyOtpStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? "Verification failed"), + backgroundColor: Colors.redAccent, ), - ), - ), + ); + } + }, + builder: (context, state) { + final isLoading = state.status == VerifyOtpStatus.loading; - // Foreground content - Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 18), + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox(height: 80), - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.4), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Colors.white.withOpacity(0.2), - ), - ), - child: BlocConsumer( - listener: (context, state) { - if (state.isVerified) { - Navigator.pushNamed(context, AppRouter.resetPassword); - } else if (state.message.isNotEmpty && - !state.isLoading) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: state.isVerified - ? Colors.green - : Colors.redAccent, - ), - ); - } - }, - builder: (context, state) { - final otpBloc = context.read(); - return Column( + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: 40.h), + // ===== LOGO SECTION ===== + Center( + child: Column( children: [ - Align( - alignment: Alignment.center, - child: Image.asset( - "assets/login/app_icon.png", - scale: 4, - ), + Image.asset( + AppAssets.appIcon, + height: 60.h, ), - const SizedBox(height: 24), + SizedBox(height: 8.h), Text( - "Verify OTP", + "Partner’s App", style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 24, + color: AppColors.primaryRed, + fontSize: 20.sp, fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 8), - Text( - "We’ve sent an OTP to your registered email. Please enter it below, or use the reset link included in the email.", - textAlign: TextAlign.start, - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 30), - - // OTP input fields - OtpTextField( - borderRadius: BorderRadius.all( - Radius.circular(6), - ), - numberOfFields: 6, - fillColor: Color(0xff242628), - cursorColor: Colors.white, - borderColor: Colors.white, - focusedBorderColor: const Color(0xFFFF4C4C), - showFieldAsBox: true, - fieldWidth: 45, - textStyle: const TextStyle( - color: Colors.white, - ), - onSubmit: (value) { - otpBloc.add(OtpChanged(value)); - otpBloc.add(OtpVerify()); - }, - onCodeChanged: (value) { - otpBloc.add(OtpChanged(value)); - }, - ), - const SizedBox(height: 24), ], - ); - }, - ), - ), - ), - ), - const SizedBox(height: 60), - BlocBuilder( - builder: (context, state) { - final otpBloc = context.read(); - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: state.isOtpFilled && !state.isLoading - ? () => otpBloc.add(OtpVerify()) - : null, - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.resolveWith(( - states, - ) { - if (states.contains( - MaterialState.disabled, - )) { - return const Color( - 0xFF9C3F42, - ); // 👈 custom disabled color - } - return const Color( - 0xFFFF4C4C, - ); // 👈 active color - }), - padding: MaterialStateProperty.all( - const EdgeInsets.symmetric(vertical: 14), - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), ), ), - child: Text( - "Verify", + SizedBox(height: 60.h), + + // ===== HEADER TEXT ===== + Text( + "Verify OTP", + textAlign: TextAlign.center, style: GoogleFonts.poppins( - color: state.isOtpFilled?Colors.white:Color(0xff9D9F9F), - fontSize: 16, + color: AppColors.black, + fontSize: 24.sp, fontWeight: FontWeight.w600, ), ), - ), - ); - }, + SizedBox(height: 12.h), + Text( + "We’ve sent an OTP to your registered email. Please enter it below.", + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + color: AppColors.textGrey, + fontSize: 16.sp, + height: 1.4, + ), + ), + SizedBox(height: 48.h), + + // ===== OTP INPUT FIELDS ===== + OtpTextField( + numberOfFields: 6, + borderColor: AppColors.borderGrey, + focusedBorderColor: AppColors.primaryRed, + showFieldAsBox: true, + fieldWidth: 45.w, + borderRadius: BorderRadius.circular(12.r), + enabledBorderColor: AppColors.borderGrey, + cursorColor: AppColors.primaryRed, + textStyle: GoogleFonts.poppins( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: AppColors.black, + ), + onCodeChanged: (String code) { + setState(() { + _otp = code; + }); + }, + onSubmit: (String verificationCode) { + setState(() { + _otp = verificationCode; + }); + context.read().add( + VerifyOtpSubmitted( + emailAddress: widget.email, + otp: verificationCode, + ), + ); + }, + ), + ], + ), + ), ), + + // ===== VERIFY BUTTON ===== + CustomButton( + text: "Verify", + isLoading: isLoading, + onPressed: _otp.length == 6 + ? () { + context.read().add( + VerifyOtpSubmitted( + emailAddress: widget.email, + otp: _otp, + ), + ); + } + : null, + ), + SizedBox(height: 24.h), ], ), ), - ), - ], + ); + }, ), ), ); diff --git a/lib/login/views/reset_password_page.dart b/lib/login/views/reset_password_page.dart index 740f250..f4227cc 100644 --- a/lib/login/views/reset_password_page.dart +++ b/lib/login/views/reset_password_page.dart @@ -1,247 +1,284 @@ -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../constants/app_assets.dart'; +import '../../constants/app_colors.dart'; import '../../core/app_router.dart'; -import '../blocs/reset_password_bloc.dart'; +import '../../custome_widgets/custom_button.dart'; +import '../../custome_widgets/custom_textfield.dart'; +import '../blocs/reset_password/reset_password_bloc.dart'; -class ResetPasswordPage extends StatelessWidget { - const ResetPasswordPage({super.key}); +class ResetPasswordPage extends StatefulWidget { + final String email; + + const ResetPasswordPage({super.key, required this.email}); @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ResetPasswordBloc(), - child: const _ResetPasswordView(), - ); - } + State createState() => _ResetPasswordPageState(); } -class _ResetPasswordView extends StatelessWidget { - const _ResetPasswordView(); +class _ResetPasswordPageState extends State { + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _passwordFocusNode = FocusNode(); + final _confirmFocusNode = FocusNode(); + + @override + void dispose() { + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _passwordFocusNode.dispose(); + _confirmFocusNode.dispose(); + super.dispose(); + } + + void _onResetPressed(BuildContext context, ResetPasswordState state) { + FocusManager.instance.primaryFocus?.unfocus(); + + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + + if (!state.isPasswordValid) { + context.read().add( + const ResetPasswordErrorToggled(true, + error: "Please fulfill all password requirements"), + ); + return; + } + + if (password != confirmPassword) { + context.read().add( + const ResetConfirmPasswordErrorToggled(true, + error: "Passwords do not match"), + ); + return; + } + + context.read().add( + ResetPasswordSubmitted( + emailAddress: widget.email, + newPassword: password, + ), + ); + } @override Widget build(BuildContext context) { - final bloc = context.read(); + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + behavior: HitTestBehavior.translucent, + child: Scaffold( + backgroundColor: AppColors.backgroundWhite, + body: BlocConsumer( + listener: (context, state) { + if (state.status == ResetPasswordStatus.success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Password reset successfully!"), + backgroundColor: AppColors.successGreen, + ), + ); + Navigator.pushNamedAndRemoveUntil( + context, + AppRouter.login, + (route) => false, + ); + } else if (state.status == ResetPasswordStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? "Reset failed"), + backgroundColor: Colors.redAccent, + ), + ); + } + }, + builder: (context, state) { + final isLoading = state.status == ResetPasswordStatus.loading; - return Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: Image.asset('assets/login/bg.png', fit: BoxFit.cover), - ), - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.3), - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(1.0), + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: 40.h), + // ===== LOGO SECTION ===== + Center( + child: Column( + children: [ + Image.asset( + AppAssets.appIcon, + height: 60.h, + ), + SizedBox(height: 8.h), + Text( + "Partner’s App", + style: GoogleFonts.poppins( + color: AppColors.primaryRed, + fontSize: 20.sp, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + SizedBox(height: 60.h), + + // ===== HEADER TEXT ===== + Text( + "Reset your password", + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + color: AppColors.black, + fontSize: 24.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + Text( + "Almost there — just set your new password", + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + color: AppColors.textGrey, + fontSize: 16.sp, + ), + ), + SizedBox(height: 48.h), + + // ===== NEW PASSWORD FIELD ===== + CustomTextField( + label: 'New Password', + hintText: 'Enter new password', + controller: _passwordController, + focusNode: _passwordFocusNode, + prefixIcon: Icons.lock_outline, + isPassword: true, + isPasswordVisible: state.isPasswordVisible, + onTogglePasswordVisibility: () { + context.read().add( + const ResetPasswordVisibilityToggled(), + ); + }, + hasError: state.showPasswordError, + errorText: state.passwordErrorText, + textInputAction: TextInputAction.next, + readOnly: isLoading, + onChanged: (val) { + context + .read() + .add(PasswordChanged(val)); + if (state.showPasswordError) { + context.read().add( + const ResetPasswordErrorToggled(false)); + } + }, + ), + SizedBox(height: 20.h), + + // ===== VALIDATION BOX ===== + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: AppColors.bgLightGrey, + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Password must contain:", + style: GoogleFonts.poppins( + fontSize: 13.sp, + color: AppColors.textGrey, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 12.h), + _buildValidationRow( + "At least 8 characters", state.hasMinLength), + _buildValidationRow( + "One uppercase letter", state.hasUppercase), + _buildValidationRow( + "One lowercase letter", state.hasLowercase), + _buildValidationRow( + "One number", state.hasNumber), + _buildValidationRow( + "One special character", state.hasSpecial), + ], + ), + ), + SizedBox(height: 20.h), + + // ===== CONFIRM PASSWORD FIELD ===== + CustomTextField( + label: 'Confirm New Password', + hintText: 'Enter your password', + controller: _confirmPasswordController, + focusNode: _confirmFocusNode, + prefixIcon: Icons.lock_outline, + isPassword: true, + isPasswordVisible: state.isConfirmPasswordVisible, + onTogglePasswordVisibility: () { + context.read().add( + const ConfirmPasswordVisibilityToggled(), + ); + }, + hasError: state.showConfirmPasswordError, + errorText: state.confirmPasswordErrorText, + textInputAction: TextInputAction.done, + readOnly: isLoading, + onChanged: (val) { + if (state.showConfirmPasswordError) { + context.read().add( + const ResetConfirmPasswordErrorToggled( + false)); + } + }, + onSubmitted: (_) => _onResetPressed(context, state), + ), + SizedBox(height: 24.h), + ], + ), + ), + ), + + // ===== RESET BUTTON ===== + CustomButton( + text: "Reset Password", + isLoading: isLoading, + onPressed: () => _onResetPressed(context, state), + ), + SizedBox(height: 24.h), ], ), ), - ), - ), - Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: BlocBuilder( - builder: (context, state) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 80), - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.center, - child: Image.asset( - "assets/login/app_icon.png", - scale: 4, - ), - ), - const SizedBox(height: 24), - Align( - alignment: Alignment.center, - child: Text( - "Reset your Password", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(height: 16), - - // ✅ Validation list - _buildValidationRow( - "Minimum of 8 characters", - state.hasMinLength), - _buildValidationRow( - "At least one uppercase letter (A–Z)", - state.hasUppercase), - _buildValidationRow( - "At least one number (0–9)", state.hasNumber), - const SizedBox(height: 20), - - // Password - TextField( - obscureText: true, - cursorColor: Colors.white, - onChanged: (value) => - bloc.add(PasswordChanged(value)), - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - prefixIcon: const Icon( - Icons.lock_outline, - color: Colors.white70, - ), - hintText: 'Enter your password', - hintStyle: - const TextStyle(color: Colors.white54), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: Colors.white54, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - const BorderSide(color: Colors.white), - ), - ), - ), - - // ✅ Strength boxes - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(4, (i) { - final filled = i < state.strengthLevel; - return Expanded( - child: Container( - margin: EdgeInsets.only( - right: i < 3 ? 6 : 0), - height: 5, - decoration: BoxDecoration( - color: filled - ? const Color(0xFFFFA500) // orange - : Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(3), - ), - ), - ); - }), - ), - - const SizedBox(height: 20), - - // Confirm Password - TextField( - obscureText: true, - cursorColor: Colors.white, - onChanged: (value) => - bloc.add(ConfirmPasswordChanged(value)), - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - prefixIcon: const Icon( - Icons.lock_outline, - color: Colors.white70, - ), - hintText: 'Retype your password', - hintStyle: - const TextStyle(color: Colors.white54), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: Colors.white54, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - const BorderSide(color: Colors.white), - ), - ), - ), - const SizedBox(height: 30), - ], - ), - ), - ), - ), - const SizedBox(height: 60), - SizedBox( - height: 52, - width: double.infinity, - child: ElevatedButton( - onPressed: () { - Navigator.pushNamed( - context, AppRouter.qrScanScreen); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFFF4C4C), - padding: - const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - "Verify", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ], + ); + }, + ), ), ); } - // ✅ Validation circle + text Widget _buildValidationRow(String text, bool isValid) { return Padding( - padding: const EdgeInsets.only(bottom: 6), + padding: EdgeInsets.only(bottom: 8.h), child: Row( children: [ + Icon( + isValid ? Icons.check_circle : Icons.circle, + size: 16.sp, + color: isValid ? AppColors.successGreen : const Color(0xFFD1D1D1), + ), + SizedBox(width: 10.w), Text( text, - style: GoogleFonts.poppins(color: Colors.white, fontSize: 13), - ), - const Spacer(), - Container( - height: 12, - width: 12, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white), - color: isValid ? Colors.white : Colors.transparent, + style: GoogleFonts.poppins( + fontSize: 13.sp, + color: isValid ? AppColors.successGreen : AppColors.textGrey, ), ), ], diff --git a/lib/main.dart b/lib/main.dart index e8bbf7b..57c1d3f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'all_bloc_poviders/all_bloc_providers.dart'; import 'core/app_router.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( statusBarColor: Colors.white, statusBarIconBrightness: Brightness.dark, @@ -18,16 +24,24 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'City Cards Partner', - debugShowCheckedModeBanner: false, - theme: ThemeData( - textTheme: GoogleFonts.poppinsTextTheme( - Theme.of(context).textTheme, - ) + return MultiBlocProvider( + providers: AllBlocProviders.providers(), + child: ScreenUtilInit( // ← Wrap here + designSize: const Size(390, 844), // ← iPhone 14 base size + minTextAdapt: true, + splitScreenMode: true, + builder: (context, child) => MaterialApp( + title: 'City Cards Partner', + debugShowCheckedModeBanner: false, + theme: ThemeData( + textTheme: GoogleFonts.poppinsTextTheme( + Theme.of(context).textTheme, + ), + ), + initialRoute: AppRouter.splashScreen, + onGenerateRoute: AppRouter.generateRoute, + ), ), - initialRoute: AppRouter.splashScreen, - onGenerateRoute: AppRouter.generateRoute, ); } -} +} \ No newline at end of file diff --git a/lib/network_api_service/api_service/api_service.dart b/lib/network_api_service/api_service/api_service.dart new file mode 100644 index 0000000..dc71698 --- /dev/null +++ b/lib/network_api_service/api_service/api_service.dart @@ -0,0 +1,266 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import '../../local_peference/local_preference.dart'; +import '../api_urls/api_urls.dart'; + +class ApiService { + static const String _baseUrl = 'https://your-api-base-url.com/api'; + + static final ApiService _instance = ApiService._internal(); + late Dio _dio; + + factory ApiService() => _instance; + + ApiService._internal() { + _dio = Dio( + BaseOptions( + baseUrl: _baseUrl, + connectTimeout: const Duration(seconds: 60), + receiveTimeout: const Duration(seconds: 60), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + // ================= RETRY INTERCEPTOR ================= + _dio.interceptors.add( + InterceptorsWrapper( + onError: (err, handler) async { + final options = err.requestOptions; + const maxRetries = 2; + final currentRetry = options.extra['retry'] as int? ?? 0; + + final shouldRetry = + currentRetry < maxRetries && + (err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.sendTimeout || + err.type == DioExceptionType.receiveTimeout); + + if (shouldRetry) { + if (kDebugMode) { + print( + '🔁 Retrying request (${currentRetry + 1}) => ${options.uri}', + ); + } + options.extra['retry'] = currentRetry + 1; + try { + final response = await _dio.fetch(options); + return handler.resolve(response); + } on DioException catch (e) { + return handler.reject(e); + } + } + + return handler.reject(err); + }, + ), + ); + + // ================= MAIN INTERCEPTOR (Queued for concurrency) ================= + _dio.interceptors.add( + QueuedInterceptorsWrapper( + onRequest: (options, handler) async { + final token = await LocalPreference.getAccessToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + + onError: (error, handler) async { + if (error.response?.statusCode == 401) { + final requestOptions = error.requestOptions; + + try { + final refreshed = await _refreshToken(); + + if (refreshed) { + final newToken = await LocalPreference.getAccessToken(); + requestOptions.headers['Authorization'] = 'Bearer $newToken'; + final response = await _dio.fetch(requestOptions); + return handler.resolve(response); + } else { + await _forceLogout(); + return handler.reject(error); + } + } catch (_) { + await _forceLogout(); + return handler.reject(error); + } + } + + handler.next(error); + }, + ), + ); + + // ================= LOGGING INTERCEPTOR ================= + if (kDebugMode) { + _dio.interceptors.add( + LogInterceptor( + request: true, + requestHeader: true, + requestBody: true, + responseBody: true, + error: true, + ), + ); + } + } + + // ================= GET ================= + Future get( + String endpoint, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + try { + return await _dio.get( + endpoint, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // ================= POST ================= + Future post( + String endpoint, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onSendProgress, + }) async { + try { + return await _dio.post( + endpoint, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // ================= PUT ================= + Future put( + String endpoint, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onSendProgress, + }) async { + try { + return await _dio.put( + endpoint, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // ================= DELETE ================= + Future delete( + String endpoint, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + try { + return await _dio.delete( + endpoint, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // ================= REFRESH TOKEN ================= + Future _refreshToken() async { + try { + final refreshToken = await LocalPreference.getRefreshToken(); + if (refreshToken == null) return false; + + final response = await _dio.post( + ApiUrls.refreshToken, + data: {"refreshToken": refreshToken}, + options: Options(headers: {'Authorization': null}), + ); + + await LocalPreference.setAccessToken(response.data['accessToken']); + return true; + } catch (_) { + return false; + } + } + + // ================= FORCE LOGOUT ================= + Future _forceLogout() async { + await LocalPreference.clearAll(); + await LocalPreference.setLogin(false); + } + + // ================= ERROR HANDLER ================= + String _handleError(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + return "Connection timeout. Please try again."; + case DioExceptionType.sendTimeout: + return "Send timeout. Please try again."; + case DioExceptionType.receiveTimeout: + return "Receive timeout. Please try again."; + case DioExceptionType.badCertificate: + return "Bad certificate."; + case DioExceptionType.badResponse: + try { + final responseData = error.response?.data; + if (responseData is Map) { + return responseData['message'] ?? + responseData['error'] ?? + "Invalid status code: ${error.response?.statusCode}"; + } + if (responseData is String) { + return responseData.isNotEmpty + ? responseData + : "Invalid status code: ${error.response?.statusCode}"; + } + return "Invalid status code: ${error.response?.statusCode}"; + } catch (_) { + return "Invalid status code: ${error.response?.statusCode}"; + } + case DioExceptionType.cancel: + return "Request was cancelled."; + case DioExceptionType.connectionError: + return "No internet connection."; + case DioExceptionType.unknown: + return "Something went wrong. Please try again."; + } + } + + // ================= UPDATE HEADERS ================= + void updateHeaders(Map headers) { + _dio.options.headers.addAll(headers); + } +} \ No newline at end of file diff --git a/lib/network_api_service/api_urls/api_urls.dart b/lib/network_api_service/api_urls/api_urls.dart new file mode 100644 index 0000000..cc42a40 --- /dev/null +++ b/lib/network_api_service/api_urls/api_urls.dart @@ -0,0 +1,18 @@ +class ApiUrls { + + // static const baseUrl = "https://devapi.citycards.betadelivery.com"; // Normal API + static const baseUrl = "https://testingapi.citycards.betadelivery.com"; // Test API + // static const baseUrl = "https://uatapi.citycard.betadelivery.com"; // Production Lvl API + + static const refreshToken = "$baseUrl/auth/refresh"; + + // ================= GET APIs ================= + static const authUserDetails = "$baseUrl/partner/auth"; + + // ================= POST APIs ================= + static const login = "$baseUrl/partner/auth/login"; + static const forgotPassword = "$baseUrl/partner/auth/forgot-password"; + static const verifyOtp = "$baseUrl/partner/auth/verify-otp"; + static const resetPassword = "$baseUrl/partner/auth/set-password"; + +} \ No newline at end of file diff --git a/lib/profile/blocs/profile/profile_bloc.dart b/lib/profile/blocs/profile/profile_bloc.dart new file mode 100644 index 0000000..9a6aef6 --- /dev/null +++ b/lib/profile/blocs/profile/profile_bloc.dart @@ -0,0 +1,27 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../profile/repository/profile_repository.dart'; +import 'profile_event.dart'; +import 'profile_state.dart'; + +class ProfileBloc extends Bloc { + final ProfileRepository _profileRepository; + + ProfileBloc({required ProfileRepository profileRepository}) + : _profileRepository = profileRepository, + super(ProfileInitial()) { + on(_onFetchUserDetails); + } + + Future _onFetchUserDetails( + FetchUserDetailsEvent event, + Emitter emit, + ) async { + emit(ProfileLoading()); + try { + final userDetails = await _profileRepository.fetchUserDetails(); + emit(ProfileLoaded(userDetails: userDetails)); + } catch (e) { + emit(ProfileError(message: e.toString())); + } + } +} diff --git a/lib/profile/blocs/profile/profile_event.dart b/lib/profile/blocs/profile/profile_event.dart new file mode 100644 index 0000000..40d7c38 --- /dev/null +++ b/lib/profile/blocs/profile/profile_event.dart @@ -0,0 +1,10 @@ +import 'package:equatable/equatable.dart'; + +abstract class ProfileEvent extends Equatable { + const ProfileEvent(); + + @override + List get props => []; +} + +class FetchUserDetailsEvent extends ProfileEvent {} diff --git a/lib/profile/blocs/profile/profile_state.dart b/lib/profile/blocs/profile/profile_state.dart new file mode 100644 index 0000000..4b20d63 --- /dev/null +++ b/lib/profile/blocs/profile/profile_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../profile/models/profile_model.dart'; + +abstract class ProfileState extends Equatable { + const ProfileState(); + + @override + List get props => []; +} + +class ProfileInitial extends ProfileState {} + +class ProfileLoading extends ProfileState {} + +class ProfileLoaded extends ProfileState { + final UserDetails userDetails; + + const ProfileLoaded({required this.userDetails}); + + @override + List get props => [userDetails]; +} + +class ProfileError extends ProfileState { + final String message; + + const ProfileError({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/profile/blocs/profile_bloc.dart b/lib/profile/blocs/profile_bloc.dart index bddfc13..361e720 100644 --- a/lib/profile/blocs/profile_bloc.dart +++ b/lib/profile/blocs/profile_bloc.dart @@ -1,4 +1,6 @@ // profile_cubit.dart +import 'package:citycards_partner_flutter/local_peference/local_preference.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../viewmodel/profile_viewmodel.dart'; @@ -28,5 +30,11 @@ class ProfileCubit extends Cubit { void logout() { // Handle logout logic here print("User logged out"); + // LocalPreference.clearAll(); + // Navigator.pushNamedAndRemoveUntil( + // context, + // AppRouter.login, + // (route) => false, + // ); } } diff --git a/lib/profile/models/profile_model.dart b/lib/profile/models/profile_model.dart new file mode 100644 index 0000000..b0c04e3 --- /dev/null +++ b/lib/profile/models/profile_model.dart @@ -0,0 +1,91 @@ +class Role { + final int id; + final String name; + + Role({ + required this.id, + required this.name, + }); + + factory Role.fromJson(Map json) { + return Role( + id: json['id'], + name: json['name'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + }; + } +} + +class UserDetails { + final int id; + final String fullName; + final String email; + final String phone; + final String round; + final Role role; + final bool isActive; + final bool isDeleted; + final String lastLoginAt; + final String joinDate; + final String createdAtRaw; + final String updatedAt; + final String updatedAtRaw; + + UserDetails({ + required this.id, + required this.fullName, + required this.email, + required this.phone, + required this.round, + required this.role, + required this.isActive, + required this.isDeleted, + required this.lastLoginAt, + required this.joinDate, + required this.createdAtRaw, + required this.updatedAt, + required this.updatedAtRaw, + }); + + factory UserDetails.fromJson(Map json) { + return UserDetails( + id: json['id'], + fullName: json['fullName'] ?? '', + email: json['email'] ?? '', + phone: json['phone'] ?? '', + round: json['round'] ?? '', + role: Role.fromJson(json['role']), + isActive: json['isActive'] ?? false, + isDeleted: json['isDeleted'] ?? false, + lastLoginAt: json['lastLoginAt'] ?? '', + joinDate: json['joinDate'] ?? '', + createdAtRaw: json['createdAtRaw'] ?? '', + updatedAt: json['updatedAt'] ?? '', + updatedAtRaw: json['updatedAtRaw'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'fullName': fullName, + 'email': email, + 'phone': phone, + 'round': round, + 'role': role.toJson(), + 'isActive': isActive, + 'isDeleted': isDeleted, + 'lastLoginAt': lastLoginAt, + 'joinDate': joinDate, + 'createdAtRaw': createdAtRaw, + 'updatedAt': updatedAt, + 'updatedAtRaw': updatedAtRaw, + }; + } +} \ No newline at end of file diff --git a/lib/profile/repository/profile_repository.dart b/lib/profile/repository/profile_repository.dart new file mode 100644 index 0000000..277c43c --- /dev/null +++ b/lib/profile/repository/profile_repository.dart @@ -0,0 +1,21 @@ + +import '../../local_peference/local_preference.dart'; +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; +import '../models/profile_model.dart'; + +class ProfileRepository { + final ApiService _apiService = ApiService(); + + Future fetchUserDetails() async { + try { + final userId = await LocalPreference.getUserId(); + final response = await _apiService.get( + '${ApiUrls.authUserDetails}/$userId', + ); + return UserDetails.fromJson(response.data); + } catch (e) { + throw Exception('Failed to fetch user details: $e'); + } + } +} diff --git a/lib/profile/views/profile_page.dart b/lib/profile/views/profile_page.dart index 0632dac..2f7ebfe 100644 --- a/lib/profile/views/profile_page.dart +++ b/lib/profile/views/profile_page.dart @@ -1,65 +1,82 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/profile_bloc.dart'; -import '../viewmodel/profile_viewmodel.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import ScreenUtil +import '../../core/app_router.dart'; +import '../../local_peference/local_preference.dart'; +import '../blocs/profile/profile_bloc.dart'; +import '../blocs/profile/profile_event.dart'; +import '../blocs/profile/profile_state.dart'; +import '../models/profile_model.dart'; // Import UserDetails model class ProfileScreen extends StatelessWidget { const ProfileScreen({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ProfileCubit(), - child: Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return Center(child: CircularProgressIndicator()); - } + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), // Use w for horizontal padding + child: BlocConsumer( + listener: (context, state) { + if (state is ProfileError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message)), + ); + } + }, + builder: (context, state) { + if (state is ProfileInitial) { + BlocProvider.of(context).add(FetchUserDetailsEvent()); + return const Center(child: CircularProgressIndicator()); + } else if (state is ProfileLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is ProfileLoaded) { + final userDetails = state.userDetails; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeaderSection( context), - SizedBox(height: 32), - _buildCustomerDetailsSection(state), - SizedBox(height: 24), - _buildAdditionalInfoSection(state, context), - SizedBox(height: 24), - Spacer(), - _buildLastLoginSection(state, context), - const SizedBox(height: 20), + _buildHeaderSection(context), + SizedBox(height: 32.h), // Use h for vertical spacing + _buildCustomerDetailsSection(userDetails), + SizedBox(height: 24.h), + _buildAdditionalInfoSection(userDetails, context), + SizedBox(height: 24.h), + const Spacer(), + _buildLastLoginSection(userDetails, context), + SizedBox(height: 20.h), ], ); - }, - ), + } else if (state is ProfileError) { + return Center(child: Text('Error: ${state.message}')); + } + return const Center(child: Text('Unknown State')); + }, ), ), ), ); } - Widget _buildHeaderSection( BuildContext context) { + Widget _buildHeaderSection(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 2.0, vertical: 14.0), + padding: EdgeInsets.symmetric(horizontal: 2.w, vertical: 14.h), // Use w and h child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ GestureDetector( child: Container( - width: 44, - height: 44, + width: 44.w, // Use w + height: 44.h, // Use h decoration: const BoxDecoration( color: Color(0xFFF95F62), shape: BoxShape.circle, ), - child: const Icon(Icons.arrow_back, color: Colors.white), + child: Icon(Icons.arrow_back, color: Colors.white, size: 24.sp), // Use sp for icon size ), onTap: (){ Navigator.pop(context); @@ -67,22 +84,22 @@ class ProfileScreen extends StatelessWidget { ), Text( 'Profile', - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w700, - fontSize: 28, + fontSize: 28.sp, // Use sp for font size ), ), SizedBox( - width: 40, + width: 40.w, // Use w ) ], ), ), - SizedBox(height: 8), + SizedBox(height: 8.h), // Use h Text( 'Manage your account, update preferences, and customize app settings for a personalized experience.', style: TextStyle( - fontSize: 14, + fontSize: 14.sp, // Use sp color: Colors.grey[600], height: 1.4, ), @@ -91,30 +108,30 @@ class ProfileScreen extends StatelessWidget { ); } - Widget _buildCustomerDetailsSection(ProfileState state) { + Widget _buildCustomerDetailsSection(UserDetails userDetails) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Customer Details', style: TextStyle( - fontSize: 24, + fontSize: 24.sp, // Use sp fontWeight: FontWeight.w600, color: Colors.black, ), ), - SizedBox(height: 16), + SizedBox(height: 16.h), // Use h Container( decoration: BoxDecoration( border: Border.symmetric(horizontal: BorderSide(color: Colors.grey[300]!)), ), child: Column( children: [ - _buildDetailRow('Name', state.name), + _buildDetailRow('Name', userDetails.fullName), _buildDivider(), - _buildDetailRow('Phone', state.phone), + _buildDetailRow('Phone', userDetails.phone), _buildDivider(), - _buildDetailRow('Role', state.role), + _buildDetailRow('Role', userDetails.role.name), ], ), ), @@ -122,26 +139,26 @@ class ProfileScreen extends StatelessWidget { ); } - Widget _buildAdditionalInfoSection(ProfileState state, BuildContext context) { + Widget _buildAdditionalInfoSection(UserDetails userDetails, BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Additional Info', style: TextStyle( - fontSize: 18, + fontSize: 18.sp, // Use sp fontWeight: FontWeight.w600, color: Colors.black, ), ), - SizedBox(height: 16), + SizedBox(height: 16.h), // Use h Container( decoration: BoxDecoration( border: Border.symmetric(horizontal: BorderSide(color: Colors.grey[300]!)), ), child: Column( children: [ - _buildActionRow('Change Email', state.email), + _buildActionRow('Email', userDetails.email), _buildDivider(), _buildActionRow('Change Password', '● ● ● ● ● ● ●'), ], @@ -151,7 +168,7 @@ class ProfileScreen extends StatelessWidget { ); } - Widget _buildLastLoginSection(ProfileState state, BuildContext context) { + Widget _buildLastLoginSection(UserDetails userDetails, BuildContext context) { return Column( children: [ SizedBox( @@ -160,33 +177,40 @@ class ProfileScreen extends StatelessWidget { children: [ Text( 'Last Login on ', - style: TextStyle(fontSize: 14, color: Colors.grey[600]), + style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]), // Use sp ), Text( - state.lastLogin, - style: TextStyle(fontSize: 14, color: Colors.black), + userDetails.lastLoginAt, + style: TextStyle(fontSize: 14.sp, color: Colors.black), // Use sp ), ], ), ), - const SizedBox(height: 14), + SizedBox(height: 14.h), // Use h SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () => context.read().logout(), + onPressed: () { + LocalPreference.clearAll(); + Navigator.pushNamedAndRemoveUntil( + context, + AppRouter.login, + (route) => false, + ); + }, style: ElevatedButton.styleFrom( backgroundColor: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), - side: BorderSide(color: Color(0xffDC2626)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), // Use w and h + side: const BorderSide(color: Color(0xffDC2626)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.r)), // Use r for radius ), child: Row( children: [ - Icon(Icons.logout, color: Color(0xffDC2626)), - SizedBox(width: 10), + Icon(Icons.logout, color: const Color(0xffDC2626), size: 24.sp), // Use sp for icon size + SizedBox(width: 10.w), // Use w Text( 'Log out', - style: TextStyle(fontSize: 16, color: Color(0xffDC2626), fontWeight: FontWeight.w600), + style: TextStyle(fontSize: 16.sp, color: const Color(0xffDC2626), fontWeight: FontWeight.w600), // Use sp ), ], ), @@ -198,12 +222,12 @@ class ProfileScreen extends StatelessWidget { Widget _buildDetailRow(String label, String value) { return Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), // Use w and h child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(flex: 2, child: Text(label, style: TextStyle(fontWeight: FontWeight.w500, color: Colors.grey[700]))), - Expanded(flex: 3, child: Text(value, style: TextStyle(color: Colors.black))), + Expanded(flex: 2, child: Text(label, style: TextStyle(fontWeight: FontWeight.w500, color: Colors.grey[700], fontSize: 14.sp))), // Use sp + Expanded(flex: 3, child: Text(value, style: TextStyle(color: Colors.black, fontSize: 14.sp))), // Use sp ], ), ); @@ -211,14 +235,14 @@ class ProfileScreen extends StatelessWidget { Widget _buildActionRow(String label, String value) { return Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), // Use w and h child: Row( children: [ - Expanded(flex: 2, child: Text(label, style: TextStyle(fontWeight: FontWeight.w500, color: Colors.grey[700]))), + Expanded(flex: 2, child: Text(label, style: TextStyle(fontWeight: FontWeight.w500, color: Colors.grey[700], fontSize: 14.sp))), // Use sp Expanded( flex: 3, child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(value, style: TextStyle(color: Colors.grey[600])), + Text(value, style: TextStyle(color: Colors.grey[600], fontSize: 14.sp)), // Use sp ]), ), ], @@ -227,6 +251,6 @@ class ProfileScreen extends StatelessWidget { } Widget _buildDivider() { - return Container(height: 1, color: Colors.grey[300], margin: EdgeInsets.symmetric(horizontal: 2)); + return Container(height: 1.h, color: Colors.grey[300], margin: EdgeInsets.symmetric(horizontal: 2.w)); // Use h and w } -} +} \ No newline at end of file diff --git a/lib/scan/view/qr_scan_screen.dart b/lib/scan/view/qr_scan_screen.dart index 50db712..b13a8f6 100644 --- a/lib/scan/view/qr_scan_screen.dart +++ b/lib/scan/view/qr_scan_screen.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:citycards_partner_flutter/constants/app_assets.dart'; import 'package:citycards_partner_flutter/core/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -288,7 +289,7 @@ class _QrScanScreenState extends State final icons = [ {'img': 'assets/scan/flash.png', 'route': '/flash'}, {'img': 'assets/scan/menu.png', 'route': '/menu'}, - {'img': 'assets/scan/logo.png', 'route': '/home'}, + {'img': AppAssets.appIcon, 'route': '/home'}, {'img': 'assets/scan/history.png', 'route': AppRouter.scanHistory}, {'img': 'assets/scan/profile.png', 'route': AppRouter.profileScreen}, ]; @@ -304,13 +305,13 @@ class _QrScanScreenState extends State final isFlash = e['route'] == '/flash'; final isMenu = e['route'] == '/menu'; final isHome = e['route'] == '/home'; - final isIdle = status == QrScanStatus.idle; + final statusIdle = status == QrScanStatus.idle; // 🔙 Show Back Button when expanded or after scan - if ((isFlash && isExpanded) || (isFlash && !isIdle)) { + if ((isFlash && isExpanded) || (isFlash && !statusIdle)) { return GestureDetector( onTap: () async { - if (!isIdle) { + if (!statusIdle) { context.read().add(ResetQrScanEvent()); } else if (sheetController.isAttached) { await sheetController.animateTo( @@ -361,7 +362,11 @@ class _QrScanScreenState extends State } } }, - child: Image.asset(e['img']!, scale: 4), + child: Image.asset( + e['img']!, + scale: isHome ? 6 : 4, + color: isHome ? Colors.black : null, + ), ); }).toList(), ); diff --git a/lib/splash/bloc/splash_bloc.dart b/lib/splash/bloc/splash_bloc.dart new file mode 100644 index 0000000..da39db6 --- /dev/null +++ b/lib/splash/bloc/splash_bloc.dart @@ -0,0 +1,34 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../local_peference/local_preference.dart'; // TODO: update path if needed +part 'splash_event.dart'; +part 'splash_state.dart'; + +class SplashBloc extends Bloc { + SplashBloc() : super(SplashInitial()) { + on(_onSplashStarted); + } + + Future _onSplashStarted( + SplashStarted event, + Emitter emit, + ) async { + emit(SplashLoading()); + + await Future.delayed(const Duration(seconds: 3)); + + final bool isLoggedIn = await LocalPreference.getLogin(); // ← CHECK LOGIN FIRST + + if (isLoggedIn) { + emit(SplashNavigateToHome()); // ← already logged in → go to QR + return; + } + + final bool showOnboarding = await LocalPreference.getOnBoarding(); + + if (showOnboarding) { + emit(SplashNavigateToOnboarding()); + } else { + emit(SplashNavigateToLogin()); + } + } +} \ No newline at end of file diff --git a/lib/splash/bloc/splash_event.dart b/lib/splash/bloc/splash_event.dart new file mode 100644 index 0000000..b652931 --- /dev/null +++ b/lib/splash/bloc/splash_event.dart @@ -0,0 +1,5 @@ +part of 'splash_bloc.dart'; + +abstract class SplashEvent {} + +class SplashStarted extends SplashEvent {} \ No newline at end of file diff --git a/lib/splash/bloc/splash_state.dart b/lib/splash/bloc/splash_state.dart new file mode 100644 index 0000000..e1bbad4 --- /dev/null +++ b/lib/splash/bloc/splash_state.dart @@ -0,0 +1,13 @@ +part of 'splash_bloc.dart'; + +abstract class SplashState {} + +class SplashInitial extends SplashState {} + +class SplashLoading extends SplashState {} + +class SplashNavigateToOnboarding extends SplashState {} + +class SplashNavigateToLogin extends SplashState {} + +class SplashNavigateToHome extends SplashState {} // ← ADD THIS \ No newline at end of file diff --git a/lib/splash/splash_view.dart b/lib/splash/splash_view.dart deleted file mode 100644 index 19823d2..0000000 --- a/lib/splash/splash_view.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:citycards_partner_flutter/core/app_router.dart'; -import 'package:flutter/material.dart'; -import 'dart:async'; - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _fadeAnimation; - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 2), - ); - - _fadeAnimation = CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - ); - - _controller.forward(); - Timer(const Duration(seconds: 3), () { - Navigator.pushReplacementNamed(context, AppRouter.onboarding); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - /// 🌄 Background Image - Image.asset( - "assets/splash/bg.png", // your full-screen background - fit: BoxFit.cover, - ), - - /// 🏙️ Logo Fade-in - FadeTransition( - opacity: _fadeAnimation, - child: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.only(top: 213), // 👈 adjust height here - child: Image.asset( - "assets/splash/logo.png",scale: 4, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/splash/view/splash_view.dart b/lib/splash/view/splash_view.dart new file mode 100644 index 0000000..d842674 --- /dev/null +++ b/lib/splash/view/splash_view.dart @@ -0,0 +1,87 @@ +import 'package:citycards_partner_flutter/core/app_router.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import '../../constants/app_assets.dart'; +import '../../local_peference/local_preference.dart'; +import '../bloc/splash_bloc.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + _controller.forward(); + context.read().add(SplashStarted()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) async { + if (state is SplashNavigateToHome) { + Navigator.pushReplacementNamed(context, AppRouter.qrScanScreen); + } else if (state is SplashNavigateToOnboarding) { + await LocalPreference.setOnBoarding(false); + if (!context.mounted) return; + Navigator.pushReplacementNamed(context, AppRouter.onboarding); + } else if (state is SplashNavigateToLogin) { + Navigator.pushReplacementNamed(context, AppRouter.login); + } + }, + child: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + /// 🌄 Background Image + Image.asset( + "assets/splash/bg.png", + fit: BoxFit.cover, + ), + + /// 🏙️ Logo Fade-in + FadeTransition( + opacity: _fadeAnimation, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only(top: 213.h), // ← ScreenUtil height + child: Image.asset( + AppAssets.appIcon, + color: Colors.white, + width: 0.75.sw, // ← ScreenUtil: 75% of screen width + fit: BoxFit.contain, + ), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 38d94c6..f5df75e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -129,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" equatable: dependency: "direct main" description: @@ -153,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" file_picker: dependency: "direct main" description: @@ -214,6 +238,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.32" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" + url: "https://pub.dev" + source: hosted + version: "5.9.3" flutter_test: dependency: "direct dev" description: flutter @@ -316,26 +348,34 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" mobile_scanner: dependency: "direct main" description: @@ -448,6 +488,62 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" simple_gesture_detector: dependency: transitive description: @@ -529,10 +625,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1b80589..611ccea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,9 @@ dependencies: mobile_scanner: ^7.1.3 equatable: ^2.0.7 flutter_launcher_icons: ^0.14.4 + shared_preferences: ^2.5.4 + dio: ^5.9.2 + flutter_screenutil: ^5.9.3 dev_dependencies: flutter_test: