diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index b9c2df5..7c59175 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -338,6 +338,7 @@ "qrCode": "رمز الاستجابة السريعة", "downgradeAppliedAfterExpiry": "سيتم تطبيق هذه الباقة تلقائيًا بعد انتهاء صلاحية باقتك الحالية.", "downgradeDeactivateInstruction": "لتحديد هذا الاشتراك، يجب عليك إلغاء تفعيل الفروع والمستخدمين حيث أن هذه الباقة تدعم فقط {branches} و {users}. يمكنك إعادة تفعيل هذه الفروع لاحقًا بعد ترقية الباقة", + "adChargeDueToSubscriptionNotice": "انتهت صلاحية باقتك أو لم يتبق لديك أي إعلانات في باقتك الحالية. سيتم محاسبتك بناءً على المدة والسعر الذي تختاره أدناه.", "vehicleDamagePart": "جزء المركبة المتضرر", "duration": "المدة", "specialServices": "الخدمات الخاصة", diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 8ac5331..6cfa3b8 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -341,6 +341,7 @@ "qrCode": "QR Code", "downgradeAppliedAfterExpiry": "This package will be applied automatically after your current package expires.", "downgradeDeactivateInstruction": "To select this subscription you need to deactivate your branches and users as this package only supports {branches} and {users}. You can reactivate these branches later after upgrading the package", + "adChargeDueToSubscriptionNotice": "Your subscription has expired or you have no ads remaining in your current plan. You'll be charged based on the duration and price you select below.", "vehicleDamagePart": "Vehicle Damage Part", "duration": "Duration", "specialServices": "Special Services", diff --git a/lib/classes/app_state.dart b/lib/classes/app_state.dart index 4785d36..e4adbab 100644 --- a/lib/classes/app_state.dart +++ b/lib/classes/app_state.dart @@ -77,6 +77,29 @@ class AppState { _providerSubscription = value; } + // A notification tapped before login completed (cold start, expired session) -- + // stashed by FirebaseMessagingService so it can be resumed once login finishes. + // Must be cleared on logout, same as setUser/setproviderSubscription below -- otherwise + // a pending notification from the previous session could fire for the next user who + // logs in on this device. + Map? _pendingNotificationData; + + Map? get getPendingNotificationData => _pendingNotificationData; + + set setPendingNotificationData(Map? value) { + _pendingNotificationData = value; + } + + // getproviderSubscription falls back to a dummy Subscription(name: "") when there's no + // real data, so adsRemaining != null is the signal that this is actual subscription data, + // not the placeholder. + // Ads-specific on purpose: only checks adsRemaining, not branchesRemaining/subUsersRemaining. + // Don't reuse this for branch/user-limit checks -- add a separate getter for those instead. + bool get hasAdsRemainingInActiveSubscription { + final subscription = getproviderSubscription.first; + return subscription.adsRemaining != null && subscription.isExpired != true && (subscription.adsRemaining ?? 0) > 0; + } + DropValue? _userRegisterCountrySelection; DropValue get getUserRegisterCountrySelection => diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 355d683..6e31013 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -339,6 +339,7 @@ class CodegenLoader extends AssetLoader { "damagePartPictures": "صور الأجزاء المتضررة", "qrCode": "رمز الاستجابة السريعة", "downgradeAppliedAfterExpiry": "سيتم تطبيق هذه الباقة تلقائيًا بعد انتهاء صلاحية باقتك الحالية.", + "adChargeDueToSubscriptionNotice": "انتهت صلاحية باقتك أو لم يتبق لديك أي إعلانات في باقتك الحالية. سيتم محاسبتك بناءً على المدة والسعر الذي تختاره أدناه.", "downgradeDeactivateInstruction": "لتحديد هذا الاشتراك، يجب عليك إلغاء تفعيل الفروع والمستخدمين حيث أن هذه الباقة تدعم فقط {branches} و {users}. يمكنك إعادة تفعيل هذه الفروع لاحقًا بعد ترقية الباقة", "vehicleDamagePart": "جزء المركبة المتضرر", @@ -1276,6 +1277,7 @@ class CodegenLoader extends AssetLoader { "damagePartPictures": "Damage Part Pictures", "qrCode": "QR Code", "downgradeAppliedAfterExpiry": "This package will be applied automatically after your current package expires.", + "adChargeDueToSubscriptionNotice": "Your subscription has expired or you have no ads remaining in your current plan. You'll be charged based on the duration and price you select below.", "downgradeDeactivateInstruction": "To select this subscription you need to deactivate your branches and users as this package only supports {branches} and {users}. You can reactivate these branches later after upgrading the package", "vehicleDamagePart": "Vehicle Damage Part", diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 3ec78f6..0e92ade 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -319,6 +319,7 @@ abstract class LocaleKeys { static const damagePartPictures = 'damagePartPictures'; static const qrCode = 'qrCode'; static const downgradeAppliedAfterExpiry = 'downgradeAppliedAfterExpiry'; + static const adChargeDueToSubscriptionNotice = 'adChargeDueToSubscriptionNotice'; static const downgradeDeactivateInstruction = 'downgradeDeactivateInstruction'; static const vehicleDamagePart = 'vehicleDamagePart'; static const duration = 'duration'; diff --git a/lib/models/advertisment_models/ad_details_model.dart b/lib/models/advertisment_models/ad_details_model.dart index 7ab4664..973a9df 100644 --- a/lib/models/advertisment_models/ad_details_model.dart +++ b/lib/models/advertisment_models/ad_details_model.dart @@ -202,6 +202,9 @@ class Vehicle { Condition? condition; Mileage? mileage; Condition? model; + // Top-level brand id, independent of model -- lets a brand-only selection (no model + // picked yet) survive a draft reload instead of relying solely on model.vehicleBrandID. + int? vehicleBrandID; ModelYear? modelyear; Condition? sellertype; Condition? transmission; @@ -236,6 +239,7 @@ class Vehicle { this.condition, this.mileage, this.model, + this.vehicleBrandID, this.modelyear, this.sellertype, this.transmission, @@ -270,6 +274,7 @@ class Vehicle { condition = json['condition'] != null ? Condition.fromJson(json['condition']) : null; mileage = json['mileage'] != null ? Mileage.fromJson(json['mileage']) : null; model = json['model'] != null ? Condition.fromJson(json['model']) : null; + vehicleBrandID = json['vehicleBrandID']; modelyear = json['modelyear'] != null ? ModelYear.fromJson(json['modelyear']) : null; sellertype = json['sellertype'] != null ? Condition.fromJson(json['sellertype']) : null; transmission = json['transmission'] != null ? Condition.fromJson(json['transmission']) : null; diff --git a/lib/models/general_models/generic_resp_model.dart b/lib/models/general_models/generic_resp_model.dart index 6c8d0c0..59515b4 100644 --- a/lib/models/general_models/generic_resp_model.dart +++ b/lib/models/general_models/generic_resp_model.dart @@ -102,6 +102,7 @@ class VehiclePosting { int? id; String? userID; int? vehicleType; + int? vehicleBrandID; int? vehicleModelID; int? vehicleModelYearID; int? vehicleColorID; @@ -132,6 +133,7 @@ class VehiclePosting { this.id, this.userID, this.vehicleType, + this.vehicleBrandID, this.vehicleModelID, this.vehicleModelYearID, this.vehicleColorID, @@ -163,6 +165,7 @@ class VehiclePosting { id = json['id']; userID = json['userID']; vehicleType = json['vehicleType']; + vehicleBrandID = json['vehicleBrandID']; vehicleModelID = json['vehicleModelID']; vehicleModelYearID = json['vehicleModelYearID']; vehicleColorID = json['vehicleColorID']; @@ -202,7 +205,7 @@ class VehiclePosting { @override String toString() { - return 'VehiclePosting{id: $id, userID: $userID, vehicleType: $vehicleType, vehicleModelID: $vehicleModelID, vehicleModelYearID: $vehicleModelYearID, vehicleColorID: $vehicleColorID, vehicleCategoryID: $vehicleCategoryID, vehicleConditionID: $vehicleConditionID, vehicleMileageID: $vehicleMileageID, vehicleTransmissionID: $vehicleTransmissionID, vehicleSellerTypeID: $vehicleSellerTypeID, cityID: $cityID, price: $price, vehicleVIN: $vehicleVIN, vehicleDescription: $vehicleDescription, vehicleTitle: $vehicleTitle, vehicleDescriptionN: $vehicleDescriptionN, isFinanceAvailable: $isFinanceAvailable, warantyYears: $warantyYears, demandAmount: $demandAmount, adStatus: $adStatus, vehiclePostingImages: $vehiclePostingImages, vehiclePostingDamageParts: $vehiclePostingDamageParts, phoneNo: $phoneNo, whatsAppNo: $whatsAppNo, odometer: $odometer, isInUsed: $isInUsed, isAccidentFree: $isAccidentFree}'; + return 'VehiclePosting{id: $id, userID: $userID, vehicleType: $vehicleType, vehicleBrandID: $vehicleBrandID, vehicleModelID: $vehicleModelID, vehicleModelYearID: $vehicleModelYearID, vehicleColorID: $vehicleColorID, vehicleCategoryID: $vehicleCategoryID, vehicleConditionID: $vehicleConditionID, vehicleMileageID: $vehicleMileageID, vehicleTransmissionID: $vehicleTransmissionID, vehicleSellerTypeID: $vehicleSellerTypeID, cityID: $cityID, price: $price, vehicleVIN: $vehicleVIN, vehicleDescription: $vehicleDescription, vehicleTitle: $vehicleTitle, vehicleDescriptionN: $vehicleDescriptionN, isFinanceAvailable: $isFinanceAvailable, warantyYears: $warantyYears, demandAmount: $demandAmount, adStatus: $adStatus, vehiclePostingImages: $vehiclePostingImages, vehiclePostingDamageParts: $vehiclePostingDamageParts, phoneNo: $phoneNo, whatsAppNo: $whatsAppNo, odometer: $odometer, isInUsed: $isInUsed, isAccidentFree: $isAccidentFree}'; } } diff --git a/lib/repositories/ads_repo.dart b/lib/repositories/ads_repo.dart index 2ccd8eb..380f354 100644 --- a/lib/repositories/ads_repo.dart +++ b/lib/repositories/ads_repo.dart @@ -190,6 +190,7 @@ class AdsRepoImp implements AdsRepo { "id": isCreateNew ? 0 : adsCreationPayloadModel.vehiclePosting!.id ?? 0, "userID": adsCreationPayloadModel.vehiclePosting!.userID, "vehicleType": adsCreationPayloadModel.vehiclePosting!.vehicleType, + "vehicleBrandID": adsCreationPayloadModel.vehiclePosting!.vehicleBrandID, "vehicleModelID": adsCreationPayloadModel.vehiclePosting!.vehicleModelID, "vehicleModelYearID": adsCreationPayloadModel.vehiclePosting!.vehicleModelYearID, "vehicleColorID": adsCreationPayloadModel.vehiclePosting!.vehicleColorID, @@ -286,6 +287,7 @@ class AdsRepoImp implements AdsRepo { "id": adsCreationPayloadModel.vehiclePosting!.id ?? 0, "userID": adsCreationPayloadModel.vehiclePosting!.userID, "vehicleType": adsCreationPayloadModel.vehiclePosting!.vehicleType, + "vehicleBrandID": adsCreationPayloadModel.vehiclePosting!.vehicleBrandID, "vehicleModelID": adsCreationPayloadModel.vehiclePosting!.vehicleModelID, "vehicleModelYearID": adsCreationPayloadModel.vehiclePosting!.vehicleModelYearID, "vehicleColorID": adsCreationPayloadModel.vehiclePosting!.vehicleColorID, diff --git a/lib/services/firebase_service.dart b/lib/services/firebase_service.dart index 45192c4..42e793b 100644 --- a/lib/services/firebase_service.dart +++ b/lib/services/firebase_service.dart @@ -14,7 +14,12 @@ import 'package:mc_common_app/main.dart'; import 'package:mc_common_app/services/chat_navigation_service.dart'; import 'package:mc_common_app/utils/enums.dart'; import 'package:mc_common_app/utils/navigator.dart'; +import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/chat_view_model.dart'; +import 'package:mc_common_app/view_models/dashboard_view_model_customer.dart'; +import 'package:mc_common_app/view_models/dashboard_view_model_provider.dart'; +import 'package:mc_common_app/models/requests_models/request_model.dart'; +import 'package:mc_common_app/view_models/requests_view_model.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; @@ -22,6 +27,11 @@ abstract class FirebaseMessagingService { Future getFirebaseToken(); Future initializeNotifications(); + + // Replays a notification tap that arrived before the user was logged in (cold start, + // expired session, etc.) -- call this right after login/OTP success finishes + // navigating to the dashboard, so the original notification target isn't lost. + Future resumePendingNotificationIfAny(); } class FirebaseMessagingServiceImp implements FirebaseMessagingService { @@ -180,6 +190,17 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { _handleNotificationNavigation(message.data); } + @override + Future resumePendingNotificationIfAny() async { + final pendingData = AppState().getPendingNotificationData; + if (pendingData == null) return; + // Clear before replaying -- _handleNotificationNavigation re-stashes it again if the + // user still isn't authenticated for some reason, so this can't lose data either way. + AppState().setPendingNotificationData = null; + log("Resuming notification navigation that was pending login: $pendingData"); + _handleNotificationNavigation(pendingData); + } + void _handleNotificationNavigation(Map data) async { log("========== Handling Notification Navigation =========="); log("Full data received: $data"); @@ -190,13 +211,33 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { return; } - // Backend sends NotiType as string: "1" = Request, "2" = Ads, "3" = General + // Not logged in yet (cold start landing on login, expired session, etc.) -- every + // navigation target below needs an authenticated VM call or a dashboard that only + // exists post-login, so there's nothing to navigate to right now. Stash it instead + // of silently dropping it; resumePendingNotificationIfAny() replays this exact call + // once login completes. + if ((AppState().getUser.data?.accessToken ?? "").isEmpty) { + log("User not authenticated yet, stashing notification data to resume after login"); + AppState().setPendingNotificationData = data; + log("======================================================"); + return; + } + + // Backend sends NotiType as string. Full mapping per backend: + // 1 - Request Offer Chat + // 2 - Advertisement Chat + // 3 - General Chat + // 4 - Others + // 5 - Road Side + // 6 - Special car & spare parts requests + // Only 6 has a screen to land on (Requests tab); 4 and 5 are upcoming features with + // nowhere to navigate yet, so they're just logged and skipped. String? notiTypeStr = data['NotiType']; int? notiType = int.tryParse(notiTypeStr ?? ''); log("NotiType from backend: $notiTypeStr (parsed: $notiType)"); - if (notiType == null || notiType < 1 || notiType > 3) { + if (notiType == null || notiType < 1 || notiType > 6) { log("Invalid or missing NotiType, skipping navigation"); log("======================================================"); return; @@ -214,6 +255,14 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { case 3: chatType = 'general_chat'; break; + case 4: + case 5: + // Others / Road Side -- upcoming features, no screen to navigate to yet. + chatType = 'upcoming_feature'; + break; + case 6: + chatType = 'requests_tab'; + break; default: log("Unknown NotiType: $notiType"); log("======================================================"); @@ -247,10 +296,17 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { log("Navigating to Advertisement Chat..."); await _navigateToAdChat(context, data); break; - case 'general': + case 'general_chat': log("Navigating to General Chat..."); await _navigateToGeneralChat(context, data); break; + case 'requests_tab': + log("Navigating to Requests tab..."); + await _navigateToRequestsTab(context, data); + break; + case 'upcoming_feature': + log("NotiType $notiType (Others/Road Side) is for an upcoming feature -- not yet implemented, skipping navigation"); + break; default: logger.e("Unknown chat type: $chatType"); } @@ -376,6 +432,83 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { } } + // Requests tab is index 4 in both apps' fragment lists. + void _switchToRequestsTab(BuildContext context) { + if (AppState().currentAppType == AppType.customer) { + Provider.of(context, listen: false).onNavbarTapped(4); + } else { + Provider.of(context, listen: false).onNavbarTapped(4); + } + } + + Future _navigateToRequestsTab(BuildContext context, Map data) async { + try { + // Switch tabs first so the Requests tab is already showing underneath by the time + // the loader (if any) comes down or the detail page opens -- matches what tapping + // the tab manually would leave behind. + _switchToRequestsTab(context); + + // NotiType 4 (Others) and 5 (Road Side) land here with no RequestID to resolve -- + // the Requests tab is as specific as we can get for them. + int requestId = int.tryParse(data['RequestID']?.toString() ?? data['request_id']?.toString() ?? '0') ?? 0; + if (requestId == 0) { + log("No RequestID in payload, just switching to Requests tab"); + return; + } + + final requestsVM = Provider.of(context, listen: false); + int requestIndex = requestsVM.myFilteredRequests.indexWhere((request) => request.id == requestId); + + if (requestIndex == -1) { + // Cold start, or the wrong request-type filter is active. Backend doesn't send + // requestType in the notification payload yet (asked, pending) -- until then, + // guess in this specific order: spare parts first, then special cars, then + // others, stopping as soon as the request turns up. Replace this loop with a + // direct filter lookup once backend adds requestType to the payload. + // This can take a few seconds (up to 3 sequential network calls) -- show a + // full-screen loader so the tap doesn't look like it did nothing. + Utils.showLoading(context); + try { + const searchOrder = [RequestsTypeEnum.serviceRequest, RequestsTypeEnum.specialCarRequest, RequestsTypeEnum.other]; + for (final requestsTypeEnum in searchOrder) { + await requestsVM.applyFilterOnRequestsVM(requestsTypeEnum: requestsTypeEnum); + requestIndex = requestsVM.myFilteredRequests.indexWhere((request) => request.id == requestId); + if (requestIndex != -1) break; + } + } finally { + Utils.hideLoading(context); + } + } + + if (requestIndex == -1) { + log("⚠️ Request $requestId not found after refresh, falling back to Requests tab"); + return; + } + + RequestModel request = requestsVM.myFilteredRequests[requestIndex]; + + // Same gate as the manual tap in requests_fragment.dart -- these statuses have + // nothing to show on the detail page, so land on the tab instead. + if (request.requestStatus == RequestStatusEnum.pending || request.requestStatus == RequestStatusEnum.cancelled || request.requestStatus == RequestStatusEnum.expired) { + log("Request $requestId status (${request.requestStatus}) has no detail view, falling back to Requests tab"); + return; + } + + requestsVM.updateCurrentSelectedRequest(request); + + if (AppState().currentAppType == AppType.customer) { + log("✅ Navigating to offers list for request: $requestId"); + navigateWithName(context, AppRoutes.offersListPage, arguments: request.id); + } else { + log("✅ Navigating to request detail for request: $requestId"); + RequestDetailPageArguments requestDetailPageArguments = RequestDetailPageArguments(requestIndex: requestIndex, requestModel: request); + navigateWithName(context, AppRoutes.requestsDetailPage, arguments: requestDetailPageArguments); + } + } catch (e) { + logger.e("Error navigating to request detail: $e"); + } + } + Future displayNotification({ required String title, required String body, diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 39747e1..d9d5f97 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -553,7 +553,7 @@ class Utils { color: chipColor, ), padding: padding, - child: text.toText(fontSize: 10, color: textColor, letterSpacing: -0.50)); + child: text.toText(fontSize: 8, color: textColor, letterSpacing: -0.60)); } static InputDecoration txtField(String label) { diff --git a/lib/view_models/ad_view_model.dart b/lib/view_models/ad_view_model.dart index 3e25ded..a388a71 100644 --- a/lib/view_models/ad_view_model.dart +++ b/lib/view_models/ad_view_model.dart @@ -1368,14 +1368,24 @@ class AdVM extends BaseVM { void onBackButtonPressed(BuildContext context) { switch (currentProgressStep) { case AdCreationStepsEnum.vehicleDetails: - // Only offer to save as a draft once both brand AND model are selected. Brand - // has no field of its own in the draft payload -- it's only ever recoverable by - // looking up model.vehicleBrandID when a draft is reopened. Confirmed on a real - // device: saving with a brand picked but no model meant the brand was silently - // lost on reopen, since there was nothing to derive it from. Any other field - // (description, images, etc.) being filled in isn't enough on its own -- a draft - // without a vehicle identified isn't meaningfully recoverable either. - bool hasData = vehicleBrandId.selectedId != -1 && vehicleModelId.selectedId != -1; + // Check if user has entered any data + bool hasData = vehicleTypeId.selectedId != -1 || + vehicleBrandId.selectedId != -1 || + vehicleModelId.selectedId != -1 || + vehicleModelYearId.selectedId != -1 || + vehicleColorId.selectedId != -1 || + vehicleCategoryId.selectedId != -1 || + vehicleConditionId.selectedId != -1 || + vehicleMileageId.selectedId != -1 || + vehicleTransmissionId.selectedId != -1 || + vehicleCountryId.selectedId != -1 || + vehicleCityId.selectedId != -1 || + vehicleDemandAmount.isNotEmpty || + vehicleVin.isNotEmpty || + vehicleTitle.isNotEmpty || + warrantyDuration.isNotEmpty || + vehicleDescription.isNotEmpty || + pickedPostingImages.isNotEmpty; if (hasData) { // Skip save confirmation when extending ad @@ -1591,6 +1601,7 @@ class AdVM extends BaseVM { id: !isNew ? previousAdDetails!.vehiclePostingID : null, userID: appState.getUser.data!.userInfo!.userId, vehicleType: vehicleTypeId.selectedId, + vehicleBrandID: vehicleBrandId.selectedId, vehicleModelID: vehicleModelId.selectedId, vehicleModelYearID: vehicleModelYearId.selectedId, vehicleColorID: vehicleColorId.selectedId, @@ -1679,6 +1690,7 @@ class AdVM extends BaseVM { id: (previousAdDetails != null && previousAdDetails!.vehiclePostingID != null) ? previousAdDetails!.vehiclePostingID : null, userID: appState.getUser.data!.userInfo!.userId, vehicleType: vehicleTypeId.selectedId != -1 ? vehicleTypeId.selectedId : null, + vehicleBrandID: vehicleBrandId.selectedId != -1 ? vehicleBrandId.selectedId : null, vehicleModelID: vehicleModelId.selectedId != -1 ? vehicleModelId.selectedId : null, vehicleModelYearID: vehicleModelYearId.selectedId != -1 ? vehicleModelYearId.selectedId : null, vehicleColorID: vehicleColorId.selectedId != -1 ? vehicleColorId.selectedId : null, @@ -2259,6 +2271,7 @@ class AdVM extends BaseVM { id: (isAdEditEnabled || isExtendAdEditEnabled) && previousAdDetails != null && previousAdDetails!.vehiclePostingID != null ? previousAdDetails!.vehiclePostingID : null, userID: appState.getUser.data!.userInfo!.userId, vehicleType: vehicleTypeId.selectedId, + vehicleBrandID: vehicleBrandId.selectedId, vehicleModelID: vehicleModelId.selectedId, vehicleModelYearID: vehicleModelYearId.selectedId, vehicleColorID: vehicleColorId.selectedId, @@ -3014,12 +3027,27 @@ class AdVM extends BaseVM { if (vehicleBrands.isEmpty) { await getVehicleBrandsByVehicleTypeId(); } - int index = vehicleBrands.indexWhere((element) => element.id == previousAdDetails!.vehicle!.model!.vehicleBrandID); + // Neither side of this is trustworthy alone: the top-level field survives even when no + // model is picked (the whole reason it was added), but as of now the backend still + // returns 0 for it even when a model/brand IS set (confirmed via a real draft response, + // model.vehicleBrandID was 2 but the top-level field was 0) -- needs flagging back to + // them. So treat null/0 on either side as "unusable" and take whichever one is actually + // a real id. + int topLevelBrandId = previousAdDetails!.vehicle!.vehicleBrandID ?? 0; + int modelBrandId = previousAdDetails!.vehicle!.model?.vehicleBrandID ?? 0; + int resolvedBrandId = topLevelBrandId > 0 ? topLevelBrandId : modelBrandId; + int index = vehicleBrands.indexWhere((element) => element.id == resolvedBrandId); if (index != -1) { await updateSelectionVehicleBrandId(SelectionModel(selectedId: vehicleBrands[index].id!, selectedOption: vehicleBrands[index].vehicleBrandDescription ?? "")); } - updateSelectionVehicleModelId(SelectionModel(selectedId: previousAdDetails!.vehicle!.model!.id!, selectedOption: previousAdDetails!.vehicle!.model!.label ?? "")); + // Same "0 means not set" convention as the other nested fields below -- an unset model + // comes back as a placeholder ({"id": 0, "label": null, ...}), not an absent field. + // Blindly trusting .id! here set selectedId to 0 instead of -1, which silently passed + // the `selectedId == -1` required-field check on Next even though no model was picked. + updateSelectionVehicleModelId((previousAdDetails!.vehicle!.model?.id ?? 0) > 0 + ? SelectionModel(selectedId: previousAdDetails!.vehicle!.model!.id!, selectedOption: previousAdDetails!.vehicle!.model!.label ?? "") + : SelectionModel(selectedOption: "", selectedId: -1, errorValue: "")); // Confirmed from a real draft API response: an unset nested field comes back as a // non-null placeholder object ({"id": 0, "label": null, ...}), not an absent/null // field -- same "0 means not set" convention the demandAmount/odometer/warantyYears diff --git a/lib/view_models/user_view_model.dart b/lib/view_models/user_view_model.dart index c4d25e1..874ed5e 100644 --- a/lib/view_models/user_view_model.dart +++ b/lib/view_models/user_view_model.dart @@ -9,7 +9,9 @@ import 'package:http/src/response.dart'; import 'package:local_auth/local_auth.dart'; import 'package:mc_common_app/classes/app_state.dart'; import 'package:mc_common_app/classes/consts.dart'; +import 'package:mc_common_app/config/dependency_injection.dart'; import 'package:mc_common_app/config/routes.dart'; +import 'package:mc_common_app/services/firebase_service.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'; @@ -428,7 +430,7 @@ class UserVM extends BaseVM { SharedPrefManager.setUserId(user.data!.userInfo!.userId ?? ""); SharedPrefManager.setRefreshToken(user.data!.refreshToken ?? ""); SharedPrefManager.setData(jsonEncode(user.data!.userInfo!.toJson())); - navigateReplaceWithName(context, AppRoutes.dashboard); + await _completeLoginNavigation(context); } else { Utils.showToast(LocaleKeys.onlyProviderApp.tr()); } @@ -439,7 +441,7 @@ class UserVM extends BaseVM { SharedPrefManager.setUserId(user.data!.userInfo!.userId ?? ""); SharedPrefManager.setRefreshToken(user.data!.refreshToken ?? ""); SharedPrefManager.setData(jsonEncode(user.data!.userInfo!.toJson())); - navigateReplaceWithName(context, AppRoutes.dashboard); + await _completeLoginNavigation(context); } else { Utils.showToast(LocaleKeys.onlyCustomerApp.tr()); } @@ -474,7 +476,7 @@ class UserVM extends BaseVM { SharedPrefManager.setUserId(user.data!.userInfo!.userId ?? ""); SharedPrefManager.setRefreshToken(user.data!.refreshToken ?? ""); SharedPrefManager.setData(jsonEncode(user.data!.userInfo!.toJson())); - navigateReplaceWithName(context, AppRoutes.dashboard); + await _completeLoginNavigation(context); } else { Utils.showToast(LocaleKeys.onlyProviderApp.tr()); //("Sorry, Only Customer's can log in this app"); @@ -486,7 +488,7 @@ class UserVM extends BaseVM { SharedPrefManager.setUserId(user.data!.userInfo!.userId ?? ""); SharedPrefManager.setRefreshToken(user.data!.refreshToken ?? ""); SharedPrefManager.setData(jsonEncode(user.data!.userInfo!.toJson())); - navigateReplaceWithName(context, AppRoutes.dashboard); + await _completeLoginNavigation(context); } else { Utils.showToast(LocaleKeys.onlyCustomerApp.tr()); } @@ -543,7 +545,7 @@ class UserVM extends BaseVM { if (verifiedUser.messageStatus == 1) { User user = User.fromJson(jsonDecode(response2.body)); AppState().setUser = user; - navigateReplaceWithName(context, AppRoutes.dashboard); + await _completeLoginNavigation(context); } else { Utils.showToast(verifiedUser.message ?? ""); } @@ -754,6 +756,15 @@ class UserVM extends BaseVM { // notifyListeners(); } + // Every successful login path (password, OTP, basic OTP) ends up here instead of + // navigating to the dashboard directly -- a notification tapped before login (cold + // start, expired session) stashes its data in AppState().pendingNotificationData, and + // this is the one place that resumes it so none of the 5 login call sites forget to. + Future _completeLoginNavigation(BuildContext context) async { + navigateReplaceWithName(context, AppRoutes.dashboard); + await injector.get().resumePendingNotificationIfAny(); + } + void logout(BuildContext context) async { Response value = await userRepo.logoutUser(); if (value.body.isNotEmpty) { @@ -773,6 +784,10 @@ class UserVM extends BaseVM { AppState().getUser.data!.userInfo!.userImageUrl = null; } AppState().setUser = null; + // Otherwise a notification tapped by the previous user (and still unresolved because + // they weren't logged in at the time) would fire for whoever logs in next on this + // device. + AppState().setPendingNotificationData = null; if (AppState().currentAppType == AppType.provider) { AppState().setproviderSubscription = null; context.read().mySubscriptionsBySp.clear(); diff --git a/lib/views/advertisement/ads_detail_view/components.dart b/lib/views/advertisement/ads_detail_view/components.dart index 745e8c0..a9948a1 100644 --- a/lib/views/advertisement/ads_detail_view/components.dart +++ b/lib/views/advertisement/ads_detail_view/components.dart @@ -1110,11 +1110,27 @@ class BuildAdDetailsActionButtonForMyAds extends StatelessWidget { title: LocaleKeys.no.tr(), txtColor: MyColors.darkPrimaryColor, fontSize: 15, - onPressed: () { + onPressed: () async { adVM.updateSelectionVehicleTypeId( SelectionModel(selectedId: adDetailsModel.vehicle!.vehicleType!, selectedOption: (adDetailsModel.vehicle!.vehicleType!).toVehicleTypeString(), errorValue: ""), ); + if (AppState().currentAppType == AppType.provider && AppState().hasAdsRemainingInActiveSubscription) { + // Subscription covers this extension -- skip the duration sheet and + // extend for free. adsDurationId: 0 is a placeholder since no duration + // is actually picked here; the backend decides isPaymentRequired from + // subscription status, not from this id. + List statuses = await adVM.createAdExtensionOrder(context, adId: adDetailsModel.id ?? -1, adsDurationId: 0); + if (statuses[0] && statuses[1]) { + navigateWithName(context, AppRoutes.paymentMethodsView, arguments: PaymentTypes.extendAds); + } else if (statuses[0] == true && statuses[1] == false) { + pop(context); + pop(context); + adVM.applyFilterOnMyAds(adPostStatusEnum: AdPostStatus.active); + } + return; + } + showMyBottomSheet(context, child: AdDurationSelectionSheet(isFromExtendAd: true, isUpdateAdSelected: false, adsID: adDetailsModel.id ?? -1)); }, ), diff --git a/lib/views/advertisement/bottom_sheets/ad_duration_selection_sheet.dart b/lib/views/advertisement/bottom_sheets/ad_duration_selection_sheet.dart index a39f479..6ca007a 100644 --- a/lib/views/advertisement/bottom_sheets/ad_duration_selection_sheet.dart +++ b/lib/views/advertisement/bottom_sheets/ad_duration_selection_sheet.dart @@ -42,6 +42,27 @@ class AdDurationSelectionSheet extends StatelessWidget { LocaleKeys.selectDuration.tr().toText(fontSize: 22, isBold: true).paddingOnly(top: 10, left: 21, right: 21), ], ), + // Shown only when a provider lands here because their subscription can't cover + // this ad/extension for free (expired, or no ads left in the plan) -- otherwise + // this sheet wouldn't be shown to them at all. Explains why they're being asked + // to pick a paid duration here instead of it being covered automatically. + if (AppState().currentAppType == AppType.provider && !AppState().hasAdsRemainingInActiveSubscription) ...[ + 8.height, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, size: 18, color: MyColors.textColor), + 8.width, + Expanded( + child: LocaleKeys.adChargeDueToSubscriptionNotice.tr().toText(fontSize: 13, color: MyColors.textColor), + ), + ], + ).toContainer( + backgroundColor: MyColors.greyButtonColor, + paddingAll: 10, + borderRadius: 6, + ).paddingOnly(left: 21, right: 21, top: 8), + ], Expanded( child: ListView.separated( shrinkWrap: true, @@ -178,6 +199,7 @@ class AdDurationSelectionSheet extends StatelessWidget { maxWidth: double.infinity, margin: const EdgeInsets.all(21), onPressed: () async { + log("isFromExtendAd && !isUpdateAdSelected: ${isFromExtendAd && !isUpdateAdSelected}"); if (adVM.isFetchingLists) { return; } else { diff --git a/lib/views/advertisement/components/ads_list_widget.dart b/lib/views/advertisement/components/ads_list_widget.dart index 6463553..fa0304f 100644 --- a/lib/views/advertisement/components/ads_list_widget.dart +++ b/lib/views/advertisement/components/ads_list_widget.dart @@ -167,7 +167,7 @@ class _AdCardState extends State { child: Column( children: [ LocaleKeys.formatNeedsUpdate.tr().toText(), - // "Ad format needs to be updated.".toText(), + // "Ad format needs to be updated.".toText(), ], ), ), @@ -214,7 +214,7 @@ class _AdCardState extends State { ), // Image indicator dots if (hasMultipleImages) - Positioned( + Positioned( bottom: 6, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), @@ -282,6 +282,7 @@ class _AdCardState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( + flex: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, @@ -312,12 +313,16 @@ class _AdCardState extends State { else if (isAdsFragment && widget.adDetails.adPostStatus! == AdPostStatus.sold && widget.adDetails.statuslabel != null && widget.adDetails.statuslabel!.isNotEmpty) ...[ Padding( padding: const EdgeInsets.only(top: 5), - child: Utils.statusContainerChip(text: isArabic ? widget.adDetails.statuslabelN ?? "": widget.adDetails.statuslabel ?? "", chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!)), + child: 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) ...[ Padding( padding: const EdgeInsets.only(top: 5), - child: Utils.statusContainerChip(text: isArabic ? widget.adDetails.statuslabelN ?? "": widget.adDetails.statuslabel ?? "", chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!)), + child: Utils.statusContainerChip( + text: isArabic ? widget.adDetails.statuslabelN ?? "" : widget.adDetails.statuslabel ?? "", + chipColor: Utils.getChipColorByAdStatus(widget.adDetails.adPostStatus!)), ), ], (widget.adDetails.vehicle!.vehicleTitle ?? "").toText( @@ -352,7 +357,8 @@ class _AdCardState extends State { 2.width, Expanded( child: "${widget.adDetails.vehicle?.mileage?.mileageStart ?? ""} - ${widget.adDetails.vehicle?.mileage?.mileageEnd ?? ""}" - "${LocaleKeys.km.tr()}".toText( + "${LocaleKeys.km.tr()}" + .toText( overflow: TextOverflow.ellipsis, fontSize: 12, letterSpacing: -0.48, @@ -374,8 +380,14 @@ class _AdCardState extends State { // : const SizedBox(), // ], children: [ - (isArabic ? widget.adDetails.vehicle!.cityNameN ?? "" : widget.adDetails.vehicle!.cityName ?? "").toText(color: MyColors.lightTextColor, fontSize: 4), - widget.adDetails.createdOn != null ? DateTime.parse(widget.adDetails.createdOn!).getTimeAgo(isArabic: context.locale.languageCode == 'ar',).toText(color: MyColors.lightTextColor, fontSize: 4) : const SizedBox(), + (isArabic ? widget.adDetails.vehicle!.cityNameN ?? "" : widget.adDetails.vehicle!.cityName ?? "").toText(color: MyColors.lightTextColor, fontSize: 4), + widget.adDetails.createdOn != null + ? DateTime.parse(widget.adDetails.createdOn!) + .getTimeAgo( + isArabic: context.locale.languageCode == 'ar', + ) + .toText(color: MyColors.lightTextColor, fontSize: 4) + : const SizedBox(), ], ), ], @@ -390,7 +402,9 @@ class _AdCardState extends State { children: [ (widget.adDetails.vehicle!.demandAmount!.toInt().toString()).toText(fontSize: 19, isBold: true, letterSpacing: -1.16, height: 29 / 19), 2.width, - (isArabic ? widget.adDetails.vehicle!.currencyN ?? "" : widget.adDetails.vehicle!.currency ?? "").toText(color: MyColors.lightTextColor, fontSize: 10, letterSpacing: -0.4, height: 16 / 10).paddingOnly(bottom: 3), + (isArabic ? widget.adDetails.vehicle!.currencyN ?? "" : widget.adDetails.vehicle!.currency ?? "") + .toText(color: MyColors.lightTextColor, fontSize: 10, letterSpacing: -0.4, height: 16 / 10) + .paddingOnly(bottom: 3), ], ), ), diff --git a/lib/views/advertisement/create_ad_view.dart b/lib/views/advertisement/create_ad_view.dart index 25ca61d..df658ac 100644 --- a/lib/views/advertisement/create_ad_view.dart +++ b/lib/views/advertisement/create_ad_view.dart @@ -80,13 +80,8 @@ class BuildFooterButton extends StatelessWidget { case AdCreationStepsEnum.vehicleDetails: return Row( children: [ - // Hide "Save as Draft" when extending ad, and until brand+model are both - // selected -- brand has no field of its own in the draft payload, it's - // only ever recoverable via model.vehicleBrandID on reload, so saving with - // a brand picked but no model silently loses the brand (confirmed on a - // real device). Same gate as the back-button confirmation in - // onBackButtonPressed. - if (!adVm.isExtendAdEditEnabled && adVm.vehicleBrandId.selectedId != -1 && adVm.vehicleModelId.selectedId != -1) ...[ + // Hide "Save as Draft" when extending ad + if (!adVm.isExtendAdEditEnabled) ...[ Expanded( child: ShowFillButton( txtColor: MyColors.black, diff --git a/lib/views/advertisement/select_ad_type_view.dart b/lib/views/advertisement/select_ad_type_view.dart index 24c6eb2..03f95b4 100644 --- a/lib/views/advertisement/select_ad_type_view.dart +++ b/lib/views/advertisement/select_ad_type_view.dart @@ -77,13 +77,14 @@ class SelectAdTypeView extends StatelessWidget { VehicleTypeModel vehicleTypeModel = adVM.vehicleTypes[index]; return InkWell( onTap: () async { - adVM.updateSelectionVehicleTypeId(SelectionModel(selectedId: vehicleTypeModel.id!, selectedOption: isArabic ? vehicleTypeModel.vehicleTypeNameN ?? "" : vehicleTypeModel.vehicleTypeName ?? "", errorValue: "")); - + adVM.updateSelectionVehicleTypeId(SelectionModel( + selectedId: vehicleTypeModel.id!, selectedOption: isArabic ? vehicleTypeModel.vehicleTypeNameN ?? "" : vehicleTypeModel.vehicleTypeName ?? "", errorValue: "")); + // Clear previous ad details when creating NEW ad (not editing) if (adsId == -1 && !adVM.isAdEditEnabled && !adVM.isDraftEditEnabled) { adVM.previousAdDetails = null; } - + if (adVM.isAdEditEnabled) { adVM.autoFillSelectedVehicleAdsDetails(context); } @@ -99,8 +100,12 @@ class SelectAdTypeView extends StatelessWidget { pop(context); adVM.applyFilterOnMyAds(adPostStatusEnum: AdPostStatus.active); } - } else { + } else if (AppState().hasAdsRemainingInActiveSubscription) { navigateReplaceWithName(context, AppRoutes.createAdView); + } else { + // Subscription expired or no ads left in it -- provider pays for this + // ad/extension just like a customer would, so collect a duration first. + showMyBottomSheet(context, child: AdDurationSelectionSheet(isUpdateAdSelected: adVM.isAdEditEnabled, isFromExtendAd: isFromExtendAd, adsID: adsId)); } } else { showMyBottomSheet(context, child: AdDurationSelectionSheet(isUpdateAdSelected: adVM.isAdEditEnabled, isFromExtendAd: isFromExtendAd, adsID: adsId)); @@ -120,13 +125,17 @@ class SelectAdTypeView extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - isArabic ? vehicleTypeModel.vehicleTypeNameN.toString().toText(fontSize: 18) - : vehicleTypeModel.vehicleTypeName.toString().toText(fontSize: 18), - // vehicleTypeModel.vehicleTypeName.toString().toText(fontSize: 18), + isArabic ? vehicleTypeModel.vehicleTypeNameN.toString().toText(fontSize: 18) : vehicleTypeModel.vehicleTypeName.toString().toText(fontSize: 18), + // vehicleTypeModel.vehicleTypeName.toString().toText(fontSize: 18), getVehicleAdTypeIcon(vehicleTypeModel.vehicleTypeEnum!), ], ), - if (isProvider) ...[ + // A provider only gets the "covered by subscription" display when + // their subscription actually covers it -- otherwise (expired, or no + // ads left) tapping this takes them to the paid duration sheet just + // like a customer, so the display here needs to say so too instead + // of claiming subscription coverage that no longer applies. + if (isProvider && AppState().hasAdsRemainingInActiveSubscription) ...[ Row( children: [ "${LocaleKeys.duration.tr()}: ".toText(fontSize: 12, color: MyColors.lightTextColor, isBold: true), @@ -139,12 +148,13 @@ class SelectAdTypeView extends StatelessWidget { children: [ LocaleKeys.duration.tr().toText(fontSize: 12, color: MyColors.lightTextColor, isBold: true), 4.width, - adVM.vehicleAdDurationId.selectedOption.toText(fontSize: 13, isBold: true), + (adVM.vehicleAdDurationId.selectedOption.isNotEmpty ? adVM.vehicleAdDurationId.selectedOption : LocaleKeys.selectDuration.tr()) + .toText(fontSize: 13, isBold: true), const Icon(Icons.keyboard_arrow_down_sharp, color: MyColors.darkPrimaryColor, size: 20), ], ).paddingOnly(top: 5, bottom: 5), ], - if (!isProvider) ...[ + if (!isProvider || !AppState().hasAdsRemainingInActiveSubscription) ...[ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ diff --git a/lib/views/appointments/widgets/appointment_service_pick_bottom_sheet.dart b/lib/views/appointments/widgets/appointment_service_pick_bottom_sheet.dart index 487da2f..e317fc4 100644 --- a/lib/views/appointments/widgets/appointment_service_pick_bottom_sheet.dart +++ b/lib/views/appointments/widgets/appointment_service_pick_bottom_sheet.dart @@ -55,55 +55,22 @@ class AppointmentServicePickBottomSheet extends StatelessWidget { children: [ Builder( builder: (context) { - print("isArabic ===== $isArabic"); - - for (var item in appointmentsVM.branchCategories) { - print("id ===== ${item.id}"); - print("value English ===== ${item.value}"); - print("valueN Arabic ===== ${item.valueN}"); - print("subValue Price ===== ${item.subValue}"); - - print( - "Displayed ===== ${isArabic ? (item.valueN ?? item.value) : item.value}", - ); - } - for (var item in appointmentsVM.branchCategories) { - print('id: ${item.id}'); - print('value: ${item.value}'); - print('subValue: ${item.subValue}'); - print("valueN ${item.valueN}"); - } - // return DropdownField( - // (DropValue value) => appointmentsVM.updateProviderCategoryId(SelectionModel(selectedId: value.id, selectedOption: value.value, itemPrice: value.subValue)), - // list: appointmentsVM.branchCategories, - // hint: LocaleKeys.selectCategory.tr(), - // dropdownValue: appointmentsVM.branchSelectedCategoryId.selectedId != -1 - // ? DropValue(appointmentsVM.branchSelectedCategoryId.selectedId, appointmentsVM.branchSelectedCategoryId.selectedOption, "") - // : null, - // ); return DropdownField( - (DropValue value) => appointmentsVM.updateProviderCategoryId( + (DropValue value) => appointmentsVM.updateProviderCategoryId( SelectionModel( selectedId: value.id, - selectedOption: isArabic - ? (value.subValue ?? value.value) - : value.value, + selectedOption: isArabic ? (value.subValue ?? value.value) : value.value, ), ), list: appointmentsVM.branchCategories, hint: LocaleKeys.selectCategory.tr(), - dropdownValue: - appointmentsVM.branchSelectedCategoryId.selectedId != -1 && - appointmentsVM.branchCategories.any( - (item) => - item.id == - appointmentsVM.branchSelectedCategoryId.selectedId, - ) + dropdownValue: appointmentsVM.branchSelectedCategoryId.selectedId != -1 && + appointmentsVM.branchCategories.any( + (item) => item.id == appointmentsVM.branchSelectedCategoryId.selectedId, + ) ? appointmentsVM.branchCategories.firstWhere( - (item) => - item.id == - appointmentsVM.branchSelectedCategoryId.selectedId, - ) + (item) => item.id == appointmentsVM.branchSelectedCategoryId.selectedId, + ) : null, isArabic: isArabic, useSubValueForArabic: true, diff --git a/lib/widgets/common_widgets/filters_list.dart b/lib/widgets/common_widgets/filters_list.dart index 9837134..1e8e0ad 100644 --- a/lib/widgets/common_widgets/filters_list.dart +++ b/lib/widgets/common_widgets/filters_list.dart @@ -38,10 +38,6 @@ class FiltersList extends StatelessWidget { itemCount: filterList.length, scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) { - print("isArabic ===== $isArabic"); - print("title EN ===== ${filterList[index].title}"); - print("title AR ===== ${filterList[index].titleN}"); - print("--------------------"); return InkWell( onTap: () { onFilterTapped(index, filterList[index].id); @@ -72,8 +68,7 @@ class FiltersList extends StatelessWidget { ], ], // Text displayed in the chip - (isArabic - ? (filterList[index].titleN.isNotEmpty ? filterList[index].titleN : filterList[index].title) : filterList[index].title).toText( + (isArabic ? (filterList[index].titleN.isNotEmpty ? filterList[index].titleN : filterList[index].title) : filterList[index].title).toText( fontSize: 13, letterSpacing: -0.14, color: filterList[index].isSelected ? MyColors.white : null,