diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index a7bf966..c3a7664 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -722,6 +722,8 @@ "customerLocation": "موقع العميل", "deliveryAvailable": "التوصيل متاح", "viewed": "تم المشاهدة", + "read": "تمت القراءة", + "sent": "تم الإرسال", "updateUserDetails": "تحديث تفاصيل المستخدم", "enterNewFirstName": "أدخل الاسم الأول", "enterNewLastName": "أدخل الاسم الأخير", @@ -834,6 +836,7 @@ "myDraftAds": "مسودتي للإعلانات", "scheduleDeletedSuccessfully": "تم حذف الجدول بنجاح", "addValidAddress": "رجى إضافة عنوان صالح", + "pleaseAddAtLeastOneImage": "يرجى إضافة صورة واحدة على الأقل لطلبك", "termsPrivacyPolicy": "الشروط وسياسة الخصوصية", "pendingApproval": "قيد الموافقة", "inReview": "قيد المراجعة", @@ -901,6 +904,6 @@ "downloadApp": "تحميل تطبيق موّاتر:", "androidApp": "أندرويد", "iosApp": "آيفون", - "pleaseWait": "يرجى الانتظار..." - + "pleaseWait": "يرجى الانتظار...", + "numberOfShipping": "عدد الشحنات" } \ No newline at end of file diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 0afc648..1414964 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -474,7 +474,7 @@ "cancelReservation": "Cancel Reservation", "reasonForCancellation": "Reason for Cancellation", "doWantCancelReservation": "Do you want to cancel the reservation", - "yourAdVisibletoBuy.": "Your ad reservation will be cancelled and this ad will be again visible to everyone to buy", + "yourAdVisibletoBuy": "Your ad reservation will be cancelled and this ad will be again visible to everyone to buy", "completeDeal": "Complete Deal", "uploadBankReceipt": "Upload Bank Receipt", "notesForAdmin": "Notes For Admin", @@ -720,6 +720,8 @@ "customerLocation": "Customer Location", "deliveryAvailable": "Delivery Available", "viewed": "Viewed", + "read": "Read", + "sent": "Sent", "itemNoLongerAvailable": "This item is no longer available.", "reactivateAd": "Reactivate Ad", "dealOutsideApp": "The deal was done outside the app with another customer.", @@ -832,6 +834,7 @@ "myDraftAds": "My Draft Ads", "scheduleDeletedSuccessfully": "The schedule has been deleted successfully", "addValidAddress": "Please add a valid address", + "pleaseAddAtLeastOneImage": "Please add at least one image for your request", "termsPrivacyPolicy": "Terms & Privacy Policy", "pendingApproval": "Pending Approval", "inReview": "In Review", @@ -899,5 +902,6 @@ "downloadApp": "Download MoWater App:", "androidApp": "Android", "iosApp": "iOS", - "pleaseWait": "Please wait..." + "pleaseWait": "Please wait...", + "numberOfShipping": "Number of Shipping" } \ No newline at end of file diff --git a/assets/tones/engine_start_tone.mp3 b/assets/tones/engine_start_tone.mp3 new file mode 100644 index 0000000..184be22 Binary files /dev/null and b/assets/tones/engine_start_tone.mp3 differ diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 4a640b0..5d812b8 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; @@ -70,6 +71,22 @@ abstract class ApiClient { } class ApiClientImp implements ApiClient { + /// Get current language code for Accept-Language header + /// Returns 'ar' for Arabic, 'en' for English (default) + String _getCurrentLanguage() { + try { + BuildContext? context = navigatorKey.currentContext; + if (context != null) { + String languageCode = context.locale.languageCode; + return languageCode == 'ar' ? 'ar' : 'en'; + } + } catch (e) { + log("Error getting current language: $e"); + } + // Default to English if context not available + return 'en'; + } + @override Future postJsonForObject(FactoryConstructor factoryConstructor, String url, T jsonObject, {String? token, Map? queryParameters, Map? headers, int retryTimes = 0}) async { @@ -117,6 +134,10 @@ class ApiClientImp implements ApiClient { }) async { try { var headers0 = {}; + + // Add Accept-Language header based on current locale + headers0['Accept-Language'] = _getCurrentLanguage(); + if (token != null) { headers0['Authorization'] = 'Bearer $token'; } @@ -233,6 +254,10 @@ class ApiClientImp implements ApiClient { Future _getForResponse(String url, {String? token, Map? queryParameters, Map? headers, int retryTimes = 0}) async { try { var headers0 = {}; + + // Add Accept-Language header based on current locale + headers0['Accept-Language'] = _getCurrentLanguage(); + if (token != null) { headers0['Authorization'] = 'Bearer $token'; } diff --git a/lib/classes/consts.dart b/lib/classes/consts.dart index 600ebb9..01f0a36 100644 --- a/lib/classes/consts.dart +++ b/lib/classes/consts.dart @@ -139,6 +139,7 @@ class ApiConsts { static String adsPhotoOfficeAppointmentCreate = "${baseUrlServices}api/Advertisement/PhotoOfficeAppointment_Create"; static String adsMCBankAccountAdGet = "${baseUrlServices}api/Advertisement/MCBankAccountAd_Get"; static String adsReserveCreate = "${baseUrlServices}api/Advertisement/AdsReserve_Create"; + static String adsReserveCancelByOwner = "${baseUrlServices}api/Advertisement/AdsReserve_CancelByOwner"; static String reserveAdPaymentOnDealDoneCreate = "${baseUrlServices}api/Advertisement/ReserveAdPaymentOnDealDone_Create"; static String adsExtendDurationCreate = "${baseUrlServices}api/Advertisement/Ads_ExtendDuration_Create"; static String adsSingleStepDraftCreate = "${baseUrlServices}api/Advertisement/AdsSingleStepDraft_Create"; @@ -380,6 +381,9 @@ class MyAssets { static String whatsAppIcon = "${assetPath}icons/whatsapp_icon.svg"; static const String arrowRight = "${assetPath}icons/ic_arrow_right.svg"; static const String brokenImage = "${assetPath}images/broken_image.png"; + + // Tones + static const String engineStartSound = "${assetPath}tones/engine_start_tone.mp3"; } RegExp numReg = RegExp(r".*[0-9].*"); @@ -419,6 +423,7 @@ class SignalrConsts { // Requests static String receiveMessageRequestOffer = "ReceiveMessageRequestOffer"; static String sendMessageRequestOffer = "SendMessageRequestOffer"; + static String requestOfferReadUpdate = "RequestOfferReadUpdate"; // Ads static String sendMessageAds = "SendMessageAds"; @@ -471,4 +476,3 @@ class GuestConsts { }, ); } - diff --git a/lib/config/dependency_injection.dart b/lib/config/dependency_injection.dart index 970a88a..2cfb009 100644 --- a/lib/config/dependency_injection.dart +++ b/lib/config/dependency_injection.dart @@ -20,7 +20,8 @@ import 'package:mc_common_app/services/firebase_service.dart'; import 'package:mc_common_app/services/payments_service.dart'; import 'package:mc_common_app/utils/location/Location.dart'; -import '../repositories/request_repo.dart'; +import 'package:mc_common_app/repositories/request_repo.dart'; +import 'package:mc_common_app/services/chat_navigation_service.dart'; Injector injector = Injector.appInstance; @@ -31,6 +32,7 @@ class AppDependencies { injector.registerSingleton(() => ApiClientImp()); injector.registerSingleton(() => CommonServicesImp()); injector.registerSingleton(() => PaymentServiceImp()); + injector.registerSingleton(() => ChatNavigationServiceImp()); injector.registerSingleton(() => LocationService()); injector.registerSingleton(() => FirebaseMessagingServiceImp( firebaseMessaging: FirebaseMessaging.instance, diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 4251fb8..93492d6 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -738,6 +738,8 @@ class CodegenLoader extends AssetLoader{ "customerLocation": "موقع العميل", "deliveryAvailable": "التوصيل متاح", "viewed": "تم المشاهدة", + "read": "تمت القراءة", + "sent": "تم الإرسال", "updateUserDetails": "تحديث تفاصيل المستخدم", "enterNewFirstName": "أدخل الاسم الأول", "enterNewLastName": "أدخل الاسم الأخير", @@ -850,6 +852,7 @@ class CodegenLoader extends AssetLoader{ "myDraftAds": "مسودتي للإعلانات", "scheduleDeletedSuccessfully": "تم حذف الجدول بنجاح", "addValidAddress": "رجى إضافة عنوان صالح", + "pleaseAddAtLeastOneImage": "يرجى إضافة صورة واحدة على الأقل لطلبك", "termsPrivacyPolicy": "الشروط وسياسة الخصوصية", "pendingApproval": "قيد الموافقة", "inReview": "قيد المراجعة", @@ -917,7 +920,8 @@ class CodegenLoader extends AssetLoader{ "downloadApp": "تحميل تطبيق موّاتر:", "androidApp": "أندرويد", "iosApp": "آيفون", - "pleaseWait": "يرجى الانتظار..." + "pleaseWait": "يرجى الانتظار...", + "numberOfShipping": "عدد الشحنات" }; static const Map _en_US = { "firstTimeLogIn": "First Time Log In", @@ -1641,6 +1645,8 @@ static const Map _en_US = { "customerLocation": "Customer Location", "deliveryAvailable": "Delivery Available", "viewed": "Viewed", + "read": "Read", + "sent": "Sent", "itemNoLongerAvailable": "This item is no longer available.", "reactivateAd": "Reactivate Ad", "dealOutsideApp": "The deal was done outside the app with another customer.", @@ -1753,6 +1759,7 @@ static const Map _en_US = { "myDraftAds": "My Draft Ads", "scheduleDeletedSuccessfully": "The schedule has been deleted successfully", "addValidAddress": "Please add a valid address", + "pleaseAddAtLeastOneImage": "Please add at least one image for your request", "termsPrivacyPolicy": "Terms & Privacy Policy", "pendingApproval": "Pending Approval", "inReview": "In Review", @@ -1820,7 +1827,8 @@ static const Map _en_US = { "downloadApp": "Download MoWater App:", "androidApp": "Android", "iosApp": "iOS", - "pleaseWait": "Please wait..." + "pleaseWait": "Please wait...", + "numberOfShipping": "Number of Shipping" }; static const Map> mapLocales = {"ar_SA": _ar_SA, "en_US": _en_US}; } diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 7757fbb..678714e 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -2,7 +2,7 @@ // ignore_for_file: constant_identifier_names -abstract class LocaleKeys { +abstract class LocaleKeys { static const firstTimeLogIn = 'firstTimeLogIn'; static const signUp = 'signUp'; static const changeMobile = 'changeMobile'; @@ -49,7 +49,7 @@ abstract class LocaleKeys { static const emailChangedSuccessfully = 'emailChangedSuccessfully'; static const passwordIsUpdated = 'passwordIsUpdated'; static const passwordShouldContains = 'passwordShouldContains'; - static const successfullyRegistered = ' successfullyRegistered'; + static const successfullyRegistered = ' successfullyRegistered'; static const pleaseEnterSamePassword = 'pleaseEnterSamePassword'; static const firstNameMandatory = 'firstNameMandatory'; static const surnameNameMandatory = 'surnameNameMandatory'; @@ -703,6 +703,8 @@ abstract class LocaleKeys { static const customerLocation = 'customerLocation'; static const deliveryAvailable = 'deliveryAvailable'; static const viewed = 'viewed'; + static const read = 'read'; + static const sent = 'sent'; static const updateUserDetails = 'updateUserDetails'; static const enterNewFirstName = 'enterNewFirstName'; static const enterNewLastName = 'enterNewLastName'; @@ -815,6 +817,7 @@ abstract class LocaleKeys { static const myDraftAds = 'myDraftAds'; static const scheduleDeletedSuccessfully = 'scheduleDeletedSuccessfully'; static const addValidAddress = 'addValidAddress'; + static const pleaseAddAtLeastOneImage = 'pleaseAddAtLeastOneImage'; static const termsPrivacyPolicy = 'termsPrivacyPolicy'; static const pendingApproval = 'pendingApproval'; static const inReview = 'inReview'; @@ -883,5 +886,5 @@ abstract class LocaleKeys { static const androidApp = 'androidApp'; static const iosApp = 'iosApp'; static const pleaseWait = 'pleaseWait'; - + static const numberOfShipping = 'numberOfShipping'; } diff --git a/lib/models/advertisment_models/reserved_ads_models.dart b/lib/models/advertisment_models/reserved_ads_models.dart index c6f7441..4f50c9f 100644 --- a/lib/models/advertisment_models/reserved_ads_models.dart +++ b/lib/models/advertisment_models/reserved_ads_models.dart @@ -26,7 +26,7 @@ class MyReservedAdsRespModel { adsID = json['adsID']; customerID = json['customerID']; paymentStatus = json['paymentStatus']; - paymentStatus = json['adsReserveStatus']; + adsReserveStatus = json['adsReserveStatus']; reservationTimeID = json['reservationTimeID']; reservationBasePrice = json['reservationBasePrice']; refundAmount = json['refundAmount']; diff --git a/lib/models/shipping_models/shipping_status_model.dart b/lib/models/shipping_models/shipping_status_model.dart index 6ee1ff4..4e36f70 100644 --- a/lib/models/shipping_models/shipping_status_model.dart +++ b/lib/models/shipping_models/shipping_status_model.dart @@ -15,6 +15,7 @@ class ShippingRequestModel { String? customerName; int? vehicleTypeID; String? vehicleType; + String? numberOfShipping; ShippingRequestModel({ this.id, @@ -28,6 +29,7 @@ class ShippingRequestModel { this.customerName, this.vehicleTypeID, this.vehicleType, + this.numberOfShipping, }); ShippingRequestModel.fromJsonShipping(Map json, bool isForShipping) { @@ -45,6 +47,7 @@ class ShippingRequestModel { customerName = json['customerName']; vehicleTypeID = json['vehicleTypeID']; vehicleType = json['vehicleType']; + numberOfShipping = json['numberOfShipping']; } } diff --git a/lib/repositories/ads_repo.dart b/lib/repositories/ads_repo.dart index 4d0a8ea..50448e5 100644 --- a/lib/repositories/ads_repo.dart +++ b/lib/repositories/ads_repo.dart @@ -7,6 +7,7 @@ import 'package:mc_common_app/classes/consts.dart'; import 'package:mc_common_app/config/dependency_injection.dart'; import 'package:mc_common_app/extensions/string_extensions.dart'; import 'package:mc_common_app/generated/locale_keys.g.dart'; +import 'package:mc_common_app/main.dart'; import 'package:mc_common_app/models/advertisment_models/ad_details_model.dart'; import 'package:mc_common_app/models/advertisment_models/ads_bank_details_model.dart'; import 'package:mc_common_app/models/advertisment_models/ads_duration_model.dart'; @@ -57,7 +58,11 @@ abstract class AdsRepo { Future> getMyAds(); - Future> getFavouriteAds(); + Future> getFavouriteAds({ + AdPostStatus? adPostStatus, + CreatedByRoleEnum? createdByRoleEnum, + int? vehicleBrandId, + }); Future toggleAdFavorite({required int adsID, required bool isFavorite}); @@ -455,16 +460,17 @@ class AdsRepoImp implements AdsRepo { ); if (adsGenericModel.messageStatus != 1 || adsGenericModel.data == null) { - Utils.showToast(adsGenericModel.message ?? LocaleKeys.somethingWrong.tr()); - return []; + throw Exception(adsGenericModel.message ?? LocaleKeys.somethingWrong.tr()); } + List reservedAds = List.generate(adsGenericModel.data.length, (index) => MyReservedAdsRespModel.fromJson(adsGenericModel.data[index])); - List selectedIdsString = reservedAds.map((component) => component.adsID.toString()).toList(); - if (selectedIdsString.isEmpty) { - return []; + if (reservedAds.isEmpty) { + return []; // No reserved ads - this is a valid state } + List selectedIdsString = reservedAds.map((component) => component.adsID.toString()).toList(); + return await getAdsPerSpecificIds(ids: selectedIdsString, reservedAds: reservedAds); } @@ -472,7 +478,7 @@ class AdsRepoImp implements AdsRepo { var params = { "AdsIDs": ids, "isActive": "true", - "PageSize": "30", + "PageSize": ids.length.toString(), // Request all reserved ads in one call }; GenericRespModel adsGenericModel = await apiClient.getJsonForObject( token: appState.getUser.data!.accessToken, @@ -481,16 +487,49 @@ class AdsRepoImp implements AdsRepo { ApiConsts.vehicleAdsGet, ); if (adsGenericModel.messageStatus != 1 || adsGenericModel.data == null) { - Utils.showToast(adsGenericModel.message ?? LocaleKeys.somethingWrong.tr()); - return []; + throw Exception(adsGenericModel.message ?? LocaleKeys.somethingWrong.tr()); } + List vehicleAdsDetails = List.generate(adsGenericModel.data.length, (index) => AdDetailsModel.fromJson(adsGenericModel.data[index], true, adsGenericModel.totalItemsCount ?? 1)); - for (int i = 0; i < vehicleAdsDetails.length; i++) { - vehicleAdsDetails[i].adReserveStatus = (reservedAds[i].adsReserveStatus ?? 0).toAdRserveStatusEnum(); + // Create a map for O(1) lookup: adsID -> reserve status + Map statusMap = {}; + for (var reserved in reservedAds) { + log("adsReserveStatus: ${reserved.adsReserveStatus}"); + + statusMap[reserved.adsID ?? 0] = (reserved.adsReserveStatus ?? 0).toAdRserveStatusEnum(); + } + + log("📋 Status Map created: ${statusMap.length} entries"); + statusMap.forEach((key, value) { + log(" Ad ID: $key -> Status: $value"); + }); + + // Assign reserve status by matching ad ID (not by index!) + log("📦 Processing ${vehicleAdsDetails.length} ads from second API"); + for (var ad in vehicleAdsDetails) { + if (statusMap.containsKey(ad.id)) { + ad.adReserveStatus = statusMap[ad.id]!; + // Backend will set adPostStatus=reserved in the response, so no need to set it manually + log(" ✅ Ad ${ad.id}: Assigned adReserveStatus ${ad.adReserveStatus}"); + } else { + // Ad exists but no reserve status found - shouldn't happen but handle it + ad.adReserveStatus = AdReserveStatus.defaultStatus; + log(" ⚠️ Ad ${ad.id}: No status found, set to defaultStatus"); + } + } + + // Show all ads with ANY reservation history (exclude only defaultStatus/none) + // This includes: reserved (1), cancelledByOwner (2), cancelledByAdmin (3), timeOver (4), dealDone (5), fullPaymentVerified (6) + int beforeFilter = vehicleAdsDetails.length; + vehicleAdsDetails.removeWhere((element) => element.adReserveStatus == AdReserveStatus.defaultStatus); + int afterFilter = vehicleAdsDetails.length; + + log("🎯 Filter Result: ${afterFilter} ads remaining (removed ${beforeFilter - afterFilter})"); + for (var ad in vehicleAdsDetails) { + log(" ✅ Keeping Ad ${ad.id} with status ${ad.adReserveStatus}"); } - vehicleAdsDetails.removeWhere((element) => element.adReserveStatus != AdReserveStatus.reserved); return vehicleAdsDetails; } @@ -515,16 +554,44 @@ class AdsRepoImp implements AdsRepo { } @override - Future> getFavouriteAds() async { + Future> getFavouriteAds({ + AdPostStatus? adPostStatus, + CreatedByRoleEnum? createdByRoleEnum, + int? vehicleBrandId, + }) async { + // Build filter parameters similar to getAllAds + Map params = { + "PageSize": "30", + }; + + // Add status filter if provided + if (adPostStatus != null) { + params["AdsStatuses"] = ["${adPostStatus.getIdFromAdPostStatusEnum()}"]; + } + + // Add created by role filter if provided + if (createdByRoleEnum != null && createdByRoleEnum != CreatedByRoleEnum.allAds) { + params["CreatedByRoles"] = ["${createdByRoleEnum.getIdFromCreatedByRoleEnum()}"]; + } + + // Add vehicle brand filter if provided + if (vehicleBrandId != null && vehicleBrandId != 0) { + params["VehicleBrandIDs"] = ["$vehicleBrandId"]; + } + GenericRespModel adsGenericModel = await apiClient.getJsonForObject( token: appState.getUser.data!.accessToken, (json) => GenericRespModel.fromJson(json), ApiConsts.favouriteAdsGet, + queryParameters: params, ); + if (adsGenericModel.messageStatus != 1 || adsGenericModel.data == null) { return []; } + List favouriteAdsDetails = List.generate(adsGenericModel.data.length, (index) => AdDetailsModel.fromJson(adsGenericModel.data[index], false, adsGenericModel.totalItemsCount ?? 1)); + return favouriteAdsDetails; } @@ -638,7 +705,7 @@ class AdsRepoImp implements AdsRepo { String token = appState.getUser.data!.accessToken ?? ""; GenericRespModel adsGenericModel = await apiClient.postJsonForObject( (json) => GenericRespModel.fromJson(json), - ApiConsts.deleteAd, + ApiConsts.adsReserveCancelByOwner, postParams, token: token, ); diff --git a/lib/repositories/shipping_repo.dart b/lib/repositories/shipping_repo.dart index bf9c2b9..6f4a837 100644 --- a/lib/repositories/shipping_repo.dart +++ b/lib/repositories/shipping_repo.dart @@ -18,9 +18,9 @@ abstract class ShippingRepo { Future> getShippingRequestListByStatus({ShippingRequestStatusEnum? shippingStatusEnum, int? requestId}); - Future updateShippingRequestStatus({required ShippingRequestStatusEnum shippingStatusEnum, required int shippingRequestId, String? comment}); + Future updateShippingRequestStatus({required ShippingRequestStatusEnum shippingStatusEnum, required int shippingRequestId, String? comment, String? numberOfShipping}); - Future updateSelfPickupRequestStatus({required SelfPickupRequestStatusEnum selfPickupStatusEnum, required int shippingRequestId, String? comment}); + Future updateSelfPickupRequestStatus({required SelfPickupRequestStatusEnum selfPickupStatusEnum, required int shippingRequestId, String? comment, String? numberOfShipping}); } class ShippingRepoImp extends ShippingRepo { @@ -78,12 +78,13 @@ class ShippingRepoImp extends ShippingRepo { } @override - Future updateShippingRequestStatus({required ShippingRequestStatusEnum shippingStatusEnum, required int shippingRequestId, String? comment}) async { + Future updateShippingRequestStatus({required ShippingRequestStatusEnum shippingStatusEnum, required int shippingRequestId, String? comment, String? numberOfShipping}) async { String token = appState.getUser.data!.accessToken ?? ""; Map queryParameters = { "id": "$shippingRequestId", "shippingStatus": "${shippingStatusEnum.getIdFromShippingStatusEnum()}", "comment": comment ?? "", + "numberOfShipping": numberOfShipping ?? "", }; GenericRespModel genericRespModel = await apiClient.postJsonForObject( @@ -101,12 +102,13 @@ class ShippingRepoImp extends ShippingRepo { } @override - Future updateSelfPickupRequestStatus({required SelfPickupRequestStatusEnum selfPickupStatusEnum, required int shippingRequestId, String? comment}) async { + Future updateSelfPickupRequestStatus({required SelfPickupRequestStatusEnum selfPickupStatusEnum, required int shippingRequestId, String? comment, String? numberOfShipping}) async { String token = appState.getUser.data!.accessToken ?? ""; Map queryParameters = { "id": "$shippingRequestId", "selfPickUpStatus": "${selfPickupStatusEnum.getIdFromSelfPickupStatusEnum()}", "comment": comment ?? "", + "numberOfShipping": numberOfShipping ?? "", }; GenericRespModel genericRespModel = await apiClient.postJsonForObject( diff --git a/lib/services/chat_navigation_service.dart b/lib/services/chat_navigation_service.dart new file mode 100644 index 0000000..69a80b9 --- /dev/null +++ b/lib/services/chat_navigation_service.dart @@ -0,0 +1,180 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:mc_common_app/classes/app_state.dart'; +import 'package:mc_common_app/config/routes.dart'; +import 'package:mc_common_app/models/chat_models/buyers_chat_for_ads_model.dart'; +import 'package:mc_common_app/models/requests_models/request_model.dart'; +import 'package:mc_common_app/utils/enums.dart'; +import 'package:mc_common_app/utils/navigator.dart'; + +/// Service for handling navigation to different chat types in the app +/// Provides centralized navigation logic for Request, Advertisement, and General chats +abstract class ChatNavigationService { + /// Navigate to Request Offer Chat screen + Future navigateToRequestOfferChat({ + required BuildContext context, + required int requestId, + required String receiverId, + required String senderId, + required int requestIndex, + required int providerIndex, + required RequestModel requestModel, + }); + + /// Navigate to Advertisement Chat screen + Future navigateToAdChat({ + required BuildContext context, + required int adsId, + required String receiverUserId, + }); + + /// Navigate to General Chat screen + Future navigateToGeneralChat({ + required BuildContext context, + required String receiverUserId, + Map? additionalData, + }); + + /// Navigate to Chat List screen based on chat type + Future navigateToChatList({ + required BuildContext context, + required ChatTypeEnum chatType, + Map? arguments, + }); +} + +class ChatNavigationServiceImp implements ChatNavigationService { + @override + Future navigateToRequestOfferChat({ + required BuildContext context, + required int requestId, + required String receiverId, + required String senderId, + required int requestIndex, + required int providerIndex, + required RequestModel requestModel, + }) async { + try { + log("Navigating to Request Offer Chat: requestId=$requestId"); + + ChatViewArgumentsForRequest args = ChatViewArgumentsForRequest( + chatTypeEnum: ChatTypeEnum.requestOffer, + requestId: requestId, + receiverId: receiverId, + senderId: senderId, + requestIndex: requestIndex, + providerIndex: providerIndex, + requestModel: requestModel, + ); + + ChatViewArguments chatViewArguments = ChatViewArguments( + chatTypeEnum: ChatTypeEnum.requestOffer, + chatViewArgumentsForRequest: args, + ); + + navigateWithName(context, AppRoutes.chatView, arguments: chatViewArguments); + } catch (e) { + log("Error navigating to request offer chat: $e"); + } + } + + @override + Future navigateToAdChat({ + required BuildContext context, + required int adsId, + required String receiverUserId, + }) async { + try { + log("Navigating to Ad Chat: adsId=$adsId, receiverId=$receiverUserId"); + + ChatViewArgumentsForAd args = ChatViewArgumentsForAd( + adsID: adsId, + receiverUserID: receiverUserId, + ); + + ChatViewArguments chatViewArguments = ChatViewArguments( + chatTypeEnum: ChatTypeEnum.ads, + chatViewArgumentsForAd: args, + ); + + navigateWithName(context, AppRoutes.chatView, arguments: chatViewArguments); + } catch (e) { + log("Error navigating to ad chat: $e"); + } + } + + @override + Future navigateToGeneralChat({ + required BuildContext context, + required String receiverUserId, + Map? additionalData, + }) async { + try { + log("Navigating to General Chat: receiverId=$receiverUserId"); + + // General chat implementation + // You can extend this based on your general chat requirements + ChatViewArguments chatViewArguments = ChatViewArguments( + chatTypeEnum: ChatTypeEnum.general, + ); + + navigateWithName(context, AppRoutes.chatView, arguments: chatViewArguments); + } catch (e) { + log("Error navigating to general chat: $e"); + } + } + + @override + Future navigateToChatList({ + required BuildContext context, + required ChatTypeEnum chatType, + Map? arguments, + }) async { + try { + log("Navigating to Chat List: type=$chatType"); + + switch (chatType) { + case ChatTypeEnum.requestOffer: + // Navigate to Providers Chat List (for customer side) + // or Requests list (for provider side) + if (AppState().currentAppType == AppType.customer) { + navigateWithName(context, AppRoutes.providersChatListPage); + } else { + // For provider, navigate to requests page + navigateWithName(context, AppRoutes.dashboard); + } + break; + + case ChatTypeEnum.ads: + // Navigate to Ads Buyers Chat List + if (arguments != null && arguments['buyers'] != null) { + navigateWithName( + context, + AppRoutes.adsBuyerChatsListView, + arguments: arguments['buyers'] as List, + ); + } else { + // Navigate to ads list if buyers not provided + navigateWithName(context, AppRoutes.dashboard); + } + break; + + case ChatTypeEnum.general: + // Navigate to General Chats List + if (arguments != null && arguments['requestId'] != null) { + navigateWithName( + context, + AppRoutes.generalChatsListForProvider, + arguments: arguments['requestId'] as int, + ); + } else { + navigateWithName(context, AppRoutes.dashboard); + } + break; + } + } catch (e) { + log("Error navigating to chat list: $e"); + } + } +} diff --git a/lib/services/firebase_service.dart b/lib/services/firebase_service.dart index 5e70d57..63fb768 100644 --- a/lib/services/firebase_service.dart +++ b/lib/services/firebase_service.dart @@ -1,13 +1,18 @@ +import 'dart:convert'; +import 'dart:developer'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:mc_common_app/classes/app_state.dart'; +import 'package:mc_common_app/config/dependency_injection.dart'; +import 'package:mc_common_app/config/routes.dart'; import 'package:mc_common_app/main.dart'; +import 'package:mc_common_app/services/chat_navigation_service.dart'; +import 'package:mc_common_app/utils/navigator.dart'; import 'package:permission_handler/permission_handler.dart'; abstract class FirebaseMessagingService { @@ -63,17 +68,39 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { defaultPresentSound: true, ); final InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid, iOS: initializationSettingsDarwin); - await flutterLocalNotifications.initialize(initializationSettings, onDidReceiveNotificationResponse: (notificationResponse) {}, ); + await flutterLocalNotifications.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (NotificationResponse response) async { + // Handle notification tap when app is in foreground/background + log("========== Notification Tapped =========="); + log("Notification ID: ${response.id}"); + log("Action ID: ${response.actionId}"); + log("Input: ${response.input}"); + log("Payload: ${response.payload}"); + log("========================================="); + + if (response.payload != null && response.payload!.isNotEmpty) { + try { + Map data = jsonDecode(response.payload!); + log("Parsed payload data: $data"); + _handleNotificationNavigation(data); + } catch (e) { + logger.e("Error parsing notification payload: $e"); + } + } + }, + ); RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage(); if (initialMessage != null) _handleNotification(initialMessage); FirebaseMessaging.onMessage.listen((RemoteMessage message) { - if (message.notification != null) _handleNotification(message); + // Foreground notification - just display, don't navigate automatically + if (message.notification != null) _handleForegroundNotification(message); }); - FirebaseMessaging.onMessageOpenedApp.listen(_handleNotification); + FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationOpenedApp); FirebaseMessaging.onBackgroundMessage(backgroundMessageHandler); @@ -86,14 +113,208 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { logger.i(body); } + void _handleForegroundNotification(RemoteMessage message) async { + log("========== Foreground Notification Received =========="); + log("Title: ${message.notification!.title}"); + log("Body: ${message.notification!.body}"); + log("Data payload: ${message.data}"); + log("Message ID: ${message.messageId}"); + log("Sent time: ${message.sentTime}"); + log("======================================================"); + + // Just display notification - DON'T navigate automatically + // Navigation happens only when user taps the notification + displayNotification( + title: message.notification!.title ?? '', + body: message.notification!.body ?? '', + payload: jsonEncode(message.data), + ); + } + + void _handleNotificationOpenedApp(RemoteMessage message) async { + log("========== Notification Opened App =========="); + log("Title: ${message.notification!.title}"); + log("Body: ${message.notification!.body}"); + log("Data payload: ${message.data}"); + log("Message ID: ${message.messageId}"); + log("Sent time: ${message.sentTime}"); + log("============================================="); + + // User tapped notification - navigate to chat + _handleNotificationNavigation(message.data); + } + void _handleNotification(RemoteMessage message) async { - logger.i(message.notification!.title); - logger.i(message.notification!.body); + // This is called when app is terminated and opened by notification + log("========== App Terminated Notification =========="); + log("Title: ${message.notification!.title}"); + log("Body: ${message.notification!.body}"); + log("Data payload: ${message.data}"); + log("Message ID: ${message.messageId}"); + log("Sent time: ${message.sentTime}"); + log("================================================="); + + // User opened app from notification - navigate to chat + _handleNotificationNavigation(message.data); + } + + void _handleNotificationNavigation(Map data) async { + log("========== Handling Notification Navigation =========="); + log("Full data received: $data"); + + if (data.isEmpty) { + log("Data is empty, skipping navigation"); + log("======================================================"); + return; + } + + // Backend sends NotiType as string: "1" = Request, "2" = Ads, "3" = General + String? notiTypeStr = data['NotiType']; + int? notiType = int.tryParse(notiTypeStr ?? ''); + + log("NotiType from backend: $notiTypeStr (parsed: $notiType)"); + + if (notiType == null || notiType < 1 || notiType > 3) { + log("Invalid or missing NotiType, skipping navigation"); + log("======================================================"); + return; + } + + // Map NotiType to chat type + String chatType; + switch (notiType) { + case 1: + chatType = 'request_offer'; + break; + case 2: + chatType = 'ad_chat'; + break; + case 3: + chatType = 'general_chat'; + break; + default: + log("Unknown NotiType: $notiType"); + log("======================================================"); + return; + } + + log("Mapped chat type: $chatType"); + + // Wait a bit for context to be available + await Future.delayed(Duration(milliseconds: 300)); + + BuildContext? context = navigatorKey.currentContext; + if (context == null) { + logger.e("Context not available for navigation"); + log("======================================================"); + return; + } + + // Log all available data fields to understand backend format + log("Available data fields:"); + data.forEach((key, value) { + log(" $key: $value"); + }); + + switch (chatType) { + case 'request_offer': + log("Navigating to Request Offer Chat..."); + await _navigateToRequestOfferChat(context, data); + break; + case 'ad_chat': + log("Navigating to Advertisement Chat..."); + await _navigateToAdChat(context, data); + break; + case 'general': + log("Navigating to General Chat..."); + await _navigateToGeneralChat(context, data); + break; + default: + logger.e("Unknown chat type: $chatType"); + } + + log("======================================================"); + } + + Future _navigateToRequestOfferChat( + BuildContext context, + Map data, + ) async { + try { + // Try both backend format and our format + int requestId = int.tryParse(data['RequestID']?.toString() ?? data['request_id']?.toString() ?? '0') ?? 0; + + if (requestId == 0) { + log("❌ Invalid or missing RequestID in notification data"); + return; + } + + log("✅ Navigating to providers chat list for request: $requestId"); + navigateWithName(context, AppRoutes.providersChatListPage); + + // TODO: If backend sends all required data (ProviderId, CustomerId), + // we can navigate directly to the chat screen + } catch (e) { + logger.e("Error navigating to request chat: $e"); + } + } + + Future _navigateToAdChat( + BuildContext context, + Map data, + ) async { + try { + // Try both backend format and our format + int adId = int.tryParse(data['AdID']?.toString() ?? data['ad_id']?.toString() ?? '0') ?? 0; + String? receiverUserId = data['ReceiverUserID'] ?? data['receiver_id']; - displayNotification(title: message.notification!.title ?? '', body: message.notification!.body ?? ''); + if (adId == 0 || receiverUserId == null) { + log("❌ Invalid AdID or ReceiverUserID in notification data"); + log(" AdID: ${data['AdID']}, ReceiverUserID: ${data['ReceiverUserID']}"); + return; + } + + log("✅ Navigating to ad chat: adId=$adId"); + final chatNavService = injector.get(); + await chatNavService.navigateToAdChat( + context: context, + adsId: adId, + receiverUserId: receiverUserId, + ); + } catch (e) { + logger.e("Error navigating to ad chat: $e"); + } + } + + Future _navigateToGeneralChat( + BuildContext context, + Map data, + ) async { + try { + // Try both backend format and our format + int requestId = int.tryParse(data['RequestID']?.toString() ?? data['request_id']?.toString() ?? '0') ?? 0; + + if (requestId == 0) { + log("❌ Invalid or missing RequestID in notification data"); + return; + } + + log("✅ Navigating to general chat list for request: $requestId"); + navigateWithName( + context, + AppRoutes.generalChatsListForProvider, + arguments: requestId, + ); + } catch (e) { + logger.e("Error navigating to general chat: $e"); + } } - Future displayNotification({required String title, required String body}) async { + Future displayNotification({ + required String title, + required String body, + String? payload, + }) async { const AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( "0", 'mowater', @@ -105,7 +326,7 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { const DarwinNotificationDetails iOSPlatformChannelSpecifics = DarwinNotificationDetails(threadIdentifier: 'thread_id'); const NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails, iOS: iOSPlatformChannelSpecifics); - await flutterLocalNotifications.show(0, title, body, notificationDetails, payload: 'item x'); + await flutterLocalNotifications.show(0, title, body, notificationDetails, payload: payload ?? 'item x'); } } diff --git a/lib/utils/enums.dart b/lib/utils/enums.dart index 28d93c7..f386e12 100644 --- a/lib/utils/enums.dart +++ b/lib/utils/enums.dart @@ -26,6 +26,7 @@ class AppEnums { enum DashboardRouteEnum { fromAdsPayment, fromAdsSubmit, + fromReserveAdsPayment, none, } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index ba15a61..0ea0b16 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -263,6 +263,54 @@ class Utils { } } + static Color getChipColorByReserveStatus(AdReserveStatus reserveStatus) { + switch (reserveStatus) { + case AdReserveStatus.reserved: + return MyColors.primaryColor; // Active reservation - blue/primary + + case AdReserveStatus.timeOver: + return MyColors.adCancelledStatusColor; // Expired - red/orange + + case AdReserveStatus.cancelledByOwner: + case AdReserveStatus.cancelledByAdmin: + return MyColors.adCancelledStatusColor; // Cancelled - red + + case AdReserveStatus.dealDone: + case AdReserveStatus.fullPaymentVerified: + return MyColors.greenColor; // Completed successfully - green + + case AdReserveStatus.defaultStatus: + default: + return MyColors.textColor; // Default/unknown - gray + } + } + + static String getReserveStatusLabel(AdReserveStatus reserveStatus) { + switch (reserveStatus) { + case AdReserveStatus.reserved: + return LocaleKeys.reserved.tr(); + + case AdReserveStatus.timeOver: + return LocaleKeys.reservationTimeOver.tr(); + + case AdReserveStatus.cancelledByOwner: + return LocaleKeys.cancelledByOwner.tr(); + + case AdReserveStatus.cancelledByAdmin: + return LocaleKeys.cancelledByAdmin.tr(); + + case AdReserveStatus.dealDone: + return LocaleKeys.dealCompleted.tr(); + + case AdReserveStatus.fullPaymentVerified: + return LocaleKeys.paymentVerified.tr(); + + case AdReserveStatus.defaultStatus: + default: + return ""; + } + } + static Color getChipColorByRequestStatus(RequestStatusEnum requestStatus) { switch (requestStatus) { case RequestStatusEnum.pending: diff --git a/lib/view_models/ad_view_model.dart b/lib/view_models/ad_view_model.dart index dd332e5..0c72f25 100644 --- a/lib/view_models/ad_view_model.dart +++ b/lib/view_models/ad_view_model.dart @@ -238,6 +238,7 @@ class AdVM extends BaseVM { bool hasMoreDataForExploreAds = true; bool hasMoreDataForMyAds = true; bool isLoadingMore = false; + bool isLoadingAdsDuration = false; Future fetchMoreExploreAds() async { if (isLoadingMore) return; @@ -295,6 +296,16 @@ class AdVM extends BaseVM { return await adsRepo.getAllAds(isMyAds: isMyAds, adPostStatus: adPostStatus, createdByRoleEnum: createdByRoleEnum, vehicleBrandId: vehicleBrandId); } + /// Check if the "Reserved" filter is currently selected in My Ads + bool get isReservedFilterSelected { + if (myAdsFilterOptions.isEmpty) return false; + var selectedFilter = myAdsFilterOptions.firstWhere( + (element) => element.isSelected, + orElse: () => FilterListModel(id: 0, title: "All Ads", isSelected: true), + ); + return selectedFilter.id.toAdPostEnum() == AdPostStatus.reserved; + } + applyFilterOnMyAds({required AdPostStatus adPostStatusEnum}) async { pageIndexForMyAds = 1; hasMoreDataForMyAds = true; @@ -310,9 +321,15 @@ class AdVM extends BaseVM { getMyAds(); return; } + // this means if the filter is reserved ads if (adPostStatusEnum == AdPostStatus.reserved) { - log("reserved: ${myReservedAds.length}"); + // Fetch reserved ads when filter is tapped (lazy loading for consistency) + setState(ViewState.busy); + await getMyReservedAds(); + setState(ViewState.idle); + + log("📊 Reserved ads count: ${myReservedAds.length}"); myAdsFilteredList = myReservedAds; for (var ad in myAdsFilteredList) { ad.isReservedByMe = true; @@ -320,6 +337,7 @@ class AdVM extends BaseVM { notifyListeners(); return; } + setState(ViewState.busy); myAdsFilteredList = await getAdsByFilter(adPostStatus: adPostStatusEnum, isMyAds: true); setState(ViewState.idle); @@ -385,10 +403,18 @@ class AdVM extends BaseVM { } } - Future getFavouriteAds() async { + Future getFavouriteAds({ + AdPostStatus? adPostStatus, + CreatedByRoleEnum? createdByRoleEnum, + int? vehicleBrandId, + }) async { try { setState(ViewState.busy); - favouriteAds = await adsRepo.getFavouriteAds(); + favouriteAds = await adsRepo.getFavouriteAds( + adPostStatus: adPostStatus, + createdByRoleEnum: createdByRoleEnum, + vehicleBrandId: vehicleBrandId, + ); notifyListeners(); setState(ViewState.idle); } catch (e) { @@ -433,7 +459,8 @@ class AdVM extends BaseVM { myAdsFilteredList = myAds; final myActiveAds = myAds.where((element) => element.adPostStatus == AdPostStatus.active).toList(); myActiveAdsForHome = myActiveAds.length >= 3 ? myActiveAds.take(3).toList() : myActiveAds; - await getMyReservedAds(); + // Removed: await getMyReservedAds(); + // Now lazy loads when "Reserved" filter is tapped (see applyFilterOnMyAds) notifyListeners(); setState(ViewState.idle); } @@ -442,12 +469,19 @@ class AdVM extends BaseVM { try { setState(ViewState.busy); myReservedAds = await adsRepo.getMyReservedAds(); - isFetchingLists = false; setState(ViewState.idle); + notifyListeners(); // Notify UI of data changes return myReservedAds; } catch (e) { - logger.e(e.toString()); + logger.e("Error fetching reserved ads: ${e.toString()}"); setState(ViewState.idle); + myReservedAds = []; // Explicitly set to empty list on error + notifyListeners(); // Notify UI of error state + + // Show user-friendly error message + String errorMessage = e.toString().replaceAll('Exception: ', ''); + Utils.showToast(errorMessage); + return []; } } @@ -458,9 +492,18 @@ class AdVM extends BaseVM { } Future getVehicleAdsDuration() async { - final countryId = AppState().getUser.data!.userInfo!.countryId; - vehicleAdsDurations = await adsRepo.getAdsDuration(countryId: countryId); + isLoadingAdsDuration = true; notifyListeners(); + try { + final countryId = AppState().getUser.data!.userInfo!.countryId; + vehicleAdsDurations = await adsRepo.getAdsDuration(countryId: countryId); + isLoadingAdsDuration = false; + notifyListeners(); + } catch (e) { + isLoadingAdsDuration = false; + notifyListeners(); + rethrow; + } } Future getVehicleAdsSpecialServices({required int cityId, required int countryId, required int specialServiceType}) async { @@ -529,6 +572,11 @@ class AdVM extends BaseVM { Utils.showToast(respModel.message ?? LocaleKeys.somethingWrong.tr()); return; } + + // Reset the cancel reason text field + reservationCancelReason = ""; + reservationCancelError = ""; + Utils.hideLoading(context); Utils.showToast(LocaleKeys.yourReservationCancelled.tr()); updateIsExploreAds(false); @@ -2724,15 +2772,23 @@ class AdVM extends BaseVM { bool isArabic = AppState().isArabic(context); updateSelectionVehicleModelId(SelectionModel(selectedId: previousAdDetails!.vehicle!.model!.id!, selectedOption: previousAdDetails!.vehicle!.model!.label ?? "")); updateSelectionVehicleModelYearId(SelectionModel(selectedId: previousAdDetails!.vehicle!.modelyear!.id!, selectedOption: previousAdDetails!.vehicle!.modelyear!.label ?? "")); - updateSelectionVehicleColorId(SelectionModel(selectedId: previousAdDetails!.vehicle!.color!.id!, selectedOption: isArabic ? previousAdDetails!.vehicle!.color!.labelN.toString() : previousAdDetails!.vehicle!.color!.label ?? "")); - updateSelectionVehicleConditionId(SelectionModel(selectedId: previousAdDetails!.vehicle!.condition!.id!, selectedOption: isArabic ? previousAdDetails!.vehicle!.condition!.labelN.toString() :previousAdDetails!.vehicle!.condition!.label ?? "")); - updateSelectionVehicleCategoryId(SelectionModel(selectedId: previousAdDetails!.vehicle!.category!.id!, selectedOption: isArabic ? previousAdDetails!.vehicle!.category!.labelN.toString() :previousAdDetails!.vehicle!.category!.label ?? "")); + updateSelectionVehicleColorId(SelectionModel( + selectedId: previousAdDetails!.vehicle!.color!.id!, selectedOption: isArabic ? previousAdDetails!.vehicle!.color!.labelN.toString() : previousAdDetails!.vehicle!.color!.label ?? "")); + updateSelectionVehicleConditionId(SelectionModel( + selectedId: previousAdDetails!.vehicle!.condition!.id!, + selectedOption: isArabic ? previousAdDetails!.vehicle!.condition!.labelN.toString() : previousAdDetails!.vehicle!.condition!.label ?? "")); + updateSelectionVehicleCategoryId(SelectionModel( + selectedId: previousAdDetails!.vehicle!.category!.id!, selectedOption: isArabic ? previousAdDetails!.vehicle!.category!.labelN.toString() : previousAdDetails!.vehicle!.category!.label ?? "")); updateSelectionVehicleMileageId(SelectionModel( selectedId: previousAdDetails!.vehicle!.mileage!.id!, selectedOption: "${previousAdDetails!.vehicle!.mileage!.mileageStart} - ${previousAdDetails!.vehicle!.mileage!.mileageEnd}", )); - updateSelectionVehicleTransmissionId(SelectionModel(selectedId: previousAdDetails!.vehicle!.transmission!.id!, selectedOption:isArabic ? previousAdDetails!.vehicle!.transmission!.labelN.toString() : previousAdDetails!.vehicle!.transmission!.label ?? "")); - updateSelectionVehicleSellerTypeId(SelectionModel(selectedId: previousAdDetails!.vehicle!.sellertype!.id!, selectedOption: isArabic ? previousAdDetails!.vehicle!.sellertype!.labelN.toString() :previousAdDetails!.vehicle!.sellertype!.label ?? "")); + updateSelectionVehicleTransmissionId(SelectionModel( + selectedId: previousAdDetails!.vehicle!.transmission!.id!, + selectedOption: isArabic ? previousAdDetails!.vehicle!.transmission!.labelN.toString() : previousAdDetails!.vehicle!.transmission!.label ?? "")); + updateSelectionVehicleSellerTypeId(SelectionModel( + selectedId: previousAdDetails!.vehicle!.sellertype!.id!, + selectedOption: isArabic ? previousAdDetails!.vehicle!.sellertype!.labelN.toString() : previousAdDetails!.vehicle!.sellertype!.label ?? "")); int indexCountry = vehicleCountries.indexWhere((element) => element.id == previousAdDetails!.vehicle!.countryID); if (indexCountry != -1) { updateSelectionVehicleCountryId(SelectionModel(selectedId: vehicleCountries[indexCountry].id!, selectedOption: vehicleCountries[indexCountry].countryName ?? "")); diff --git a/lib/view_models/chat_view_model.dart b/lib/view_models/chat_view_model.dart index c075154..a672365 100644 --- a/lib/view_models/chat_view_model.dart +++ b/lib/view_models/chat_view_model.dart @@ -35,6 +35,20 @@ class ChatVM extends BaseVM { HubConnection? hubConnection; bool _isSubscribed = false; // Track if listeners are subscribed + // Store ViewModels to avoid context dependency + RequestsVM? _requestsVM; + AdVM? _adVM; + + // Track if a message is currently being sent (prevents duplicate sends) + bool isSendingMessage = false; + + // Method to inject ViewModels (called once from Dashboard) + void setViewModels({required RequestsVM requestsVM, required AdVM adVM}) { + _requestsVM = requestsVM; + _adVM = adVM; + logger.i("ViewModels injected into ChatVM"); + } + String chatMessageText = ""; updateChatMessageText(String value) { @@ -211,6 +225,28 @@ class ChatVM extends BaseVM { int providerIndex = serviceProviderOffersList.indexWhere((element) => element.providerUserId == currentMessage.senderUserID); if (providerIndex != -1) { + // Check if message already exists to prevent duplicates + bool messageExists = false; + + if (currentMessage.id != null) { + // For messages with ID, use ID for duplicate detection + messageExists = serviceProviderOffersList[providerIndex].chatMessages!.any((msg) => msg.id == currentMessage.id); + } else { + // For messages without ID (locally created), use content-based duplicate detection + // Check if same sender, same text, same reqOfferID within short time window + messageExists = serviceProviderOffersList[providerIndex].chatMessages!.any((msg) { + return msg.senderUserID == currentMessage.senderUserID && + msg.chatText == currentMessage.chatText && + msg.reqOfferID == currentMessage.reqOfferID && + msg.messageType == currentMessage.messageType; + }); + } + + if (messageExists) { + log("Message already exists (id: ${currentMessage.id}), skipping duplicate"); + continue; + } + if (currentMessage.chatMessageTypeEnum == ChatMessageTypeEnum.offer) { for (var chatMessage in serviceProviderOffersList[providerIndex].chatMessages!) { if (chatMessage.chatMessageTypeEnum == ChatMessageTypeEnum.offer && chatMessage.reqOffer!.id == currentMessage.reqOffer!.id) { @@ -234,6 +270,28 @@ class ChatVM extends BaseVM { } log("requestIndex: $requestIndex"); if (requestIndex != -1) { + // Check if message already exists to prevent duplicates + bool messageExists = false; + + if (currentMessage.id != null) { + // For messages with ID, use ID for duplicate detection + messageExists = requestsVM.myFilteredRequests[requestIndex].chatMessages.any((msg) => msg.id == currentMessage.id); + } else { + // For messages without ID (locally created), use content-based duplicate detection + // Check if same sender, same text, same reqOfferID within short time window + messageExists = requestsVM.myFilteredRequests[requestIndex].chatMessages.any((msg) { + return msg.senderUserID == currentMessage.senderUserID && + msg.chatText == currentMessage.chatText && + msg.reqOfferID == currentMessage.reqOfferID && + msg.messageType == currentMessage.messageType; + }); + } + + if (messageExists) { + log("Message already exists (id: ${currentMessage.id}), skipping duplicate"); + continue; + } + if (currentMessage.chatMessageTypeEnum == ChatMessageTypeEnum.offer) { for (var chatMessage in requestsVM.myFilteredRequests[requestIndex].chatMessages) { if (chatMessage.chatMessageTypeEnum == ChatMessageTypeEnum.offer && chatMessage.reqOffer!.id == currentMessage.reqOffer!.id) { @@ -242,6 +300,13 @@ class ChatVM extends BaseVM { chatMessage.reqOffer!.requestOfferStatusEnum = currentMessage.reqOffer!.requestOfferStatusEnum; } } + + // If this offer was accepted, update the request status to inProgress + // This ensures all other offers show "offerNoLongerAvailable" on provider side + if (currentMessage.reqOffer!.requestOfferStatusEnum == RequestOfferStatusEnum.accepted) { + log("Offer accepted, updating request status to inProgress"); + requestsVM.myFilteredRequests[requestIndex].requestStatus = RequestStatusEnum.inProgress; + } } requestsVM.addChatMessagesInRequestsModel(msg: currentMessage, index: requestIndex); scrollChatDown(); @@ -251,7 +316,19 @@ class ChatVM extends BaseVM { notifyListeners(); } - void _subscribeToReceiveRequestOfferMessages(BuildContext context) { + void _subscribeToReceiveRequestOfferMessages() { + if (_requestsVM == null) { + logger.e("RequestsVM not set! Cannot subscribe to request offer messages."); + return; + } + + // Clear existing listener first to prevent duplicates + try { + hubConnection!.off(SignalrConsts.receiveMessageRequestOffer); + } catch (e) { + // Ignore if no listener exists + } + hubConnection!.on(SignalrConsts.receiveMessageRequestOffer, (List? arguments) { logger.i(arguments); @@ -261,9 +338,10 @@ class ChatVM extends BaseVM { final chatMessage = ChatMessageModel.fromJson(message as Map, isForReqOfferImagesURLs: true); chat.add(chatMessage); } - onNewMessageReceivedForRequestOffer(messages: chat, requestsVM: context.read()); + onNewMessageReceivedForRequestOffer(messages: chat, requestsVM: _requestsVM!); // Utils.showToast(arguments.toString()); }); + logger.i("Subscribed to RequestOffer messages"); } Future markMessagesAsRead(BuildContext context, List messageIds, ChatTypeEnum chatTypeEnum) async { @@ -308,6 +386,7 @@ class ChatVM extends BaseVM { hubConnection!.off(SignalrConsts.receiveMessageRequestOffer); hubConnection!.off(SignalrConsts.receiveMessageAds); hubConnection!.off(SignalrConsts.receiveMessageGeneral); + hubConnection!.off(SignalrConsts.requestOfferReadUpdate); _isSubscribed = false; logger.i("Cleared all SignalR listeners"); } catch (e) { @@ -346,13 +425,17 @@ class ChatVM extends BaseVM { // Clear old listeners before subscribing (prevents duplicates) _clearAllListeners(); - // Subscribe to messages (only if context is available for first-time connection) - // For reconnections, we'll re-subscribe without context - if (context != null) { - _subscribeToReceiveRequestOfferMessages(context); - _subscribeToReceiveAdMessages(context); - _subscribeToReceiveGeneralMessages(context); + // Subscribe to messages (no context needed anymore!) + if (_requestsVM != null && _adVM != null) { + _subscribeToReceiveRequestOfferMessages(); + _subscribeToReceiveAdMessages(); + _subscribeToReceiveGeneralMessages(); + _subscribeToRequestOfferReadUpdate(); _isSubscribed = true; + log("✅ All SignalR listeners subscribed | Hub: ${hubConnection!.connectionId}"); + log("📍 Events: ReceiveMessageRequestOffer, ReceiveMessageAds, ReceiveMessageGeneral, RequestOfferReadUpdate"); + } else { + logger.w("ViewModels not injected yet. Listeners will be subscribed after injection."); } // Configure handlers @@ -367,11 +450,22 @@ class ChatVM extends BaseVM { _isSubscribed = false; }); - hubConnection!.onreconnected((connectionId) { + hubConnection!.onreconnected((connectionId) async { logger.i("onReconnected: ${connectionId.toString()}"); - // Re-subscribe after automatic reconnect (SignalR's own reconnect, not ours) - // This is safe because we clear listeners in onreconnecting _retryCount = 0; // Reset on successful reconnect + + // ✅ CRITICAL FIX: Re-subscribe listeners after automatic reconnect + if (_requestsVM != null && _adVM != null) { + _clearAllListeners(); + _subscribeToReceiveRequestOfferMessages(); + _subscribeToReceiveAdMessages(); + _subscribeToReceiveGeneralMessages(); + _subscribeToRequestOfferReadUpdate(); + _isSubscribed = true; + logger.i("✅ Listeners re-subscribed after automatic reconnection"); + } else { + logger.e("❌ Cannot re-subscribe: ViewModels not set!"); + } }); } catch (e) { logger.e("Error during hub connection setup: ${e.toString()}"); @@ -527,83 +621,112 @@ class ChatVM extends BaseVM { }) async { if (chatMessageType == ChatMessageTypeEnum.freeText && message.isEmpty) return false; - // Ensure connection is established - if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { - logger.i("Hub Not Connected!! Attempting to connect..."); - await buildHubConnection(context: context); + // ✅ Prevent duplicate sends + if (isSendingMessage) { + logger.w("Message already being sent, ignoring duplicate tap"); + return false; + } - // Wait a bit for connection to establish - await Future.delayed(const Duration(milliseconds: 500)); + isSendingMessage = true; + notifyListeners(); - // Check again after attempt + try { + // Ensure connection is established if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { - Utils.showToast("Failed to connect. Please try again."); - return false; - } - } - - if (hubConnection!.state == HubConnectionState.connected) { - final userId = AppState().getUser.data!.userInfo!.userId.toString(); - final name = AppState().getUser.data!.userInfo!.firstName.toString(); - List> requestImagesMap = []; - List messageImages = getMessagesImageList(); - if (messageImages.isNotEmpty && chatMessageType == ChatMessageTypeEnum.image) { - for (var element in messageImages) { - requestImagesMap.add(element.toJson()); + logger.i("Hub Not Connected!! Attempting to connect..."); + await buildHubConnection(context: context); + + // Wait a bit for connection to establish + await Future.delayed(const Duration(milliseconds: 500)); + + // Check again after attempt + if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { + Utils.showToast("Failed to connect. Please try again."); + isSendingMessage = false; + notifyListeners(); + return false; } } - final obj = { - "ReceiverUserID": receiverId, - "SenderUserID": userId, - "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), - "ChatText": chatMessageType == ChatMessageTypeEnum.image ? "${messageImages.length} Image(s)" : message, - "RequestID": requestId, - "ReqOfferID": latestOfferId, - }; - - if (chatMessageType == ChatMessageTypeEnum.image) { - obj.addAll({"ReqOfferImages": requestImagesMap}); - } - logger.i(obj); - - try { - await hubConnection!.invoke( - SignalrConsts.sendMessageRequestOffer, - args: [obj], - ); - ChatMessageModel chatMessageModel = ChatMessageModel( - messageType: chatMessageType.getIdFromChatMessageTypeEnum(), - chatText: message, - isMyMessage: true, - requestID: requestId, - senderName: name, - senderUserID: userId, - chatMessageTypeEnum: chatMessageType, - receiverUserID: receiverId, - messageImages: messageImages, - ); - - if (AppState().currentAppType == AppType.customer) { - int providerIndex = serviceProviderOffersList.indexWhere((element) => element.providerUserId == receiverId); - if (providerIndex != -1) { - serviceProviderOffersList[providerIndex].chatMessages!.add(chatMessageModel); + if (hubConnection!.state == HubConnectionState.connected) { + final userId = AppState().getUser.data!.userInfo!.userId.toString(); + final name = AppState().getUser.data!.userInfo!.firstName.toString(); + List> requestImagesMap = []; + List messageImages = getMessagesImageList(); + if (messageImages.isNotEmpty && chatMessageType == ChatMessageTypeEnum.image) { + for (var element in messageImages) { + requestImagesMap.add(element.toJson()); } - } else { - int providerIndex = context.read().myFilteredRequests.indexWhere((element) => (element.customerUserID == receiverId && element.id == requestId)); - log("providerIndex2:$providerIndex"); - if (providerIndex != -1) { - context.read().addChatMessagesInRequestsModel(msg: chatMessageModel, index: providerIndex); + } + final obj = { + "ReceiverUserID": receiverId, + "SenderUserID": userId, + "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), + "ChatText": chatMessageType == ChatMessageTypeEnum.image ? "${messageImages.length} Image(s)" : message, + "RequestID": requestId, + "ReqOfferID": latestOfferId, + }; + + if (chatMessageType == ChatMessageTypeEnum.image) { + obj.addAll({"ReqOfferImages": requestImagesMap}); + } + logger.i(obj); + + try { + await hubConnection!.invoke( + SignalrConsts.sendMessageRequestOffer, + args: [obj], + ); + + ChatMessageModel chatMessageModel = ChatMessageModel( + messageType: chatMessageType.getIdFromChatMessageTypeEnum(), + chatText: message, + isMyMessage: true, + requestID: requestId, + senderName: name, + senderUserID: userId, + chatMessageTypeEnum: chatMessageType, + receiverUserID: receiverId, + messageImages: messageImages, + ); + + if (AppState().currentAppType == AppType.customer) { + int providerIndex = serviceProviderOffersList.indexWhere((element) => element.providerUserId == receiverId); + if (providerIndex != -1) { + serviceProviderOffersList[providerIndex].chatMessages!.add(chatMessageModel); + } + } else { + // ✅ Use stored RequestsVM instead of context.read + if (_requestsVM != null) { + int providerIndex = _requestsVM!.myFilteredRequests.indexWhere((element) => (element.customerUserID == receiverId && element.id == requestId)); + log("providerIndex2:$providerIndex"); + if (providerIndex != -1) { + _requestsVM!.addChatMessagesInRequestsModel(msg: chatMessageModel, index: providerIndex); + } + } } + + isSendingMessage = false; + notifyListeners(); + return true; + } catch (e) { + logger.e("error in invoking SendMessage: ${e.toString()}"); + Utils.showToast("Failed to send message"); + isSendingMessage = false; + notifyListeners(); + return false; } - return true; - } catch (e) { - logger.e("error in invoking SendMessage: ${e.toString()}"); - Utils.showToast("Failed to send message"); - return false; } + + isSendingMessage = false; + notifyListeners(); + return false; + } catch (e) { + logger.e("Unexpected error in onMessageSendForRequest: ${e.toString()}"); + isSendingMessage = false; + notifyListeners(); + return false; } - return false; } List serviceProviderOffersList = []; @@ -806,7 +929,19 @@ class ChatVM extends BaseVM { } } - void _subscribeToReceiveAdMessages(BuildContext context) { + void _subscribeToReceiveAdMessages() { + if (_adVM == null) { + logger.e("AdVM not set! Cannot subscribe to ad messages."); + return; + } + + // Clear existing listener first to prevent duplicates + try { + hubConnection!.off(SignalrConsts.receiveMessageAds); + } catch (e) { + // Ignore if no listener exists + } + hubConnection!.on(SignalrConsts.receiveMessageAds, (List? arguments) { if (arguments == null || arguments.isEmpty) return; List chat = []; @@ -818,10 +953,28 @@ class ChatVM extends BaseVM { logger.i(arguments); // Utils.showToast(arguments.toString()); }); + logger.i("Subscribed to Ads messages"); } Future onNewMessageReceivedForAds({required List messages}) async { for (var msg in messages) { + // Check if message already exists to prevent duplicates + bool messageExists = false; + + if (msg.id != null) { + // For messages with ID, use ID for duplicate detection + messageExists = currentMessagesForAds.any((existingMsg) => existingMsg.id == msg.id); + } else { + // For messages without ID (locally created), use content-based duplicate detection + messageExists = currentMessagesForAds.any((existingMsg) { + return existingMsg.senderUserID == msg.senderUserID && existingMsg.chatText == msg.chatText && existingMsg.messageType == msg.messageType; + }); + } + + if (messageExists) { + log("Message already exists in ads chat (id: ${msg.id}), skipping duplicate"); + continue; + } currentMessagesForAds.add(msg); } scrollChatDown(); @@ -837,63 +990,89 @@ class ChatVM extends BaseVM { }) async { if (message.isEmpty) return false; - // Ensure connection is established - if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { - logger.i("Hub Not Connected!! Attempting to connect..."); - await buildHubConnection(context: context); + // ✅ Prevent duplicate sends + if (isSendingMessage) { + logger.w("Message already being sent, ignoring duplicate tap"); + return false; + } - // Wait a bit for connection to establish - await Future.delayed(const Duration(milliseconds: 500)); + isSendingMessage = true; + notifyListeners(); - // Check again after attempt + try { + // Ensure connection is established if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { - Utils.showToast("Failed to connect. Please try again."); - return false; + logger.i("Hub Not Connected!! Attempting to connect..."); + await buildHubConnection(context: context); + + // Wait a bit for connection to establish + await Future.delayed(const Duration(milliseconds: 500)); + + // Check again after attempt + if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { + Utils.showToast("Failed to connect. Please try again."); + isSendingMessage = false; + notifyListeners(); + return false; + } } - } - - if (hubConnection!.state == HubConnectionState.connected) { - final userId = AppState().getUser.data!.userInfo!.userId.toString(); - final name = AppState().getUser.data!.userInfo!.firstName.toString(); - final obj = { - "ReceiverUserID": receiverId, - // "SenderUserID": userId, - "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), - "ChatText": message, - "AdsID": adId, - }; - logger.i("$obj"); - - try { - await hubConnection!.invoke( - SignalrConsts.sendMessageAds, - args: [obj], - ); - ChatMessageModel chatMessageModel = ChatMessageModel( - messageType: chatMessageType.getIdFromChatMessageTypeEnum(), - chatText: message, - isMyMessage: true, - senderName: name, - senderUserID: userId, - chatMessageTypeEnum: ChatMessageTypeEnum.freeText, - receiverUserID: receiverId, - ); - - currentMessagesForAds.add(chatMessageModel); - - notifyListeners(); - - if (AppState().currentAppType == AppType.customer) { - } else {} - return true; - } catch (e) { - logger.e("error in invoking SendMessage: ${e.toString()}"); - Utils.showToast("Failed to send message"); - return false; + if (hubConnection!.state == HubConnectionState.connected) { + final userId = AppState().getUser.data!.userInfo!.userId.toString(); + final name = AppState().getUser.data!.userInfo!.firstName.toString(); + final obj = { + "ReceiverUserID": receiverId, + // "SenderUserID": userId, + "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), + "ChatText": message, + "AdsID": adId, + }; + logger.i("$obj"); + + try { + await hubConnection!.invoke( + SignalrConsts.sendMessageAds, + args: [obj], + ); + + ChatMessageModel chatMessageModel = ChatMessageModel( + messageType: chatMessageType.getIdFromChatMessageTypeEnum(), + chatText: message, + isMyMessage: true, + senderName: name, + senderUserID: userId, + chatMessageTypeEnum: ChatMessageTypeEnum.freeText, + receiverUserID: receiverId, + ); + + currentMessagesForAds.add(chatMessageModel); + + notifyListeners(); + + if (AppState().currentAppType == AppType.customer) { + } else {} + + isSendingMessage = false; + notifyListeners(); + return true; + } catch (e) { + logger.e("error in invoking SendMessage: ${e.toString()}"); + Utils.showToast("Failed to send message"); + isSendingMessage = false; + notifyListeners(); + return false; + } } + + isSendingMessage = false; + notifyListeners(); + return false; + } catch (e) { + logger.e("Unexpected error in onTextMessageSendForAds: ${e.toString()}"); + isSendingMessage = false; + notifyListeners(); + return false; } - return false; } // ========================General ================== @@ -928,13 +1107,38 @@ class ChatVM extends BaseVM { Future onNewMessageReceivedForGeneral({required List messages}) async { for (var msg in messages) { + // Check if message already exists to prevent duplicates + bool messageExists = false; + + if (msg.id != null) { + // For messages with ID, use ID for duplicate detection + messageExists = currentMessagesForGeneralChat.any((existingMsg) => existingMsg.id == msg.id); + } else { + // For messages without ID (locally created), use content-based duplicate detection + messageExists = currentMessagesForGeneralChat.any((existingMsg) { + return existingMsg.senderUserID == msg.senderUserID && existingMsg.chatText == msg.chatText && existingMsg.messageType == msg.messageType; + }); + } + + if (messageExists) { + log("Message already exists in general chat (id: ${msg.id}), skipping duplicate"); + continue; + } currentMessagesForGeneralChat.add(msg); // ✅ FIXED: Use correct list } scrollChatDown(); notifyListeners(); } - void _subscribeToReceiveGeneralMessages(BuildContext context) { + void _subscribeToReceiveGeneralMessages() { + // Clear existing listener first to prevent duplicates + try { + hubConnection!.off(SignalrConsts.receiveMessageGeneral); + } catch (e) { + // Ignore if no listener exists + } + + // General messages don't need specific VM, just storing messages hubConnection!.on(SignalrConsts.receiveMessageGeneral, (List? arguments) { if (arguments == null || arguments.isEmpty) return; List chat = []; @@ -946,6 +1150,121 @@ class ChatVM extends BaseVM { logger.i(arguments); // Utils.showToast(arguments.toString()); }); + logger.i("Subscribed to General messages"); + } + + void _subscribeToRequestOfferReadUpdate() { + if (_requestsVM == null) { + logger.e("RequestsVM not set! Cannot subscribe to request offer read updates."); + return; + } + + // Clear existing listener first to prevent duplicates + try { + hubConnection!.off(SignalrConsts.requestOfferReadUpdate); + } catch (e) { + // Ignore if no listener exists + } + + hubConnection!.on(SignalrConsts.requestOfferReadUpdate, (List? arguments) { + log("🔔 RequestOfferReadUpdate EVENT TRIGGERED! Args: $arguments"); + + if (arguments == null || arguments.isEmpty) { + log("⚠️ RequestOfferReadUpdate: Arguments are null or empty"); + return; + } + + try { + final data = arguments[0] as Map; + log("✅ Parsed RequestOfferReadUpdate data: reqOfferChatID=${data['reqOfferChatID']}, isRead=${data['isRead']}"); + _handleRequestOfferReadUpdate(data); + } catch (e) { + log("❌ Error parsing RequestOfferReadUpdate: ${e.toString()}"); + } + }); + + log("✅ Subscribed to RequestOfferReadUpdate"); + } + + void _handleRequestOfferReadUpdate(Map data) async { + try { + final int? messageId = data['reqOfferChatID']; + final int? reqOfferID = data['reqOfferID']; + final bool? isRead = data['isRead']; + final String? readOn = data['readOn']; + + if (messageId == null) { + log("❌ RequestOfferReadUpdate: reqOfferChatID is null"); + return; + } + + log("📨 Processing read update: msgID=$messageId, offerID=$reqOfferID, isRead=$isRead"); + + // For Provider App: Reload messages from API to get updated read status and IDs + if (AppState().currentAppType == AppType.provider && _requestsVM != null) { + // Check if viewing the chat currently (currentSelectedRequest is set) + if (_requestsVM!.currentSelectedRequest != null) { + log("🔄 Reloading messages for currentSelectedRequest (ID: ${_requestsVM!.currentSelectedRequest!.id})"); + + await _reloadRequestMessages( + requestId: _requestsVM!.currentSelectedRequest!.id, + requestOfferId: 0, // Provider side always uses 0 (sees all offers for request) + customerId: _requestsVM!.currentSelectedRequest!.customerId ?? 0, + ); + + log("✅ Reloaded messages for currentSelectedRequest - read status updated"); + } else { + // No current chat open, optionally reload all requests + // (In most cases, we only care about updating the currently visible chat) + log("ℹ️ Read update received but no active chat open, skipping reload"); + } + } + + // For Customer App: Not needed (customer is the one marking as read) + if (AppState().currentAppType == AppType.customer) { + log("RequestOfferReadUpdate on customer side (ignored)"); + } + } catch (e) { + log("❌ Error in _handleRequestOfferReadUpdate: ${e.toString()}"); + } + } + + /// Helper method to reload request messages from API without showing loading dialog + Future _reloadRequestMessages({ + required int requestId, + required int requestOfferId, + required int customerId, + }) async { + try { + int providerId = AppState().getUser.data!.userInfo!.providerId!; + + log("🔄 Fetching messages from API: requestId=$requestId, requestOfferId=$requestOfferId"); + + List chatMessages = await chatRepo.getUsersChatMessagesForRequests( + providerId: providerId, + customerId: customerId, + requestOfferId: requestOfferId, + requestId: requestId, + ); + + // Find the request in myFilteredRequests and update its messages + int requestIndex = _requestsVM!.myFilteredRequests.indexWhere((req) => req.id == requestId); + if (requestIndex != -1) { + _requestsVM!.myFilteredRequests[requestIndex].chatMessages = chatMessages; + + // Also update currentSelectedRequest if it's the same request + if (_requestsVM!.currentSelectedRequest != null && _requestsVM!.currentSelectedRequest!.id == requestId) { + _requestsVM!.currentSelectedRequest!.chatMessages = chatMessages; + } + + log("✅ Updated ${chatMessages.length} messages for request $requestId"); + notifyListeners(); + } else { + log("⚠️ Request $requestId not found in myFilteredRequests"); + } + } catch (e) { + log("❌ Error reloading messages: ${e.toString()}"); + } } Future onTextMessageSendForGeneralChat({ @@ -957,59 +1276,85 @@ class ChatVM extends BaseVM { }) async { if (message.isEmpty) return false; - // Ensure connection is established - if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { - logger.i("Hub Not Connected!! Attempting to connect..."); - await buildHubConnection(context: context); + // ✅ Prevent duplicate sends + if (isSendingMessage) { + logger.w("Message already being sent, ignoring duplicate tap"); + return false; + } - // Wait a bit for connection to establish - await Future.delayed(const Duration(milliseconds: 500)); + isSendingMessage = true; + notifyListeners(); - // Check again after attempt + try { + // Ensure connection is established if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { - Utils.showToast("Failed to connect. Please try again."); - return false; + logger.i("Hub Not Connected!! Attempting to connect..."); + await buildHubConnection(context: context); + + // Wait a bit for connection to establish + await Future.delayed(const Duration(milliseconds: 500)); + + // Check again after attempt + if (hubConnection == null || hubConnection!.state != HubConnectionState.connected) { + Utils.showToast("Failed to connect. Please try again."); + isSendingMessage = false; + notifyListeners(); + return false; + } } - } - - if (hubConnection!.state == HubConnectionState.connected) { - final userId = AppState().getUser.data!.userInfo!.userId.toString(); - final name = AppState().getUser.data!.userInfo!.firstName.toString(); - final obj = { - "ReceiverUserID": receiverId, - "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), - "ChatText": message, - }; - logger.i("$obj"); - - try { - await hubConnection!.invoke( - SignalrConsts.sendMessageGeneral, - args: [obj], - ); - ChatMessageModel chatMessageModel = ChatMessageModel( - messageType: chatMessageType.getIdFromChatMessageTypeEnum(), - chatText: message, - isMyMessage: true, - senderName: name, - senderUserID: userId, - chatMessageTypeEnum: ChatMessageTypeEnum.freeText, - receiverUserID: receiverId, - ); - - currentMessagesForGeneralChat.add(chatMessageModel); - notifyListeners(); - - if (AppState().currentAppType == AppType.customer) { - } else {} - return true; - } catch (e) { - logger.e("error in invoking SendMessage: ${e.toString()}"); - Utils.showToast("Failed to send message"); - return false; + if (hubConnection!.state == HubConnectionState.connected) { + final userId = AppState().getUser.data!.userInfo!.userId.toString(); + final name = AppState().getUser.data!.userInfo!.firstName.toString(); + final obj = { + "ReceiverUserID": receiverId, + "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), + "ChatText": message, + }; + logger.i("$obj"); + + try { + await hubConnection!.invoke( + SignalrConsts.sendMessageGeneral, + args: [obj], + ); + + ChatMessageModel chatMessageModel = ChatMessageModel( + messageType: chatMessageType.getIdFromChatMessageTypeEnum(), + chatText: message, + isMyMessage: true, + senderName: name, + senderUserID: userId, + chatMessageTypeEnum: ChatMessageTypeEnum.freeText, + receiverUserID: receiverId, + ); + + currentMessagesForGeneralChat.add(chatMessageModel); + notifyListeners(); + + if (AppState().currentAppType == AppType.customer) { + } else {} + + isSendingMessage = false; + notifyListeners(); + return true; + } catch (e) { + logger.e("error in invoking SendMessage: ${e.toString()}"); + Utils.showToast("Failed to send message"); + isSendingMessage = false; + notifyListeners(); + return false; + } } + + isSendingMessage = false; + notifyListeners(); + return false; + } catch (e) { + logger.e("Unexpected error in onTextMessageSendForGeneralChat: ${e.toString()}"); + isSendingMessage = false; + notifyListeners(); + return false; } - return false; } } diff --git a/lib/view_models/dashboard_view_model_customer.dart b/lib/view_models/dashboard_view_model_customer.dart index abdf321..f465923 100644 --- a/lib/view_models/dashboard_view_model_customer.dart +++ b/lib/view_models/dashboard_view_model_customer.dart @@ -42,26 +42,26 @@ class DashboardVmCustomer extends BaseVM with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); - logger.i("App lifecycle state changed to: $state"); - + log("App lifecycle state changed to: $state"); + if (_context == null) return; - + final chatVM = Provider.of(_context!, listen: false); - + switch (state) { case AppLifecycleState.resumed: - // App came back to foreground - ensure SignalR is connected + // App came back to foreground - ensure SignalR is connected logger.i("App resumed - checking SignalR connection"); chatVM.buildHubConnection(context: _context); break; case AppLifecycleState.paused: - // App went to background - keep connection alive (as per user requirement) + // App went to background - keep connection alive (as per user requirement) logger.i("App paused - SignalR staying connected"); break; case AppLifecycleState.inactive: case AppLifecycleState.detached: case AppLifecycleState.hidden: - // Other states - no action needed + // Other states - no action needed break; } } @@ -107,6 +107,9 @@ class DashboardVmCustomer extends BaseVM with WidgetsBindingObserver { RequestsVM requestsVM = Provider.of(context, listen: false); ChatVM chatVM = Provider.of(context, listen: false); + // ✅ Inject ViewModels into ChatVM (only needs to be done once, but safe to repeat) + chatVM.setViewModels(requestsVM: requestsVM, adVM: adVM); + await appointmentsVM.populateBranchesFilterList(); await appointmentsVM.populateAppointmentsFilterList(); await adVM.populateAdsFilterList(); diff --git a/lib/view_models/dashboard_view_model_provider.dart b/lib/view_models/dashboard_view_model_provider.dart index 1d02098..9da5397 100644 --- a/lib/view_models/dashboard_view_model_provider.dart +++ b/lib/view_models/dashboard_view_model_provider.dart @@ -122,6 +122,10 @@ class DashboardVMProvider extends BaseVM with WidgetsBindingObserver { final serviceVM = context.read(); final adVM = context.read(); final subscriptionsVM = context.read(); + + // ✅ Inject ViewModels into ChatVM (only needs to be done once, but safe to repeat) + chatVM.setViewModels(requestsVM: requestsVM, adVM: adVM); + await requestsVM.populateDataForRequestsFilter(); await requestsVM.populateDeliveryOptionEnums(); await appointmentVM.populateAppointmentsFilterList(); diff --git a/lib/view_models/payment_view_model.dart b/lib/view_models/payment_view_model.dart index 41b0717..44c8d54 100644 --- a/lib/view_models/payment_view_model.dart +++ b/lib/view_models/payment_view_model.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; import 'package:mc_common_app/classes/app_state.dart'; import 'package:mc_common_app/config/routes.dart'; import 'package:mc_common_app/extensions/string_extensions.dart'; @@ -85,7 +86,8 @@ class PaymentVM extends ChangeNotifier { ); // Utils.hideLoading(context); // Commented out: matching pair with showLoading - // TODO: VERIFY THIS WITH ZAHOOR + log("isPaid: $isPaid"); + if (!isPaid) { // Utils.showToast(LocaleKeys.paymentFailed.tr()); // pop(context); @@ -93,8 +95,11 @@ class PaymentVM extends ChangeNotifier { } else { Utils.showToast(LocaleKeys.paymentSuccessful.tr()); } + + // Call onVerified callback - success methods handle their own context checks onVerified(); } catch (e) { + log("verifyPayments error: ${e.toString()}"); Utils.showToast(e.toString()); return; } @@ -145,7 +150,7 @@ class PaymentVM extends ChangeNotifier { context: context, paymentTypeId: paymentTypeEnum.getIdFromPaymentTypesEnum(), id: orderProviderSubscriptionId, - onVerified: () => onSubscriptionPaymentSuccess(context), + onVerified: () => onSubscriptionPaymentSuccess(), ); break; @@ -171,6 +176,14 @@ class PaymentVM extends ChangeNotifier { break; case PaymentTypes.adReserve: + await verifyPayments( + context: context, + paymentTypeId: paymentTypeEnum.getIdFromPaymentTypesEnum(), + id: currentAdId, + onVerified: () => onAdReservePaymentSuccess(context), + ); + break; + case PaymentTypes.ads: case PaymentTypes.extendAds: await verifyPayments( @@ -185,6 +198,18 @@ class PaymentVM extends ChangeNotifier { } void onRequestPaymentSuccess(BuildContext context) { + log("onRequestPaymentSuccess"); + // Check if context is still mounted before using it + if (!context.mounted) { + log("Context not mounted, using navigatorKey instead"); + // Use navigatorKey to navigate without context dependency + navigatorKey.currentState?.pushNamedAndRemoveUntil( + AppRoutes.dashboard, + (route) => false, + ); + return; + } + if (AppState().currentAppType == AppType.provider) { context.read().onNavbarTapped(4); } else { @@ -194,6 +219,19 @@ class PaymentVM extends ChangeNotifier { } void onAdsPaymentSuccess(BuildContext context) { + log("onAdsPaymentSuccess"); + // Check if context is still mounted before using it + if (!context.mounted) { + log("Context not mounted, using navigatorKey instead"); + // Use navigatorKey to navigate without context dependency + navigatorKey.currentState?.pushNamedAndRemoveUntil( + AppRoutes.dashboard, + (route) => false, + arguments: DashboardRouteEnum.fromAdsPayment, + ); + return; + } + context.read().updateIsExploreAds(false); if (AppState().currentAppType == AppType.provider) { context.read().onNavbarTapped(3); @@ -205,13 +243,68 @@ class PaymentVM extends ChangeNotifier { navigateReplaceWithNameUntilRoute(context, AppRoutes.dashboard, arguments: DashboardRouteEnum.fromAdsPayment); } - void onSubscriptionPaymentSuccess(BuildContext context) { - pop(context); + void onAdReservePaymentSuccess(BuildContext context) { + log("onAdReservePaymentSuccess - Reserve ads payment completed"); + + // Always use navigatorKey for reliable navigation after payment + // Payment flow often unmounts context, so navigatorKey is more reliable + log("Navigating to dashboard with reserved ads filter"); + + // Navigate to dashboard first + navigatorKey.currentState?.pushNamedAndRemoveUntil( + AppRoutes.dashboard, + (route) => false, + arguments: DashboardRouteEnum.fromReserveAdsPayment, + ); + + // Use a post-frame callback to ensure navigation completes before updating state + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = navigatorKey.currentContext; + if (context != null && context.mounted) { + try { + // Navigate to My Ads tab and apply Reserved filter + context.read().updateIsExploreAds(false); + if (AppState().currentAppType == AppType.provider) { + context.read().onNavbarTapped(3); // My Ads tab + context.read().applyFilterOnMyAds(adPostStatusEnum: AdPostStatus.reserved); + } else { + context.read().onNavbarTapped(3); // My Ads tab + context.read().applyFilterOnMyAds(adPostStatusEnum: AdPostStatus.reserved); + } + log("✅ Successfully navigated to Reserved Ads"); + } catch (e) { + log("⚠️ Error updating AdVM after navigation: $e"); + } + } else { + log("⚠️ Context not available for post-navigation updates"); + } + }); + } + + void onSubscriptionPaymentSuccess() { + log("onSubscriptionPaymentSuccess"); + // Use navigatorKey to navigate without context dependency + // This works even after payment browser is closed and context is unmounted + navigatorKey.currentState?.pushNamedAndRemoveUntil( + AppRoutes.dashboard, + (route) => false, + ); // context.read().getSubscriptionBySP(AppState().getUser.data?.userInfo?.providerId.toString() ?? "", true); - navigateReplaceWithNameUntilRoute(context, AppRoutes.dashboard); } void onAppointmentSuccess(BuildContext context) { + log("onAppointmentSuccess"); + // Check if context is still mounted before using it + if (!context.mounted) { + log("Context not mounted, using navigatorKey instead"); + // Use navigatorKey to navigate without context dependency + navigatorKey.currentState?.pushNamedAndRemoveUntil( + AppRoutes.dashboard, + (route) => false, + ); + return; + } + if (AppState().currentAppType == AppType.provider) { context.read().onNavbarTapped(1); } else { diff --git a/lib/view_models/requests_view_model.dart b/lib/view_models/requests_view_model.dart index c373bac..586b3fa 100644 --- a/lib/view_models/requests_view_model.dart +++ b/lib/view_models/requests_view_model.dart @@ -115,7 +115,6 @@ class RequestsVM extends BaseVM { Future getRequests({bool isNeedToRebuild = false}) async { if (isNeedToRebuild) setState(ViewState.busy); applyFilterOnRequestsVM(requestsTypeEnum: RequestsTypeEnum.specialCarRequest); - isOfferSent = false; setState(ViewState.idle); } @@ -216,7 +215,7 @@ class RequestsVM extends BaseVM { if (acceptedRequestsTypeFilterOptions.isEmpty) { for (int i = 0; i < myRequestsTypeEnum.length; i++) { - acceptedRequestsTypeFilterOptions.add(FilterListModel(title: myRequestsTypeEnum[i].enumValueStr, titleN: myRequestsTypeEnum[i].enumValueStrN, isSelected: false, id: myRequestsTypeEnum[i].enumValue)); + acceptedRequestsTypeFilterOptions.add(FilterListModel(title: myRequestsTypeEnum[i].enumValueStr, isSelected: false, id: myRequestsTypeEnum[i].enumValue)); } acceptedRequestsTypeFilterOptions[0].isSelected = true; } @@ -302,7 +301,6 @@ class RequestsVM extends BaseVM { } await getRequestsBasedOnFilters(); - isOfferSent = false; notifyListeners(); } @@ -383,16 +381,16 @@ class RequestsVM extends BaseVM { if (requestsTypeFilterOptions.isEmpty || acceptedRequestsTypeFilterOptions.isEmpty) { for (int i = 0; i < myRequestsTypeEnum.length; i++) { - requestsTypeFilterOptions.add(FilterListModel(title: myRequestsTypeEnum[i].enumValueStr,titleN: myRequestsTypeEnum[i].enumValueStrN, isSelected: false, id: myRequestsTypeEnum[i].enumValue)); - acceptedRequestsTypeFilterOptions.add(FilterListModel(title: myRequestsTypeEnum[i].enumValueStr, titleN: myRequestsTypeEnum[i].enumValueStrN, isSelected: false, id: myRequestsTypeEnum[i].enumValue)); + requestsTypeFilterOptions.add(FilterListModel(title: myRequestsTypeEnum[i].enumValueStr, isSelected: false, id: myRequestsTypeEnum[i].enumValue)); + acceptedRequestsTypeFilterOptions.add(FilterListModel(title: myRequestsTypeEnum[i].enumValueStr, isSelected: false, id: myRequestsTypeEnum[i].enumValue)); } } // Initialize spare parts condition filters (All, New, Used) if (sparePartsConditionFilterOptions.isEmpty) { sparePartsConditionFilterOptions.add(FilterListModel(title: "All", titleN: "الكل", isSelected: true, id: 3)); // All - sparePartsConditionFilterOptions.add(FilterListModel(title: "New Spare Part", titleN: "قطعة غيار جديدة", isSelected: false, id: 1)); // New - sparePartsConditionFilterOptions.add(FilterListModel(title: "Used Spare Part",titleN: "قطعة غيار مستعملة", isSelected: false, id: 2)); // Used + sparePartsConditionFilterOptions.add(FilterListModel(title: "New", titleN: " جديدة", isSelected: false, id: 1)); // New + sparePartsConditionFilterOptions.add(FilterListModel(title: "Used",titleN: " مستعملة", isSelected: false, id: 2)); // Used } if (vehicleBrands.isEmpty) { @@ -930,6 +928,9 @@ class RequestsVM extends BaseVM { } else if (address.isEmpty && requestTypeId.selectedId == 2) { Utils.showToast(LocaleKeys.addValidAddress.tr()); isValid = false; + } else if (pickedVehicleImages.isEmpty) { + Utils.showToast(LocaleKeys.pleaseAddAtLeastOneImage.tr()); + isValid = false; } return isValid; } @@ -1220,12 +1221,6 @@ class RequestsVM extends BaseVM { return requestImages; } - bool isOfferSent = false; - - updateIsOfferSent(var value) { - isOfferSent = value; - notifyListeners(); - } Future updateOfferFromProvider({ required String message, @@ -1306,81 +1301,94 @@ class RequestsVM extends BaseVM { required int? offerId, }) async { if (isSendOfferValidated()) { - final chatVM = context.read(); - List images = getMessagesImageList(offerId: context.read().latestOfferId); - List offerImages = []; - images.forEach((element) { - offerImages.add(element.toJson()); - }); - - bool status = await chatVM.onOfferSendForRequest( - receiverId: receiverId, - chatMessageType: ChatMessageTypeEnum.offer, - message: message, - requestId: requestId, - offerPrice: offerPrice, - isDeliveryAvailable: isDeliveryAvailable, - offerDeliveryOption: offerDeliveryOption, - manufacturedByName: manufacturedByName, - manufacturedOn: manufacturedOn, - serviceItemName: serviceItemName, - offerImages: offerImages, - context: context, - ); - log("status from onOfferSendForRequest: $status"); + try { + Utils.showLoading(context); - if (!status) { - Utils.showToast(LocaleKeys.somethingWrong.tr()); - return; - } + final chatVM = context.read(); + List images = getMessagesImageList(offerId: context.read().latestOfferId); + List offerImages = []; + images.forEach((element) { + offerImages.add(element.toJson()); + }); - updateIsOfferSent(true); - - final senderName = AppState().getUser.data!.userInfo!.firstName; - final senderId = AppState().getUser.data!.userInfo!.userId; - resetSendOfferBottomSheet(); - Navigator.pop(context); - ChatMessageModel chatMessageModel = ChatMessageModel( - isMyMessage: true, - chatText: message, - messageType: ChatMessageTypeEnum.offer.getIdFromChatMessageTypeEnum(), - senderName: senderName, - senderUserID: senderId, - receiverUserID: receiverId, - chatMessageTypeEnum: ChatMessageTypeEnum.offer, - requestID: requestModel.id, - offerStatus: RequestOfferStatusEnum.offer.getIdFromRequestOfferStatusEnum(), - reqOffer: ReqOffer( - offerStatus: RequestOfferStatusEnum.offer.getIdFromRequestOfferStatusEnum(), - requestID: requestModel.id, - price: double.parse(offerPrice), + bool status = await chatVM.onOfferSendForRequest( + receiverId: receiverId, + chatMessageType: ChatMessageTypeEnum.offer, + message: message, + requestId: requestId, + offerPrice: offerPrice, isDeliveryAvailable: isDeliveryAvailable, - requestDeliveryOption: offerDeliveryOption, + offerDeliveryOption: offerDeliveryOption, manufacturedByName: manufacturedByName, manufacturedOn: manufacturedOn, serviceItemName: serviceItemName, - requestOfferStatusEnum: RequestOfferStatusEnum.offer, - comment: message, - offerStatusText: "", - reqOfferImages: images, - ), - ); + offerImages: offerImages, + context: context, + ); + log("status from onOfferSendForRequest: $status"); - context.read().onNewMessageReceivedForRequestOffer(messages: [chatMessageModel], requestsVM: this, isMyOwnOffer: true); - - if (!isFromChatScreen) { - ChatViewArgumentsForRequest chatViewArgumentsForRequest = ChatViewArgumentsForRequest( - chatTypeEnum: ChatTypeEnum.requestOffer, - requestId: requestModel.id, - receiverId: requestModel.customerID, - senderId: senderId ?? "", - requestIndex: requestIndex, - providerIndex: -1, - requestModel: requestModel, + if (!status) { + Utils.hideLoading(context); + Utils.showToast(LocaleKeys.somethingWrong.tr()); + return; + } + + // Update the specific request's isChatted status locally for immediate UI feedback + myFilteredRequests[requestIndex].isChatted = true; + notifyListeners(); + + final senderName = AppState().getUser.data!.userInfo!.firstName; + final senderId = AppState().getUser.data!.userInfo!.userId; + resetSendOfferBottomSheet(); + + Utils.hideLoading(context); + Navigator.pop(context); + ChatMessageModel chatMessageModel = ChatMessageModel( + isMyMessage: true, + chatText: message, + messageType: ChatMessageTypeEnum.offer.getIdFromChatMessageTypeEnum(), + senderName: senderName, + senderUserID: senderId, + receiverUserID: receiverId, + chatMessageTypeEnum: ChatMessageTypeEnum.offer, + requestID: requestModel.id, + offerStatus: RequestOfferStatusEnum.offer.getIdFromRequestOfferStatusEnum(), + reqOffer: ReqOffer( + offerStatus: RequestOfferStatusEnum.offer.getIdFromRequestOfferStatusEnum(), + requestID: requestModel.id, + price: double.parse(offerPrice), + isDeliveryAvailable: isDeliveryAvailable, + requestDeliveryOption: offerDeliveryOption, + manufacturedByName: manufacturedByName, + manufacturedOn: manufacturedOn, + serviceItemName: serviceItemName, + requestOfferStatusEnum: RequestOfferStatusEnum.offer, + comment: message, + offerStatusText: "", + reqOfferImages: images, + ), ); - ChatViewArguments chatViewArguments = ChatViewArguments(chatTypeEnum: ChatTypeEnum.requestOffer, chatViewArgumentsForRequest: chatViewArgumentsForRequest); - navigateWithName(context, AppRoutes.chatView, arguments: chatViewArguments); + context.read().onNewMessageReceivedForRequestOffer(messages: [chatMessageModel], requestsVM: this, isMyOwnOffer: true); + + if (!isFromChatScreen) { + ChatViewArgumentsForRequest chatViewArgumentsForRequest = ChatViewArgumentsForRequest( + chatTypeEnum: ChatTypeEnum.requestOffer, + requestId: requestModel.id, + receiverId: requestModel.customerID, + senderId: senderId ?? "", + requestIndex: requestIndex, + providerIndex: -1, + requestModel: requestModel, + ); + + ChatViewArguments chatViewArguments = ChatViewArguments(chatTypeEnum: ChatTypeEnum.requestOffer, chatViewArgumentsForRequest: chatViewArgumentsForRequest); + navigateWithName(context, AppRoutes.chatView, arguments: chatViewArguments); + } + } catch (e) { + Utils.hideLoading(context); + log(e.toString()); + Utils.showToast(e.toString()); } } } diff --git a/lib/view_models/shipping_management_view_model.dart b/lib/view_models/shipping_management_view_model.dart index 446faa9..2697868 100644 --- a/lib/view_models/shipping_management_view_model.dart +++ b/lib/view_models/shipping_management_view_model.dart @@ -30,11 +30,16 @@ class ShippingManagementVM extends BaseVM { List shippingRequestStatusesList = []; String requestStatusComments = ""; + String numberOfShipping = ""; void updateRequestStatusComments(var value) { requestStatusComments = value; } + void updateNumberOfShipping(var value) { + numberOfShipping = value; + } + bool isSelfPickupTapped = false; void updateIsSelfPickupTapped(var value) { @@ -55,6 +60,7 @@ class ShippingManagementVM extends BaseVM { } selfPickupRequestsFilterOptions[0].isSelected = true; requestStatusComments = ""; + numberOfShipping = ""; } Future populateSelfPickupRequestFilterList() async { @@ -174,10 +180,16 @@ class ShippingManagementVM extends BaseVM { Future onUpdateShippingStatusTapped({required BuildContext context, required ShippingRequestStatusEnum shippingStatusEnum, required int shippingRequestId}) async { Utils.showLoading(context); try { - GenericRespModel? genericRespModel = await shippingRepo.updateShippingRequestStatus(shippingStatusEnum: shippingStatusEnum, shippingRequestId: shippingRequestId, comment: requestStatusComments); + GenericRespModel? genericRespModel = await shippingRepo.updateShippingRequestStatus( + shippingStatusEnum: shippingStatusEnum, + shippingRequestId: shippingRequestId, + comment: requestStatusComments, + numberOfShipping: numberOfShipping.isEmpty ? null : numberOfShipping, + ); Utils.showToast(genericRespModel.message.toString()); Utils.hideLoading(context); requestStatusComments = ""; + numberOfShipping = ""; return genericRespModel.messageStatus == 1; } catch (e) { logger.i(e.toString()); @@ -190,11 +202,16 @@ class ShippingManagementVM extends BaseVM { Future onUpdateSelfPickupStatusTapped({required BuildContext context, required SelfPickupRequestStatusEnum selfPickupRequestStatusEnum, required int shippingRequestId}) async { Utils.showLoading(context); try { - GenericRespModel? genericRespModel = - await shippingRepo.updateSelfPickupRequestStatus(selfPickupStatusEnum: selfPickupRequestStatusEnum, shippingRequestId: shippingRequestId, comment: requestStatusComments); + GenericRespModel? genericRespModel = await shippingRepo.updateSelfPickupRequestStatus( + selfPickupStatusEnum: selfPickupRequestStatusEnum, + shippingRequestId: shippingRequestId, + comment: requestStatusComments, + numberOfShipping: numberOfShipping.isEmpty ? null : numberOfShipping, + ); Utils.showToast(genericRespModel.message.toString()); Utils.hideLoading(context); requestStatusComments = ""; + numberOfShipping = ""; return genericRespModel.messageStatus == 1; } catch (e) { logger.i(e.toString()); diff --git a/lib/views/advertisement/ads_detail_view/ads_detail_view.dart b/lib/views/advertisement/ads_detail_view/ads_detail_view.dart index 08f9860..4f603e5 100644 --- a/lib/views/advertisement/ads_detail_view/ads_detail_view.dart +++ b/lib/views/advertisement/ads_detail_view/ads_detail_view.dart @@ -51,7 +51,8 @@ class _AdsDetailViewState extends State { Future onAdDetailsLoaded() async { context.read().updateCurrentAdId(id: widget.adDetails.id!); - if ((widget.adDetails.isMyAd ?? false) && (widget.adDetails.adPostStatus == AdPostStatus.reserved)) { + // Load banking info when viewing MY created ad that someone else reserved + if ((widget.adDetails.isMyAd ?? false) && !(widget.adDetails.isReservedByMe ?? false) && (widget.adDetails.adPostStatus == AdPostStatus.reserved)) { await adVM.getAdBankingAccountInfo(adId: widget.adDetails.id!); } } @@ -114,8 +115,9 @@ class _AdsDetailViewState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( - child: "${widget.adDetails.vehicle!.vehicleTitle} | ${isArabic ? widget.adDetails.vehicle!.color!.labelN : widget.adDetails.vehicle!.color!.label}|" - " ${widget.adDetails.id}".toText(fontSize: 16, maxLines: 2), + child: "${widget.adDetails.vehicle!.vehicleTitle} | ${isArabic ? widget.adDetails.vehicle!.color!.labelN : widget.adDetails.vehicle!.color!.label}|" + " ${widget.adDetails.id}" + .toText(fontSize: 16, maxLines: 2), ), (isArabic ? widget.adDetails.vehicle!.cityNameN ?? "" : widget.adDetails.vehicle!.cityName ?? "").toText(fontSize: 10, color: MyColors.lightTextColor), ], @@ -126,7 +128,7 @@ class _AdsDetailViewState extends State { Row( children: [ ("${LocaleKeys.vehicleModel.tr()}: ").toText(fontSize: 12, color: MyColors.lightTextColor), - "${isArabic? widget.adDetails.vehicle!.model!.labelN :widget.adDetails.vehicle!.model!.label}".toText(fontSize: 12), + "${isArabic ? widget.adDetails.vehicle!.model!.labelN : widget.adDetails.vehicle!.model!.label}".toText(fontSize: 12), ], ), (isArabic ? widget.adDetails.vehicle!.countryNameN?? '' : widget.adDetails.vehicle!.countryName?? '').toText(fontSize: 10, color: MyColors.lightTextColor), @@ -138,7 +140,6 @@ class _AdsDetailViewState extends State { "${widget.adDetails.vehicle!.modelyear!.label}".toText(fontSize: 12), ], ), - Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -156,7 +157,7 @@ class _AdsDetailViewState extends State { Row( children: [ ("${LocaleKeys.transmission.tr()}: ").toText(fontSize: 12, color: MyColors.lightTextColor), - "${isArabic? widget.adDetails.vehicle!.transmission!.labelN :widget.adDetails.vehicle!.transmission!.label}".toText(fontSize: 12), + "${isArabic ? widget.adDetails.vehicle!.transmission!.labelN : widget.adDetails.vehicle!.transmission!.label}".toText(fontSize: 12), ], ), Row( @@ -461,7 +462,9 @@ class _AdsDetailViewState extends State { @override Widget build(BuildContext context) { Widget actionWidget = const SizedBox(); + // Show delete button ONLY for ads I created/own (not ads I reserved from others) if ((widget.adDetails.isMyAd ?? false) && + !(widget.adDetails.isReservedByMe ?? false) && (widget.adDetails.adPostStatus != AdPostStatus.reserved) && (widget.adDetails.adPostStatus != AdPostStatus.active) && (widget.adDetails.adPostStatus != AdPostStatus.pendingForPost)) { @@ -522,7 +525,8 @@ class _AdsDetailViewState extends State { 12.height, buildPersonalInformationCard(context: context), ], - if (widget.adDetails.isMyAd ?? false) ...[ + // Show special services and dates ONLY for ads I created/own (not ads I reserved from others) + if ((widget.adDetails.isMyAd ?? false) && !(widget.adDetails.isReservedByMe ?? false)) ...[ if (widget.adDetails.specialservice != null && widget.adDetails.specialservice!.isNotEmpty) ...[ 12.height, buildSpecialServicesContainer(), @@ -553,7 +557,8 @@ class _AdsDetailViewState extends State { ), 14.height, ], - widget.adDetails.isMyAd ?? false ? BuildAdDetailsActionButtonForMyAds(adDetailsModel: widget.adDetails) : BuildAdDetailsActionButtonForExploreAds(adDetailsModel: widget.adDetails), + // Show action buttons (mark as sold, etc.) ONLY for ads I created/own (not ads I reserved) + (widget.adDetails.isMyAd ?? false) ? BuildAdDetailsActionButtonForMyAds(adDetailsModel: widget.adDetails) : BuildAdDetailsActionButtonForExploreAds(adDetailsModel: widget.adDetails), ], ), ) @@ -583,12 +588,12 @@ class _FavoriteAdButtonState extends State with SingleTickerPr void initState() { super.initState(); isFavorite = widget.adDetails.isAdFavorite ?? false; - + _animationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); - + _scaleAnimation = Tween(begin: 1.0, end: 1.3).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); @@ -676,13 +681,13 @@ class ShareAdButton extends StatelessWidget { void _shareAd() { final vehicle = adDetails.vehicle!; - + // Build share message String shareMessage = ''; - + // Add header shareMessage += '${LocaleKeys.checkOutThisAd.tr()}\n\n'; - + // Vehicle details shareMessage += '${vehicle.vehicleTitle ?? ""}\n'; shareMessage += '${LocaleKeys.model.tr()}: ${vehicle.modelyear?.label ?? ""}\n'; @@ -692,10 +697,10 @@ class ShareAdButton extends StatelessWidget { shareMessage += '${LocaleKeys.location.tr()}: ${vehicle.cityName}\n'; } shareMessage += '\n'; - + // App download links shareMessage += '${LocaleKeys.downloadApp.tr()}\n'; - + if (Platform.isAndroid) { shareMessage += '${LocaleKeys.androidApp.tr()}: https://play.google.com/store/apps/details?id=com.mowater.app\n'; } else if (Platform.isIOS) { @@ -705,7 +710,7 @@ class ShareAdButton extends StatelessWidget { shareMessage += '${LocaleKeys.androidApp.tr()}: https://play.google.com/store/apps/details?id=com.mowater.app\n'; shareMessage += '${LocaleKeys.iosApp.tr()}: https://apps.apple.com/app/mowater/id123456789\n'; } - + // Share using share_plus Share.share(shareMessage); } diff --git a/lib/views/advertisement/ads_detail_view/components.dart b/lib/views/advertisement/ads_detail_view/components.dart index 6824f93..1d8cdf0 100644 --- a/lib/views/advertisement/ads_detail_view/components.dart +++ b/lib/views/advertisement/ads_detail_view/components.dart @@ -276,7 +276,6 @@ class BuildAdDetailsActionButtonForMyAds extends StatelessWidget { void onBookPhotographyServiceClicked(BuildContext context, {required AdDetailsModel adDetailsModel}) async { AdVM adVM = context.read(); - log("adDetailsModel.vehicle!.cityID: ${adDetailsModel.vehicle!.cityID}"); if (adVM.photoOfficeSelectedId.selectedId == -1) { adVM.getPhotographyServiceScheduleListByOffices( cityId: adDetailsModel.vehicle!.cityID ?? AppState().getUser.data!.userInfo!.cityId ?? 0, @@ -759,6 +758,9 @@ class BuildAdDetailsActionButtonForMyAds extends StatelessWidget { } Future buildCancelReservationBottomSheet(BuildContext context, {required AdDetailsModel adDetails}) { + // Capture parent context that will remain valid + final BuildContext parentContext = context; + return showModalBottomSheet( context: context, isScrollControlled: true, @@ -794,18 +796,21 @@ class BuildAdDetailsActionButtonForMyAds extends StatelessWidget { bool status = adVM.validateReservationCancelReason(); if (status) { Navigator.pop(context); + // Save reason before showing confirmation dialog + final String cancelReason = adVM.reservationCancelReason; return actionConfirmationBottomSheet( - context: context, + context: parentContext, // Use parent context title: LocaleKeys.doWantCancelReservation.tr().toText(fontSize: 28, isBold: true, letterSpacing: -1.44), - subtitle: LocaleKeys.yourAdVisibletoBuy.tr(), + subtitle: "yourAdVisibletoBuy.".tr(), actionButtonYes: Expanded( child: ShowFillButton( maxHeight: 55, title: LocaleKeys.yes.tr(), fontSize: 15, - onPressed: () { - Navigator.pop(context); - adVM.cancelMyAdReservation(context, adId: adDetails.id!, reason: ""); + onPressed: () async { + Navigator.pop(parentContext); // Pop confirmation + // Call with parent context that's still valid + await adVM.cancelMyAdReservation(parentContext, adId: adDetails.id!, reason: cancelReason); }, ), ), @@ -1040,12 +1045,16 @@ class BuildAdDetailsActionButtonForMyAds extends StatelessWidget { @override Widget build(BuildContext context) { + log("adDetailsModel.adPostStatus: ${adDetailsModel.adPostStatus}"); + switch (adDetailsModel.adPostStatus!) { case AdPostStatus.pendingForPayment: return pendingForPaymentAction(context, ad: adDetailsModel); case AdPostStatus.active: return markAsSoldAction(context); case AdPostStatus.reserved: + // adPostStatus=reserved is set in getMyReservedAds() + // adReserveStatus tells us the detailed status (reserved, timeOver, dealDone, etc.) AdVM adVM = context.watch(); if (adVM.state == ViewState.busy) { return const CircularProgressIndicator(); diff --git a/lib/views/advertisement/components/ads_list_widget.dart b/lib/views/advertisement/components/ads_list_widget.dart index 54eecab..441f9d6 100644 --- a/lib/views/advertisement/components/ads_list_widget.dart +++ b/lib/views/advertisement/components/ads_list_widget.dart @@ -174,8 +174,8 @@ class _AdCardState extends State { children: [ ClipRRect( child: SizedBox( - width: 100, - height: 100, + width: 80, + height: 80, child: hasMultipleImages ? PageView.builder( controller: _pageController, @@ -275,31 +275,21 @@ class _AdCardState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ - // if (isAdsFragment && adDetails.adPostStatus! == AdPostStatus.sold && adDetails.statuslabel != null && adDetails.statuslabel!.isNotEmpty) ...[ - // Utils.statusContainerChip(text: adDetails.statuslabel ?? "", chipColor: Utils.getChipColorByAdStatus(adDetails.adPostStatus!)), - // ] else if (isAdsFragment && !context.read().isExploreAdsTapped && adDetails.statuslabel != null && adDetails.statuslabel!.isNotEmpty) ...[ - // Utils.statusContainerChip(text: adDetails.statuslabel ?? "", chipColor: Utils.getChipColorByAdStatus(adDetails.adPostStatus!)), - // ], - if (isAdsFragment && - widget.adDetails.adPostStatus! == AdPostStatus.sold && - widget.adDetails.statuslabel != null && - widget.adDetails.statuslabel!.isNotEmpty) ...[ + // When viewing reserved ads filter, show ONLY reserve status badge + if (widget.adDetails.isReservedByMe == true && + widget.adDetails.adReserveStatus != null && + widget.adDetails.adReserveStatus != AdReserveStatus.defaultStatus && + context.read().isReservedFilterSelected) ...[ Utils.statusContainerChip( - text: isArabic - ? (widget.adDetails.statuslabelN ?? "") - : (widget.adDetails.statuslabel ?? ""), - chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!), - ), - ] else if (isAdsFragment && - !context.read().isExploreAdsTapped && - widget.adDetails.statuslabel != null && - widget.adDetails.statuslabel!.isNotEmpty) ...[ - Utils.statusContainerChip( - text: isArabic - ? (widget.adDetails.statuslabelN ?? "") - : (widget.adDetails.statuslabel ?? ""), - chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!), + text: Utils.getReserveStatusLabel(widget.adDetails.adReserveStatus!), + chipColor: Utils.getChipColorByReserveStatus(widget.adDetails.adReserveStatus!) ), + ] + // Otherwise, show regular ad status for MyAds + else if (isAdsFragment && widget.adDetails.adPostStatus! == AdPostStatus.sold && widget.adDetails.statuslabel != null && widget.adDetails.statuslabel!.isNotEmpty) ...[ + Utils.statusContainerChip(text: widget.adDetails.statuslabel ?? "", chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!)), + ] else if (isAdsFragment && !context.read().isExploreAdsTapped && widget.adDetails.statuslabel != null && widget.adDetails.statuslabel!.isNotEmpty) ...[ + Utils.statusContainerChip(text: widget.adDetails.statuslabel ?? "", chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!)), ], // Show Accident Free chip for explore ads when isAccidentFree is true if (isAdsFragment && context.read().isExploreAdsTapped && widget.adDetails.vehicle?.isAccidentFree == true) ...[ diff --git a/lib/views/advertisement/favourite_ads_view.dart b/lib/views/advertisement/favourite_ads_view.dart index fa8eee0..54a3968 100644 --- a/lib/views/advertisement/favourite_ads_view.dart +++ b/lib/views/advertisement/favourite_ads_view.dart @@ -10,6 +10,7 @@ import 'package:mc_common_app/views/advertisement/components/ads_list_widget.dar import 'package:mc_common_app/widgets/common_widgets/app_bar.dart'; import 'package:mc_common_app/widgets/common_widgets/filters_list.dart'; import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; +import 'package:mc_common_app/widgets/shimmer/home_shimmer_widgets.dart'; import 'package:provider/provider.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -36,12 +37,13 @@ class _FavouriteAdsViewState extends State { super.initState(); } - List getFilteredAds(AdVM adVM) { - if (selectedBrandId == 0) { - return adVM.favouriteAds; - } - return adVM.favouriteAds.where((ad) => ad.vehicle?.model?.vehicleBrandID == selectedBrandId).toList(); - } + // Removed client-side filtering - now using API filter + // List getFilteredAds(AdVM adVM) { + // if (selectedBrandId == 0) { + // return adVM.favouriteAds; + // } + // return adVM.favouriteAds.where((ad) => ad.vehicle?.model?.vehicleBrandID == selectedBrandId).toList(); + // } @override Widget build(BuildContext context) { @@ -58,7 +60,7 @@ class _FavouriteAdsViewState extends State { FiltersList( showImages: true, filterList: adVM.exploreAdsFilterOptions, - onFilterTapped: (index, selectedFilterId) { + onFilterTapped: (index, selectedFilterId) async { setState(() { selectedBrandId = selectedFilterId; }); @@ -67,6 +69,11 @@ class _FavouriteAdsViewState extends State { filter.isSelected = false; } adVM.exploreAdsFilterOptions[index].isSelected = true; + + // Call API with filter (0 = all brands) + await adVM.getFavouriteAds( + vehicleBrandId: selectedFilterId == 0 ? null : selectedFilterId, + ); }, needLeftPadding: false, ).paddingOnly(left: 21), @@ -75,11 +82,13 @@ class _FavouriteAdsViewState extends State { Expanded( child: RefreshIndicator( onRefresh: () async { - await adVM.getFavouriteAds(); + await adVM.getFavouriteAds( + vehicleBrandId: selectedBrandId == 0 ? null : selectedBrandId, + ); }, child: adVM.state == ViewState.busy - ? const Center(child: CircularProgressIndicator()) - : getFilteredAds(adVM).isEmpty + ? const AdsListShimmer(itemCount: 5) + : adVM.favouriteAds.isEmpty ? Center( child: Text( LocaleKeys.noFavouriteAds.tr(), @@ -90,7 +99,7 @@ class _FavouriteAdsViewState extends State { isAdsFragment: false, isDraftAds: false, shouldShowAdStatus: false, - adsList: getFilteredAds(adVM), + adsList: adVM.favouriteAds, // Use API-filtered list directly hasMoreData: false, ), ), diff --git a/lib/views/chat/chat_view.dart b/lib/views/chat/chat_view.dart index 78640d9..011305f 100644 --- a/lib/views/chat/chat_view.dart +++ b/lib/views/chat/chat_view.dart @@ -307,25 +307,34 @@ class _ChatViewState extends State { ], Expanded( flex: 1, - child: const Icon(Icons.send_rounded, color: MyColors.darkPrimaryColor, size: 30).onPress( - () async { - ChatMessageTypeEnum chatMessageTypeEnum = ChatMessageTypeEnum.freeText; + child: chatVM.isSendingMessage + ? const SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(MyColors.darkPrimaryColor), + ), + ) + : const Icon(Icons.send_rounded, color: MyColors.darkPrimaryColor, size: 30).onPress( + () async { + ChatMessageTypeEnum chatMessageTypeEnum = ChatMessageTypeEnum.freeText; - if (chatVM.pickedImagesForMessage.isNotEmpty) { - chatMessageTypeEnum = ChatMessageTypeEnum.image; - } - final status = await onMessageSend(chatMessageType: chatMessageTypeEnum); + if (chatVM.pickedImagesForMessage.isNotEmpty) { + chatMessageTypeEnum = ChatMessageTypeEnum.image; + } + final status = await onMessageSend(chatMessageType: chatMessageTypeEnum); - if (status) { - chatVM.scrollChatDown(); - if (chatMessageTypeEnum == ChatMessageTypeEnum.freeText) { - chatVM.clearChatMessageText(); - } else if (chatMessageTypeEnum == ChatMessageTypeEnum.image) { - chatVM.clearPickedImagesForMessage(); - } - } - }, - ), + if (status) { + chatVM.scrollChatDown(); + if (chatMessageTypeEnum == ChatMessageTypeEnum.freeText) { + chatVM.clearChatMessageText(); + } else if (chatMessageTypeEnum == ChatMessageTypeEnum.image) { + chatVM.clearPickedImagesForMessage(); + } + } + }, + ), ), ], ], diff --git a/lib/views/chat/widgets/chat_message_widget.dart b/lib/views/chat/widgets/chat_message_widget.dart index 3d5495c..c2c7ca4 100644 --- a/lib/views/chat/widgets/chat_message_widget.dart +++ b/lib/views/chat/widgets/chat_message_widget.dart @@ -780,19 +780,36 @@ class _ChatMessageCustomWidgetState extends State { Widget buildFreeTextDetailsInMessage({required ChatMessageTypeEnum chatMessageTypeEnum}) { return Row( children: [ - if (chatMessageTypeEnum == ChatMessageTypeEnum.offer && (widget.chatMessageModel.isMyMessage == true) && (widget.chatMessageModel.isRead ?? false)) ...[ - Row( - children: [ - const Icon(Icons.remove_red_eye_outlined, size: 12, color: MyColors.lightTextColor), - 4.width, - LocaleKeys.viewed.tr().toText(fontSize: 10, color: MyColors.lightTextColor, fontWeight: MyFonts.Medium), - ], - ), - ] else if (AppState().currentAppType == AppType.provider && + // For Provider: Show status and edit button + if (AppState().currentAppType == AppType.provider && chatMessageTypeEnum == ChatMessageTypeEnum.offer && - widget.chatMessageModel.reqOffer!.requestOfferStatusEnum == RequestOfferStatusEnum.offer && - (widget.chatMessageModel.isMyMessage == true && widget.chatMessageModel.reqOffer != null && widget.chatMessageModel.reqOffer!.id != null)) ...[ - MyAssets.icEdit.buildSvg(color: MyColors.white, height: 15).onPress(() => onOfferEditIconPressed()), + widget.chatMessageModel.isMyMessage == true && + widget.chatMessageModel.reqOffer != null && + widget.chatMessageModel.reqOffer!.requestOfferStatusEnum == RequestOfferStatusEnum.offer) ...[ + // Show read status if message is read + if (widget.chatMessageModel.isRead ?? false) ...[ + Row( + children: [ + const Icon(Icons.done_all, size: 14, color: Colors.blue), + 4.width, + LocaleKeys.read.tr().toText(fontSize: 10, color: Colors.blue, fontWeight: MyFonts.Medium), + 8.width, + ], + ), + ] + // Show edit button if message is NOT read + else ...[ + Row( + children: [ + const Icon(Icons.check, size: 14, color: MyColors.lightTextColor), + 4.width, + LocaleKeys.sent.tr().toText(fontSize: 10, color: MyColors.lightTextColor, fontWeight: MyFonts.Medium), + 8.width, + MyAssets.icEdit.buildSvg(color: MyColors.white, height: 15).onPress(() => onOfferEditIconPressed()), + 8.width, + ], + ), + ], ], Expanded( child: Directionality( diff --git a/lib/views/common_fragments/ads_fragment.dart b/lib/views/common_fragments/ads_fragment.dart index 6e01f16..b2a00c3 100644 --- a/lib/views/common_fragments/ads_fragment.dart +++ b/lib/views/common_fragments/ads_fragment.dart @@ -46,158 +46,218 @@ class AdsFragment extends StatelessWidget { isRemoveBackButton: true, actions: [ Padding( - padding: EdgeInsets.only(top: adVM.adsFiltersCounter > 0 ? 20 : 0, right: 21), + padding: EdgeInsets.only( + top: adVM.adsFiltersCounter > 0 ? 20 : 0, right: 21), child: Badge( isLabelVisible: adVM.adsFiltersCounter > 0, largeSize: 15, smallSize: 20, backgroundColor: MyColors.darkPrimaryColor, label: Text('${adVM.adsFiltersCounter}'), - child: MyAssets.searchIcon.buildSvg(), + child: MyAssets.searchIcon.buildSvg(height: 22, width: 22), ), ).onPress(() { navigateWithName(context, AppRoutes.adsFilterView); }) ], ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, + arguments: false); }), - body: SizedBox( - width: double.infinity, - height: double.infinity, - child: Column( - children: [ - 16.height, - Consumer( - builder: (BuildContext context, AdVM adVM, Widget? child) { - return Column( - children: [ - Row( + body: Stack( + children: [ + SizedBox( + width: double.infinity, + height: double.infinity, + child: Column( + children: [ + 16.height, + Consumer( + builder: + (BuildContext context, AdVM adVM, Widget? child) { + return Column( children: [ - Expanded( - child: ShowFillButton( - isFilled: adVM.isExploreAdsTapped, - maxHeight: 55, - fontSize: 15, - title: LocaleKeys.exploreAds.tr(), - txtColor: adVM.isExploreAdsTapped ? MyColors.white : MyColors.darkTextColor, - onPressed: () { - log("accessToken: ${AppState().getUser.data!.accessToken}"); - log("AppState().getUser.data!.userInfo!.userId;: ${AppState().getUser.data!.userInfo!.userId}"); + Row( + children: [ + Expanded( + child: ShowFillButton( + isFilled: adVM.isExploreAdsTapped, + maxHeight: 55, + fontSize: 15, + title: LocaleKeys.exploreAds.tr(), + txtColor: adVM.isExploreAdsTapped + ? MyColors.white + : MyColors.darkTextColor, + onPressed: () { + log("accessToken: ${AppState().getUser.data!.accessToken}"); + log("AppState().getUser.data!.userInfo!.userId;: ${AppState().getUser.data!.userInfo!.userId}"); - if (adVM.myAds.isEmpty) { - adVM.getMyAds(); + if (adVM.myAds.isEmpty) { + adVM.getMyAds(); + } + adVM.updateIsExploreAds(true); + }, + ), + ), + 12.width, + Expanded( + child: ShowFillButton( + isFilled: !adVM.isExploreAdsTapped, + txtColor: !adVM.isExploreAdsTapped + ? MyColors.white + : MyColors.darkTextColor, + maxHeight: 55, + fontSize: 15, + title: LocaleKeys.myAds.tr(), + onPressed: () { + adVM.updateIsExploreAds(false); + }, + ).toViewOnly(context, onTap: () { + navigateWithName( + context, AppRoutes.loginWithPassword, + arguments: false); + }), + ), + ], + ).horPaddingMain(), + if (adVM.isExploreAdsTapped) ...[ + if (adVM.adsFiltersCounter == 0) ...[ + 16.height, + FiltersList( + showImages: true, + filterList: adVM.exploreAdsFilterOptions, + onFilterTapped: (index, selectedFilterId) { + adVM.applyFilterOnExploreAds( + vehicleBrandId: selectedFilterId); + }, + needLeftPadding: false, + ).paddingOnly(left: 21), + ] + ] else ...[ + 16.height, + FiltersList( + filterList: adVM.myAdsFilterOptions, + onFilterTapped: (index, selectedFilterId) { + adVM.applyFilterOnMyAds( + adPostStatusEnum: + selectedFilterId.toAdPostEnum()); + }, + needLeftPadding: false, + ).paddingOnly(left: 21), + ], + ], + ); + }, + ), + 16.height, + Expanded( + child: RefreshIndicator( + onRefresh: () async { + if (adVM.isExploreAdsTapped) { + int vehicleBrandId = adVM.exploreAdsFilterOptions + .firstWhere((element) => element.isSelected) + .id; + adVM.applyFilterOnExploreAds( + vehicleBrandId: vehicleBrandId); + } else { + AdPostStatus adPostStatusEnum = adVM + .myAdsFilterOptions + .firstWhere((element) => element.isSelected) + .id + .toAdPostEnum(); + adVM.applyFilterOnMyAds( + adPostStatusEnum: adPostStatusEnum); + } + }, + child: adVM.state == ViewState.busy + ? const AdsListShimmer(itemCount: 5) + : AdsListWidget( + isAdsFragment: true, + isDraftAds: false, + shouldShowAdStatus: !adVM.isExploreAdsTapped, + adsList: getAdsList(adVM), + hasMoreData: adVM.isExploreAdsTapped + ? adVM.hasMoreDataForExploreAds + : adVM.hasMoreDataForMyAds, + onFetchMoreAds: () async { + AdPostStatus adPostStatusEnum = adVM + .myAdsFilterOptions + .firstWhere( + (element) => element.isSelected) + .id + .toAdPostEnum(); + + if (adVM.isExploreAdsTapped) { + await adVM.fetchMoreExploreAds(); + } else { + await adVM.fetchMoreMyAds( + adsStatus: adPostStatusEnum); } - adVM.updateIsExploreAds(true); }, ), - ), - 12.width, - Expanded( - child: ShowFillButton( - isFilled: !adVM.isExploreAdsTapped, - txtColor: !adVM.isExploreAdsTapped ? MyColors.white : MyColors.darkTextColor, - maxHeight: 55, - fontSize: 15, - title: LocaleKeys.myAds.tr(), - onPressed: () { - adVM.updateIsExploreAds(false); - }, - ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); - }), - ), - ], - ).horPaddingMain(), - if (adVM.isExploreAdsTapped) ...[ - if (adVM.adsFiltersCounter == 0) ...[ - 16.height, - FiltersList( - showImages: true, - filterList: adVM.exploreAdsFilterOptions, - onFilterTapped: (index, selectedFilterId) { - adVM.applyFilterOnExploreAds(vehicleBrandId: selectedFilterId); - }, - needLeftPadding: false, - ).paddingOnly(left: 21), - ] - ] else ...[ - 16.height, - FiltersList( - filterList: adVM.myAdsFilterOptions, - onFilterTapped: (index, selectedFilterId) { - adVM.applyFilterOnMyAds(adPostStatusEnum: selectedFilterId.toAdPostEnum()); - }, - needLeftPadding: false, - ).paddingOnly(left: 21), - ], - ], - ); - }, + ), + ), + if (adVM.isLoadingMore) ...[ + const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + )) + ] + ], ), - 16.height, - Expanded( - child: RefreshIndicator( - onRefresh: () async { - if (adVM.isExploreAdsTapped) { - int vehicleBrandId = adVM.exploreAdsFilterOptions.firstWhere((element) => element.isSelected).id; - adVM.applyFilterOnExploreAds(vehicleBrandId: vehicleBrandId); - } else { - AdPostStatus adPostStatusEnum = adVM.myAdsFilterOptions.firstWhere((element) => element.isSelected).id.toAdPostEnum(); - adVM.applyFilterOnMyAds(adPostStatusEnum: adPostStatusEnum); - } - }, - child: adVM.state == ViewState.busy - ? const AdsListShimmer(itemCount: 5) - : AdsListWidget( - isAdsFragment: true, - isDraftAds: false, - shouldShowAdStatus: !adVM.isExploreAdsTapped, - adsList: getAdsList(adVM), - hasMoreData: adVM.isExploreAdsTapped ? adVM.hasMoreDataForExploreAds : adVM.hasMoreDataForMyAds, - onFetchMoreAds: () async { - AdPostStatus adPostStatusEnum = adVM.myAdsFilterOptions.firstWhere((element) => element.isSelected).id.toAdPostEnum(); - - if (adVM.isExploreAdsTapped) { - await adVM.fetchMoreExploreAds(); - } else { - await adVM.fetchMoreMyAds(adsStatus: adPostStatusEnum); - } - }, - ), + ), + // Loading overlay when FAB is clicked and API is fetching + if (adVM.isLoadingAdsDuration) + Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + child: const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + MyColors.darkPrimaryColor), + ), ), ), - if (adVM.isLoadingMore) ...[ - const Center( - child: Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator(), - )) - ] - ], - ), + ], ), floatingActionButton: FloatingActionButton( onPressed: () async { - adVM.getVehicleAdsDuration(); + // ✅ Await API calls with loading state + await adVM.getVehicleAdsDuration(); + + // ✅ Ensure vehicle types are loaded + if (adVM.vehicleTypes.isEmpty) { + await adVM.getVehicleTypes(); + } + if (AppState().userType != UserType.providerDealer) { - if (adVM.vehicleAdDurationId.selectedId == -1 && adVM.vehicleAdsDurations.isNotEmpty) { + if (adVM.vehicleAdDurationId.selectedId == -1 && + adVM.vehicleAdsDurations.isNotEmpty) { SelectionModel selection = SelectionModel( selectedId: adVM.vehicleAdsDurations.first.id ?? 0, - selectedOption: ("${adVM.vehicleAdsDurations.first.days} ${LocaleKeys.daysVar.tr()}"), + selectedOption: + ("${adVM.vehicleAdsDurations.first.days} ${LocaleKeys.daysVar.tr()}"), itemPrice: adVM.vehicleAdsDurations.first.price!.toString(), - currency: adVM.vehicleAdsDurations.first.currency!.toString(), + currency: + adVM.vehicleAdsDurations.first.currency!.toString(), ); adVM.updateVehicleAdDurationId(selection); } } - navigateWithName(context, AppRoutes.selectAdTypeView, arguments: [injector.get().currentAppType == AppType.provider, false, -1]); + navigateWithName(context, AppRoutes.selectAdTypeView, arguments: [ + injector.get().currentAppType == AppType.provider, + false, + -1 + ]); }, backgroundColor: MyColors.darkPrimaryColor, child: const Icon(Icons.add, color: MyColors.white), ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, + arguments: false); }), ); }, diff --git a/lib/views/common_fragments/requests_fragment.dart b/lib/views/common_fragments/requests_fragment.dart index 823bb2d..8000122 100644 --- a/lib/views/common_fragments/requests_fragment.dart +++ b/lib/views/common_fragments/requests_fragment.dart @@ -29,7 +29,9 @@ class MyRequestsFragment extends StatelessWidget { void cancelRequestBottomSheet(BuildContext context, int requestId) { return actionConfirmationBottomSheet( context: context, - title: LocaleKeys.cancelRequestPrompt.tr().toText(fontSize: 28, isBold: true, letterSpacing: -1.44), + title: LocaleKeys.cancelRequestPrompt + .tr() + .toText(fontSize: 28, isBold: true, letterSpacing: -1.44), subtitle: LocaleKeys.requestPermanentlyCancelled.tr(), actionButtonYes: Expanded( child: ShowFillButton( @@ -41,7 +43,10 @@ class MyRequestsFragment extends StatelessWidget { final requestVM = context.read(); - bool status = await requestVM.onActionRequestTapped(context: context, requestStatusEnum: RequestStatusEnum.cancelled, requestId: requestId); + bool status = await requestVM.onActionRequestTapped( + context: context, + requestStatusEnum: RequestStatusEnum.cancelled, + requestId: requestId); if (status) { await requestVM.getRequests(isNeedToRebuild: true); } @@ -69,7 +74,8 @@ class MyRequestsFragment extends StatelessWidget { return false; } - if (requestStatusEnum == RequestStatusEnum.pending || requestStatusEnum == RequestStatusEnum.submitted) { + if (requestStatusEnum == RequestStatusEnum.pending || + requestStatusEnum == RequestStatusEnum.submitted) { return true; } return false; @@ -77,7 +83,8 @@ class MyRequestsFragment extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer(builder: (BuildContext context, RequestsVM requestsVM, Widget? child) { + return Consumer( + builder: (BuildContext context, RequestsVM requestsVM, Widget? child) { return Scaffold( appBar: CustomAppBar( title: LocaleKeys.manageRequests.tr(), @@ -86,7 +93,9 @@ class MyRequestsFragment extends StatelessWidget { if (AppState().currentAppType == AppType.provider) ...[ Padding( padding: const EdgeInsets.only(top: 8, bottom: 8, right: 15), - child: const Icon(Icons.messenger_outline_rounded, color: Colors.black, size: 18).toContainer( + child: const Icon(Icons.messenger_outline_rounded, + color: Colors.black, size: 18) + .toContainer( borderRadius: 80, borderColor: MyColors.lightGreyEFColor, isEnabledBorder: true, @@ -96,21 +105,24 @@ class MyRequestsFragment extends StatelessWidget { }) ], Padding( - padding: EdgeInsets.only(top: requestsVM.requestsFiltersCounter > 0 ? 20 : 0, right: 21), + padding: EdgeInsets.only( + top: requestsVM.requestsFiltersCounter > 0 ? 20 : 0, + right: 21), child: Badge( isLabelVisible: requestsVM.requestsFiltersCounter > 0, largeSize: 15, smallSize: 20, backgroundColor: MyColors.darkPrimaryColor, label: Text('${requestsVM.requestsFiltersCounter}'), - child: MyAssets.searchIcon.buildSvg(), + child: MyAssets.searchIcon.buildSvg(height: 22, width: 22), ), ).onPress(() { navigateWithName(context, AppRoutes.requestsFilterView); }), ], ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, + arguments: false); }), body: Container( color: MyColors.backgroundColor, @@ -122,7 +134,8 @@ class MyRequestsFragment extends StatelessWidget { FiltersList( filterList: requestsVM.requestsTypeFilterOptions, onFilterTapped: (index, selectedFilterId) { - requestsVM.applyFilterOnRequestsVM(requestsTypeEnum: selectedFilterId.toRequestTypeEnum()); + requestsVM.applyFilterOnRequestsVM( + requestsTypeEnum: selectedFilterId.toRequestTypeEnum()); }, ), // Show condition filters for all request types @@ -131,27 +144,31 @@ class MyRequestsFragment extends StatelessWidget { FiltersList( filterList: requestsVM.sparePartsConditionFilterOptions, onFilterTapped: (index, selectedFilterId) { - requestsVM.applySparePartsConditionFilter(conditionId: selectedFilterId); + requestsVM.applySparePartsConditionFilter( + conditionId: selectedFilterId); }, ), ], 8.height, Expanded( child: RefreshIndicator( - onRefresh: () async => await requestsVM.getRequestsBasedOnFilters(), + onRefresh: () async => + await requestsVM.getRequestsBasedOnFilters(), child: requestsVM.state == ViewState.busy ? const RequestsListShimmer(itemCount: 5) : requestsVM.myFilteredRequests.isEmpty ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - LocaleKeys.noRequeststoShow.tr().toText(fontSize: 16, color: MyColors.lightTextColor), + LocaleKeys.noRequeststoShow.tr().toText( + fontSize: 16, color: MyColors.lightTextColor), if (requestsVM.requestsFiltersCounter > 0) ...[ 8.height, InkWell( onTap: () async { requestsVM.clearRequestsFilters(); - await requestsVM.getRequestsBasedOnFilters(); + await requestsVM + .getRequestsBasedOnFilters(); }, child: LocaleKeys.clearFilters.tr().toText( fontSize: 14, @@ -170,43 +187,76 @@ class MyRequestsFragment extends StatelessWidget { secondaryBackground: Container( color: const Color(0xFFDD0505), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end, children: [ - LocaleKeys.cancel.tr().toText(fontSize: 15, fontWeight: MyFonts.Medium, height: 23 / 25, color: MyColors.white).margin(right: 23), + LocaleKeys.cancel + .tr() + .toText( + fontSize: 15, + fontWeight: MyFonts.Medium, + height: 23 / 25, + color: MyColors.white) + .margin(right: 23), ], ), ), dragStartBehavior: DragStartBehavior.start, - direction: shouldDismiss(requestsVM.myFilteredRequests[index].requestStatus) ? DismissDirection.endToStart : DismissDirection.none, - confirmDismiss: (DismissDirection direction) async { - cancelRequestBottomSheet(context, requestsVM.myFilteredRequests[index].id); + direction: shouldDismiss(requestsVM + .myFilteredRequests[index] + .requestStatus) + ? DismissDirection.endToStart + : DismissDirection.none, + confirmDismiss: + (DismissDirection direction) async { + cancelRequestBottomSheet(context, + requestsVM.myFilteredRequests[index].id); return; }, child: RequestItem( - request: requestsVM.myFilteredRequests[index], + request: + requestsVM.myFilteredRequests[index], shouldShowStatuses: true, onTap: () async { - RequestModel request = requestsVM.myFilteredRequests[index]; - requestsVM.updateCurrentSelectedRequest(request); + RequestModel request = + requestsVM.myFilteredRequests[index]; + requestsVM.updateCurrentSelectedRequest( + request); log("request.requestStatus: ${request.requestStatus}"); - if (request.requestStatus == RequestStatusEnum.pending || - request.requestStatus == RequestStatusEnum.cancelled || - request.requestStatus == RequestStatusEnum.expired) { + if (request.requestStatus == + RequestStatusEnum.pending || + request.requestStatus == + RequestStatusEnum.cancelled || + request.requestStatus == + RequestStatusEnum.expired) { return; } - if (AppState().currentAppType == AppType.provider) { - RequestDetailPageArguments requestDetailPageArguments = RequestDetailPageArguments(requestIndex: index, requestModel: request); - navigateWithName(context, AppRoutes.requestsDetailPage, arguments: requestDetailPageArguments); + if (AppState().currentAppType == + AppType.provider) { + RequestDetailPageArguments + requestDetailPageArguments = + RequestDetailPageArguments( + requestIndex: index, + requestModel: request); + navigateWithName(context, + AppRoutes.requestsDetailPage, + arguments: + requestDetailPageArguments); } else { - requestsVM.myFilteredRequests[index].offerCount = 0; + requestsVM.myFilteredRequests[index] + .offerCount = 0; requestsVM.notifyListeners(); - navigateWithName(context, AppRoutes.offersListPage, arguments: request.id); + navigateWithName( + context, AppRoutes.offersListPage, + arguments: request.id); } }).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); + navigateWithName( + context, AppRoutes.loginWithPassword, + arguments: false); }), ); }, @@ -214,7 +264,8 @@ class MyRequestsFragment extends StatelessWidget { return 16.height; }, itemCount: requestsVM.myFilteredRequests.length, - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16, top: 8), + padding: const EdgeInsets.only( + left: 16, right: 16, bottom: 16, top: 8), ), )) ], @@ -222,14 +273,16 @@ class MyRequestsFragment extends StatelessWidget { ), floatingActionButton: AppState().currentAppType == AppType.customer ? FloatingActionButton( - onPressed: () => navigateWithName(context, AppRoutes.createRequestPage), + onPressed: () => + navigateWithName(context, AppRoutes.createRequestPage), backgroundColor: MyColors.darkPrimaryColor, child: const Icon( Icons.add, color: MyColors.white, ), ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, + arguments: false); }) : null, ); diff --git a/lib/views/requests/request_detail_page.dart b/lib/views/requests/request_detail_page.dart index 78671be..3e596d4 100644 --- a/lib/views/requests/request_detail_page.dart +++ b/lib/views/requests/request_detail_page.dart @@ -312,7 +312,7 @@ class RequestDetailPage extends StatelessWidget { appBar: CustomAppBar( title: LocaleKeys.requestDetail.tr(), actions: [ - (context.read().isOfferSent || requestDetailPageArguments.requestModel.isChatted) + requestDetailPageArguments.requestModel.isChatted ? Padding( padding: const EdgeInsets.only(top: 8, bottom: 8, right: 21), child: const Icon(Icons.messenger_outline_rounded, color: Colors.black, size: 18).toContainer( @@ -341,7 +341,7 @@ class RequestDetailPage extends StatelessWidget { allPading: 12, margin: const EdgeInsets.only(top: 21, right: 21, left: 21), ), - if (!context.read().isOfferSent && !requestDetailPageArguments.requestModel.isChatted) ...[ + if (!requestDetailPageArguments.requestModel.isChatted) ...[ ShowFillButton( maxWidth: double.infinity, margin: const EdgeInsets.all(15), diff --git a/lib/views/shipping_management/shipping_management_view.dart b/lib/views/shipping_management/shipping_management_view.dart index 001017b..f0a803b 100644 --- a/lib/views/shipping_management/shipping_management_view.dart +++ b/lib/views/shipping_management/shipping_management_view.dart @@ -121,6 +121,17 @@ class _ShippingManagementViewState extends State { }, ), 12.height, + // ✅ Show numberOfShipping field only when updating from pending to initiated (first update) + if (shippingRequestStatusEnum == ShippingRequestStatusEnum.pending) ...[ + TxtField( + value: shippingManagementVM.numberOfShipping, + errorValue: "", + keyboardType: TextInputType.text, + hint: LocaleKeys.numberOfShipping.tr(), + onChanged: (v) => shippingManagementVM.updateNumberOfShipping(v), + ), + 12.height, + ], TxtField( maxLines: 4, value: shippingManagementVM.requestStatusComments, @@ -202,6 +213,17 @@ class _ShippingManagementViewState extends State { }, ), 12.height, + // ✅ Show numberOfShipping field only when updating from pending to preparingToCollect (first update) + if (selfPickupRequestStatusEnum == SelfPickupRequestStatusEnum.pending) ...[ + TxtField( + value: shippingManagementVM.numberOfShipping, + errorValue: "", + keyboardType: TextInputType.text, + hint: LocaleKeys.numberOfShipping.tr(), + onChanged: (v) => shippingManagementVM.updateNumberOfShipping(v), + ), + 12.height, + ], TxtField( maxLines: 4, value: shippingManagementVM.requestStatusComments, @@ -352,6 +374,10 @@ class _ShippingManagementViewState extends State { if (shippingRequest.vehicleType != null && shippingRequest.vehicleType!.isNotEmpty) ...[ ("${LocaleKeys.itemType.tr()}: " "${shippingRequest.vehicleType}" ?? "").toText(color: MyColors.lightTextColor, fontSize: 12, fontWeight: MyFonts.Medium), ], + // ✅ Display numberOfShipping when not null + if (shippingRequest.numberOfShipping != null && shippingRequest.numberOfShipping!.isNotEmpty) ...[ + ("${LocaleKeys.numberOfShipping.tr()}: ${shippingRequest.numberOfShipping}").toText(color: MyColors.lightTextColor, fontSize: 12, fontWeight: MyFonts.Medium), + ], if (shippingRequest.comment != null && shippingRequest.comment!.isNotEmpty) ...[ ("${LocaleKeys.comment.tr()}: ${shippingRequest.comment ?? ""}").toText(color: MyColors.lightTextColor, fontSize: 12, fontWeight: MyFonts.Medium), ], diff --git a/lib/views/splash/splash_page.dart b/lib/views/splash/splash_page.dart index 3208edf..6f83629 100644 --- a/lib/views/splash/splash_page.dart +++ b/lib/views/splash/splash_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:mc_common_app/classes/consts.dart'; import 'package:mc_common_app/config/routes.dart'; import 'package:mc_common_app/utils/navigator.dart'; @@ -6,6 +7,8 @@ import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/ad_view_model.dart'; import 'package:mc_common_app/view_models/chat_view_model.dart'; import 'package:provider/provider.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'dart:typed_data'; class SplashPage extends StatefulWidget { const SplashPage({super.key}); @@ -16,14 +19,29 @@ class SplashPage extends StatefulWidget { class SplashPageState extends State with WidgetsBindingObserver { bool showFlashingLights = false; + final AudioPlayer _audioPlayer = AudioPlayer(); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _playEngineSound(); startSplashTimer(); } + Future _playEngineSound() async { + try { + // Load audio from package assets using rootBundle (same pattern as Image.asset) + // MyAssets.engineStartSound = "packages/mc_common_app/assets/tones/engine_start_tone.mp3" + final ByteData data = await rootBundle.load(MyAssets.engineStartSound); + final Uint8List bytes = data.buffer.asUint8List(); + await _audioPlayer.play(BytesSource(bytes)); + } catch (e) { + // Ignore errors if sound fails to play + debugPrint('Error playing engine sound: $e'); + } + } + void startSplashTimer() { // After 3 seconds, show the flashing lights gif Future.delayed(const Duration(seconds: 3), () { @@ -45,6 +63,8 @@ class SplashPageState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _audioPlayer.stop(); + _audioPlayer.dispose(); super.dispose(); } @@ -64,8 +84,12 @@ class SplashPageState extends State with WidgetsBindingObserver { // }); // } - navigateToNextDeciderPage() { - navigateReplaceWithName(context, AppRoutes.registerSelection); + navigateToNextDeciderPage() async { + // Stop the audio before navigating + await _audioPlayer.stop(); + if (mounted) { + navigateReplaceWithName(context, AppRoutes.registerSelection); + } } @override diff --git a/lib/views/user/change_password_page.dart b/lib/views/user/change_password_page.dart index 7704885..f59debb 100644 --- a/lib/views/user/change_password_page.dart +++ b/lib/views/user/change_password_page.dart @@ -47,11 +47,13 @@ class _ChangePasswordPageState extends State { // 12.height, TxtField( hint: LocaleKeys.enterOldPassword.tr(), + isPasswordEnabled: true, onChanged: (v) => currentPassword = v, ), 12.height, TxtField( hint: LocaleKeys.enterNewPassword.tr(), + isPasswordEnabled: true, onChanged: (v) => newPassword = v, ), 20.height, diff --git a/lib/views/user/confirm_new_password_page.dart b/lib/views/user/confirm_new_password_page.dart index 94032a6..0ddf424 100644 --- a/lib/views/user/confirm_new_password_page.dart +++ b/lib/views/user/confirm_new_password_page.dart @@ -21,6 +21,8 @@ class ConfirmNewPasswordPage extends StatefulWidget { class _ConfirmNewPasswordPageState extends State { String newPassword = ""; String confirmPassword = ''; + bool _obscureNewPassword = true; + bool _obscureConfirmPassword = true; @override Widget build(BuildContext context) { @@ -42,7 +44,6 @@ class _ConfirmNewPasswordPageState extends State { 12.height, TextFormField( decoration: InputDecoration( - // hintText: "Enter New Password", hintText: LocaleKeys.enterNewPassword.tr(), hintStyle: const TextStyle(color: Colors.grey), border: const OutlineInputBorder( @@ -50,14 +51,24 @@ class _ConfirmNewPasswordPageState extends State { Radius.circular(5.0), ), ), + suffixIcon: IconButton( + icon: Icon( + _obscureNewPassword ? Icons.visibility_off : Icons.visibility, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscureNewPassword = !_obscureNewPassword; + }); + }, + ), ), - obscureText: true, + obscureText: _obscureNewPassword, onChanged: (v) => newPassword = v, ), 12.height, TextFormField( decoration: InputDecoration( - // hintText: "Confirm Password", hintText: LocaleKeys.confirmPassword.tr(), hintStyle: const TextStyle(color: Colors.grey), border: const OutlineInputBorder( @@ -65,8 +76,19 @@ class _ConfirmNewPasswordPageState extends State { Radius.circular(5.0), ), ), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirmPassword ? Icons.visibility_off : Icons.visibility, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + ), ), - obscureText: true, + obscureText: _obscureConfirmPassword, onChanged: (v) => confirmPassword = v, ), 40.height, diff --git a/lib/widgets/common_widgets/filters_list.dart b/lib/widgets/common_widgets/filters_list.dart index 6eb0ac9..d7e4b64 100644 --- a/lib/widgets/common_widgets/filters_list.dart +++ b/lib/widgets/common_widgets/filters_list.dart @@ -36,8 +36,6 @@ class FiltersList extends StatelessWidget { itemCount: filterList.length, scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) { - print("=================================================="); - print(filterList[index].title); return InkWell( onTap: () { onFilterTapped(index, filterList[index].id); diff --git a/lib/widgets/txt_field.dart b/lib/widgets/txt_field.dart index eddbc98..253a4e0 100644 --- a/lib/widgets/txt_field.dart +++ b/lib/widgets/txt_field.dart @@ -8,7 +8,7 @@ import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; import 'package:sizer/sizer.dart'; -class TxtField extends StatelessWidget { +class TxtField extends StatefulWidget { String? value; String? hint; String? lable; @@ -70,20 +70,26 @@ class TxtField extends StatelessWidget { this.isNeedLabelOnTop = true, }); + @override + State createState() => _TxtFieldState(); +} + +class _TxtFieldState extends State { TextEditingController controller = TextEditingController(); + bool _obscurePassword = true; @override Widget build(BuildContext context) { - controller.text = value ?? ""; + controller.text = widget.value ?? ""; controller.selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length)); return Column( children: [ - if (isNeedLabelOnTop && hint != null) ...[ + if (widget.isNeedLabelOnTop && widget.hint != null) ...[ Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - (hint ?? "").toText( + (widget.hint ?? "").toText( color: borderColor, fontSize: 13, fontWeight: MyFonts.Medium, @@ -93,10 +99,10 @@ class TxtField extends StatelessWidget { 4.height, ], InkWell( - onTap: isNeedClickAll == false + onTap: widget.isNeedClickAll == false ? null : () { - onTap!(); + widget.onTap!(); }, customBorder: Utils.inkWellCorner(), child: Row( @@ -104,70 +110,80 @@ class TxtField extends StatelessWidget { Expanded( child: Container( decoration: BoxDecoration( - color: isBackgroundEnabled ? MyColors.textFieldColor : Colors.white, + color: widget.isBackgroundEnabled ? MyColors.textFieldColor : Colors.white, borderRadius: const BorderRadius.all(Radius.circular(0)), ), child: TextField( style: const TextStyle(color: MyColors.darkTextColor, fontSize: 15, fontWeight: MyFonts.Medium), - textInputAction: isSearchBar ? TextInputAction.search : null, - keyboardType: keyboardType, + textInputAction: widget.isSearchBar ? TextInputAction.search : null, + keyboardType: widget.keyboardType, autofocus: false, controller: controller, - enabled: isNeedClickAll == true ? false : true, - maxLines: maxLines, + enabled: widget.isNeedClickAll == true ? false : true, + maxLines: widget.maxLines, onTap: () {}, - obscureText: isPasswordEnabled ?? false, - onChanged: onChanged, - onSubmitted: onSubmitted, - inputFormatters: allowOnlyOneDigit && numbersOnly + obscureText: widget.isPasswordEnabled == true ? _obscurePassword : false, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + inputFormatters: widget.allowOnlyOneDigit && widget.numbersOnly ? [FilteringTextInputFormatter.allow(RegExp('[0-9]')), LengthLimitingTextInputFormatter(1)] - : numbersOnly + : widget.numbersOnly ? [FilteringTextInputFormatter.allow(RegExp('[0-9]'))] : [], decoration: InputDecoration( - labelText: lable, + labelText: widget.lable, alignLabelWithHint: true, fillColor: Colors.white, focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: isNeedBorder ? MyColors.darkPrimaryColor : Colors.transparent, width: isNeedBorder ? 2.0 : 0), + borderSide: BorderSide(color: widget.isNeedBorder ? MyColors.darkPrimaryColor : Colors.transparent, width: widget.isNeedBorder ? 2.0 : 0), borderRadius: BorderRadius.circular(0.0), ), enabledBorder: OutlineInputBorder( - // borderSide: BorderSide(color: MyColors.textFieldColor, width: isNeedBorder ? 1.0 : 0), - borderSide: BorderSide(color: isNeedBorder ? MyColors.darkPrimaryColor : Colors.transparent, width: isNeedBorder ? 2.0 : 0), + borderSide: BorderSide(color: widget.isNeedBorder ? MyColors.darkPrimaryColor : Colors.transparent, width: widget.isNeedBorder ? 2.0 : 0), borderRadius: BorderRadius.circular(0.0), ), disabledBorder: OutlineInputBorder( - // borderSide: BorderSide(color: MyColors.textFieldColor, width: isNeedBorder ? 1.0 : 0), - borderSide: BorderSide(color: isNeedBorder ? MyColors.darkPrimaryColor : Colors.transparent, width: isNeedBorder ? 2.0 : 0), + borderSide: BorderSide(color: widget.isNeedBorder ? MyColors.darkPrimaryColor : Colors.transparent, width: widget.isNeedBorder ? 2.0 : 0), borderRadius: BorderRadius.circular(0.0), ), - suffixIcon: postfixData != null - ? InkWell( - onTap: onPostFixPressed, - child: Icon(postfixData, color: postFixDataColor), + suffixIcon: widget.isPasswordEnabled == true + ? IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, ) - : postfixWidget, - prefixIcon: prefixData != null ? Icon(prefixData, color: borderColor) : preFixWidget, + : widget.postfixData != null + ? InkWell( + onTap: widget.onPostFixPressed, + child: Icon(widget.postfixData, color: widget.postFixDataColor), + ) + : widget.postfixWidget, + prefixIcon: widget.prefixData != null ? Icon(widget.prefixData, color: borderColor) : widget.preFixWidget, labelStyle: const TextStyle(color: borderColor, fontSize: 13, fontWeight: MyFonts.Medium), hintStyle: const TextStyle(color: borderColor, fontSize: 13, fontWeight: MyFonts.Medium), - hintText: isNeedLabelOnTop ? null : hint ?? "", - contentPadding: prefixData == null + hintText: widget.isNeedLabelOnTop ? null : widget.hint ?? "", + contentPadding: widget.prefixData == null ? EdgeInsets.only( left: 12, right: 12, - top: maxLines != null ? 12 : 0, - bottom: maxLines != null ? 12 : 0, + top: widget.maxLines != null ? 12 : 0, + bottom: widget.maxLines != null ? 12 : 0, ) : EdgeInsets.zero, ), ), ), ), - if (isNeedFilterButton) 8.width, - if (isNeedFilterButton) + if (widget.isNeedFilterButton) 8.width, + if (widget.isNeedFilterButton) InkWell( - onTap: isNeedClickAll + onTap: widget.isNeedClickAll ? null : () { controller.clear(); @@ -177,30 +193,26 @@ class TxtField extends StatelessWidget { height: 55, child: Card( color: accentColor, - // margin: EdgeInsets.all(4), - // shape: cardRadius(0), child: Icon( - postfixData ?? Icons.filter_alt, - color: postFixDataColor, + widget.postfixData ?? Icons.filter_alt, + color: widget.postFixDataColor, ), ), ), ), - if (isButtonEnable) + if (widget.isButtonEnable) Material( child: InkWell( - onTap: onTap, + onTap: widget.onTap, customBorder: Utils.inkWellCorner(), child: SizedBox( height: 55, child: Card( color: accentColor, - // margin: EdgeInsets.all(4), - // shape: cardRadius(0), child: Center( child: Padding( padding: const EdgeInsets.only(left: 12, right: 12), - child: (buttonTitle ?? "Search").toText( + child: (widget.buttonTitle ?? "Search").toText( color: Colors.white, fontSize: 16, letterSpacing: -0.64, @@ -214,11 +226,11 @@ class TxtField extends StatelessWidget { ], ), ), - if (errorValue != "") + if (widget.errorValue != "") Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - errorValue.toText(fontSize: 12, color: Colors.red), + widget.errorValue.toText(fontSize: 12, color: Colors.red), ], ).paddingOnly(right: 10), ], diff --git a/pubspec.lock b/pubspec.lock index 8a3bd1e..0f627d3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: f16640453cc47487b7de72a2b28d37c7df1ac97999849f4a46d92b1d2b0f093d + url: "https://pub.dev" + source: hosted + version: "6.7.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" + url: "https://pub.dev" + source: hosted + version: "4.3.1" auto_size_text: dependency: "direct main" description: @@ -1484,4 +1540,4 @@ packages: version: "6.5.0" sdks: dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index d78b051..3946f8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: change_app_package_name: ^1.3.0 share_plus: any toastification: ^2.3.0 + audioplayers: ^6.0.0 geolocator_android: 4.5.5 @@ -78,6 +79,7 @@ flutter: assets: - assets/ - assets/langs/ + - assets/tones/ - assets/icons/ - assets/icons/payments/ - assets/images/