From 94cd74a135b475c4abfc54af7eadf732982a17cf Mon Sep 17 00:00:00 2001 From: "Raj.Ghag" Date: Fri, 17 Apr 2026 15:55:54 +0530 Subject: [PATCH] api intigreted of scan Qr and recent scan histoy ,scan history,scan history details --- .../reports/problems/problems-report.html | 663 ++++++++++++ ios/Podfile.lock | 15 +- lib/all_bloc_poviders/all_bloc_providers.dart | 37 + lib/core/app_router.dart | 5 +- lib/local_peference/local_preference.dart | 5 + lib/login/blocs/forgot_password_bloc.dart | 82 -- lib/login/blocs/login/login_bloc.dart | 8 +- lib/login/blocs/login_bloc.dart | 0 lib/login/blocs/otp_bloc.dart | 103 -- lib/login/blocs/reset_password_bloc.dart | 93 -- .../blocs/verify_otp/verify_otp_bloc.dart | 5 + .../blocs/verify_otp/verify_otp_event.dart | 9 + .../blocs/verify_otp/verify_otp_state.dart | 6 +- .../models/{login.dart => login_model.dart} | 19 +- lib/login/repositories/login_repository.dart | 2 +- .../viewmodels/forgot_password_viewmodel.dart | 0 lib/login/viewmodels/login_viewmodel.dart | 0 lib/login/viewmodels/otp_viewmodel.dart | 0 .../viewmodels/reset_password_viewmodel.dart | 0 lib/login/views/otp_verification_page.dart | 35 +- .../api_service/api_service.dart | 101 +- .../api_urls/api_urls.dart | 7 +- lib/onboarding/views/onboarding_page.dart | 4 +- .../recent_scan_history_bloc.dart | 26 + .../recent_scan_history_event.dart | 10 + .../recent_scan_history_state.dart | 30 + .../submit_qr_code/submit_qr_code_bloc.dart | 70 ++ .../submit_qr_code/submit_qr_code_event.dart | 21 + .../submit_qr_code/submit_qr_code_state.dart | 42 + .../models/recent_scan_history_model.dart | 100 ++ .../recent_scan_history_repository.dart | 18 + .../repository/submit_qr_code_repository.dart | 29 + lib/scan/view/qr_scan_screen.dart | 967 +++++++++++------- .../blocs/scan_history/scan_history_bloc.dart | 206 ++++ .../scan_history/scan_history_event.dart | 70 ++ .../scan_history/scan_history_state.dart | 96 ++ lib/scan_history/blocs/scan_history_bloc.dart | 2 +- .../scan_history_details_bloc.dart | 25 + .../scan_history_details_event.dart | 19 + .../scan_history_details_state.dart | 30 + .../models/scan_history_details_model.dart | 121 +++ .../models/scan_history_model.dart | 155 ++- .../models/scan_history_model_old.dart | 13 + .../repositories/scan_history_repository.dart | 55 +- .../scan_history_repository_old.dart | 18 + .../viewmodels/scan_history_viewmodel.dart | 4 +- .../views/scan_history_detail_page.dart | 42 +- lib/scan_history/views/scan_history_page.dart | 327 +++--- .../blocs/raise_ticket/raise_ticket_bloc.dart | 99 ++ .../raise_ticket/raise_ticket_event.dart | 44 + .../raise_ticket/raise_ticket_state.dart | 43 + .../support_details/support_details_bloc.dart | 30 + .../support_details_event.dart | 12 + .../support_details_state.dart | 38 + lib/support/model/support_details_model.dart | 190 ++++ .../repository/raise_ticket_repository.dart | 38 + .../support_details_repository.dart | 22 + lib/support/view/support_form_page.dart | 467 +++++---- pubspec.lock | 116 ++- pubspec.yaml | 3 + 60 files changed, 3678 insertions(+), 1119 deletions(-) create mode 100644 android/build/reports/problems/problems-report.html delete mode 100644 lib/login/blocs/forgot_password_bloc.dart delete mode 100644 lib/login/blocs/login_bloc.dart delete mode 100644 lib/login/blocs/otp_bloc.dart delete mode 100644 lib/login/blocs/reset_password_bloc.dart rename lib/login/models/{login.dart => login_model.dart} (67%) delete mode 100644 lib/login/viewmodels/forgot_password_viewmodel.dart delete mode 100644 lib/login/viewmodels/login_viewmodel.dart delete mode 100644 lib/login/viewmodels/otp_viewmodel.dart delete mode 100644 lib/login/viewmodels/reset_password_viewmodel.dart create mode 100644 lib/scan/bloc/recent_scan_history/recent_scan_history_bloc.dart create mode 100644 lib/scan/bloc/recent_scan_history/recent_scan_history_event.dart create mode 100644 lib/scan/bloc/recent_scan_history/recent_scan_history_state.dart create mode 100644 lib/scan/bloc/submit_qr_code/submit_qr_code_bloc.dart create mode 100644 lib/scan/bloc/submit_qr_code/submit_qr_code_event.dart create mode 100644 lib/scan/bloc/submit_qr_code/submit_qr_code_state.dart create mode 100644 lib/scan/models/recent_scan_history_model.dart create mode 100644 lib/scan/repository/recent_scan_history_repository.dart create mode 100644 lib/scan/repository/submit_qr_code_repository.dart create mode 100644 lib/scan_history/blocs/scan_history/scan_history_bloc.dart create mode 100644 lib/scan_history/blocs/scan_history/scan_history_event.dart create mode 100644 lib/scan_history/blocs/scan_history/scan_history_state.dart create mode 100644 lib/scan_history/blocs/scan_history_details/scan_history_details_bloc.dart create mode 100644 lib/scan_history/blocs/scan_history_details/scan_history_details_event.dart create mode 100644 lib/scan_history/blocs/scan_history_details/scan_history_details_state.dart create mode 100644 lib/scan_history/models/scan_history_details_model.dart create mode 100644 lib/scan_history/models/scan_history_model_old.dart create mode 100644 lib/scan_history/repositories/scan_history_repository_old.dart create mode 100644 lib/support/blocs/raise_ticket/raise_ticket_bloc.dart create mode 100644 lib/support/blocs/raise_ticket/raise_ticket_event.dart create mode 100644 lib/support/blocs/raise_ticket/raise_ticket_state.dart create mode 100644 lib/support/blocs/support_details/support_details_bloc.dart create mode 100644 lib/support/blocs/support_details/support_details_event.dart create mode 100644 lib/support/blocs/support_details/support_details_state.dart create mode 100644 lib/support/model/support_details_model.dart create mode 100644 lib/support/repository/raise_ticket_repository.dart create mode 100644 lib/support/repository/support_details_repository.dart diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..5294026 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ce821a3..60af84f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -45,6 +45,9 @@ PODS: - SDWebImage (5.21.3): - SDWebImage/Core (= 5.21.3) - SDWebImage/Core (5.21.3) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - SwiftyGif (5.4.5) DEPENDENCIES: @@ -53,6 +56,7 @@ DEPENDENCIES: - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) SPEC REPOS: trunk: @@ -72,16 +76,19 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/mobile_scanner/darwin" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 - mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce diff --git a/lib/all_bloc_poviders/all_bloc_providers.dart b/lib/all_bloc_poviders/all_bloc_providers.dart index 6be4eeb..5a9ca79 100644 --- a/lib/all_bloc_poviders/all_bloc_providers.dart +++ b/lib/all_bloc_poviders/all_bloc_providers.dart @@ -6,6 +6,20 @@ 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'; +import '../support/blocs/support_details/support_details_bloc.dart'; +import '../support/repository/support_details_repository.dart'; +import '../support/blocs/raise_ticket/raise_ticket_bloc.dart'; +import '../support/repository/raise_ticket_repository.dart'; +import '../scan/bloc/submit_qr_code/submit_qr_code_bloc.dart'; +import '../scan/repository/submit_qr_code_repository.dart'; +import '../scan_history/blocs/scan_history/scan_history_bloc.dart'; + +import '../scan_history/blocs/scan_history_details/scan_history_details_bloc.dart'; +import '../scan_history/repositories/scan_history_repository.dart'; +import '../scan/bloc/recent_scan_history/recent_scan_history_bloc.dart'; +import '../scan/repository/recent_scan_history_repository.dart'; + + class AllBlocProviders { AllBlocProviders._(); // Private constructor — not meant to be instantiated @@ -32,6 +46,29 @@ class AllBlocProviders { BlocProvider( create: (_) => ProfileBloc(profileRepository: ProfileRepository()), ), + // ─── Support ───────────────────────────────────────────────────────── + BlocProvider( + create: (_) => SupportDetailsBloc(repository: SupportDetailsRepository()), + ), + BlocProvider( + create: (_) => RaiseTicketBloc(raiseTicketRepository: RaiseTicketRepository()), + ), + // ─── Scan History ──────────────────────────────────────────────────── + BlocProvider( + create: (_) => ScanHistoryBloc(repository: ScanHistoryRepository(),), + ), + BlocProvider( + create: (_) => ScanHistoryDetailsBloc(ScanHistoryRepository()), + ), + BlocProvider( + create: (_) => RecentScanHistoryBloc(RecentScanHistoryRepository()), + ), + BlocProvider( + create: (_) => SubmitQrCodeBloc(repository: SubmitQrCodeRepository()), + ), ]; + + + } } \ No newline at end of file diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index c22212f..d1add29 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -61,9 +61,10 @@ class AppRouter { case splashScreen: return MaterialPageRoute(builder: (_) => const SplashScreen()); case scanHistoryDetailPage: + final passId = settings.arguments as int? ?? 0; return MaterialPageRoute( - builder: (_) => const ScanHistoryDetailPage( - passId: 'P214125125', + builder: (_) => ScanHistoryDetailPage( + passId: passId, )); case selectedTimeSlotPage: return MaterialPageRoute(builder: (_) => const SelectedTimeSlotPage()); diff --git a/lib/local_peference/local_preference.dart b/lib/local_peference/local_preference.dart index 8bc55de..8573001 100644 --- a/lib/local_peference/local_preference.dart +++ b/lib/local_peference/local_preference.dart @@ -44,6 +44,11 @@ class LocalPreference { return prefs.getString(_keyAccessToken) ?? ""; } + static Future clearAccessToken() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyAccessToken); + } + // -------------------- REFRESH TOKEN -------------------- static Future setRefreshToken(String token) async { diff --git a/lib/login/blocs/forgot_password_bloc.dart b/lib/login/blocs/forgot_password_bloc.dart deleted file mode 100644 index 7d1e848..0000000 --- a/lib/login/blocs/forgot_password_bloc.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -// ---------------- EVENTS ---------------- -abstract class ForgotPasswordEvent {} - -class EmailChanged extends ForgotPasswordEvent { - final String email; - EmailChanged(this.email); -} - -class SendResetLink extends ForgotPasswordEvent {} - -// ---------------- STATE ---------------- -class ForgotPasswordState { - final String email; - final bool isValidEmail; - final bool isLoading; - final bool isSuccess; - final String message; - - const ForgotPasswordState({ - required this.email, - required this.isValidEmail, - required this.isLoading, - required this.isSuccess, - required this.message, - }); - - ForgotPasswordState copyWith({ - String? email, - bool? isValidEmail, - bool? isLoading, - bool? isSuccess, - String? message, - }) { - return ForgotPasswordState( - email: email ?? this.email, - isValidEmail: isValidEmail ?? this.isValidEmail, - isLoading: isLoading ?? this.isLoading, - isSuccess: isSuccess ?? this.isSuccess, - message: message ?? this.message, - ); - } - - factory ForgotPasswordState.initial() => const ForgotPasswordState( - email: '', - isValidEmail: false, - isLoading: false, - isSuccess: false, - message: '', - ); -} - -// ---------------- BLOC ---------------- -class ForgotPasswordBloc - extends Bloc { - ForgotPasswordBloc() : super(ForgotPasswordState.initial()) { - // when email changes - on((event, emit) { - final isValid = _isValidEmail(event.email); - emit(state.copyWith(email: event.email, isValidEmail: isValid)); - }); - - // when button clicked - on((event, emit) async { - if (!state.isValidEmail) return; - - emit(state.copyWith(isLoading: true, message: '')); - await Future.delayed(const Duration(seconds: 2)); // simulate API delay - emit(state.copyWith( - isLoading: false, - isSuccess: true, - message: 'Reset link sent successfully!', - )); - }); - } - - bool _isValidEmail(String email) { - final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); - return regex.hasMatch(email); - } -} diff --git a/lib/login/blocs/login/login_bloc.dart b/lib/login/blocs/login/login_bloc.dart index 91a7724..dc0d587 100644 --- a/lib/login/blocs/login/login_bloc.dart +++ b/lib/login/blocs/login/login_bloc.dart @@ -1,7 +1,8 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../local_peference/local_preference.dart'; -import '../../models/login.dart'; +import '../../models/login_model.dart'; import '../../repositories/login_repository.dart'; part 'login_event.dart'; part 'login_state.dart'; @@ -32,7 +33,10 @@ class LoginBloc extends Bloc { password: event.password, rememberMe: event.rememberMe, ); - + if (kDebugMode) { + print('🔍 Login response accessToken: ${loginData.accessToken}'); + print('🔍 Login response refreshToken: ${loginData.refreshToken}'); + } // ── Save to local preference ────────────────────────────────────── await Future.wait([ LocalPreference.setAccessToken(loginData.accessToken), diff --git a/lib/login/blocs/login_bloc.dart b/lib/login/blocs/login_bloc.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/login/blocs/otp_bloc.dart b/lib/login/blocs/otp_bloc.dart deleted file mode 100644 index 96cfcfd..0000000 --- a/lib/login/blocs/otp_bloc.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -// --------------------- EVENTS --------------------- -abstract class OtpEvent {} - -/// Triggered when user types or pastes OTP -class OtpChanged extends OtpEvent { - final String otp; - OtpChanged(this.otp); -} - -/// Triggered when user presses Verify button -class OtpVerify extends OtpEvent {} - -/// (Optional for later) Triggered when user taps "Resend OTP" -class OtpResend extends OtpEvent {} - - -// --------------------- STATE --------------------- -class OtpState { - final String otp; - final bool isVerified; - final bool isLoading; - final bool isResending; - final String message; - - /// Computed property to know if OTP input is complete (6 digits) - bool get isOtpFilled => otp.length == 6; - - const OtpState({ - required this.otp, - required this.isVerified, - required this.isLoading, - required this.isResending, - required this.message, - }); - - OtpState copyWith({ - String? otp, - bool? isVerified, - bool? isLoading, - bool? isResending, - String? message, - }) { - return OtpState( - otp: otp ?? this.otp, - isVerified: isVerified ?? this.isVerified, - isLoading: isLoading ?? this.isLoading, - isResending: isResending ?? this.isResending, - message: message ?? this.message, - ); - } - - factory OtpState.initial() => const OtpState( - otp: '', - isVerified: false, - isLoading: false, - isResending: false, - message: '', - ); -} - - -// --------------------- BLOC --------------------- -class OtpBloc extends Bloc { - OtpBloc() : super(OtpState.initial()) { - // Handle typing input - on((event, emit) { - emit(state.copyWith(otp: event.otp, message: '')); - }); - - // Handle Verify - on((event, emit) async { - if (!state.isOtpFilled) return; // no action if OTP incomplete - - emit(state.copyWith(isLoading: true, message: '')); - - await Future.delayed(const Duration(seconds: 2)); // simulate API delay - - // Mock success condition — replace with API later - if (state.otp == "123456") { - emit(state.copyWith( - isVerified: true, - isLoading: false, - message: "OTP verified successfully!", - )); - } else { - emit(state.copyWith( - isVerified: false, - isLoading: false, - message: "Invalid OTP. Please try again.", - )); - } - }); - - // Handle Resend OTP (optional for later) - on((event, emit) async { - emit(state.copyWith(isResending: true, message: 'Resending OTP...')); - await Future.delayed(const Duration(seconds: 3)); // simulate resend - emit(state.copyWith(isResending: false, message: 'OTP resent successfully!')); - }); - } -} diff --git a/lib/login/blocs/reset_password_bloc.dart b/lib/login/blocs/reset_password_bloc.dart deleted file mode 100644 index f206d8e..0000000 --- a/lib/login/blocs/reset_password_bloc.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// EVENTS -abstract class ResetPasswordEvent {} - -class PasswordChanged extends ResetPasswordEvent { - final String password; - PasswordChanged(this.password); -} - -class ConfirmPasswordChanged extends ResetPasswordEvent { - final String confirmPassword; - ConfirmPasswordChanged(this.confirmPassword); -} - -/// STATE -class ResetPasswordState { - final String password; - final String confirmPassword; - final bool hasMinLength; - final bool hasUppercase; - final bool hasNumber; - final int strengthLevel; // 0–4 based on password strength - - const ResetPasswordState({ - required this.password, - required this.confirmPassword, - required this.hasMinLength, - required this.hasUppercase, - required this.hasNumber, - required this.strengthLevel, - }); - - factory ResetPasswordState.initial() => const ResetPasswordState( - password: '', - confirmPassword: '', - hasMinLength: false, - hasUppercase: false, - hasNumber: false, - strengthLevel: 0, - ); - - ResetPasswordState copyWith({ - String? password, - String? confirmPassword, - bool? hasMinLength, - bool? hasUppercase, - bool? hasNumber, - int? strengthLevel, - }) { - return ResetPasswordState( - password: password ?? this.password, - confirmPassword: confirmPassword ?? this.confirmPassword, - hasMinLength: hasMinLength ?? this.hasMinLength, - hasUppercase: hasUppercase ?? this.hasUppercase, - hasNumber: hasNumber ?? this.hasNumber, - strengthLevel: strengthLevel ?? this.strengthLevel, - ); - } -} - -/// BLOC -class ResetPasswordBloc extends Bloc { - ResetPasswordBloc() : super(ResetPasswordState.initial()) { - on((event, emit) { - final pwd = event.password; - final hasMin = pwd.length >= 8; - final hasUp = pwd.contains(RegExp(r'[A-Z]')); - final hasNum = pwd.contains(RegExp(r'[0-9]')); - - // count how many validations passed (max 3) - int passed = [hasMin, hasUp, hasNum].where((e) => e).length; - - // convert to 4-box level (0–4) - int level = 0; - if (passed == 1) level = 1; - if (passed == 2) level = 2; - if (passed == 3) level = 4; // all conditions passed => full bar - - emit(state.copyWith( - password: pwd, - hasMinLength: hasMin, - hasUppercase: hasUp, - hasNumber: hasNum, - strengthLevel: level, - )); - }); - - on((event, emit) { - emit(state.copyWith(confirmPassword: event.confirmPassword)); - }); - } -} diff --git a/lib/login/blocs/verify_otp/verify_otp_bloc.dart b/lib/login/blocs/verify_otp/verify_otp_bloc.dart index 242e871..d5fd623 100644 --- a/lib/login/blocs/verify_otp/verify_otp_bloc.dart +++ b/lib/login/blocs/verify_otp/verify_otp_bloc.dart @@ -11,9 +11,14 @@ class VerifyOtpBloc extends Bloc { VerifyOtpBloc({OtpRepository? otpRepository}) : _otpRepository = otpRepository ?? OtpRepository(), super(const VerifyOtpState()) { + on(_onOtpChanged); on(_onVerifyOtpSubmitted); } + void _onOtpChanged(OtpChanged event, Emitter emit) { + emit(state.copyWith(otp: event.otp)); + } + Future _onVerifyOtpSubmitted( VerifyOtpSubmitted event, Emitter emit, diff --git a/lib/login/blocs/verify_otp/verify_otp_event.dart b/lib/login/blocs/verify_otp/verify_otp_event.dart index 0abe38d..bbc8fd0 100644 --- a/lib/login/blocs/verify_otp/verify_otp_event.dart +++ b/lib/login/blocs/verify_otp/verify_otp_event.dart @@ -7,6 +7,15 @@ abstract class VerifyOtpEvent extends Equatable { List get props => []; } +class OtpChanged extends VerifyOtpEvent { + final String otp; + + const OtpChanged({required this.otp}); + + @override + List get props => [otp]; +} + class VerifyOtpSubmitted extends VerifyOtpEvent { final String emailAddress; final String otp; diff --git a/lib/login/blocs/verify_otp/verify_otp_state.dart b/lib/login/blocs/verify_otp/verify_otp_state.dart index e8d2522..16cb891 100644 --- a/lib/login/blocs/verify_otp/verify_otp_state.dart +++ b/lib/login/blocs/verify_otp/verify_otp_state.dart @@ -5,22 +5,26 @@ enum VerifyOtpStatus { initial, loading, success, failure } class VerifyOtpState extends Equatable { final VerifyOtpStatus status; final String? errorMessage; + final String otp; const VerifyOtpState({ this.status = VerifyOtpStatus.initial, this.errorMessage, + this.otp = '', }); VerifyOtpState copyWith({ VerifyOtpStatus? status, String? errorMessage, + String? otp, }) { return VerifyOtpState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, + otp: otp ?? this.otp, ); } @override - List get props => [status, errorMessage]; + List get props => [status, errorMessage, otp]; } \ No newline at end of file diff --git a/lib/login/models/login.dart b/lib/login/models/login_model.dart similarity index 67% rename from lib/login/models/login.dart rename to lib/login/models/login_model.dart index eb7d6b5..219aa03 100644 --- a/lib/login/models/login.dart +++ b/lib/login/models/login_model.dart @@ -12,18 +12,29 @@ class LoginModel { }); factory LoginModel.fromJson(Map json) { + // Debug logs (remove in production) + // print("ACCESS TOKEN: ${json['accessToken']}"); + // print("REFRESH TOKEN: ${json['partner_refresh_token']}"); + return LoginModel( accessToken: json['accessToken'] ?? '', - refreshToken: json['refreshToken'] ?? '', - refreshMaxAge: json['refreshMaxAge'] ?? 0, - partner: PartnerModel.fromJson(json['partner']), + refreshToken: json['partner_refresh_token'] ?? '', // ✅ fixed key + refreshMaxAge: json['refreshMaxAge'] ?? 0, // may not come from API + partner: json['partner'] != null + ? PartnerModel.fromJson(json['partner']) + : const PartnerModel( + id: 0, + email: '', + name: '', + roleXid: 0, + ), ); } Map toJson() { return { 'accessToken': accessToken, - 'refreshToken': refreshToken, + 'partner_refresh_token': refreshToken, // ✅ match API 'refreshMaxAge': refreshMaxAge, 'partner': partner.toJson(), }; diff --git a/lib/login/repositories/login_repository.dart b/lib/login/repositories/login_repository.dart index d8f9e88..81fb871 100644 --- a/lib/login/repositories/login_repository.dart +++ b/lib/login/repositories/login_repository.dart @@ -1,6 +1,6 @@ import '../../network_api_service/api_service/api_service.dart'; import '../../network_api_service/api_urls/api_urls.dart'; -import '../models/login.dart'; +import '../models/login_model.dart'; class LoginRepository { final ApiService _apiService = ApiService(); diff --git a/lib/login/viewmodels/forgot_password_viewmodel.dart b/lib/login/viewmodels/forgot_password_viewmodel.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/login/viewmodels/login_viewmodel.dart b/lib/login/viewmodels/login_viewmodel.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/login/viewmodels/otp_viewmodel.dart b/lib/login/viewmodels/otp_viewmodel.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/login/viewmodels/reset_password_viewmodel.dart b/lib/login/viewmodels/reset_password_viewmodel.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/login/views/otp_verification_page.dart b/lib/login/views/otp_verification_page.dart index a09316c..ae0ca44 100644 --- a/lib/login/views/otp_verification_page.dart +++ b/lib/login/views/otp_verification_page.dart @@ -9,18 +9,11 @@ import '../../core/app_router.dart'; import '../../custome_widgets/custom_button.dart'; import '../blocs/verify_otp/verify_otp_bloc.dart'; -class OtpVerificationPage extends StatefulWidget { +class OtpVerificationPage extends StatelessWidget { final String email; const OtpVerificationPage({super.key, required this.email}); - @override - State createState() => _OtpVerificationPageState(); -} - -class _OtpVerificationPageState extends State { - String _otp = ""; - @override Widget build(BuildContext context) { return GestureDetector( @@ -34,7 +27,7 @@ class _OtpVerificationPageState extends State { Navigator.pushReplacementNamed( context, AppRouter.resetPassword, - arguments: widget.email, + arguments: email, ); } else if (state.status == VerifyOtpStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( @@ -68,7 +61,7 @@ class _OtpVerificationPageState extends State { ), SizedBox(height: 8.h), Text( - "Partner’s App", + "Partner's App", style: GoogleFonts.poppins( color: AppColors.primaryRed, fontSize: 20.sp, @@ -92,7 +85,7 @@ class _OtpVerificationPageState extends State { ), SizedBox(height: 12.h), Text( - "We’ve sent an OTP to your registered email. Please enter it below.", + "We've sent an OTP to your registered email. Please enter it below.", textAlign: TextAlign.center, style: GoogleFonts.poppins( color: AppColors.textGrey, @@ -118,17 +111,17 @@ class _OtpVerificationPageState extends State { color: AppColors.black, ), onCodeChanged: (String code) { - setState(() { - _otp = code; - }); + context.read().add( + OtpChanged(otp: code), + ); }, onSubmit: (String verificationCode) { - setState(() { - _otp = verificationCode; - }); + context.read().add( + OtpChanged(otp: verificationCode), + ); context.read().add( VerifyOtpSubmitted( - emailAddress: widget.email, + emailAddress: email, otp: verificationCode, ), ); @@ -143,12 +136,12 @@ class _OtpVerificationPageState extends State { CustomButton( text: "Verify", isLoading: isLoading, - onPressed: _otp.length == 6 + onPressed: state.otp.length == 6 ? () { context.read().add( VerifyOtpSubmitted( - emailAddress: widget.email, - otp: _otp, + emailAddress: email, + otp: state.otp, ), ); } diff --git a/lib/network_api_service/api_service/api_service.dart b/lib/network_api_service/api_service/api_service.dart index dc71698..74ad468 100644 --- a/lib/network_api_service/api_service/api_service.dart +++ b/lib/network_api_service/api_service/api_service.dart @@ -8,11 +8,14 @@ class ApiService { static const String _baseUrl = 'https://your-api-base-url.com/api'; static final ApiService _instance = ApiService._internal(); + late Dio _dio; + late Dio _tokenDio; // ✅ Separate Dio for token refresh (no interceptors) factory ApiService() => _instance; ApiService._internal() { + // ================= MAIN DIO ================= _dio = Dio( BaseOptions( baseUrl: _baseUrl, @@ -25,7 +28,21 @@ class ApiService { ), ); - // ================= RETRY INTERCEPTOR ================= + // ================= TOKEN DIO (No interceptors — used only for refresh) ================= + _tokenDio = Dio( + BaseOptions( + baseUrl: _baseUrl, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + // ================= 1. RETRY INTERCEPTOR ================= + // ✅ Added FIRST so it only retries network errors _dio.interceptors.add( InterceptorsWrapper( onError: (err, handler) async { @@ -42,7 +59,7 @@ class ApiService { if (shouldRetry) { if (kDebugMode) { print( - '🔁 Retrying request (${currentRetry + 1}) => ${options.uri}', + '🔁 Retrying request (${currentRetry + 1}/$maxRetries) => ${options.uri}', ); } options.extra['retry'] = currentRetry + 1; @@ -54,12 +71,13 @@ class ApiService { } } - return handler.reject(err); + return handler.next(err); }, ), ); - // ================= MAIN INTERCEPTOR (Queued for concurrency) ================= + // ================= 2. MAIN INTERCEPTOR (Auth + Token Refresh) ================= + // ✅ Added SECOND — handles auth and token refresh _dio.interceptors.add( QueuedInterceptorsWrapper( onRequest: (options, handler) async { @@ -70,6 +88,10 @@ class ApiService { handler.next(options); }, + onResponse: (response, handler) { + handler.next(response); + }, + onError: (error, handler) async { if (error.response?.statusCode == 401) { final requestOptions = error.requestOptions; @@ -80,13 +102,18 @@ class ApiService { if (refreshed) { final newToken = await LocalPreference.getAccessToken(); requestOptions.headers['Authorization'] = 'Bearer $newToken'; + + // ✅ Retry original request with new token final response = await _dio.fetch(requestOptions); return handler.resolve(response); } else { await _forceLogout(); return handler.reject(error); } - } catch (_) { + } catch (e) { + if (kDebugMode) { + print('❌ Error during token refresh flow: $e'); + } await _forceLogout(); return handler.reject(error); } @@ -97,7 +124,8 @@ class ApiService { ), ); - // ================= LOGGING INTERCEPTOR ================= + // ================= 3. LOGGING INTERCEPTOR ================= + // ✅ Added LAST so it captures the final state of all requests/responses if (kDebugMode) { _dio.interceptors.add( LogInterceptor( @@ -105,7 +133,9 @@ class ApiService { requestHeader: true, requestBody: true, responseBody: true, + responseHeader: false, error: true, + logPrint: (log) => print('📡 $log'), ), ); } @@ -198,26 +228,67 @@ class ApiService { } // ================= REFRESH TOKEN ================= + // ✅ Uses _tokenDio (no interceptors) to avoid QueuedInterceptor deadlock Future _refreshToken() async { try { final refreshToken = await LocalPreference.getRefreshToken(); - if (refreshToken == null) return false; - final response = await _dio.post( + if (kDebugMode) print('🔍 Refresh token from storage: $refreshToken'); + + if (refreshToken == null || refreshToken.isEmpty) { + if (kDebugMode) print('❌ No refresh token found'); + return false; + } + + if (kDebugMode) print('🔄 Attempting token refresh...'); + + final response = await _tokenDio.post( ApiUrls.refreshToken, - data: {"refreshToken": refreshToken}, - options: Options(headers: {'Authorization': null}), + data: '', // ✅ Empty body — server reads token from Cookie header + options: Options( + headers: { + // ✅ Manually inject refresh token as cookie header + 'Cookie': 'partner_refresh_token=$refreshToken', + }, + validateStatus: (status) => status != null && status < 500, + ), ); - await LocalPreference.setAccessToken(response.data['accessToken']); + if (kDebugMode) { + print("🔄 Refresh response status: ${response.statusCode}"); + print("✅ REFRESH RESPONSE => ${response.data}"); + } + + if (response.statusCode != 200 && response.statusCode != 201) { + if (kDebugMode) { + print('❌ Refresh failed with status: ${response.statusCode}'); + } + return false; + } + + final newAccessToken = response.data['accessToken']; + + if (newAccessToken == null || (newAccessToken as String).isEmpty) { + if (kDebugMode) print('❌ Access token missing in refresh response'); + return false; + } + + await LocalPreference.setAccessToken(newAccessToken); + if (kDebugMode) print('✅ Token refreshed successfully'); return true; - } catch (_) { + + } on DioException catch (e) { + if (kDebugMode) print('❌ Refresh token DioException: ${e.message}'); + return false; + } catch (e) { + if (kDebugMode) print('❌ Refresh token unexpected error: $e'); return false; } } // ================= FORCE LOGOUT ================= Future _forceLogout() async { + if (kDebugMode) print('🚪 Force logout triggered'); await LocalPreference.clearAll(); await LocalPreference.setLogin(false); } @@ -241,10 +312,8 @@ class ApiService { responseData['error'] ?? "Invalid status code: ${error.response?.statusCode}"; } - if (responseData is String) { - return responseData.isNotEmpty - ? responseData - : "Invalid status code: ${error.response?.statusCode}"; + if (responseData is String && responseData.isNotEmpty) { + return responseData; } return "Invalid status code: ${error.response?.statusCode}"; } catch (_) { diff --git a/lib/network_api_service/api_urls/api_urls.dart b/lib/network_api_service/api_urls/api_urls.dart index cc42a40..4924ce6 100644 --- a/lib/network_api_service/api_urls/api_urls.dart +++ b/lib/network_api_service/api_urls/api_urls.dart @@ -4,15 +4,16 @@ class ApiUrls { 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"; + static const refreshToken = "$baseUrl/partner/auth/refresh"; // ================= GET APIs ================= static const authUserDetails = "$baseUrl/partner/auth"; - + static const supportDetails = "$baseUrl/mobile/partners/support"; + static const scanHistory = "$baseUrl/mobile/partners/scan-history"; // ================= 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"; - + static const redeem = "$baseUrl/mobile/partners/redeem"; } \ No newline at end of file diff --git a/lib/onboarding/views/onboarding_page.dart b/lib/onboarding/views/onboarding_page.dart index 3937a24..1687963 100644 --- a/lib/onboarding/views/onboarding_page.dart +++ b/lib/onboarding/views/onboarding_page.dart @@ -68,7 +68,9 @@ class _OnboardingPageState extends State { child: Align( alignment: Alignment.topRight, child: OutlinedButton( - onPressed: () {}, + onPressed: () { + _skip(); + }, style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.white), foregroundColor: Colors.white, diff --git a/lib/scan/bloc/recent_scan_history/recent_scan_history_bloc.dart b/lib/scan/bloc/recent_scan_history/recent_scan_history_bloc.dart new file mode 100644 index 0000000..b37af9b --- /dev/null +++ b/lib/scan/bloc/recent_scan_history/recent_scan_history_bloc.dart @@ -0,0 +1,26 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import '../../repository/recent_scan_history_repository.dart'; +import '../../models/recent_scan_history_model.dart'; + +part 'recent_scan_history_event.dart'; +part 'recent_scan_history_state.dart'; + +class RecentScanHistoryBloc extends Bloc { + final RecentScanHistoryRepository _repository; + + RecentScanHistoryBloc(this._repository) : super(RecentScanHistoryInitial()) { + on(_onFetchRecentScanHistory); + } + + Future _onFetchRecentScanHistory( + FetchRecentScanHistory event, Emitter emit) async { + emit(RecentScanHistoryLoading()); + try { + final history = await _repository.fetchRecentScanHistory(); + emit(RecentScanHistoryLoaded(history)); + } catch (e) { + emit(RecentScanHistoryError(e.toString())); + } + } +} diff --git a/lib/scan/bloc/recent_scan_history/recent_scan_history_event.dart b/lib/scan/bloc/recent_scan_history/recent_scan_history_event.dart new file mode 100644 index 0000000..815fd76 --- /dev/null +++ b/lib/scan/bloc/recent_scan_history/recent_scan_history_event.dart @@ -0,0 +1,10 @@ +part of 'recent_scan_history_bloc.dart'; + +abstract class RecentScanHistoryEvent extends Equatable { + const RecentScanHistoryEvent(); + + @override + List get props => []; +} + +class FetchRecentScanHistory extends RecentScanHistoryEvent {} diff --git a/lib/scan/bloc/recent_scan_history/recent_scan_history_state.dart b/lib/scan/bloc/recent_scan_history/recent_scan_history_state.dart new file mode 100644 index 0000000..76a2875 --- /dev/null +++ b/lib/scan/bloc/recent_scan_history/recent_scan_history_state.dart @@ -0,0 +1,30 @@ +part of 'recent_scan_history_bloc.dart'; + +abstract class RecentScanHistoryState extends Equatable { + const RecentScanHistoryState(); + + @override + List get props => []; +} + +class RecentScanHistoryInitial extends RecentScanHistoryState {} + +class RecentScanHistoryLoading extends RecentScanHistoryState {} + +class RecentScanHistoryLoaded extends RecentScanHistoryState { + final List history; + + const RecentScanHistoryLoaded(this.history); + + @override + List get props => [history]; +} + +class RecentScanHistoryError extends RecentScanHistoryState { + final String message; + + const RecentScanHistoryError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/scan/bloc/submit_qr_code/submit_qr_code_bloc.dart b/lib/scan/bloc/submit_qr_code/submit_qr_code_bloc.dart new file mode 100644 index 0000000..7f028b1 --- /dev/null +++ b/lib/scan/bloc/submit_qr_code/submit_qr_code_bloc.dart @@ -0,0 +1,70 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import '../../repository/submit_qr_code_repository.dart'; + +part 'submit_qr_code_event.dart'; +part 'submit_qr_code_state.dart'; + +class SubmitQrCodeBloc + extends Bloc { + final SubmitQrCodeRepository _repository; + + SubmitQrCodeBloc({SubmitQrCodeRepository? repository}) + : _repository = repository ?? SubmitQrCodeRepository(), + super(const SubmitQrCodeInitial()) { + on(_onSubmitQrCode); + on(_onReset); + } + + Future _onSubmitQrCode( + SubmitQrCodeEventTriggered event, + Emitter emit, + ) async { + if (event.qrCode.trim().isEmpty) { + emit(const SubmitQrCodeFailure( + errorMessage: 'QR code cannot be empty')); + return; + } + + emit(const SubmitQrCodeLoading()); + + try { + final response = await _repository.submitQrCode( + qrCode: event.qrCode, + ); + + final success = response['success'] == true; + final message = response['message']?.toString(); + final error = response['error']?.toString(); + + if (success) { + emit(SubmitQrCodeSuccess( + data: response, + message: message, + )); + } else { + emit(SubmitQrCodeFailure( + errorMessage: message ?? 'Failed to submit QR Code', + error: error, + )); + } + + } on Exception catch (e) { + emit(SubmitQrCodeFailure( + errorMessage: e.toString().replaceFirst('Exception: ', ''), + )); + } catch (e) { + emit(SubmitQrCodeFailure( + errorMessage: 'Unexpected error: $e', + )); + } + } + + void _onReset( + ResetSubmitQrCodeEvent event, + Emitter emit, + ) { + emit(const SubmitQrCodeInitial()); + } +} \ No newline at end of file diff --git a/lib/scan/bloc/submit_qr_code/submit_qr_code_event.dart b/lib/scan/bloc/submit_qr_code/submit_qr_code_event.dart new file mode 100644 index 0000000..f00aa75 --- /dev/null +++ b/lib/scan/bloc/submit_qr_code/submit_qr_code_event.dart @@ -0,0 +1,21 @@ +part of 'submit_qr_code_bloc.dart'; + +abstract class SubmitQrCodeEvent extends Equatable { + const SubmitQrCodeEvent(); + + @override + List get props => []; +} + +class SubmitQrCodeEventTriggered extends SubmitQrCodeEvent { + final String qrCode; + + const SubmitQrCodeEventTriggered({required this.qrCode}); + + @override + List get props => [qrCode]; +} + +class ResetSubmitQrCodeEvent extends SubmitQrCodeEvent { + const ResetSubmitQrCodeEvent(); +} \ No newline at end of file diff --git a/lib/scan/bloc/submit_qr_code/submit_qr_code_state.dart b/lib/scan/bloc/submit_qr_code/submit_qr_code_state.dart new file mode 100644 index 0000000..5ce965b --- /dev/null +++ b/lib/scan/bloc/submit_qr_code/submit_qr_code_state.dart @@ -0,0 +1,42 @@ +part of 'submit_qr_code_bloc.dart'; + +abstract class SubmitQrCodeState extends Equatable { + const SubmitQrCodeState(); + + @override + List get props => []; +} + +class SubmitQrCodeInitial extends SubmitQrCodeState { + const SubmitQrCodeInitial(); +} + +class SubmitQrCodeLoading extends SubmitQrCodeState { + const SubmitQrCodeLoading(); +} + +class SubmitQrCodeSuccess extends SubmitQrCodeState { + final Map data; + final String? message; + + const SubmitQrCodeSuccess({ + required this.data, + this.message, + }); + + @override + List get props => [data, message]; +} + +class SubmitQrCodeFailure extends SubmitQrCodeState { + final String errorMessage; + final String? error; + + const SubmitQrCodeFailure({ + required this.errorMessage, + this.error, + }); + + @override + List get props => [errorMessage, error]; +} \ No newline at end of file diff --git a/lib/scan/models/recent_scan_history_model.dart b/lib/scan/models/recent_scan_history_model.dart new file mode 100644 index 0000000..7a81b0d --- /dev/null +++ b/lib/scan/models/recent_scan_history_model.dart @@ -0,0 +1,100 @@ +import 'package:intl/intl.dart'; + +class RecentScanHistoryResponse { + final List data; + + RecentScanHistoryResponse({ + required this.data, + }); + + factory RecentScanHistoryResponse.fromJson(Map json) { + return RecentScanHistoryResponse( + data: (json['data'] as List?) + ?.map((e) => RecentScanHistory.fromJson(e as Map)) + .toList() ?? + [], + ); + } +} + +class RecentScanHistory { + final int id; + final String passId; + final String qrCode; + final String bookingNumber; + final int attractionId; + final String attractionTitle; + final String customerName; + final String customerEmail; + final String cardType; + final int? scannedByPartnerStaffId; + final String scannedByPartnerStaffName; + final String status; + final String reason; + final DateTime? checkedInDatetime; + final DateTime? activatedAt; + final DateTime? qrExpiresAt; + final DateTime? validUpto; + final DateTime? createdAt; + + RecentScanHistory({ + required this.id, + required this.passId, + required this.qrCode, + required this.bookingNumber, + required this.attractionId, + required this.attractionTitle, + required this.customerName, + required this.customerEmail, + required this.cardType, + this.scannedByPartnerStaffId, + required this.scannedByPartnerStaffName, + required this.status, + required this.reason, + this.checkedInDatetime, + this.activatedAt, + this.qrExpiresAt, + this.validUpto, + this.createdAt, + }); + + factory RecentScanHistory.fromJson(Map json) { + return RecentScanHistory( + id: json['id'] ?? 0, + passId: (json['passId'] ?? '').toString(), + qrCode: (json['qrCode'] ?? '').toString(), + bookingNumber: (json['bookingNumber'] ?? '').toString(), + attractionId: json['attractionId'] ?? 0, + attractionTitle: (json['attractionTitle'] ?? '').toString(), + customerName: (json['customerName'] ?? '').toString(), + customerEmail: (json['customerEmail'] ?? '').toString(), + cardType: (json['cardType'] ?? '').toString(), + scannedByPartnerStaffId: json['scannedByPartnerStaffId'], + scannedByPartnerStaffName: + (json['scannedByPartnerStaffName'] ?? '').toString(), + status: (json['status'] ?? '').toString(), + reason: (json['reason'] ?? '').toString(), + checkedInDatetime: _parseDate(json['checkedInDatetime']), + activatedAt: _parseDate(json['activatedAt']), + qrExpiresAt: _parseDate(json['qrExpiresAt']), + validUpto: _parseDate(json['validUpto']), + createdAt: _parseDate(json['createdAt']), + ); + } + + static DateTime? _parseDate(dynamic date) { + if (date == null) return null; + try { + return DateTime.parse(date.toString()); + } catch (e) { + return null; + } + } + + static final DateFormat _formatter = DateFormat('dd MMM yyyy, hh:mm a'); + + String formatTime(DateTime? date) { + if (date == null) return 'N/A'; + return _formatter.format(date); + } +} diff --git a/lib/scan/repository/recent_scan_history_repository.dart b/lib/scan/repository/recent_scan_history_repository.dart new file mode 100644 index 0000000..411a4a0 --- /dev/null +++ b/lib/scan/repository/recent_scan_history_repository.dart @@ -0,0 +1,18 @@ +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; +import '../models/recent_scan_history_model.dart'; + +class RecentScanHistoryRepository { + final ApiService _apiService = ApiService(); + + Future> fetchRecentScanHistory() async { + try { + final response = await _apiService.get(ApiUrls.scanHistory); + final RecentScanHistoryResponse scanResponse = + RecentScanHistoryResponse.fromJson(response.data); + return scanResponse.data; + } catch (e) { + throw Exception('Failed to fetch recent scan history: $e'); + } + } +} diff --git a/lib/scan/repository/submit_qr_code_repository.dart b/lib/scan/repository/submit_qr_code_repository.dart new file mode 100644 index 0000000..cb1b466 --- /dev/null +++ b/lib/scan/repository/submit_qr_code_repository.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; + +class SubmitQrCodeRepository { + final ApiService _apiService = ApiService(); + + Future> submitQrCode({ + required String qrCode, + }) async { + try { + final response = await _apiService.post( + ApiUrls.redeem, + data: { + "code": qrCode, + }, + ); + + return response.data as Map; + } on DioException catch (e) { + if (e.response?.data != null && e.response?.data is Map) { + return e.response?.data as Map; + } + rethrow; + } catch (e) { + throw Exception('$e'); + } + } +} \ No newline at end of file diff --git a/lib/scan/view/qr_scan_screen.dart b/lib/scan/view/qr_scan_screen.dart index b13a8f6..80dc8b9 100644 --- a/lib/scan/view/qr_scan_screen.dart +++ b/lib/scan/view/qr_scan_screen.dart @@ -3,11 +3,13 @@ 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'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; -import '../bloc/qr_scan_bloc.dart'; -import '../bloc/qr_scan_event.dart'; -import '../bloc/qr_scan_state.dart'; -import '../viewmodel/qr_scan_view_model.dart'; +import 'package:intl/intl.dart'; +import '../bloc/submit_qr_code/submit_qr_code_bloc.dart'; +import '../bloc/recent_scan_history/recent_scan_history_bloc.dart'; +import '../models/recent_scan_history_model.dart'; + class QrScanScreen extends StatefulWidget { const QrScanScreen({super.key}); @@ -27,6 +29,7 @@ class _QrScanScreenState extends State final ValueNotifier initialSize = ValueNotifier(0.5); final DraggableScrollableController sheetController = DraggableScrollableController(); + final TextEditingController _manualCodeController = TextEditingController(); @override void initState() { @@ -47,12 +50,19 @@ class _QrScanScreenState extends State final torchState = _cameraController.value.torchState; _isTorchOn.value = (torchState == TorchState.on); }); + + // 📡 Fetch recent scan history + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(FetchRecentScanHistory()); + }); } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); _cameraController.dispose(); + _manualCodeController.dispose(); super.dispose(); } @@ -82,371 +92,459 @@ class _QrScanScreenState extends State } } + String _extractQrCode(String rawData) { + if (!rawData.contains("QR Code :")) return rawData; + final lines = rawData.split('\n'); + for (var line in lines) { + if (line.contains("QR Code :")) { + return line.split(':').last.trim(); + } + } + return rawData; + } + @override Widget build(BuildContext context) { final Offset scanCenter = Offset( MediaQuery.of(context).size.width / 2, - MediaQuery.of(context).size.height / 2 - 78, + MediaQuery.of(context).size.height / 2 - 78.h, ); - return BlocProvider( - create: (_) => QrScanBloc(), - child: Builder( - builder: (context) { - final viewModel = QrScanViewModel(context.read()); - - return BlocBuilder( - builder: (context, state) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final RenderBox? box = - collapsedKey.currentContext?.findRenderObject() - as RenderBox?; - if (box != null) { - final height = box.size.height; - final screenHeight = MediaQuery.of(context).size.height; - final fraction = (height / screenHeight).clamp(0.42, 0.55); - if (initialSize.value != fraction) { - initialSize.value = fraction; + return BlocListener( + listener: (context, state) { + if (state is SubmitQrCodeSuccess || state is SubmitQrCodeFailure) { + context.read().add(FetchRecentScanHistory()); + } + }, + child: Builder( + builder: (context) { + return BlocBuilder( + builder: (context, state) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final RenderBox? box = + collapsedKey.currentContext?.findRenderObject() + as RenderBox?; + if (box != null) { + final height = box.size.height; + final screenHeight = MediaQuery.of(context).size.height; + final fraction = (height / screenHeight).clamp(0.42, 0.55); + if (initialSize.value != fraction) { + initialSize.value = fraction; + } } - } - }); + }); - return ValueListenableBuilder( - valueListenable: initialSize, - builder: (context, initFraction, _) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - /// 🚫 Show this if no camera available (iOS simulator) - if (!_cameraAvailable) - const Center( - child: Text( - "Camera not available on iOS Simulator.\nPlease test on a real device.", - style: TextStyle( - color: Colors.white, - fontSize: 16, - ), - textAlign: TextAlign.center, - ), - ) - else - /// 📷 QR Scanner - MobileScanner( - controller: _cameraController, - fit: BoxFit.cover, - onDetect: (capture) { - final barcode = - capture.barcodes.first.rawValue ?? ''; - if (barcode.isNotEmpty && !state.isLocked) { - viewModel.onQrDetected(barcode); - } - }, - ), - - /// 🔲 Scanner Frame - if (_cameraAvailable) - Positioned( - left: scanCenter.dx - 125, - top: scanCenter.dy - 200, - child: CustomPaint( - size: const Size(250, 250), - painter: CornerBorderPainter( - const LinearGradient( - colors: [Colors.white, Colors.white], + return ValueListenableBuilder( + valueListenable: initialSize, + builder: (context, initFraction, _) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + /// 🚫 Show this if no camera available (iOS simulator) + if (!_cameraAvailable) + Center( + child: Text( + "Camera not available on iOS Simulator.\nPlease test on a real device.", + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + textAlign: TextAlign.center, + ), + ) + else + + /// 📷 QR Scanner + MobileScanner( + controller: _cameraController, + fit: BoxFit.cover, + onDetect: (capture) { + final barcode = + capture.barcodes.first.rawValue ?? ''; + if (barcode.isNotEmpty && + state is! SubmitQrCodeLoading && + state is SubmitQrCodeInitial) { + final extractedCode = _extractQrCode(barcode); + _cameraController.stop(); + context.read().add( + SubmitQrCodeEventTriggered( + qrCode: extractedCode)); + } + }, + ), + + /// 🔲 Scanner Frame + if (_cameraAvailable) + Positioned( + left: scanCenter.dx - 125.w, + top: scanCenter.dy - 200.h, + child: CustomPaint( + size: Size(250.w, 250.h), + painter: CornerBorderPainter( + const LinearGradient( + colors: [Colors.white, Colors.white], + ), + strokeWidth: 4.w, ), - strokeWidth: 4, ), ), - ), - /// 🧾 Draggable Bottom Sheet - SafeArea( - bottom: false, - child: - NotificationListener< - DraggableScrollableNotification - >( - onNotification: (notification) { - final value = notification.extent; - sheetExtent.value = value; + /// 🧾 Draggable Bottom Sheet + SafeArea( + bottom: false, + child: NotificationListener< + DraggableScrollableNotification>( + onNotification: (notification) { + final value = notification.extent; + sheetExtent.value = value; - if (value >= 0.9) { - _cameraController.stop(); - } else if (value <= initFraction + 0.02) { - _safeStartCamera(); - } + if (value >= 0.9) { + _cameraController.stop(); + } else if (value <= initFraction + 0.02) { + _safeStartCamera(); + } - return false; - }, - child: DraggableScrollableSheet( - controller: sheetController, - initialChildSize: initFraction, - minChildSize: initFraction, - maxChildSize: state.isLocked - ? initFraction - : 1.0, - snap: true, - snapSizes: [ - initFraction, - if (!state.isLocked && 1.0 > initFraction) - 1.0, - ], - builder: (context, scrollController) { - return ValueListenableBuilder( - valueListenable: sheetExtent, - builder: (context, extent, _) { - final isExpanded = extent > 0.7; - final double radius = isExpanded - ? 0 - : 24; + return false; + }, + child: DraggableScrollableSheet( + controller: sheetController, + initialChildSize: initFraction, + minChildSize: initFraction, + maxChildSize: 1.0, + snap: true, + snapSizes: [ + initFraction, + if (1.0 > initFraction) 1.0, + ], + builder: (context, scrollController) { + return ValueListenableBuilder( + valueListenable: sheetExtent, + builder: (context, extent, _) { + final isExpanded = extent > 0.7; + final double radius = + isExpanded ? 0 : 24.r; - return AnimatedContainer( - duration: const Duration( - milliseconds: 250, + return AnimatedContainer( + duration: const Duration( + milliseconds: 250, + ), + padding: EdgeInsets.symmetric( + horizontal: 16.w, vertical: 8.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(radius), ), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical( - top: Radius.circular(radius), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity( - 0.1, - ), - blurRadius: 8, - offset: const Offset(0, -2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity( + 0.1, ), + blurRadius: 8.r, + offset: Offset(0, -2.h), + ), + ], + ), + child: SingleChildScrollView( + controller: scrollController, + physics: + const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _headerIcons( + context, + isExpanded, + state, + _cameraController, + sheetController, + initFraction, + ), + SizedBox(height: 12.h), + if (state is SubmitQrCodeLoading) + const Center( + child: Padding( + padding: + EdgeInsets.all(20), + child: + CircularProgressIndicator( + color: Colors + .red))) + else if (state + is SubmitQrCodeSuccess) + _successLayout(state) + else if (state + is SubmitQrCodeFailure) + _failedLayout(state) + else + (isExpanded + ? _expandedLayout( + context, + initFraction, + ) + : _collapsedLayout( + context, + initFraction, + key: collapsedKey, + )), ], ), - child: SingleChildScrollView( - controller: scrollController, - physics: - const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - _headerIcons( - context, - isExpanded, - state.status, - _cameraController, - sheetController, - initFraction, - ), - const SizedBox(height: 20), - - if (state.status == - QrScanStatus.success) - _successLayout() - else if (state.status == - QrScanStatus.failed) - _failedLayout(context) - else - (isExpanded - ? _expandedLayout( - context, - initFraction, - ) - : _collapsedLayout( - context, - initFraction, - key: collapsedKey, - )), - ], - ), - ), - ); - }, - ); - }, - ), + ), + ); + }, + ); + }, ), - ), - ], - ), - ); - }, - ); - }, - ); - }, - ), - ); - } + ), + ), + ], + ), + ); + }, + ); + }, + ); + }, + ), + ); + } Widget _headerIcons( BuildContext context, bool isExpanded, - QrScanStatus status, + SubmitQrCodeState status, MobileScannerController controller, DraggableScrollableController sheetController, double initFraction, ) { - final icons = [ - {'img': 'assets/scan/flash.png', 'route': '/flash'}, - {'img': 'assets/scan/menu.png', 'route': '/menu'}, - {'img': AppAssets.appIcon, 'route': '/home'}, - {'img': 'assets/scan/history.png', 'route': AppRouter.scanHistory}, - {'img': 'assets/scan/profile.png', 'route': AppRouter.profileScreen}, - ]; - return ValueListenableBuilder( valueListenable: controller, builder: (context, state, _) { final isTorchOn = state.torchState == TorchState.on; + final statusIdle = status is SubmitQrCodeInitial; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: icons.map((e) { - final isFlash = e['route'] == '/flash'; - final isMenu = e['route'] == '/menu'; - final isHome = e['route'] == '/home'; - final statusIdle = status == QrScanStatus.idle; - - // 🔙 Show Back Button when expanded or after scan - if ((isFlash && isExpanded) || (isFlash && !statusIdle)) { - return GestureDetector( + return Stack( + alignment: Alignment.center, + children: [ + // Left: Flash or Back + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( onTap: () async { - if (!statusIdle) { - context.read().add(ResetQrScanEvent()); - } else if (sheetController.isAttached) { - await sheetController.animateTo( - initFraction, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); + if ((isExpanded) || (!statusIdle)) { + if (!statusIdle) { + context + .read() + .add(const ResetSubmitQrCodeEvent()); + _safeStartCamera(); + } else if (sheetController.isAttached) { + await sheetController.animateTo( + initFraction, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } else { + await controller.toggleTorch(); } }, - child: const Icon( - Icons.arrow_back, - color: Colors.redAccent, - size: 28, - ), - ); - } - - // 🚫 Hide menu button - if (isMenu) return const SizedBox.shrink(); - - if (isFlash) { - return GestureDetector( - onTap: () async { - await controller.toggleTorch(); - }, - child: Image.asset( - isTorchOn - ? 'assets/scan/flash_on.png' - : 'assets/scan/flash.png', - scale: 4, - ), - ); - } - - // 🏠 Other navigation icons - return GestureDetector( - onTap: () async { - if (!isHome) { - await controller.stop(); - await Navigator.pushNamed(context, e['route']!); - _safeStartCamera(); - if (sheetController.isAttached) { - await sheetController.animateTo( - initFraction, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - } - }, - child: Image.asset( - e['img']!, - scale: isHome ? 6 : 4, - color: isHome ? Colors.black : null, + child: ((isExpanded) || (!statusIdle)) + ? Icon( + Icons.arrow_back, + color: Colors.redAccent, + size: 28.sp, + ) + : Image.asset( + isTorchOn + ? 'assets/scan/flash_on.png' + : 'assets/scan/flash.png', + width: 24.w, + height: 24.h, + fit: BoxFit.contain, + ), ), - ); - }).toList(), + ), + // Center: Logo + Image.asset( + AppAssets.appIcon, + width: 120.w, + height: 32.h, + fit: BoxFit.contain, + color: Colors.black, + ), + // Right: History and Profile + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () async { + await controller.stop(); + await Navigator.pushNamed(context, AppRouter.scanHistory); + _safeStartCamera(); + }, + child: Image.asset( + 'assets/scan/history.png', + width: 24.w, + height: 24.h, + fit: BoxFit.contain, + ), + ), + SizedBox(width: 12.w), + GestureDetector( + onTap: () async { + await controller.stop(); + await Navigator.pushNamed( + context, AppRouter.profileScreen); + _safeStartCamera(); + }, + child: Image.asset( + 'assets/scan/profile.png', + width: 24.w, + height: 24.h, + fit: BoxFit.contain, + ), + ), + ], + ), + ), + ], ); }, ); } + /// ✅ Success Layout - Widget _successLayout() => Card( - color: Colors.green[50], - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _successLayout(SubmitQrCodeSuccess state) { + final redemption = state.data['redemption'] as Map? ?? {}; + + String formatDate(String? dateStr) { + if (dateStr == null) return "N/A"; + try { + final date = DateTime.parse(dateStr); + return DateFormat('dd MMMM, yyyy').format(date); + } catch (e) { + return dateStr; + } + } + + String formatScanTime(String? scanTime) { + if (scanTime == null) return "N/A"; + try { + final date = DateFormat("dd-MM-yyyy HH:mm").parse(scanTime); + return DateFormat("dd/MM/yy 'at' hh:mm a").format(date); + } catch (e) { + return scanTime; + } + } + + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFE6F4EA), + borderRadius: BorderRadius.circular(20.r), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - "Customer Name: Ashley Johnson", - style: TextStyle(fontWeight: FontWeight.w500), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRichText("Customer Name", redemption['customerName'] ?? 'N/A'), + SizedBox(height: 10.h), + _buildRichText("Pass Type", redemption['cardType'] ?? 'N/A'), + SizedBox(height: 10.h), + _buildRichText("Valid until", formatDate(redemption['validUpto'])), + SizedBox(height: 10.h), + _buildRichText("Time of scan", formatScanTime(redemption['timeScanned'])), + ], + ), ), - Text("Pass Type: Selective Pass", style: TextStyle()), - Text("Valid until: 31 December, 2024", style: TextStyle()), - Text("Time of scan: 06/11/24 at 11:00 PM", style: TextStyle()), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + SizedBox(width: 12.w), + Column( + mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check, color: const Color(0xFF1E8E3E), size: 48.sp), Text( "Success", style: TextStyle( - color: Colors.green[700], + color: const Color(0xFF1E8E3E), fontWeight: FontWeight.w600, + fontSize: 18.sp, ), ), - const Icon(Icons.check_circle, color: Colors.green, size: 32), ], ), ], ), - ), - ); + ); + } /// ❌ Failed Layout - Widget _failedLayout(BuildContext context) => Card( - color: Colors.red[50], - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Reason: Already Used", - style: TextStyle( - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - Text( - "Suggested Action: Inform the guest this pass has already been redeemed.", - style: TextStyle(color: Colors.black87), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Failed", - style: TextStyle( - color: Colors.red[700], - fontWeight: FontWeight.w600, - ), + Widget _failedLayout(SubmitQrCodeFailure state) => Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFFDE8E8), + borderRadius: BorderRadius.circular(20.r), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRichText("Reason", state.errorMessage), + SizedBox(height: 10.h), + _buildRichText( + "Suggested Action", + state.error ?? "Inform the guest this pass has already been redeemed.", + ), + ], ), - const Icon(Icons.cancel, color: Colors.red, size: 32), - ], + ), + SizedBox(width: 12.w), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cancel, color: const Color(0xFFD32F2F), size: 48.sp), + Text( + "Failed", + style: TextStyle( + color: const Color(0xFFD32F2F), + fontWeight: FontWeight.w600, + fontSize: 18.sp, + ), + ), + ], + ), + ], + ), + ); + + Widget _buildRichText(String label, String value) { + return RichText( + text: TextSpan( + style: TextStyle( + color: Colors.black87, + fontSize: 14.sp, + height: 1.2, + ), + children: [ + TextSpan(text: "$label: "), + TextSpan( + text: value, + style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), - ), - ); + ); + } /// 📱 Collapsed Layout Widget _collapsedLayout( @@ -458,21 +556,23 @@ class _QrScanScreenState extends State key: key, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center(child: Image.asset("assets/scan/mobile.png", scale: 6)), - const SizedBox(height: 10), + Center( + child: Image.asset("assets/scan/mobile.png", + width: 80.w, height: 80.h, fit: BoxFit.contain)), + SizedBox(height: 10.h), Center( child: Text( "Position your phone to make sure the QR code is within the frame", textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: Colors.black87), + style: TextStyle(fontSize: 14.sp, color: Colors.black87), ), ), - const SizedBox(height: 25), + SizedBox(height: 25.h), Text( "Quick Links", - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16.sp), ), - const SizedBox(height: 10), + SizedBox(height: 10.h), Row( children: [ Expanded( @@ -484,7 +584,7 @@ class _QrScanScreenState extends State initFraction, ), ), - const SizedBox(width: 8), + SizedBox(width: 8.w), Expanded( child: _quickLink( context, @@ -494,7 +594,7 @@ class _QrScanScreenState extends State initFraction, ), ), - const SizedBox(width: 8), + SizedBox(width: 8.w), Expanded( child: _quickLink( context, @@ -517,46 +617,114 @@ class _QrScanScreenState extends State children: [ Text( "Manual Entry", - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16.sp), ), - const SizedBox(height: 6), - Text("Enter 10-digit redemption code", style: TextStyle(fontSize: 13)), - const SizedBox(height: 8), + SizedBox(height: 6.h), + Text("Enter redemption code", style: TextStyle(fontSize: 13.sp)), + SizedBox(height: 8.h), TextField( - keyboardType: TextInputType.number, - maxLength: 10, + controller: _manualCodeController, + maxLength: 30, + style: TextStyle(fontSize: 14.sp), decoration: InputDecoration( - hintText: "eg: 1234-567-890", + hintText: "eg: QR-1683500000000-123", counterText: "", - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: + EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10.r)), + suffixIcon: IconButton( + icon: const Icon(Icons.send, color: Colors.redAccent), + onPressed: () { + if (_manualCodeController.text.isNotEmpty) { + context.read().add( + SubmitQrCodeEventTriggered( + qrCode: _manualCodeController.text)); + } + }, + ), ), ), - const SizedBox(height: 20), - Text( - "Recent Scans", - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 24), + SizedBox(height: 20.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Recent Scans", + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 24.sp), + ), + GestureDetector( + onTap: () async { + await _cameraController.stop(); + await Navigator.pushNamed(context, AppRouter.scanHistory); + _safeStartCamera(); + }, + child: Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.redAccent,width: 0.8)), + ), + child: Text( + "View All", + style: TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14.sp, + color: Colors.redAccent, + ), + ), + ), + ), + ], ), - const SizedBox(height: 10), - _scanCard( - "Last Scan", - "Failed", - Colors.red, - const Color(0xFFFFE5E5), - reason: "Already Used", + SizedBox(height: 10.h), + BlocBuilder( + builder: (context, state) { + if (state is RecentScanHistoryLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is RecentScanHistoryError) { + return Text("Error: ${state.message}", style: const TextStyle(color: Colors.red)); + } else if (state is RecentScanHistoryLoaded) { + if (state.history.isEmpty) { + return const Text("No recent scans found."); + } + // Only show the last 2 for the "Recent Scans" preview + final displayHistory = state.history.take(2).toList(); + return Column( + children: displayHistory.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSuccess = item.status.toLowerCase() == "success"; + final label = index == 0 ? "Last Scan" : "Previous"; + + return GestureDetector( + onTap: () { + Navigator.pushNamed(context, AppRouter.scanHistoryDetailPage, arguments: item.id); + }, + child: Padding( + padding: EdgeInsets.only(bottom: 12.h), + child: _scanCard( + label, + item.attractionTitle.isEmpty ? "N/A" : item.attractionTitle, + item.status, + isSuccess ? const Color(0xFF1E8E3E) : const Color(0xFFD32F2F), + isSuccess ? const Color(0xFFE6F4EA) : const Color(0xFFFDE8E8), + reason: item.reason, + isSuccess: isSuccess, + ), + ), + ); + }).toList(), + ); + } + return const SizedBox.shrink(); + }, ), - const SizedBox(height: 10), - _scanCard( - "Previous Scan", - "Success", - Colors.green, - const Color(0xFFE5F9E7), - ), - const SizedBox(height: 25), + SizedBox(height: 15.h), + Text( "Quick Links", - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16.sp), ), - const SizedBox(height: 10), + SizedBox(height: 10.h), Row( children: [ Expanded( @@ -568,7 +736,7 @@ class _QrScanScreenState extends State initFraction, ), ), - const SizedBox(width: 10), + SizedBox(width: 10.w), Expanded( child: _quickLink( context, @@ -580,7 +748,7 @@ class _QrScanScreenState extends State ), ], ), - const SizedBox(height: 10), + SizedBox(height: 10.h), Row( children: [ Expanded( @@ -592,7 +760,7 @@ class _QrScanScreenState extends State initFraction, ), ), - const SizedBox(width: 10), + SizedBox(width: 10.w), Expanded( child: _quickLink( context, @@ -610,49 +778,78 @@ class _QrScanScreenState extends State /// 🔘 Scan Card Widget _scanCard( + String label, String title, String status, Color color, Color bgColor, { String? reason, + required bool isSuccess, }) { - return Card( - color: bgColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: color), + return Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10.r), + border: Border.all(color: color.withOpacity(0.3), width: 1.w), ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - status == "Failed" ? Icons.cancel : Icons.check_circle, - color: color, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16.sp, + color: Colors.black54, + letterSpacing: 0.2, ), - Text( - status, - style: TextStyle(fontWeight: FontWeight.w600, color: color), + ), + SizedBox(height: 2.h), + Row( + children: [ + Icon( + isSuccess ? Icons.check_circle : Icons.cancel, + color: color, + size: 18.sp, + ), + SizedBox(width: 4.w), + Text( + status, + style: TextStyle( + fontWeight: FontWeight.w600, + color: color, + fontSize: 16.sp, + ), + ), + ], + ), + if (!isSuccess && reason != null && reason.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 4.h), + child: Text( + "Reason: $reason", + style: TextStyle( + fontSize: 12.sp, + color: Colors.red[700], + ), + ), ), - if (reason != null) - Text("Reason: $reason", style: TextStyle(fontSize: 13)), - ], - ), + ], ), - Image.asset("assets/scan/ticket_qr.png", scale: 4), - ], - ), + ), + SizedBox(width: 12.w), + Image.asset( + "assets/scan/ticket_qr.png", + width: 75.w, + height: 75.h, + fit: BoxFit.contain, + ), + ], ), ); } @@ -683,17 +880,17 @@ class _QrScanScreenState extends State child: Container( decoration: BoxDecoration( color: Colors.red[100], - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(10.r), ), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric(vertical: 10.h), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Image.asset(icon, scale: 4), - const SizedBox(height: 6), + Image.asset(icon, scale: 4, width: 24.w, height: 24.h), + SizedBox(height: 6.h), Text( label, - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + style: TextStyle(fontSize: 13.sp, fontWeight: FontWeight.w500), ), ], ), diff --git a/lib/scan_history/blocs/scan_history/scan_history_bloc.dart b/lib/scan_history/blocs/scan_history/scan_history_bloc.dart new file mode 100644 index 0000000..8c9dcb1 --- /dev/null +++ b/lib/scan_history/blocs/scan_history/scan_history_bloc.dart @@ -0,0 +1,206 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; + +import '../../models/scan_history_model.dart'; +import '../../repositories/scan_history_repository.dart'; + +part 'scan_history_event.dart'; +part 'scan_history_state.dart'; + +class ScanHistoryBloc extends Bloc { + final ScanHistoryRepository _repository; + + ScanHistoryBloc({ScanHistoryRepository? repository}) + : _repository = repository ?? ScanHistoryRepository(), + super(ScanHistoryInitial()) { + on(_onFetchScanHistory); + on(_onRefreshScanHistory); + on(_onUpdateDate); + on(_onUpdateStatus); + on(_onSearchScanHistory); + on(_onSelectScanHistory); + on(_onClearSelectedScanHistory); + } + + // ─── Fetch ──────────────────────────────────────────────────────────────── + + Future _onFetchScanHistory( + FetchScanHistoryEvent event, + Emitter emit, + ) async { + DateTime? selectedDate = event.clearDate ? null : (event.date ?? state.selectedDate); + String selectedStatus = event.status ?? state.selectedStatus; + + emit(ScanHistoryLoading( + selectedDate: selectedDate, + selectedStatus: selectedStatus, + )); + + try { + String? formattedDate; + if (selectedDate != null) { + formattedDate = DateFormat('yyyy-MM-dd').format(selectedDate); + } + + final List items = await _repository.fetchScanHistory( + date: formattedDate, + status: selectedStatus, + ); + emit(ScanHistoryLoaded( + allItems: items, + filteredItems: items, + selectedDate: selectedDate, + selectedStatus: selectedStatus, + )); + } catch (e) { + emit(ScanHistoryError( + errorMessage: e.toString(), + selectedDate: selectedDate, + selectedStatus: selectedStatus, + )); + } + } + + // ─── Refresh ────────────────────────────────────────────────────────────── + + Future _onRefreshScanHistory( + RefreshScanHistoryEvent event, + Emitter emit, + ) async { + if (state is! ScanHistoryLoaded) return; + + final currentState = state as ScanHistoryLoaded; + emit(currentState.copyWith(isRefreshing: true)); + + try { + String? formattedDate; + if (currentState.selectedDate != null) { + formattedDate = DateFormat('yyyy-MM-dd').format(currentState.selectedDate!); + } + + final List items = await _repository.fetchScanHistory( + date: formattedDate, + status: currentState.selectedStatus, + ); + emit(ScanHistoryLoaded( + allItems: items, + filteredItems: items, + selectedDate: currentState.selectedDate, + selectedStatus: currentState.selectedStatus, + )); + } catch (e) { + emit(ScanHistoryError( + errorMessage: e.toString(), + selectedDate: currentState.selectedDate, + selectedStatus: currentState.selectedStatus, + )); + } + } + + // ─── Update Filters ─────────────────────────────────────────────────────── + + Future _onUpdateDate( + UpdateScanHistoryDateEvent event, + Emitter emit, + ) async { + add(FetchScanHistoryEvent( + date: event.date, + clearDate: event.date == null, + status: state.selectedStatus, + )); + } + + Future _onUpdateStatus( + UpdateScanHistoryStatusEvent event, + Emitter emit, + ) async { + add(FetchScanHistoryEvent( + date: state.selectedDate, + status: event.status, + )); + } + + // ─── Search ─────────────────────────────────────────────────────────────── + + void _onSearchScanHistory( + SearchScanHistoryEvent event, + Emitter emit, + ) { + List allItems = []; + String query = event.query.trim().toLowerCase(); + + if (state is ScanHistoryLoaded) { + allItems = (state as ScanHistoryLoaded).allItems; + } else if (state is ScanHistoryDetailState) { + allItems = (state as ScanHistoryDetailState).allItems; + } else { + return; + } + + final filtered = query.isEmpty + ? allItems + : allItems.where((item) { + return item.customerName.toLowerCase().contains(query) || + item.customerEmail.toLowerCase().contains(query) || + item.bookingNumber.toLowerCase().contains(query) || + item.attractionTitle.toLowerCase().contains(query) || + item.status.toLowerCase().contains(query) || + item.cardType.toLowerCase().contains(query); + }).toList(); + + if (state is ScanHistoryLoaded) { + emit((state as ScanHistoryLoaded).copyWith( + filteredItems: filtered, + searchQuery: event.query, + )); + } else if (state is ScanHistoryDetailState) { + final s = state as ScanHistoryDetailState; + emit(ScanHistoryDetailState( + selectedItem: s.selectedItem, + allItems: s.allItems, + filteredItems: filtered, + searchQuery: event.query, + selectedDate: s.selectedDate, + selectedStatus: s.selectedStatus, + )); + } + } + + // ─── Select Detail ──────────────────────────────────────────────────────── + + void _onSelectScanHistory( + SelectScanHistoryEvent event, + Emitter emit, + ) { + if (state is ScanHistoryLoaded) { + final s = state as ScanHistoryLoaded; + emit(ScanHistoryDetailState( + selectedItem: event.selectedItem, + allItems: s.allItems, + filteredItems: s.filteredItems, + searchQuery: s.searchQuery, + selectedDate: s.selectedDate, + selectedStatus: s.selectedStatus, + )); + } + } + + // ─── Clear / Back ───────────────────────────────────────────────────────── + + void _onClearSelectedScanHistory( + ClearSelectedScanHistoryEvent event, + Emitter emit, + ) { + if (state is ScanHistoryDetailState) { + final s = state as ScanHistoryDetailState; + emit(ScanHistoryLoaded( + allItems: s.allItems, + filteredItems: s.filteredItems, + searchQuery: s.searchQuery, + selectedDate: s.selectedDate, + selectedStatus: s.selectedStatus, + )); + } + } +} \ No newline at end of file diff --git a/lib/scan_history/blocs/scan_history/scan_history_event.dart b/lib/scan_history/blocs/scan_history/scan_history_event.dart new file mode 100644 index 0000000..0f53e78 --- /dev/null +++ b/lib/scan_history/blocs/scan_history/scan_history_event.dart @@ -0,0 +1,70 @@ +part of 'scan_history_bloc.dart'; + +abstract class ScanHistoryEvent extends Equatable { + const ScanHistoryEvent(); + + @override + List get props => []; +} + +/// Triggered to fetch the scan history list from the API +class FetchScanHistoryEvent extends ScanHistoryEvent { + final DateTime? date; + final String? status; + final bool clearDate; // Explicitly set to true if we want to clear the date filter + + const FetchScanHistoryEvent({this.date, this.status, this.clearDate = false}); + + @override + List get props => [date, status, clearDate]; +} + +/// Triggered to refresh the scan history list (pull-to-refresh) +class RefreshScanHistoryEvent extends ScanHistoryEvent { + const RefreshScanHistoryEvent(); +} + +/// Triggered to update the selected date filter +class UpdateScanHistoryDateEvent extends ScanHistoryEvent { + final DateTime? date; // Null means 'All' + + const UpdateScanHistoryDateEvent({this.date}); + + @override + List get props => [date]; +} + +/// Triggered to update the selected status filter +class UpdateScanHistoryStatusEvent extends ScanHistoryEvent { + final String status; + + const UpdateScanHistoryStatusEvent({required this.status}); + + @override + List get props => [status]; +} + +/// Triggered to search/filter scan history by keyword +class SearchScanHistoryEvent extends ScanHistoryEvent { + final String query; + + const SearchScanHistoryEvent({required this.query}); + + @override + List get props => [query]; +} + +/// Triggered to select a specific scan history item for detail view +class SelectScanHistoryEvent extends ScanHistoryEvent { + final ScanHistory selectedItem; + + const SelectScanHistoryEvent({required this.selectedItem}); + + @override + List get props => [selectedItem]; +} + +/// Triggered to clear the selected item (navigate back from detail) +class ClearSelectedScanHistoryEvent extends ScanHistoryEvent { + const ClearSelectedScanHistoryEvent(); +} \ No newline at end of file diff --git a/lib/scan_history/blocs/scan_history/scan_history_state.dart b/lib/scan_history/blocs/scan_history/scan_history_state.dart new file mode 100644 index 0000000..5f90583 --- /dev/null +++ b/lib/scan_history/blocs/scan_history/scan_history_state.dart @@ -0,0 +1,96 @@ +part of 'scan_history_bloc.dart'; + +abstract class ScanHistoryState extends Equatable { + final DateTime? selectedDate; + final String selectedStatus; + + const ScanHistoryState({ + this.selectedDate, + required this.selectedStatus, + }); + + @override + List get props => [selectedDate, selectedStatus]; +} + +class ScanHistoryInitial extends ScanHistoryState { + ScanHistoryInitial({DateTime? date, String? status}) + : super( + selectedDate: date, // Null means 'All' + selectedStatus: status ?? 'all', + ); +} + +class ScanHistoryLoading extends ScanHistoryState { + const ScanHistoryLoading({super.selectedDate, required super.selectedStatus}); +} + +class ScanHistoryLoaded extends ScanHistoryState { + final List allItems; + final List filteredItems; + final String searchQuery; + final bool isRefreshing; + + const ScanHistoryLoaded({ + super.selectedDate, + required super.selectedStatus, + required this.allItems, + required this.filteredItems, + this.searchQuery = '', + this.isRefreshing = false, + }); + + ScanHistoryLoaded copyWith({ + List? allItems, + List? filteredItems, + String? searchQuery, + bool? isRefreshing, + DateTime? selectedDate, + bool clearDate = false, + String? selectedStatus, + }) { + return ScanHistoryLoaded( + allItems: allItems ?? this.allItems, + filteredItems: filteredItems ?? this.filteredItems, + searchQuery: searchQuery ?? this.searchQuery, + isRefreshing: isRefreshing ?? this.isRefreshing, + selectedDate: clearDate ? null : (selectedDate ?? this.selectedDate), + selectedStatus: selectedStatus ?? this.selectedStatus, + ); + } + + @override + List get props => [allItems, filteredItems, searchQuery, isRefreshing, selectedDate, selectedStatus]; +} + +class ScanHistoryDetailState extends ScanHistoryState { + final ScanHistory selectedItem; + final List allItems; + final List filteredItems; + final String searchQuery; + + const ScanHistoryDetailState({ + super.selectedDate, + required super.selectedStatus, + required this.selectedItem, + required this.allItems, + required this.filteredItems, + required this.searchQuery, + }); + + @override + List get props => [selectedItem, allItems, filteredItems, searchQuery, selectedDate, selectedStatus]; +} + +class ScanHistoryError extends ScanHistoryState { + final String errorMessage; + + const ScanHistoryError({ + super.selectedDate, + required super.selectedStatus, + required this.errorMessage, + }); + + @override + List get props => [errorMessage, selectedDate, selectedStatus]; +} \ No newline at end of file diff --git a/lib/scan_history/blocs/scan_history_bloc.dart b/lib/scan_history/blocs/scan_history_bloc.dart index 3383be7..169c5d2 100644 --- a/lib/scan_history/blocs/scan_history_bloc.dart +++ b/lib/scan_history/blocs/scan_history_bloc.dart @@ -1,6 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../viewmodels/scan_history_viewmodel.dart'; -import '../models/scan_history_model.dart'; +import '../models/scan_history_model_old.dart'; // EVENTS abstract class ScanHistoryEvent {} diff --git a/lib/scan_history/blocs/scan_history_details/scan_history_details_bloc.dart b/lib/scan_history/blocs/scan_history_details/scan_history_details_bloc.dart new file mode 100644 index 0000000..15abd30 --- /dev/null +++ b/lib/scan_history/blocs/scan_history_details/scan_history_details_bloc.dart @@ -0,0 +1,25 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../repositories/scan_history_repository.dart'; +import '../../models/scan_history_details_model.dart'; +import 'package:equatable/equatable.dart'; +part 'scan_history_details_event.dart'; +part 'scan_history_details_state.dart'; + +class ScanHistoryDetailsBloc extends Bloc { + final ScanHistoryRepository _repository; + + ScanHistoryDetailsBloc(this._repository) : super(ScanHistoryDetailsInitial()) { + on(_onFetchScanHistoryDetails); + } + + Future _onFetchScanHistoryDetails( + FetchScanHistoryDetails event, Emitter emit) async { + emit(ScanHistoryDetailsLoading()); + try { + final details = await _repository.fetchScanHistoryDetails(event.id); + emit(ScanHistoryDetailsLoaded(details)); + } catch (e) { + emit(ScanHistoryDetailsError(e.toString())); + } + } +} diff --git a/lib/scan_history/blocs/scan_history_details/scan_history_details_event.dart b/lib/scan_history/blocs/scan_history_details/scan_history_details_event.dart new file mode 100644 index 0000000..2e5885f --- /dev/null +++ b/lib/scan_history/blocs/scan_history_details/scan_history_details_event.dart @@ -0,0 +1,19 @@ +part of 'scan_history_details_bloc.dart'; + + +abstract class ScanHistoryDetailsEvent extends Equatable { + + const ScanHistoryDetailsEvent(); + + @override + List get props => []; +} + +class FetchScanHistoryDetails extends ScanHistoryDetailsEvent { + final int id; + + const FetchScanHistoryDetails(this.id); + + @override + List get props => [id]; +} diff --git a/lib/scan_history/blocs/scan_history_details/scan_history_details_state.dart b/lib/scan_history/blocs/scan_history_details/scan_history_details_state.dart new file mode 100644 index 0000000..a1d9cdd --- /dev/null +++ b/lib/scan_history/blocs/scan_history_details/scan_history_details_state.dart @@ -0,0 +1,30 @@ +part of 'scan_history_details_bloc.dart'; + +abstract class ScanHistoryDetailsState extends Equatable { + const ScanHistoryDetailsState(); + + @override + List get props => []; +} + +class ScanHistoryDetailsInitial extends ScanHistoryDetailsState {} + +class ScanHistoryDetailsLoading extends ScanHistoryDetailsState {} + +class ScanHistoryDetailsLoaded extends ScanHistoryDetailsState { + final ScanHistoryDetails details; + + const ScanHistoryDetailsLoaded(this.details); + + @override + List get props => [details]; +} + +class ScanHistoryDetailsError extends ScanHistoryDetailsState { + final String message; + + const ScanHistoryDetailsError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/scan_history/models/scan_history_details_model.dart b/lib/scan_history/models/scan_history_details_model.dart new file mode 100644 index 0000000..7264951 --- /dev/null +++ b/lib/scan_history/models/scan_history_details_model.dart @@ -0,0 +1,121 @@ +class ScanHistoryDetails { + final int id; + final String qrCode; + final String qrNumber; + final String status; + final String reason; + final int partnerId; + final int attractionId; + final String attractionTitle; + final String cityName; + final String bookingNumber; + final String customerName; + final String customerMobile; + final String customerEmail; + final String cardType; + final int scannedByPartnerStaffId; + final String scannedByPartnerStaffName; + final String validUpto; + final String activatedAt; + final String checkedInDatetime; + final String qrExpiresAt; + final String createdAt; + final String updatedAt; + + ScanHistoryDetails({ + required this.id, + required this.qrCode, + required this.qrNumber, + required this.status, + required this.reason, + required this.partnerId, + required this.attractionId, + required this.attractionTitle, + required this.cityName, + required this.bookingNumber, + required this.customerName, + required this.customerMobile, + required this.customerEmail, + required this.cardType, + required this.scannedByPartnerStaffId, + required this.scannedByPartnerStaffName, + required this.validUpto, + required this.activatedAt, + required this.checkedInDatetime, + required this.qrExpiresAt, + required this.createdAt, + required this.updatedAt, + }); + + /// ✅ Factory with N/A fallback + factory ScanHistoryDetails.fromJson(Map json) { + return ScanHistoryDetails( + id: json['id'] ?? 0, + + qrCode: _value(json['qrCode']), + qrNumber: _value(json['qrNumber']), + status: _value(json['status']), + reason: _value(json['reason']), + + partnerId: json['partnerId'] ?? 0, + attractionId: json['attractionId'] ?? 0, + + attractionTitle: _value(json['attractionTitle']), + cityName: _value(json['cityName']), + bookingNumber: _value(json['bookingNumber']), + + customerName: _value(json['customerName']), + customerMobile: _value(json['customerMobile']), + customerEmail: _value(json['customerEmail']), + cardType: _value(json['cardType']), + + scannedByPartnerStaffId: json['scannedByPartnerStaffId'] ?? 0, + scannedByPartnerStaffName: + _value(json['scannedByPartnerStaffName']), + + validUpto: _value(json['validUpto']), + activatedAt: _value(json['activatedAt']), + checkedInDatetime: _value(json['checkedInDatetime']), + qrExpiresAt: _value(json['qrExpiresAt']), + + createdAt: _value(json['createdAt']), + updatedAt: _value(json['updatedAt']), + ); + } + + /// ✅ Convert back to JSON (optional but useful) + Map toJson() { + return { + 'id': id, + 'qrCode': qrCode, + 'qrNumber': qrNumber, + 'status': status, + 'reason': reason, + 'partnerId': partnerId, + 'attractionId': attractionId, + 'attractionTitle': attractionTitle, + 'cityName': cityName, + 'bookingNumber': bookingNumber, + 'customerName': customerName, + 'customerMobile': customerMobile, + 'customerEmail': customerEmail, + 'cardType': cardType, + 'scannedByPartnerStaffId': scannedByPartnerStaffId, + 'scannedByPartnerStaffName': scannedByPartnerStaffName, + 'validUpto': validUpto, + 'activatedAt': activatedAt, + 'checkedInDatetime': checkedInDatetime, + 'qrExpiresAt': qrExpiresAt, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + } + + /// ✅ Common helper for null / empty / "null" + static String _value(dynamic val) { + if (val == null) return "N/A"; + if (val.toString().trim().isEmpty) return "N/A"; + if (val.toString().toLowerCase() == "null") return "N/A"; + return val.toString(); + } +} \ No newline at end of file diff --git a/lib/scan_history/models/scan_history_model.dart b/lib/scan_history/models/scan_history_model.dart index e8315a0..ce3c20c 100644 --- a/lib/scan_history/models/scan_history_model.dart +++ b/lib/scan_history/models/scan_history_model.dart @@ -1,13 +1,148 @@ -class ScanHistoryModel { - final String passId; - final String? reason; - final String time; - final String status; // "Success" or "Failed" +import 'package:intl/intl.dart'; - ScanHistoryModel({ - required this.passId, - this.reason, - required this.time, - required this.status, +class ScanHistoryResponse { + final List data; + + ScanHistoryResponse({ + required this.data, }); + + factory ScanHistoryResponse.fromJson(Map json) { + return ScanHistoryResponse( + data: (json['data'] as List?) + ?.map((e) => ScanHistory.fromJson(e as Map)) + .toList() ?? + [], + ); + } } + +class ScanHistory { + final int id; + final String passId; + final String qrCode; + final String bookingNumber; + final int attractionId; + final String attractionTitle; + final String customerName; + final String customerEmail; + final String cardType; + final int? scannedByPartnerStaffId; + final String scannedByPartnerStaffName; + final String status; + final String reason; // ✅ ADDED + final DateTime? checkedInDatetime; + final DateTime? activatedAt; + final DateTime? qrExpiresAt; + final DateTime? validUpto; + final DateTime? createdAt; + + ScanHistory({ + required this.id, + required this.passId, + required this.qrCode, + required this.bookingNumber, + required this.attractionId, + required this.attractionTitle, + required this.customerName, + required this.customerEmail, + required this.cardType, + this.scannedByPartnerStaffId, + required this.scannedByPartnerStaffName, + required this.status, + required this.reason, // ✅ ADDED + this.checkedInDatetime, + this.activatedAt, + this.qrExpiresAt, + this.validUpto, + this.createdAt, + }); + + factory ScanHistory.fromJson(Map json) { + return ScanHistory( + id: json['id'] ?? 0, + passId: (json['passId'] ?? 'N/A').toString(), + qrCode: (json['qrCode'] ?? '').toString(), + bookingNumber: (json['bookingNumber'] ?? 'N/A').toString(), + attractionId: json['attractionId'] ?? 0, + attractionTitle: (json['attractionTitle'] ?? '').toString(), + customerName: (json['customerName'] ?? '').toString(), + customerEmail: (json['customerEmail'] ?? '').toString(), + cardType: (json['cardType'] ?? '').toString(), + scannedByPartnerStaffId: json['scannedByPartnerStaffId'], + scannedByPartnerStaffName: + (json['scannedByPartnerStaffName'] ?? '').toString(), + status: (json['status'] ?? '').toString(), + reason: (json['reason'] ?? '').toString(), // ✅ ADDED + checkedInDatetime: _parseDate(json['checkedInDatetime']), + activatedAt: _parseDate(json['activatedAt']), + qrExpiresAt: _parseDate(json['qrExpiresAt']), + validUpto: _parseDate(json['validUpto']), + createdAt: _parseDate(json['createdAt']), + ); + } + + /// 🔥 Safe Date Parser + static DateTime? _parseDate(dynamic date) { + if (date == null) return null; + try { + return DateTime.parse(date.toString()); + } catch (e) { + return null; + } + } + + /// 🔥 Date Formatter + static final DateFormat _formatter = + DateFormat('dd MMM yyyy, hh:mm a'); + + String _formatDate(DateTime? date) { + if (date == null) return 'N/A'; + return _formatter.format(date); + } + + /// ✅ UI Friendly Getters + + String get displayCheckedIn => _formatDate(checkedInDatetime); + + String get displayActivatedAt => _formatDate(activatedAt); + + String get displayQrExpiresAt => _formatDate(qrExpiresAt); + + String get displayValidUpto => _formatDate(validUpto); + + String get displayCreatedAt => _formatDate(createdAt); + + String get displayStaffName => + scannedByPartnerStaffName.isNotEmpty + ? scannedByPartnerStaffName + : 'N/A'; + + String get displayStatus => + status.isNotEmpty ? status : 'N/A'; + + String get displayReason => + reason.isNotEmpty ? reason : 'N/A'; + + String get displayCustomerName => + customerName.isNotEmpty ? customerName : 'N/A'; + + String get displayEmail => + customerEmail.isNotEmpty ? customerEmail : 'N/A'; + + String get displayAttraction => + attractionTitle.isNotEmpty ? attractionTitle : 'N/A'; + + /// 🎯 STATUS HELPERS (VERY USEFUL FOR UI) + + bool get isSuccess => status.toLowerCase() == 'success'; + + bool get isFailed => status.toLowerCase() == 'failed'; + + /// 🎨 Optional: Status Color Helper (UI usage) + String get statusColorHex { + if (isSuccess) return '#4CAF50'; // green + if (isFailed) return '#F44336'; // red + return '#9E9E9E'; // grey + } +} \ No newline at end of file diff --git a/lib/scan_history/models/scan_history_model_old.dart b/lib/scan_history/models/scan_history_model_old.dart new file mode 100644 index 0000000..e8315a0 --- /dev/null +++ b/lib/scan_history/models/scan_history_model_old.dart @@ -0,0 +1,13 @@ +class ScanHistoryModel { + final String passId; + final String? reason; + final String time; + final String status; // "Success" or "Failed" + + ScanHistoryModel({ + required this.passId, + this.reason, + required this.time, + required this.status, + }); +} diff --git a/lib/scan_history/repositories/scan_history_repository.dart b/lib/scan_history/repositories/scan_history_repository.dart index 2e4c1d5..51c20b4 100644 --- a/lib/scan_history/repositories/scan_history_repository.dart +++ b/lib/scan_history/repositories/scan_history_repository.dart @@ -1,18 +1,49 @@ +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; import '../models/scan_history_model.dart'; +import '../models/scan_history_details_model.dart'; + class ScanHistoryRepository { - Future> fetchScanHistory(DateTime date, String statusFilter) async { - await Future.delayed(const Duration(milliseconds: 500)); + final ApiService _apiService = ApiService(); - // Mock data - List data = [ - ScanHistoryModel(passId: '#P214125125', reason: 'Already Used', time: '05/11/24 on 11:00PM', status: 'Failed'), - ScanHistoryModel(passId: '#P214125125', time: '05/11/24 on 11:00PM', status: 'Success'), - ScanHistoryModel(passId: '#P214125125', reason: 'Invalid Code', time: '05/11/24 on 11:00PM', status: 'Failed'), - ScanHistoryModel(passId: '#P214125125', time: '05/11/24 on 11:00PM', status: 'Success'), - ]; + /// Fetch Scan History List with filters + Future> fetchScanHistory({String? date, String? status}) async { + try { + Map queryParameters = {}; + if (date != null) queryParameters['date'] = date; + if (status != null && status != 'all') queryParameters['status'] = status.toLowerCase(); - if (statusFilter == 'All Status') return data; - return data.where((item) => item.status == statusFilter).toList(); + final response = await _apiService.get( + ApiUrls.scanHistory, + queryParameters: queryParameters, + ); + + final ScanHistoryResponse scanResponse = + ScanHistoryResponse.fromJson(response.data); + + return scanResponse.data; + } catch (e) { + throw Exception('Failed to fetch scan history: $e'); + } } -} + + /// Fetch Scan History Details by ID + Future fetchScanHistoryDetails(int id) async { + try { + final response = await _apiService.get("${ApiUrls.scanHistory}/$id"); + + // Check if data is wrapped in a 'data' key or is at the root + final dynamic rawData = response.data; + if (rawData is Map) { + if (rawData.containsKey('data')) { + return ScanHistoryDetails.fromJson(rawData['data']); + } + return ScanHistoryDetails.fromJson(rawData); + } + throw Exception('Invalid response format'); + } catch (e) { + throw Exception('Failed to fetch scan history details: $e'); + } + } +} \ No newline at end of file diff --git a/lib/scan_history/repositories/scan_history_repository_old.dart b/lib/scan_history/repositories/scan_history_repository_old.dart new file mode 100644 index 0000000..dd09ea1 --- /dev/null +++ b/lib/scan_history/repositories/scan_history_repository_old.dart @@ -0,0 +1,18 @@ +import '../models/scan_history_model_old.dart'; + +class ScanHistoryRepository { + Future> fetchScanHistory(DateTime date, String statusFilter) async { + await Future.delayed(const Duration(milliseconds: 500)); + + // Mock data + List data = [ + ScanHistoryModel(passId: '#P214125125', reason: 'Already Used', time: '05/11/24 on 11:00PM', status: 'Failed'), + ScanHistoryModel(passId: '#P214125125', time: '05/11/24 on 11:00PM', status: 'Success'), + ScanHistoryModel(passId: '#P214125125', reason: 'Invalid Code', time: '05/11/24 on 11:00PM', status: 'Failed'), + ScanHistoryModel(passId: '#P214125125', time: '05/11/24 on 11:00PM', status: 'Success'), + ]; + + if (statusFilter == 'All Status') return data; + return data.where((item) => item.status == statusFilter).toList(); + } +} diff --git a/lib/scan_history/viewmodels/scan_history_viewmodel.dart b/lib/scan_history/viewmodels/scan_history_viewmodel.dart index 8ec7b4c..14abeda 100644 --- a/lib/scan_history/viewmodels/scan_history_viewmodel.dart +++ b/lib/scan_history/viewmodels/scan_history_viewmodel.dart @@ -1,5 +1,5 @@ -import '../repositories/scan_history_repository.dart'; -import '../models/scan_history_model.dart'; +import '../repositories/scan_history_repository_old.dart'; +import '../models/scan_history_model_old.dart'; class ScanHistoryViewModel { final ScanHistoryRepository repository; diff --git a/lib/scan_history/views/scan_history_detail_page.dart b/lib/scan_history/views/scan_history_detail_page.dart index 64096fa..c327d4f 100644 --- a/lib/scan_history/views/scan_history_detail_page.dart +++ b/lib/scan_history/views/scan_history_detail_page.dart @@ -2,12 +2,11 @@ import 'package:citycards_partner_flutter/core/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/scan_history_detail_bloc.dart'; -import '../viewmodels/scan_history_detail_viewmodel.dart'; +import '../blocs/scan_history_details/scan_history_details_bloc.dart'; class ScanHistoryDetailPage extends StatelessWidget { - final String passId; + final int passId; const ScanHistoryDetailPage({super.key, required this.passId}); TextStyle _headerStyle() => const TextStyle( @@ -67,18 +66,18 @@ class ScanHistoryDetailPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ScanHistoryDetailBloc()..add(LoadScanHistoryDetail(passId)), + return BlocProvider.value( + value: context.read()..add(FetchScanHistoryDetails(passId)), child: Scaffold( backgroundColor: Colors.white, body: SafeArea( - child: BlocBuilder( + child: BlocBuilder( builder: (context, state) { - if (state is ScanHistoryDetailLoading) { + if (state is ScanHistoryDetailsLoading) { return const Center(child: CircularProgressIndicator()); } - if (state is ScanHistoryDetailLoaded) { - final vm = PassDetailViewModel.fromMap(state.data); + if (state is ScanHistoryDetailsLoaded) { + final data = state.details; return Column( children: [ Padding( @@ -101,21 +100,22 @@ class ScanHistoryDetailPage extends StatelessWidget { Expanded( child: Center( child: Text( - '#${vm.displayPassId}', + '#${data.bookingNumber}', style: const TextStyle( - fontWeight: FontWeight.w700, fontSize: 28, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ), const SizedBox(width: 12), - InkWell( - onTap: (){ - Navigator.pushNamed(context, AppRouter.profileScreen); - }, - child: Image.asset("assets/app/profile.png",scale: 4)), + InkWell( + onTap: () { + Navigator.pushNamed(context, AppRouter.profileScreen); + }, + child: Image.asset("assets/app/profile.png", scale: 4)), ], ), ), @@ -130,21 +130,21 @@ class ScanHistoryDetailPage extends StatelessWidget { // Pass Summary Text('Pass Summary', style: _headerStyle()), _doubleDivider(), - _twoColumnRow('Card Type', vm.cardType, 'Validity', vm.validity), + _twoColumnRow('Card Type', data.cardType, 'Validity', data.status), const SizedBox(height: 16), Text('Customer Details', style: _headerStyle()), _doubleDivider(), - _twoColumnRow('Customer Name', vm.customerName, 'Phone', vm.phone), + _twoColumnRow('Customer Name', data.customerName, 'Phone', data.customerMobile), _divider(), Text('Email', style: _labelStyle()), const SizedBox(height: 6), - Text(vm.email, style: _valueStyle()), + Text(data.customerEmail, style: _valueStyle()), const SizedBox(height: 16), Text('Attraction Details', style: _headerStyle()), _doubleDivider(), - _twoColumnRow('City', vm.city, 'Attraction Name', vm.attractionName), + _twoColumnRow('City', data.cityName, 'Attraction Name', data.attractionTitle), const SizedBox(height: 60), ], @@ -155,7 +155,7 @@ class ScanHistoryDetailPage extends StatelessWidget { ); } - if (state is ScanHistoryDetailError) { + if (state is ScanHistoryDetailsError) { return Center(child: Text(state.message)); } diff --git a/lib/scan_history/views/scan_history_page.dart b/lib/scan_history/views/scan_history_page.dart index 7212ced..bd97695 100644 --- a/lib/scan_history/views/scan_history_page.dart +++ b/lib/scan_history/views/scan_history_page.dart @@ -1,23 +1,29 @@ import 'package:citycards_partner_flutter/core/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../blocs/scan_history_bloc.dart'; -import '../repositories/scan_history_repository.dart'; -import '../viewmodels/scan_history_viewmodel.dart'; +import 'package:intl/intl.dart'; +import '../blocs/scan_history/scan_history_bloc.dart'; import '../models/scan_history_model.dart'; -class ScanHistoryPage extends StatelessWidget { +class ScanHistoryPage extends StatefulWidget { const ScanHistoryPage({super.key}); + @override + State createState() => _ScanHistoryPageState(); +} + +class _ScanHistoryPageState extends State { + @override + void initState() { + super.initState(); + + // ✅ API call when page opens + context.read().add(const FetchScanHistoryEvent()); + } + @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ScanHistoryBloc( - viewModel: ScanHistoryViewModel(repository: ScanHistoryRepository()), - )..add(LoadScanHistory()), - child: const _ScanHistoryView(), - ); + return const _ScanHistoryView(); } } @@ -32,7 +38,7 @@ class _ScanHistoryView extends StatelessWidget { backgroundColor: Colors.white, elevation: 0, centerTitle: true, - title: Text( + title: const Text( "Scan History", style: TextStyle( fontWeight: FontWeight.w600, @@ -40,15 +46,13 @@ class _ScanHistoryView extends StatelessWidget { color: Colors.black, ), ), - leading: Padding( - padding: EdgeInsets.only(left: 12), + leading: Padding( + padding: const EdgeInsets.only(left: 12), child: InkWell( - onTap: () { - Navigator.pop(context); - }, - child: CircleAvatar( - maxRadius: 44, - backgroundColor: Color(0xffF95F62), + onTap: () => Navigator.pop(context), + child: const CircleAvatar( + maxRadius: 44, + backgroundColor: Color(0xffF95F62), child: Icon(Icons.arrow_back, color: Colors.white)), ), ), @@ -56,13 +60,11 @@ class _ScanHistoryView extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: InkWell( - onTap: (){ - Navigator.pushNamed(context, AppRouter.profileScreen); - }, - child: Image.asset("assets/app/profile.png",scale: 4), + onTap: () => Navigator.pushNamed(context, AppRouter.profileScreen), + child: Image.asset("assets/app/profile.png", scale: 4), ), ), - SizedBox(width: 2,) + const SizedBox(width: 2) ], ), body: Padding( @@ -74,108 +76,100 @@ class _ScanHistoryView extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + const Text( "View a detailed log of all scanned QR codes, including timestamps, results, and customer details for easy tracking and verification.", - style: TextStyle( - fontSize: 14, - color: Colors.black,fontWeight: FontWeight.w400 - ), + style: TextStyle(fontSize: 14, color: Colors.black, fontWeight: FontWeight.w400), ), const SizedBox(height: 16), - // Date + Status Row + // Filters Row( children: [ + // Date Filter Expanded( - child: GestureDetector( - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: state.selectedDate, - firstDate: DateTime(2020), - lastDate: DateTime(2030), - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.light( - primary: Color(0xffF95F62), + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Date", style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration(color: const Color(0xffF8F8F8), borderRadius: BorderRadius.circular(8)), + child: Row( + children: [ + Expanded( + child: Text( + state.selectedDate == null ? "All" : DateFormat('dd/MM/yy').format(state.selectedDate!), + style: const TextStyle(fontSize: 13), ), ), - child: child!, - ); - }, - ); - if (picked != null) { - bloc.add(UpdateDate(picked)); - } - }, - child: Row( - children: [ - Text("Date", - style: TextStyle( - fontSize: 13, color: Colors.black,fontWeight: FontWeight.w400)), - const SizedBox(width: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xffF8F8F8), - borderRadius: BorderRadius.circular(50), - ), - child: Row( - children: [ - const Icon(Icons.calendar_month, - color: Color(0xffF95F62), size: 18), - const SizedBox(width: 8), - Text( - "${state.selectedDate.day.toString().padLeft(2, '0')}/${state.selectedDate.month.toString().padLeft(2, '0')}/${state.selectedDate.year.toString().substring(2)}", - style: TextStyle(fontSize: 13), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.calendar_month, color: Color(0xffF95F62), size: 20), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xffF95F62), // header & selected day bg + onPrimary: Colors.white, // header text & selected day text + onSurface: Colors.black, // calendar day text + ), + ), + child: child!, + ); + }, + ); + if (picked != null) bloc.add(UpdateScanHistoryDateEvent(date: picked)); + }, + ), + if (state.selectedDate != null) + IconButton( + padding: const EdgeInsets.only(left: 8), + constraints: const BoxConstraints(), + icon: const Icon(Icons.close, color: Colors.grey, size: 18), + onPressed: () => bloc.add(const UpdateScanHistoryDateEvent(date: null)), ), - ], - ), + ], ), - ], - ), + ), + ], ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), + // Status Filter Expanded( - child: Row( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Status", - style: TextStyle( - fontSize: 14, color: Colors.black,fontWeight: FontWeight.w400)), - const SizedBox(width: 12), - Expanded( - child: Container( - height: 35, - decoration: BoxDecoration( - color: const Color(0xffF8F8F8), - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - dropdownColor: Colors.white, - value: state.selectedStatus, - icon: const Icon(Icons.keyboard_arrow_down_rounded,size: 26, - color: Color(0xffF95F62)), - items: const [ - DropdownMenuItem( - value: 'All Status', - child: Text("All Status")), - DropdownMenuItem( - value: 'Failed', child: Text("Failed")), - DropdownMenuItem( - value: 'Success', - child: Text("Success")), - ], - onChanged: (val) { - if (val != null) bloc.add(UpdateStatus(val)); - }, - style: TextStyle( - fontSize: 14, color: Colors.black,fontWeight: FontWeight.w400), - ), + const Text("Status", style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Container( + height: 40, + decoration: BoxDecoration(color: const Color(0xffF8F8F8), borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: state.selectedStatus, + icon: const Icon(Icons.keyboard_arrow_down, color: Color(0xffF95F62)), + items: const [ + DropdownMenuItem(value: 'all', child: Text("All", style: TextStyle(fontSize: 13))), + DropdownMenuItem(value: 'success', child: Text("Success", style: TextStyle(fontSize: 13))), + DropdownMenuItem(value: 'failed', child: Text("Failed", style: TextStyle(fontSize: 13))), + ], + onChanged: (val) { + if (val != null) bloc.add(UpdateScanHistoryStatusEvent(status: val)); + }, ), ), ), @@ -186,9 +180,7 @@ class _ScanHistoryView extends StatelessWidget { ), const SizedBox(height: 20), - - // Data Section - Expanded(child: _buildStateUI(state,context)), + Expanded(child: _buildStateUI(state, context)), ], ); }, @@ -197,43 +189,63 @@ class _ScanHistoryView extends StatelessWidget { ); } - Widget _buildStateUI(ScanHistoryState state,BuildContext context) { - if (state.isLoading) { - return const Center( - child: CircularProgressIndicator(color: Color(0xffF95F62)), - ); - } else if (state.error != null) { - return Center(child: Text(state.error!)); - } else if (state.history.isEmpty) { - return Center( - child: Text("No records found", - style: TextStyle(color: Colors.black54)), - ); + Widget _buildStateUI(ScanHistoryState state, BuildContext context) { + if (state is ScanHistoryLoading) { + return const Center(child: CircularProgressIndicator(color: Color(0xffF95F62))); + } else if (state is ScanHistoryError) { + return Center(child: Text(state.errorMessage)); + } + + List displayItems = []; + if (state is ScanHistoryLoaded) displayItems = state.filteredItems; + else if (state is ScanHistoryDetailState) displayItems = state.filteredItems; + + if (displayItems.isEmpty && state is! ScanHistoryInitial) { + return const Center(child: Text("No records found", style: TextStyle(color: Colors.black54))); } - return ListView.builder( - itemCount: state.history.length, - itemBuilder: (context, index) { - final item = state.history[index]; - return _buildCard(item,context); - }, + return RefreshIndicator( + onRefresh: () async => context.read().add(const RefreshScanHistoryEvent()), + child: ListView.builder( + itemCount: displayItems.length, + itemBuilder: (context, index) => _buildCard(displayItems[index], context), + ), ); } - Widget _buildCard(ScanHistoryModel item,BuildContext context) { - final isSuccess = item.status == "Success"; + Widget _buildCard(ScanHistory item, BuildContext context) { + final status = item.status.toLowerCase(); + Color bgColor = const Color(0xffE9F9EF); + Color borderColor = const Color(0xffB5E5C1); + IconData icon = Icons.check_circle; + Color iconColor = const Color(0xff2DCC70); + + if (status == "failed") { + bgColor = const Color(0xffFCEAEA); + borderColor = const Color(0xffF5B1B1); + icon = Icons.cancel; + iconColor = const Color(0xffF95F62); + } else if (status == "pending") { + bgColor = Colors.orange.shade50; + borderColor = Colors.orange.shade200; + icon = Icons.pending; + iconColor = Colors.orange; + } + return InkWell( - onTap: (){ - Navigator.pushNamed(context, AppRouter.scanHistoryDetailPage); + onTap: () { + context.read().add(SelectScanHistoryEvent(selectedItem: item)); + Navigator.pushNamed(context, AppRouter.scanHistoryDetailPage, arguments: item.id).then((_) { + if (context.mounted) context.read().add(const ClearSelectedScanHistoryEvent()); + }); }, child: Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isSuccess ? const Color(0xffE9F9EF) : const Color(0xffFCEAEA), + color: bgColor, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSuccess ? const Color(0xffB5E5C1) : const Color(0xffF5B1B1)), + border: Border.all(color: borderColor), ), child: Row( children: [ @@ -241,38 +253,17 @@ class _ScanHistoryView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Pass ID: ${item.passId}", - style: TextStyle( - fontWeight: FontWeight.w600, fontSize: 13)), - if (item.reason != null) ...[ - const SizedBox(height: 4), - Text("Reason: ${item.reason!}", - style: TextStyle( - fontSize: 12, color: Colors.black87)), - ], + Text("Pass ID: ${item.bookingNumber}", style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), const SizedBox(height: 4), - Text("Time: ${item.time}", - style: TextStyle( - fontSize: 12, color: Colors.black87)), + Text("Reason: ${item.reason}", style: const TextStyle(fontSize: 12, color: Colors.black87)), + const SizedBox(height: 4), + Text("Time: ${item.displayCreatedAt}", style: const TextStyle(fontSize: 12, color: Colors.black87)), const SizedBox(height: 8), Row( children: [ - Icon( - isSuccess ? Icons.check_circle : Icons.cancel, - color: isSuccess - ? const Color(0xff2DCC70) - : const Color(0xffF95F62), - size: 20, - ), + Icon(icon, color: iconColor, size: 20), const SizedBox(width: 6), - Text( - item.status, - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.w600, - fontSize: 24, - ), - ), + Text(item.status.toUpperCase(), style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600, fontSize: 24)), ], ), ], @@ -284,4 +275,4 @@ class _ScanHistoryView extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/support/blocs/raise_ticket/raise_ticket_bloc.dart b/lib/support/blocs/raise_ticket/raise_ticket_bloc.dart new file mode 100644 index 0000000..76425d9 --- /dev/null +++ b/lib/support/blocs/raise_ticket/raise_ticket_bloc.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; // Import ImagePicker +import '../../repository/raise_ticket_repository.dart'; + +part 'raise_ticket_event.dart'; +part 'raise_ticket_state.dart'; + +class RaiseTicketBloc extends Bloc { + final RaiseTicketRepository _raiseTicketRepository; + final ImagePicker _picker = ImagePicker(); // Initialize ImagePicker + static const int _maxFileSizeInBytes = 5 * 1024 * 1024; // 5 MB limit + + RaiseTicketBloc({RaiseTicketRepository? raiseTicketRepository}) + : _raiseTicketRepository = + raiseTicketRepository ?? RaiseTicketRepository(), + super(const RaiseTicketState()) { + on(_onSubjectChanged); + on(_onDescriptionChanged); + on(_onAttachmentPicked); + on(_onAttachmentRemoved); + on(_onRaiseTicketSubmitted); + on(_onReset); + } + + void _onSubjectChanged( + RaiseTicketSubjectChanged event, Emitter emit) { + emit(state.copyWith(subject: event.subject)); + } + + void _onDescriptionChanged( + RaiseTicketDescriptionChanged event, Emitter emit) { + emit(state.copyWith(description: event.description)); + } + + Future _onAttachmentPicked( + RaiseTicketAttachmentPicked event, Emitter emit) async { + // The event now passes the File directly after it's picked in the UI + final File file = event.attachment; + final int fileSize = await file.length(); + + if (fileSize > _maxFileSizeInBytes) { + emit(state.copyWith( + attachment: null, + fileSizeExceeded: true, + errorMessage: "File size exceeds 5MB limit.", + )); + } else { + emit(state.copyWith( + attachment: file, + fileSizeExceeded: false, + errorMessage: null, + )); + } + } + + void _onAttachmentRemoved( + RaiseTicketAttachmentRemoved event, Emitter emit) { + emit(state.copyWith( + clearAttachment: true, + fileSizeExceeded: false, + errorMessage: null, + )); + } + + Future _onRaiseTicketSubmitted( + RaiseTicketSubmitted event, Emitter emit) async { + if (state.subject.isEmpty || state.description.isEmpty) { + emit(state.copyWith( + errorMessage: "Subject and description cannot be empty.")); + return; + } + if (state.fileSizeExceeded) { + emit(state.copyWith(errorMessage: "Cannot submit: file size exceeded.")); + return; + } + + emit(state.copyWith(status: RaiseTicketStatus.loading, errorMessage: null)); + try { + await _raiseTicketRepository.raiseTicket( + subject: state.subject, + description: state.description, + attachment: state.attachment, + ); + emit(state.copyWith(status: RaiseTicketStatus.success)); + } catch (e) { + emit(state.copyWith( + status: RaiseTicketStatus.failure, + errorMessage: e.toString(), + )); + } + } + + void _onReset( + RaiseTicketReset event, Emitter emit) { + emit(const RaiseTicketState()); + } +} \ No newline at end of file diff --git a/lib/support/blocs/raise_ticket/raise_ticket_event.dart b/lib/support/blocs/raise_ticket/raise_ticket_event.dart new file mode 100644 index 0000000..8cd042d --- /dev/null +++ b/lib/support/blocs/raise_ticket/raise_ticket_event.dart @@ -0,0 +1,44 @@ +part of 'raise_ticket_bloc.dart'; + +abstract class RaiseTicketEvent extends Equatable { + const RaiseTicketEvent(); + + @override + List get props => []; +} + +class RaiseTicketSubjectChanged extends RaiseTicketEvent { + final String subject; + const RaiseTicketSubjectChanged(this.subject); + + @override + List get props => [subject]; +} + +class RaiseTicketDescriptionChanged extends RaiseTicketEvent { + final String description; + const RaiseTicketDescriptionChanged(this.description); + + @override + List get props => [description]; +} + +class RaiseTicketAttachmentPicked extends RaiseTicketEvent { + final File attachment; + const RaiseTicketAttachmentPicked(this.attachment); + + @override + List get props => [attachment]; +} + +class RaiseTicketAttachmentRemoved extends RaiseTicketEvent { + const RaiseTicketAttachmentRemoved(); +} + +class RaiseTicketSubmitted extends RaiseTicketEvent { + const RaiseTicketSubmitted(); +} + +class RaiseTicketReset extends RaiseTicketEvent { + const RaiseTicketReset(); +} diff --git a/lib/support/blocs/raise_ticket/raise_ticket_state.dart b/lib/support/blocs/raise_ticket/raise_ticket_state.dart new file mode 100644 index 0000000..8487bc5 --- /dev/null +++ b/lib/support/blocs/raise_ticket/raise_ticket_state.dart @@ -0,0 +1,43 @@ +part of 'raise_ticket_bloc.dart'; + +enum RaiseTicketStatus { initial, loading, success, failure } + +class RaiseTicketState extends Equatable { + final RaiseTicketStatus status; + final String subject; + final String description; + final File? attachment; + final bool fileSizeExceeded; + final String? errorMessage; + + const RaiseTicketState({ + this.status = RaiseTicketStatus.initial, + this.subject = '', + this.description = '', + this.attachment, + this.fileSizeExceeded = false, + this.errorMessage, + }); + + RaiseTicketState copyWith({ + RaiseTicketStatus? status, + String? subject, + String? description, + File? attachment, + bool clearAttachment = false, + bool? fileSizeExceeded, + String? errorMessage, + }) { + return RaiseTicketState( + status: status ?? this.status, + subject: subject ?? this.subject, + description: description ?? this.description, + attachment: clearAttachment ? null : attachment ?? this.attachment, + fileSizeExceeded: fileSizeExceeded ?? this.fileSizeExceeded, + errorMessage: errorMessage, + ); + } + + @override + List get props => [status, subject, description, attachment, fileSizeExceeded, errorMessage]; +} \ No newline at end of file diff --git a/lib/support/blocs/support_details/support_details_bloc.dart b/lib/support/blocs/support_details/support_details_bloc.dart new file mode 100644 index 0000000..c31b711 --- /dev/null +++ b/lib/support/blocs/support_details/support_details_bloc.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../model/support_details_model.dart'; +import '../../repository/support_details_repository.dart'; + +part 'support_details_event.dart'; +part 'support_details_state.dart'; + +class SupportDetailsBloc extends Bloc { + final SupportDetailsRepository _repository; + + SupportDetailsBloc({SupportDetailsRepository? repository}) + : _repository = repository ?? SupportDetailsRepository(), + super(const SupportDetailsInitial()) { + on(_onFetchSupportDetails); + } + + Future _onFetchSupportDetails( + FetchSupportDetailsEvent event, + Emitter emit, + ) async { + emit(const SupportDetailsLoading()); + try { + final SupportDetailModel data = await _repository.fetchSupportDetails(); + emit(SupportDetailsLoaded(supportDetail: data)); + } catch (e) { + emit(SupportDetailsError(message: e.toString())); + } + } +} \ No newline at end of file diff --git a/lib/support/blocs/support_details/support_details_event.dart b/lib/support/blocs/support_details/support_details_event.dart new file mode 100644 index 0000000..87f3a7b --- /dev/null +++ b/lib/support/blocs/support_details/support_details_event.dart @@ -0,0 +1,12 @@ +part of 'support_details_bloc.dart'; + +abstract class SupportDetailsEvent extends Equatable { + const SupportDetailsEvent(); + + @override + List get props => []; +} + +class FetchSupportDetailsEvent extends SupportDetailsEvent { + const FetchSupportDetailsEvent(); +} \ No newline at end of file diff --git a/lib/support/blocs/support_details/support_details_state.dart b/lib/support/blocs/support_details/support_details_state.dart new file mode 100644 index 0000000..97806e5 --- /dev/null +++ b/lib/support/blocs/support_details/support_details_state.dart @@ -0,0 +1,38 @@ +part of 'support_details_bloc.dart'; + +abstract class SupportDetailsState extends Equatable { + const SupportDetailsState(); + + @override + List get props => []; +} + +// Initial state before any event is fired +class SupportDetailsInitial extends SupportDetailsState { + const SupportDetailsInitial(); +} + +// Loading state while API call is in progress +class SupportDetailsLoading extends SupportDetailsState { + const SupportDetailsLoading(); +} + +// Success state with fetched data +class SupportDetailsLoaded extends SupportDetailsState { + final SupportDetailModel supportDetail; + + const SupportDetailsLoaded({required this.supportDetail}); + + @override + List get props => [supportDetail]; +} + +// Error state with a message +class SupportDetailsError extends SupportDetailsState { + final String message; + + const SupportDetailsError({required this.message}); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/support/model/support_details_model.dart b/lib/support/model/support_details_model.dart new file mode 100644 index 0000000..df49468 --- /dev/null +++ b/lib/support/model/support_details_model.dart @@ -0,0 +1,190 @@ +// ================= RESPONSE WRAPPER ================= + +class SupportDetailResponse { + final SupportDetailModel data; + + SupportDetailResponse({ + required this.data, + }); + + factory SupportDetailResponse.fromJson(Map? json) { + return SupportDetailResponse( + data: json != null + ? SupportDetailModel.fromJson(json) + : SupportDetailModel.empty(), + ); + } +} + +// ================= MAIN MODEL ================= + +class SupportDetailModel { + final int id; + final int partnerXid; + final int userXid; + final int roleXid; + final String fullName; + final String email; + final String phone; + final String round; + final bool isActive; + final bool isDeleted; + final String lastLoginAt; + final String createdAt; + final String updatedAt; + final SupportRoleModel role; + final PartnerModel partner; + + SupportDetailModel({ + required this.id, + required this.partnerXid, + required this.userXid, + required this.roleXid, + required this.fullName, + required this.email, + required this.phone, + required this.round, + required this.isActive, + required this.isDeleted, + required this.lastLoginAt, + required this.createdAt, + required this.updatedAt, + required this.role, + required this.partner, + }); + + factory SupportDetailModel.fromJson(Map json) { + return SupportDetailModel( + id: json['id'] ?? 0, + partnerXid: json['partnerXid'] ?? 0, + userXid: json['userXid'] ?? 0, + roleXid: json['roleXid'] ?? 0, + fullName: json['fullName'] ?? "N/A", + email: json['email'] ?? "N/A", + phone: json['phone'] ?? "N/A", + round: json['round'] ?? "N/A", + isActive: json['isActive'] ?? false, + isDeleted: json['isDeleted'] ?? false, + lastLoginAt: json['lastLoginAt'] ?? "N/A", + createdAt: json['createdAt'] ?? "N/A", + updatedAt: json['updatedAt'] ?? "N/A", + role: json['role'] != null + ? SupportRoleModel.fromJson(json['role']) + : SupportRoleModel.empty(), + partner: json['partner'] != null + ? PartnerModel.fromJson(json['partner']) + : PartnerModel.empty(), + ); + } + + factory SupportDetailModel.empty() { + return SupportDetailModel( + id: 0, + partnerXid: 0, + userXid: 0, + roleXid: 0, + fullName: "N/A", + email: "N/A", + phone: "N/A", + round: "N/A", + isActive: false, + isDeleted: false, + lastLoginAt: "N/A", + createdAt: "N/A", + updatedAt: "N/A", + role: SupportRoleModel.empty(), + partner: PartnerModel.empty(), + ); + } +} + +// ================= ROLE MODEL ================= + +class SupportRoleModel { + final int id; + final String name; + final bool isActive; + final String createdAt; + final String updatedAt; + + SupportRoleModel({ + required this.id, + required this.name, + required this.isActive, + required this.createdAt, + required this.updatedAt, + }); + + factory SupportRoleModel.fromJson(Map json) { + return SupportRoleModel( + id: json['id'] ?? 0, + name: json['name'] ?? "N/A", + isActive: json['isActive'] ?? false, + createdAt: json['createdAt'] ?? "N/A", + updatedAt: json['updatedAt'] ?? "N/A", + ); + } + + factory SupportRoleModel.empty() { + return SupportRoleModel( + id: 0, + name: "N/A", + isActive: false, + createdAt: "N/A", + updatedAt: "N/A", + ); + } + + Map toJson() { + return { + "id": id, + "name": name, + "isActive": isActive, + "createdAt": createdAt, + "updatedAt": updatedAt, + }; + } +} + +// ================= PARTNER MODEL ================= + +class PartnerModel { + final int id; + final String businessName; + final String emailAddress; + final String phoneNumber; + + PartnerModel({ + required this.id, + required this.businessName, + required this.emailAddress, + required this.phoneNumber, + }); + + factory PartnerModel.fromJson(Map json) { + return PartnerModel( + id: json['id'] ?? 0, + businessName: json['businessName'] ?? "N/A", + emailAddress: json['emailAddress'] ?? "N/A", + phoneNumber: json['phoneNumber'] ?? "N/A", + ); + } + + factory PartnerModel.empty() { + return PartnerModel( + id: 0, + businessName: "N/A", + emailAddress: "N/A", + phoneNumber: "N/A", + ); + } + + Map toJson() { + return { + "id": id, + "businessName": businessName, + "emailAddress": emailAddress, + "phoneNumber": phoneNumber, + }; + } +} \ No newline at end of file diff --git a/lib/support/repository/raise_ticket_repository.dart b/lib/support/repository/raise_ticket_repository.dart new file mode 100644 index 0000000..242a0bd --- /dev/null +++ b/lib/support/repository/raise_ticket_repository.dart @@ -0,0 +1,38 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import '../../network_api_service/api_service/api_service.dart'; +import '../../network_api_service/api_urls/api_urls.dart'; + +class RaiseTicketRepository { + final ApiService _apiService = ApiService(); + + Future raiseTicket({ + required String subject, + required String description, + File? attachment, + }) async { + try { + final Map formMap = { + "subject": subject, + "description": description, + }; + + if (attachment != null) { + final fileName = attachment.path.split('/').last; + formMap["attachment"] = await MultipartFile.fromFile( + attachment.path, + filename: fileName, + ); + } + + final formData = FormData.fromMap(formMap); + + await _apiService.post( + ApiUrls.supportDetails, + data: formData, + ); + } catch (e) { + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/support/repository/support_details_repository.dart b/lib/support/repository/support_details_repository.dart new file mode 100644 index 0000000..c16c880 --- /dev/null +++ b/lib/support/repository/support_details_repository.dart @@ -0,0 +1,22 @@ +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 '../model/support_details_model.dart'; + +class SupportDetailsRepository { + final ApiService _apiService = ApiService(); + + Future fetchSupportDetails() async { + try { + final userId = await LocalPreference.getUserId(); + + final response = await _apiService.get( + '${ApiUrls.supportDetails}/$userId', + ); + + return SupportDetailModel.fromJson(response.data); + } catch (e) { + throw Exception('Failed to fetch support details: $e'); + } + } +} \ No newline at end of file diff --git a/lib/support/view/support_form_page.dart b/lib/support/view/support_form_page.dart index 44e9ccc..45429c1 100644 --- a/lib/support/view/support_form_page.dart +++ b/lib/support/view/support_form_page.dart @@ -1,258 +1,355 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../blocs/ticket_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; -class SupportFormPage extends StatelessWidget { +import '../blocs/raise_ticket/raise_ticket_bloc.dart'; +import '../blocs/support_details/support_details_bloc.dart'; + +class SupportFormPage extends StatefulWidget { const SupportFormPage({super.key}); @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => TicketBloc(), - child: const _SupportFormView(), - ); - } + State createState() => _SupportFormPageState(); } -class _SupportFormView extends StatelessWidget { - const _SupportFormView(); +class _SupportFormPageState extends State { + final TextEditingController _subjectController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + + @override + void initState() { + super.initState(); + // Trigger the fetch event for support details once when the page is initialized + context.read().add(FetchSupportDetailsEvent()); + + // Listen to existing state if we are coming back or resuming (optional) + final state = context.read().state; + _subjectController.text = state.subject; + _descriptionController.text = state.description; + } + + @override + void dispose() { + _subjectController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final bloc = context.read(); - return Scaffold( - backgroundColor: Colors.white, - bottomNavigationBar: BlocBuilder( - builder: (context, state) {return - Visibility( - visible: MediaQuery.of(context).viewInsets.bottom == 0.0, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: state.isLoading - ? null - : () => bloc.add(SubmitTicket()), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xffF95F62), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), + return BlocConsumer( + listener: (context, state) { + if (state.status == RaiseTicketStatus.success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Ticket submitted successfully!")), + ); + _subjectController.clear(); + _descriptionController.clear(); + context.read().add(const RaiseTicketReset()); + Navigator.pop(context); + } else if (state.status == RaiseTicketStatus.failure && + state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error: ${state.errorMessage}")), + ); + } else if (state.fileSizeExceeded && state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error: ${state.errorMessage}")), + ); + // Auto-remove the invalid attachment + // context.read().add(const RaiseTicketAttachmentRemoved()); + } + }, + builder: (context, state) { + final raiseTicketBloc = context.read(); + + return Scaffold( + backgroundColor: Colors.white, + bottomNavigationBar: Visibility( + visible: MediaQuery.of(context).viewInsets.bottom == 0.0, + child: Padding( + padding: EdgeInsets.all(16.w), + child: SizedBox( + width: double.infinity, + height: 52.h, + child: ElevatedButton( + onPressed: state.status == RaiseTicketStatus.loading || + state.fileSizeExceeded || + state.subject.isEmpty || + state.description.isEmpty + ? null + : () => raiseTicketBloc.add(const RaiseTicketSubmitted()), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffF95F62), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r)), + ), + child: state.status == RaiseTicketStatus.loading + ? SizedBox( + width: 20.w, + height: 20.h, + child: const CircularProgressIndicator( + color: Colors.white, strokeWidth: 2), + ) + : Text( + "Submit Ticket", + style: TextStyle( + color: Colors.white, + fontSize: 20.sp, + fontWeight: FontWeight.w600), + ), ), - child: state.isLoading - ? const CircularProgressIndicator(color: Colors.white) - : Text("Submit Ticket", - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600)), ), - ), - ), - ); - }), - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: Padding( - padding: EdgeInsetsGeometry.symmetric(horizontal: 8), - child: InkWell( - onTap: (){ - Navigator.pop(context); - }, - child: Container( - width: 44, - height: 44, - decoration: const BoxDecoration( - color: Color(0xFFF95F62), - shape: BoxShape.circle, - ), - child: const Icon(Icons.arrow_back, color: Colors.white), ), ), - ), - forceMaterialTransparency: true, - centerTitle: true, - title: Text("Support", - style: TextStyle(fontWeight: FontWeight.w700, color: Colors.black,fontSize: 32)), - ), - body: BlocBuilder( - builder: (context, state) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w), + child: InkWell( + onTap: () { + Navigator.pop(context); + }, + child: Container( + width: 44.w, + height: 44.h, + decoration: const BoxDecoration( + color: Color(0xFFF95F62), + shape: BoxShape.circle, + ), + child: Icon(Icons.arrow_back, color: Colors.white, size: 24.sp), + ), + ), + ), + forceMaterialTransparency: true, + centerTitle: true, + title: Text("Support", + style: TextStyle( + fontWeight: FontWeight.w700, + color: Colors.black, + fontSize: 32.sp)), + ), + body: SingleChildScrollView( + padding: EdgeInsets.all(20.w), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.center, child: Text( - textAlign: TextAlign.center, "Need help? We’re here for you. Raise a ticket and our support team will get back to you shortly", - style: TextStyle(color: Colors.black, fontSize: 13,), + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black, fontSize: 13.sp), ), ), - const SizedBox(height: 20), - Text("Subject", style: TextStyle(fontWeight: FontWeight.w500,fontSize: 14)), - const SizedBox(height: 6), + SizedBox(height: 20.h), + Text("Subject", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14.sp)), + SizedBox(height: 6.h), TextField( - onChanged: (v) => bloc.add(SubjectChanged(v)), - decoration: InputDecoration( + controller: _subjectController, + onChanged: (v) => raiseTicketBloc.add(RaiseTicketSubjectChanged(v)), + decoration: InputDecoration( fillColor: Colors.black.withOpacity(0.04), hintText: "Enter Subject", filled: true, enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.24), width: 1.0.w), + borderRadius: BorderRadius.circular(8.r), ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), - ), - disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.24), width: 1.0.w), + borderRadius: BorderRadius.circular(8.r), ), border: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.24), width: 1.0.w), + borderRadius: BorderRadius.circular(8.r), ), ), ), - const SizedBox(height: 20), - Text("Description", style: TextStyle(fontWeight: FontWeight.w500,fontSize: 14)), - const SizedBox(height: 6), + SizedBox(height: 20.h), + Text("Description", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14.sp)), + SizedBox(height: 6.h), TextField( - onChanged: (v) => bloc.add(DescriptionChanged(v)), + controller: _descriptionController, + onChanged: (v) => raiseTicketBloc.add(RaiseTicketDescriptionChanged(v)), maxLines: 4, - decoration: InputDecoration( + decoration: InputDecoration( fillColor: Colors.black.withOpacity(0.04), hintText: "Enter Description", filled: true, enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.24), width: 1.0.w), + borderRadius: BorderRadius.circular(8.r), ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), - ), - disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.24), width: 1.0.w), + borderRadius: BorderRadius.circular(8.r), ), border: OutlineInputBorder( - borderSide: BorderSide(color: Colors.black.withOpacity(0.24), width: 1.0), - borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.24), width: 1.0.w), + borderRadius: BorderRadius.circular(8.r), ), ), ), - const SizedBox(height: 20), - Text("File upload", style: TextStyle(fontWeight: FontWeight.w500,fontSize: 14)), - const SizedBox(height: 6), + SizedBox(height: 20.h), + Text("File upload", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14.sp)), + SizedBox(height: 6.h), GestureDetector( - onTap: () => bloc.add(UploadFile()), + onTap: () async { + // pickMedia allows both images and videos + final XFile? pickedFile = await _picker.pickMedia(); + if (pickedFile != null) { + raiseTicketBloc.add(RaiseTicketAttachmentPicked(File(pickedFile.path))); + } + }, child: Container( - height: 45, + height: 45.h, decoration: BoxDecoration( color: Colors.black.withOpacity(0.04), border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(6.r), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - state.selectedFile != null - ? state.selectedFile!.name - : "Upload File", - style: TextStyle( - color: state.selectedFile != null - ? Colors.black - : Colors.black54, + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Text( + state.attachment != null + ? state.attachment!.path.split('/').last + : (state.fileSizeExceeded ? "File too large (>5MB).Pick another" : "Upload File"), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: state.attachment != null + ? Colors.black + : (state.fileSizeExceeded ? Colors.red : Colors.black54), + fontSize: 13.sp, + ), ), ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Image.asset("assets/support/icon/upload.png",scale: 4,), + padding: EdgeInsets.symmetric(horizontal: 8.w), + child: Image.asset( + "assets/support/icon/upload.png", + scale: 4, + width: 24.w, + height: 24.h, + ), ), ], ), ), ), - const SizedBox(height: 30), + if (state.attachment != null) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => raiseTicketBloc.add(const RaiseTicketAttachmentRemoved()), + child: Text( + "Remove File", + style: TextStyle(color: Colors.red, fontSize: 12.sp), + ), + ), + ), + SizedBox(height: 30.h), Text("Contact Details", - style: TextStyle( - fontWeight: FontWeight.w600, fontSize: 24)), - const SizedBox(height: 12), - Divider(), - Row( - children: [ - Expanded( - flex: 1, - child: Row( - children: [ - const Icon(Icons.email_outlined, color: Colors.black87), - const SizedBox(width: 12), - Text("Email", style: TextStyle(fontSize: 13)), - ], - ), - ), - Expanded( - flex: 2, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox(width: 12), - Text("Lila Hart", style: TextStyle(fontSize: 13,color: Colors.black)), - ], - ), - ), - ], + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 24.sp)), + SizedBox(height: 12.h), + const Divider(), + BlocBuilder( + builder: (context, supportState) { + String email = "Loading..."; + String phone = "Loading..."; + + if (supportState is SupportDetailsLoaded) { + email = supportState.supportDetail.partner.emailAddress; + phone = supportState.supportDetail.partner.phoneNumber; + } else if (supportState is SupportDetailsError) { + email = "Error loading"; + phone = "Error loading"; + } + + return Column( + children: [ + Row( + children: [ + Expanded( + flex: 1, + child: Row( + children: [ + Icon(Icons.email_outlined, color: Colors.black87, size: 20.sp), + SizedBox(width: 12.w), + Text("Email", style: TextStyle(fontSize: 13.sp)), + ], + ), + ), + Expanded( + flex: 2, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox(width: 12.w), + Text(email, + style: TextStyle( + fontSize: 13.sp, color: Colors.black)), + ], + ), + ), + ], + ), + const Divider(), + SizedBox(height: 12.h), + Row( + children: [ + Expanded( + flex: 1, + child: Row( + children: [ + Icon(Icons.phone_outlined, color: Colors.black87, size: 20.sp), + SizedBox(width: 12.w), + Text("Phone", style: TextStyle(fontSize: 13.sp)), + ], + ), + ), + Expanded( + flex: 2, + child: Row( + children: [ + SizedBox(width: 12.w), + Text(phone, style: TextStyle(fontSize: 13.sp)), + ], + ), + ), + ], + ), + ], + ); + }, ), - - - Divider(), - const SizedBox(height: 12), - Row( - - children: [ - Expanded( - flex: 1, - child: Row( - - children: [ - const Icon(Icons.phone_outlined, color: Colors.black87), - const SizedBox(width: 12), - Text("Phone", style: TextStyle(fontSize: 13)), - ], - ), - ), - - Expanded( - flex: 2, - child: Row( - children: [ - const SizedBox(width: 12), - Text("(+971) 050 4245 564", - style: TextStyle(fontSize: 13)), - ], - ), - ), - ], - ), - Divider(), - const SizedBox(height: 80), - + const Divider(), + SizedBox(height: 80.h), ], ), - ); - }, - ), + ), + ); + }, ); } -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index f5df75e..b1995be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + sha256: "963da02c1ef64cb5ac20de948c9e5940aa351f1e34a12b1d327c83d85b7e8fff" + url: "https://pub.dev" + source: hosted + version: "4.0.9" cross_file: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.9.2" + dio_cookie_manager: + dependency: "direct main" + description: + name: dio_cookie_manager + sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744" + url: "https://pub.dev" + source: hosted + version: "3.4.0" dio_web_adapter: dependency: transitive description: @@ -185,6 +201,38 @@ packages: url: "https://pub.dev" source: hosted version: "10.3.3" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -296,6 +344,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -702,5 +814,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.1" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 611ccea..38c6fa0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,9 @@ dependencies: shared_preferences: ^2.5.4 dio: ^5.9.2 flutter_screenutil: ^5.9.3 + dio_cookie_manager: ^3.4.0 + cookie_jar: ^4.0.9 + image_picker: ^1.2.1 dev_dependencies: flutter_test: