Bug Fixes

faiz_development_common
FaizHashmi 1 day ago
parent 886487ebd4
commit a451f91f7a

@ -338,6 +338,7 @@
"qrCode": "رمز الاستجابة السريعة",
"downgradeAppliedAfterExpiry": "سيتم تطبيق هذه الباقة تلقائيًا بعد انتهاء صلاحية باقتك الحالية.",
"downgradeDeactivateInstruction": "لتحديد هذا الاشتراك، يجب عليك إلغاء تفعيل الفروع والمستخدمين حيث أن هذه الباقة تدعم فقط {branches} و {users}. يمكنك إعادة تفعيل هذه الفروع لاحقًا بعد ترقية الباقة",
"adChargeDueToSubscriptionNotice": "انتهت صلاحية باقتك أو لم يتبق لديك أي إعلانات في باقتك الحالية. سيتم محاسبتك بناءً على المدة والسعر الذي تختاره أدناه.",
"vehicleDamagePart": "جزء المركبة المتضرر",
"duration": "المدة",
"specialServices": "الخدمات الخاصة",

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

@ -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<String, dynamic>? _pendingNotificationData;
Map<String, dynamic>? get getPendingNotificationData => _pendingNotificationData;
set setPendingNotificationData(Map<String, dynamic>? 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 =>

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

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

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

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

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

@ -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<String> getFirebaseToken();
Future<void> 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<void> resumePendingNotificationIfAny();
}
class FirebaseMessagingServiceImp implements FirebaseMessagingService {
@ -180,6 +190,17 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService {
_handleNotificationNavigation(message.data);
}
@override
Future<void> 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<String, dynamic> 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<DashboardVmCustomer>(context, listen: false).onNavbarTapped(4);
} else {
Provider.of<DashboardVMProvider>(context, listen: false).onNavbarTapped(4);
}
}
Future<void> _navigateToRequestsTab(BuildContext context, Map<String, dynamic> 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<RequestsVM>(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<void> displayNotification({
required String title,
required String body,

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

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

@ -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<void> _completeLoginNavigation(BuildContext context) async {
navigateReplaceWithName(context, AppRoutes.dashboard);
await injector.get<FirebaseMessagingService>().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<SubscriptionsVM>().mySubscriptionsBySp.clear();

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

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

@ -167,7 +167,7 @@ class _AdCardState extends State<AdCard> {
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<AdCard> {
),
// 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<AdCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
@ -312,12 +313,16 @@ class _AdCardState extends State<AdCard> {
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<AdVM>().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<AdCard> {
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<AdCard> {
// : 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<AdCard> {
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),
],
),
),

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

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

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

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

Loading…
Cancel
Save