add login flow with api integration and screen ui updated.

This commit is contained in:
2026-03-27 19:21:31 +05:30
parent dd72f058a9
commit 8bdd12a9b6
47 changed files with 2738 additions and 963 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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<BlocProvider> providers() {
return [
// ─── Splash ──────────────────────────────────────────────────────────
BlocProvider<SplashBloc>(
create: (_) => SplashBloc(),
),
BlocProvider<LoginBloc>(
create: (_) => LoginBloc(),
),
BlocProvider<ForgotPasswordBloc>(
create: (_) => ForgotPasswordBloc(),
),
BlocProvider<VerifyOtpBloc>(
create: (_) => VerifyOtpBloc(),
),
BlocProvider<ResetPasswordBloc>(
create: (_) => ResetPasswordBloc(),
),
// ─── Profile ─────────────────────────────────────────────────────────
BlocProvider<ProfileBloc>(
create: (_) => ProfileBloc(profileRepository: ProfileRepository()),
),
];
}
}

View File

@@ -0,0 +1,3 @@
class AppAssets {
static const String appIcon = "assets/login/app_icon.png";
}

View File

@@ -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;
}

View File

@@ -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 {
);
}
}
}
}

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -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<String>? onChanged;
final ValueChanged<String>? 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),
),
),
],
);
}
}

View File

@@ -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<void> setLogin(bool value) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyLogin, value);
}
static Future<bool> getLogin() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyLogin) ?? false;
}
// -------------------- USER ID --------------------
static Future<void> setUserId(int id) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyUserId, id);
}
static Future<int> getUserId() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.getInt(_keyUserId) ?? 0;
}
// -------------------- ACCESS TOKEN --------------------
static Future<void> setAccessToken(String token) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAccessToken, token);
}
static Future<String> getAccessToken() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAccessToken) ?? "";
}
// -------------------- REFRESH TOKEN --------------------
static Future<void> setRefreshToken(String token) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyRefreshToken, token);
}
static Future<String> getRefreshToken() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyRefreshToken) ?? "";
}
// -------------------- ONBOARDING --------------------
static Future<void> setOnBoarding(bool value) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyOnBoarding, value);
}
static Future<bool> getOnBoarding() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyOnBoarding) ?? true;
}
// -------------------- CLEAR --------------------
static Future<void> clearAll() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.clear();
}
}

View File

@@ -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<ForgotPasswordEvent, ForgotPasswordState> {
final ForgotPasswordRepository _forgotPasswordRepository;
ForgotPasswordBloc({ForgotPasswordRepository? forgotPasswordRepository})
: _forgotPasswordRepository = forgotPasswordRepository ?? ForgotPasswordRepository(),
super(const ForgotPasswordState()) {
on<ForgotPasswordSubmitted>(_onForgotPasswordSubmitted);
on<ForgotPasswordEmailErrorToggled>(_onEmailErrorToggled);
}
Future<void> _onForgotPasswordSubmitted(
ForgotPasswordSubmitted event,
Emitter<ForgotPasswordState> 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<ForgotPasswordState> emit,
) {
emit(state.copyWith(showEmailError: event.show));
}
}

View File

@@ -0,0 +1,25 @@
part of 'forgot_password_bloc.dart';
abstract class ForgotPasswordEvent extends Equatable {
const ForgotPasswordEvent();
@override
List<Object?> get props => [];
}
class ForgotPasswordSubmitted extends ForgotPasswordEvent {
final String emailAddress;
const ForgotPasswordSubmitted({required this.emailAddress});
@override
List<Object?> get props => [emailAddress];
}
class ForgotPasswordEmailErrorToggled extends ForgotPasswordEvent {
final bool show;
const ForgotPasswordEmailErrorToggled(this.show);
@override
List<Object?> get props => [show];
}

View File

@@ -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<Object?> get props => [status, errorMessage, showEmailError];
}

View File

@@ -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<LoginEvent, LoginState> {
final LoginRepository _loginRepository;
LoginBloc({LoginRepository? loginRepository})
: _loginRepository = loginRepository ?? LoginRepository(),
super(const LoginState()) {
on<LoginSubmitted>(_onLoginSubmitted);
on<LoginPasswordVisibilityToggled>(_onPasswordVisibilityToggled);
on<LoginRememberMeToggled>(_onRememberMeToggled);
on<LoginEmailErrorToggled>(_onEmailErrorToggled);
on<LoginPasswordErrorToggled>(_onPasswordErrorToggled);
}
// ================= LOGIN SUBMITTED =================
Future<void> _onLoginSubmitted(
LoginSubmitted event,
Emitter<LoginState> 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<LoginState> emit,
) {
emit(state.copyWith(isPasswordVisible: !state.isPasswordVisible));
}
// ================= REMEMBER ME =================
void _onRememberMeToggled(
LoginRememberMeToggled event,
Emitter<LoginState> emit,
) {
emit(state.copyWith(rememberMe: event.value));
}
void _onEmailErrorToggled(
LoginEmailErrorToggled event,
Emitter<LoginState> emit,
) {
emit(state.copyWith(showEmailError: event.show));
}
void _onPasswordErrorToggled(
LoginPasswordErrorToggled event,
Emitter<LoginState> emit,
) {
emit(state.copyWith(showPasswordError: event.show));
}
}

View File

@@ -0,0 +1,51 @@
part of 'login_bloc.dart';
abstract class LoginEvent extends Equatable {
const LoginEvent();
@override
List<Object?> 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<Object?> get props => [emailAddress, password, rememberMe];
}
class LoginPasswordVisibilityToggled extends LoginEvent {
const LoginPasswordVisibilityToggled();
}
class LoginRememberMeToggled extends LoginEvent {
final bool value;
const LoginRememberMeToggled(this.value);
@override
List<Object?> get props => [value];
}
class LoginEmailErrorToggled extends LoginEvent {
final bool show;
const LoginEmailErrorToggled(this.show);
@override
List<Object?> get props => [show];
}
class LoginPasswordErrorToggled extends LoginEvent {
final bool show;
const LoginPasswordErrorToggled(this.show);
@override
List<Object?> get props => [show];
}

View File

@@ -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<Object?> get props => [
status,
isPasswordVisible,
rememberMe,
errorMessage,
loginData,
showEmailError,
showPasswordError,
];
}

View File

@@ -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<ResetPasswordEvent, ResetPasswordState> {
final ResetPasswordRepository _resetPasswordRepository;
ResetPasswordBloc({ResetPasswordRepository? resetPasswordRepository})
: _resetPasswordRepository =
resetPasswordRepository ?? ResetPasswordRepository(),
super(const ResetPasswordState()) {
on<ResetPasswordSubmitted>(_onResetPasswordSubmitted);
on<ResetPasswordVisibilityToggled>(_onResetPasswordVisibilityToggled);
on<ConfirmPasswordVisibilityToggled>(_onConfirmPasswordVisibilityToggled);
on<PasswordChanged>(_onPasswordChanged);
on<ResetPasswordErrorToggled>(_onPasswordErrorToggled);
on<ResetConfirmPasswordErrorToggled>(_onConfirmPasswordErrorToggled);
}
Future<void> _onResetPasswordSubmitted(
ResetPasswordSubmitted event,
Emitter<ResetPasswordState> 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<ResetPasswordState> emit,
) {
emit(state.copyWith(isPasswordVisible: !state.isPasswordVisible));
}
void _onConfirmPasswordVisibilityToggled(
ConfirmPasswordVisibilityToggled event,
Emitter<ResetPasswordState> emit,
) {
emit(state.copyWith(
isConfirmPasswordVisible: !state.isConfirmPasswordVisible));
}
void _onPasswordChanged(
PasswordChanged event,
Emitter<ResetPasswordState> 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<ResetPasswordState> emit,
) {
emit(state.copyWith(
showPasswordError: event.show,
passwordErrorText: event.error,
));
}
void _onConfirmPasswordErrorToggled(
ResetConfirmPasswordErrorToggled event,
Emitter<ResetPasswordState> emit,
) {
emit(state.copyWith(
showConfirmPasswordError: event.show,
confirmPasswordErrorText: event.error,
));
}
}

View File

@@ -0,0 +1,55 @@
part of 'reset_password_bloc.dart';
abstract class ResetPasswordEvent extends Equatable {
const ResetPasswordEvent();
@override
List<Object?> get props => [];
}
class ResetPasswordSubmitted extends ResetPasswordEvent {
final String emailAddress;
final String newPassword;
const ResetPasswordSubmitted({
required this.emailAddress,
required this.newPassword,
});
@override
List<Object?> 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<Object?> get props => [password];
}
class ResetPasswordErrorToggled extends ResetPasswordEvent {
final bool show;
final String? error;
const ResetPasswordErrorToggled(this.show, {this.error});
@override
List<Object?> get props => [show, error];
}
class ResetConfirmPasswordErrorToggled extends ResetPasswordEvent {
final bool show;
final String? error;
const ResetConfirmPasswordErrorToggled(this.show, {this.error});
@override
List<Object?> get props => [show, error];
}

View File

@@ -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<Object?> get props => [
status,
isPasswordVisible,
isConfirmPasswordVisible,
errorMessage,
hasMinLength,
hasUppercase,
hasLowercase,
hasNumber,
hasSpecial,
showPasswordError,
showConfirmPasswordError,
passwordErrorText,
confirmPasswordErrorText,
];
}

View File

@@ -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<VerifyOtpEvent, VerifyOtpState> {
final OtpRepository _otpRepository;
VerifyOtpBloc({OtpRepository? otpRepository})
: _otpRepository = otpRepository ?? OtpRepository(),
super(const VerifyOtpState()) {
on<VerifyOtpSubmitted>(_onVerifyOtpSubmitted);
}
Future<void> _onVerifyOtpSubmitted(
VerifyOtpSubmitted event,
Emitter<VerifyOtpState> 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: ', ''),
));
}
}
}

View File

@@ -0,0 +1,21 @@
part of 'verify_otp_bloc.dart';
abstract class VerifyOtpEvent extends Equatable {
const VerifyOtpEvent();
@override
List<Object?> get props => [];
}
class VerifyOtpSubmitted extends VerifyOtpEvent {
final String emailAddress;
final String otp;
const VerifyOtpSubmitted({
required this.emailAddress,
required this.otp,
});
@override
List<Object?> get props => [emailAddress, otp];
}

View File

@@ -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<Object?> get props => [status, errorMessage];
}

View File

@@ -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<String, dynamic> json) {
return LoginModel(
accessToken: json['accessToken'] ?? '',
refreshToken: json['refreshToken'] ?? '',
refreshMaxAge: json['refreshMaxAge'] ?? 0,
partner: PartnerModel.fromJson(json['partner']),
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return PartnerModel(
id: json['id'] ?? 0,
email: json['email'] ?? '',
name: json['name'] ?? '',
roleXid: json['roleXid'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
'roleXid': roleXid,
};
}
}

View File

@@ -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<void> 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');
}
}
}

View File

@@ -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<LoginModel> 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<String, dynamic>);
} catch (e) {
rethrow;
}
}
}

View File

@@ -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<void> 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');
}
}
}

View File

@@ -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<void> 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');
}
}
}

View File

@@ -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<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
}
class _ForgotPasswordView extends StatelessWidget {
const _ForgotPasswordView();
class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
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<ForgotPasswordBloc>().add(ForgotPasswordEmailErrorToggled(!isEmailValid));
if (isEmailValid) {
context.read<ForgotPasswordBloc>().add(
ForgotPasswordSubmitted(emailAddress: email),
);
}
}
@override
Widget build(BuildContext context) {
final bloc = context.read<ForgotPasswordBloc>();
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<ForgotPasswordBloc, ForgotPasswordState>(
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<ForgotPasswordBloc, ForgotPasswordState>(
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? Dont worry — just enter your email and well 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(
"Partners 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<Color>(
// (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<Color>(
(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<ForgotPasswordBloc>().add(
ForgotPasswordEmailErrorToggled(!_isEmailValid(val.trim())),
);
}
},
onSubmitted: (_) => _onSendLinkPressed(context),
),
],
),
),
],
);
},
),
// ===== SEND LINK BUTTON =====
CustomButton(
text: "Send OTP",
isLoading: isLoading,
onPressed: () => _onSendLinkPressed(context),
),
SizedBox(height: 24.h),
],
),
),
),
),
],
);
},
),
),
);
}

View File

@@ -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<LoginPage> {
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<LoginBloc>().add(LoginEmailErrorToggled(!isEmailValid));
context.read<LoginBloc>().add(LoginPasswordErrorToggled(!isPasswordValid));
if (isEmailValid && isPasswordValid) {
context.read<LoginBloc>().add(
LoginSubmitted(
emailAddress: email,
password: password,
rememberMe: context.read<LoginBloc>().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<LoginBloc, LoginState>(
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<LoginBloc>().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<LoginBloc>().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<LoginBloc>().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<LoginBloc>().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),
],
),
),
),
),
],
);
},
),
),
);
}

View File

@@ -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<OtpVerificationPage> createState() => _OtpVerificationPageState();
}
class _OtpVerificationPageState extends State<OtpVerificationPage> {
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<VerifyOtpBloc, VerifyOtpState>(
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<OtpBloc, OtpState>(
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<OtpBloc>();
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",
"Partners App",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 24,
color: AppColors.primaryRed,
fontSize: 20.sp,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
"Weve 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<OtpBloc, OtpState>(
builder: (context, state) {
final otpBloc = context.read<OtpBloc>();
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: state.isOtpFilled && !state.isLoading
? () => otpBloc.add(OtpVerify())
: null,
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.resolveWith<Color>((
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(
"Weve 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<VerifyOtpBloc>().add(
VerifyOtpSubmitted(
emailAddress: widget.email,
otp: verificationCode,
),
);
},
),
],
),
),
),
// ===== VERIFY BUTTON =====
CustomButton(
text: "Verify",
isLoading: isLoading,
onPressed: _otp.length == 6
? () {
context.read<VerifyOtpBloc>().add(
VerifyOtpSubmitted(
emailAddress: widget.email,
otp: _otp,
),
);
}
: null,
),
SizedBox(height: 24.h),
],
),
),
),
],
);
},
),
),
);

View File

@@ -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<ResetPasswordPage> createState() => _ResetPasswordPageState();
}
class _ResetPasswordView extends StatelessWidget {
const _ResetPasswordView();
class _ResetPasswordPageState extends State<ResetPasswordPage> {
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<ResetPasswordBloc>().add(
const ResetPasswordErrorToggled(true,
error: "Please fulfill all password requirements"),
);
return;
}
if (password != confirmPassword) {
context.read<ResetPasswordBloc>().add(
const ResetConfirmPasswordErrorToggled(true,
error: "Passwords do not match"),
);
return;
}
context.read<ResetPasswordBloc>().add(
ResetPasswordSubmitted(
emailAddress: widget.email,
newPassword: password,
),
);
}
@override
Widget build(BuildContext context) {
final bloc = context.read<ResetPasswordBloc>();
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
behavior: HitTestBehavior.translucent,
child: Scaffold(
backgroundColor: AppColors.backgroundWhite,
body: BlocConsumer<ResetPasswordBloc, ResetPasswordState>(
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(
"Partners 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<ResetPasswordBloc>().add(
const ResetPasswordVisibilityToggled(),
);
},
hasError: state.showPasswordError,
errorText: state.passwordErrorText,
textInputAction: TextInputAction.next,
readOnly: isLoading,
onChanged: (val) {
context
.read<ResetPasswordBloc>()
.add(PasswordChanged(val));
if (state.showPasswordError) {
context.read<ResetPasswordBloc>().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<ResetPasswordBloc>().add(
const ConfirmPasswordVisibilityToggled(),
);
},
hasError: state.showConfirmPasswordError,
errorText: state.confirmPasswordErrorText,
textInputAction: TextInputAction.done,
readOnly: isLoading,
onChanged: (val) {
if (state.showConfirmPasswordError) {
context.read<ResetPasswordBloc>().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<ResetPasswordBloc, ResetPasswordState>(
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 (AZ)",
state.hasUppercase),
_buildValidationRow(
"At least one number (09)", 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,
),
),
],

View File

@@ -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<void> 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,
);
}
}
}

View File

@@ -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<Response> get(
String endpoint, {
Map<String, dynamic>? 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<Response> post(
String endpoint, {
dynamic data,
Map<String, dynamic>? 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<Response> put(
String endpoint, {
dynamic data,
Map<String, dynamic>? 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<Response> delete(
String endpoint, {
dynamic data,
Map<String, dynamic>? 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<bool> _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<void> _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<String, dynamic>) {
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<String, dynamic> headers) {
_dio.options.headers.addAll(headers);
}
}

View File

@@ -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";
}

View File

@@ -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<ProfileEvent, ProfileState> {
final ProfileRepository _profileRepository;
ProfileBloc({required ProfileRepository profileRepository})
: _profileRepository = profileRepository,
super(ProfileInitial()) {
on<FetchUserDetailsEvent>(_onFetchUserDetails);
}
Future<void> _onFetchUserDetails(
FetchUserDetailsEvent event,
Emitter<ProfileState> emit,
) async {
emit(ProfileLoading());
try {
final userDetails = await _profileRepository.fetchUserDetails();
emit(ProfileLoaded(userDetails: userDetails));
} catch (e) {
emit(ProfileError(message: e.toString()));
}
}
}

View File

@@ -0,0 +1,10 @@
import 'package:equatable/equatable.dart';
abstract class ProfileEvent extends Equatable {
const ProfileEvent();
@override
List<Object> get props => [];
}
class FetchUserDetailsEvent extends ProfileEvent {}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../profile/models/profile_model.dart';
abstract class ProfileState extends Equatable {
const ProfileState();
@override
List<Object> get props => [];
}
class ProfileInitial extends ProfileState {}
class ProfileLoading extends ProfileState {}
class ProfileLoaded extends ProfileState {
final UserDetails userDetails;
const ProfileLoaded({required this.userDetails});
@override
List<Object> get props => [userDetails];
}
class ProfileError extends ProfileState {
final String message;
const ProfileError({required this.message});
@override
List<Object> get props => [message];
}

View File

@@ -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<ProfileState> {
void logout() {
// Handle logout logic here
print("User logged out");
// LocalPreference.clearAll();
// Navigator.pushNamedAndRemoveUntil(
// context,
// AppRouter.login,
// (route) => false,
// );
}
}

View File

@@ -0,0 +1,91 @@
class Role {
final int id;
final String name;
Role({
required this.id,
required this.name,
});
factory Role.fromJson(Map<String, dynamic> json) {
return Role(
id: json['id'],
name: json['name'],
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
};
}
}

View File

@@ -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<UserDetails> 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');
}
}
}

View File

@@ -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<ProfileCubit, ProfileState>(
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<ProfileBloc, ProfileState>(
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<ProfileBloc>(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<ProfileCubit>().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
}
}
}

View File

@@ -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<QrScanScreen>
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<QrScanScreen>
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<QrScanBloc>().add(ResetQrScanEvent());
} else if (sheetController.isAttached) {
await sheetController.animateTo(
@@ -361,7 +362,11 @@ class _QrScanScreenState extends State<QrScanScreen>
}
}
},
child: Image.asset(e['img']!, scale: 4),
child: Image.asset(
e['img']!,
scale: isHome ? 6 : 4,
color: isHome ? Colors.black : null,
),
);
}).toList(),
);

View File

@@ -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<SplashEvent, SplashState> {
SplashBloc() : super(SplashInitial()) {
on<SplashStarted>(_onSplashStarted);
}
Future<void> _onSplashStarted(
SplashStarted event,
Emitter<SplashState> 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());
}
}
}

View File

@@ -0,0 +1,5 @@
part of 'splash_bloc.dart';
abstract class SplashEvent {}
class SplashStarted extends SplashEvent {}

View File

@@ -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

View File

@@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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,
),
),
),
),
],
),
);
}
}

View File

@@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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<SplashBloc>().add(SplashStarted());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<SplashBloc, SplashState>(
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,
),
),
),
),
],
),
),
);
}
}

View File

@@ -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:

View File

@@ -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: