diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index 0f3cbc5..736be83 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -336,6 +336,8 @@ "vehiclePart": "جزء المركبة", "damagePartPictures": "صور الأجزاء المتضررة", "qrCode": "رمز الاستجابة السريعة", + "downgradeAppliedAfterExpiry": "سيتم تطبيق هذه الباقة تلقائيًا بعد انتهاء صلاحية باقتك الحالية.", + "downgradeDeactivateInstruction": "لتحديد هذا الاشتراك، يجب عليك إلغاء تفعيل الفروع والمستخدمين حيث أن هذه الباقة تدعم فقط {branches} و {users}. يمكنك إعادة تفعيل هذه الفروع لاحقًا بعد ترقية الباقة", "vehicleDamagePart": "جزء المركبة المتضرر", "duration": "المدة", "specialServices": "الخدمات الخاصة", @@ -419,6 +421,7 @@ "daysVar": "يوم", "adCharges": "رسوم الإعلان", "select": "اختر", + "selectOption": "اختر خيار", "deleteAdConfirmation": "هل تريد حذف الإعلان؟", "deleteAdConfirmationMessage": "سيتم حذف إعلانك نهائيًا ولا يمكنك التراجع عن هذا الإجراء", "mileage": "المسافة المقطوعة", @@ -461,6 +464,8 @@ "bookAndPay": "الحجز والدفع", "reservationAmounts": "مبالغ الحجز", "tax": "الضريبة", + "requester": "مقدم الطلب", + "mowaterAdvertisement": "شركة مواتر", "adsFilter": "تصفية الإعلانات", "searchByCity": "البحث حسب المدينة", "selectCity": "اختر المدينة", diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 360ffd2..6e2035b 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -339,6 +339,8 @@ "vehiclePart": "Vehicle Part", "damagePartPictures": "Damage Part Pictures", "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", "vehicleDamagePart": "Vehicle Damage Part", "duration": "Duration", "specialServices": "Special Services", @@ -423,6 +425,7 @@ "daysVar": "Days", "adCharges": "Ad Charges", "select": "Select", + "selectOption": "Select Option", "deleteAdConfirmation": "Do you want to delete the ad?", "deleteAdConfirmationMessage": "Your ad will be permanently deleted and you cannot undo this action", "mileage": "Mileage", @@ -465,6 +468,8 @@ "bookAndPay": "Book and Pay", "reservationAmounts": "Reservation Amounts", "tax": "Tax", + "requester": "Requester", + "mowaterAdvertisement": "Mowater Company", "adsFilter": "Ads Filter", "searchByCity": "Search By City", "selectCity": "Select City", diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 65a3218..6bf946a 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -87,6 +87,36 @@ class ApiClientImp implements ApiClient { return 'en'; } + // The "I/flutter (pid):" prefix on every line is added by Android's own logcat viewer + // when displaying a multi-line log message -- confirmed this happens regardless of how + // the app prints (debugPrint, plain print, single call or chunked), so it can't be + // avoided from here. Given that cleanup is unavoidable either way, use the shared + // `logger` (PrettyPrinter) for a readable structure instead of hand-rolled plain text. + // Gated by GlobalConsts.showApiResponseInLogs: off by default (URL + payload only, + // since response bodies are noisy and usually not needed), flip on to also see the + // response. + void _logApiCall({required String method, required String url, String? payload, String? response}) { + if (kReleaseMode) return; + logger.d("----- $method -----"); + logger.i(url); + if (payload != null && payload.isNotEmpty) { + logger.d("--- Payload ---"); + logger.i(_tryDecode(payload)); + } + if (GlobalConsts.showApiResponseInLogs && response != null) { + logger.d("--- Response ---"); + logger.i(_tryDecode(response)); + } + } + + dynamic _tryDecode(String raw) { + try { + return jsonDecode(raw); + } catch (_) { + return raw; + } + } + @override Future postJsonForObject(FactoryConstructor factoryConstructor, String url, T jsonObject, {String? token, Map? queryParameters, Map? headers, int retryTimes = 0}) async { @@ -154,14 +184,7 @@ class ApiClientImp implements ApiClient { response = await _post(Uri.parse(url), body: requestBody, headers: headers0).timeout(const Duration(seconds: 100)); - if (!kReleaseMode) { - logger.d("------URL------"); - logger.i(url); - logger.d("------Payload------"); - logger.i(jsonDecode(requestBody)); - logger.d("------Response------"); - logger.i(jsonDecode(response.body)); - } + _logApiCall(method: 'POST', url: url, payload: requestBody, response: response.body); if (response.statusCode >= 200 && response.statusCode < 500) { var jsonData = jsonDecode(response.body); if (jsonData["StatusMessage"] != null && jsonData["StatusMessage"] == "Unauthorized user attempt to access API") { @@ -270,17 +293,9 @@ class ApiClientImp implements ApiClient { if (isFirstCall) url = '$url?$queryString'; } - if (!kReleaseMode) { - log("---URL---"); - log(url); - log("---Params---"); - log("$queryParameters"); - } var response = await _get(Uri.parse(url), headers: headers0).timeout(const Duration(seconds: 120)); - if (!kReleaseMode) { - logger.i(jsonDecode(response.body)); - } + _logApiCall(method: 'GET', url: url, payload: queryParameters != null ? jsonEncode(queryParameters) : null, response: response.body); if (response.statusCode >= 200 && response.statusCode < 500) { var jsonData = jsonDecode(response.body); if (jsonData["StatusMessage"] != null && jsonData["StatusMessage"] == "Unauthorized user attempt to access API") { diff --git a/lib/classes/consts.dart b/lib/classes/consts.dart index a74e57c..76e72d8 100644 --- a/lib/classes/consts.dart +++ b/lib/classes/consts.dart @@ -212,6 +212,11 @@ class ApiConsts { } class GlobalConsts { + // When false, API logs print only the request URL and payload. When true, the + // response body is also printed. Flip this on only while actively debugging a + // specific API issue -- flip back off afterwards, since response bodies are noisy. + static bool showApiResponseInLogs = false; + static String appNameCustomer = "MOWATER Customer"; static String appNameProvider = "MOWATER Provider"; diff --git a/lib/extensions/string_extensions.dart b/lib/extensions/string_extensions.dart index 41e7c99..fc5da55 100644 --- a/lib/extensions/string_extensions.dart +++ b/lib/extensions/string_extensions.dart @@ -966,7 +966,7 @@ extension RequestOfferStatusEnumExt on int { } else if (this == 5) { return RequestOfferStatusEnum.cancel; } - return RequestOfferStatusEnum.cancel; + return RequestOfferStatusEnum.defaultStatus; } } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index b599eac..9ac84b7 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -352,6 +352,8 @@ class CodegenLoader extends AssetLoader{ "vehiclePart": "جزء المركبة", "damagePartPictures": "صور الأجزاء المتضررة", "qrCode": "رمز الاستجابة السريعة", + "downgradeAppliedAfterExpiry": "سيتم تطبيق هذه الباقة تلقائيًا بعد انتهاء صلاحية باقتك الحالية.", + "downgradeDeactivateInstruction": "لتحديد هذا الاشتراك، يجب عليك إلغاء تفعيل الفروع والمستخدمين حيث أن هذه الباقة تدعم فقط {branches} و {users}. يمكنك إعادة تفعيل هذه الفروع لاحقًا بعد ترقية الباقة", "vehicleDamagePart": "جزء المركبة المتضرر", "duration": "المدة", "specialServices": "الخدمات الخاصة", @@ -435,6 +437,7 @@ class CodegenLoader extends AssetLoader{ "daysVar": "يوم", "adCharges": "رسوم الإعلان", "select": "اختر", + "selectOption": "اختر خيار", "deleteAdConfirmation": "هل تريد حذف الإعلان؟", "deleteAdConfirmationMessage": "سيتم حذف إعلانك نهائيًا ولا يمكنك التراجع عن هذا الإجراء", "mileage": "المسافة المقطوعة", @@ -477,6 +480,8 @@ class CodegenLoader extends AssetLoader{ "bookAndPay": "الحجز والدفع", "reservationAmounts": "مبالغ الحجز", "tax": "الضريبة", + "requester": "مقدم الطلب", + "mowaterAdvertisement": "شركة مواتر", "adsFilter": "تصفية الإعلانات", "searchByCity": "البحث حسب المدينة", "selectCity": "اختر المدينة", @@ -1285,6 +1290,8 @@ static const Map _en_US = { "vehiclePart": "Vehicle Part", "damagePartPictures": "Damage Part Pictures", "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", "vehicleDamagePart": "Vehicle Damage Part", "duration": "Duration", "specialServices": "Special Services", @@ -1369,6 +1376,7 @@ static const Map _en_US = { "daysVar": "Days", "adCharges": "Ad Charges", "select": "Select", + "selectOption": "Select Option", "deleteAdConfirmation": "Do you want to delete the ad?", "deleteAdConfirmationMessage": "Your ad will be permanently deleted and you cannot undo this action", "mileage": "Mileage", @@ -1411,6 +1419,8 @@ static const Map _en_US = { "bookAndPay": "Book and Pay", "reservationAmounts": "Reservation Amounts", "tax": "Tax", + "requester": "Requester", + "mowaterAdvertisement": "Mowater Company", "adsFilter": "Ads Filter", "searchByCity": "Search By City", "selectCity": "Select City", diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 2d62590..8658a3f 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'; @@ -50,7 +50,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'; @@ -318,6 +318,8 @@ abstract class LocaleKeys { static const vehiclePart = 'vehiclePart'; static const damagePartPictures = 'damagePartPictures'; static const qrCode = 'qrCode'; + static const downgradeAppliedAfterExpiry = 'downgradeAppliedAfterExpiry'; + static const downgradeDeactivateInstruction = 'downgradeDeactivateInstruction'; static const vehicleDamagePart = 'vehicleDamagePart'; static const duration = 'duration'; static const specialServices = 'specialServices'; @@ -400,6 +402,7 @@ abstract class LocaleKeys { static const daysVar = 'daysVar'; static const adCharges = 'adCharges'; static const select = 'select'; + static const selectOption = 'selectOption'; static const deleteAdConfirmation = 'deleteAdConfirmation'; static const deleteAdConfirmationMessage = 'deleteAdConfirmationMessage'; static const mileage = 'mileage'; @@ -442,6 +445,8 @@ abstract class LocaleKeys { static const bookAndPay = 'bookAndPay'; static const reservationAmounts = 'reservationAmounts'; static const tax = 'tax'; + static const requester = 'requester'; + static const mowaterAdvertisement = 'mowaterAdvertisement'; static const adsFilter = 'adsFilter'; static const searchByCity = 'searchByCity'; static const selectCity = 'selectCity'; diff --git a/lib/models/subscriptions_models/subscription_model.dart b/lib/models/subscriptions_models/subscription_model.dart index ea7330d..6c0a6ca 100644 --- a/lib/models/subscriptions_models/subscription_model.dart +++ b/lib/models/subscriptions_models/subscription_model.dart @@ -118,7 +118,7 @@ class Subscription { subscriptionSubUsers: json["subscriptionSubUsers"], subscriptionAds: json["subscriptionAds"], totalBranches: json["totalBranches"], - totalSubUsers: json["totalBranches"], + totalSubUsers: json["totalSubUsers"], totalAds: json["totalAds"], branchesRemaining: json["branchesRemaining"], subUsersRemaining: json["subUsersRemaining"], diff --git a/lib/repositories/ads_repo.dart b/lib/repositories/ads_repo.dart index 332c82a..0e58ecf 100644 --- a/lib/repositories/ads_repo.dart +++ b/lib/repositories/ads_repo.dart @@ -483,7 +483,7 @@ class AdsRepoImp implements AdsRepo { return await getAdsPerSpecificIds(ids: selectedIdsString, reservedAds: reservedAds); } - Future> getAdsPerSpecificIds({required List ids, required List reservedAds}) async { + Future> getAdsPerSpecificIds({required List ids, required List reservedAds, bool applyReservationFilter = true}) async { var params = { "AdsIDs": ids, "isActive": "true", @@ -509,7 +509,7 @@ class AdsRepoImp implements AdsRepo { statusMap[reserved.adsID ?? 0] = (reserved.adsReserveStatus ?? 0).toAdRserveStatusEnum(); reserveIdMap[reserved.adsID ?? 0] = reserved.id ?? 0; // Store reserve ID - } + } log("📋 Status Map created: ${statusMap.length} entries"); statusMap.forEach((key, value) { @@ -531,15 +531,20 @@ class AdsRepoImp implements AdsRepo { } } - // 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}"); + if (applyReservationFilter) { + // 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) + // Only meaningful for the "My Reserved Ads" list — callers fetching a specific + // ad by id (e.g. deep links) should NOT filter, or every ad with no reservation + // history (the normal case) gets incorrectly dropped. + 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}"); + } } return vehicleAdsDetails; @@ -630,9 +635,15 @@ class AdsRepoImp implements AdsRepo { @override Future getAdDetailsById({required int adId}) async { try { - List ads = await getAdsPerSpecificIds(ids: [adId.toString()], reservedAds: []); + List ads = await getAdsPerSpecificIds(ids: [adId.toString()], reservedAds: [], applyReservationFilter: false); if (ads.isEmpty) return null; - return ads.first; + AdDetailsModel ad = ads.first; + // getAdsPerSpecificIds hardcodes isMyAd=true, which is only correct for its + // original "My Reserved Ads" use case. This method can fetch ANY ad (e.g. from a + // shared deep link), so recompute isMyAd from actual ownership instead. + final String? currentUserId = appState.getUser.data?.userInfo?.userId; + ad.isMyAd = currentUserId != null && ad.userID == currentUserId; + return ad; } catch (e) { logger.e(e.toString()); return null; diff --git a/lib/services/ad_deep_link_service.dart b/lib/services/ad_deep_link_service.dart index a2fdf74..2ce0591 100644 --- a/lib/services/ad_deep_link_service.dart +++ b/lib/services/ad_deep_link_service.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'dart:developer'; import 'package:app_links/app_links.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; import 'package:mc_common_app/config/routes.dart'; import 'package:mc_common_app/generated/locale_keys.g.dart'; -import 'package:mc_common_app/utils/navigator.dart'; +import 'package:mc_common_app/models/advertisment_models/ad_details_model.dart'; import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/view_models/ad_view_model.dart'; import 'package:provider/provider.dart'; @@ -18,27 +18,54 @@ import 'package:provider/provider.dart'; class AdDeepLinkService { static final AppLinks _appLinks = AppLinks(); static StreamSubscription? _subscription; + static Uri? _lastHandledUri; + + // Route name given to the inert, self-removing placeholder pushed from main.dart's + // onUnknownRoute for Android's native deep-link route forwarding (see that file for + // why it exists). It's pushed and removed again within a frame, so the route-settle + // polling below must ignore it rather than treat its brief presence/absence as a real + // navigation change -- otherwise the route name never reads as "stable" and _openAd + // gives up without ever opening the ad. + static const String ignoredPlaceholderRouteName = '__ad_deep_link_ignore__'; + + static String? _currentTopRouteName; + + /// Register via MaterialApp(navigatorObservers: [AdDeepLinkService.routeObserver]). + /// This is the only reliable way to know the current top route name from here -- + /// confirmed on a real device: ModalRoute.of(navigatorKey.currentContext) does NOT + /// work for this. navigatorKey.currentContext is the Navigator widget's OWN element, + /// which sits ABOVE all its routes in the tree, so it has no ModalRoute ancestor and + /// that call returns null essentially always. That silently made every route-settle + /// poll below read null forever, so _openAd always timed out and gave up without ever + /// opening the ad, regardless of login state or what screen was actually showing. + static final NavigatorObserver routeObserver = _RouteNameTracker(); /// Call once, after the app's first frame (so [navigatorKey] is attached to a Navigator). static Future init() async { + debugPrint("AdDeepLinkService: init() called"); try { final Uri? initialUri = await _appLinks.getInitialLink(); + debugPrint("AdDeepLinkService: getInitialLink() -> $initialUri"); if (initialUri != null) { _handleUri(initialUri); } } catch (e) { - log("AdDeepLinkService: failed to read initial link - $e"); + debugPrint("AdDeepLinkService: failed to read initial link - $e"); } _subscription?.cancel(); _subscription = _appLinks.uriLinkStream.listen( - _handleUri, - onError: (Object e) => log("AdDeepLinkService: stream error - $e"), + (uri) { + debugPrint("AdDeepLinkService: uriLinkStream emitted $uri"); + _handleUri(uri); + }, + onError: (Object e) => debugPrint("AdDeepLinkService: stream error - $e"), ); + debugPrint("AdDeepLinkService: uriLinkStream subscription active"); } static void _handleUri(Uri uri) { - log("AdDeepLinkService: received $uri"); + debugPrint("AdDeepLinkService: received $uri"); // https://ms.hmg.com/ad/1541 -> host="ms.hmg.com", pathSegments=["ad", "1541"] // mowatercustomer://ad/1541 -> host="ad" (Dart treats it as the authority), pathSegments=["1541"] @@ -53,22 +80,127 @@ class AdDeepLinkService { } if (adId == null) return; + + // app_links can emit the same initial uri twice (once via getInitialLink, once as + // the stream's first event) -- skip the duplicate rather than run _openAd twice. + if (uri == _lastHandledUri) { + debugPrint("AdDeepLinkService: ignoring duplicate $uri"); + return; + } + _lastHandledUri = uri; _openAd(adId); } + // Deliberately no loading dialog anywhere in here: on a cold start (app was killed), + // this races the app's own bootstrap navigation -- confirmed on a real device, the + // bootstrap chain is SplashPage's hardcoded 5s timer -> pushReplacementNamed to + // registerSelection -> (if a saved login exists) a silent auto-login with its own + // showLoading/hideLoading dialog -> pushReplacementNamed again towards the dashboard. + // Every one of those calls uses a *captured* BuildContext and mutates the same + // Navigator via Navigator.of(context), regardless of what else has since been pushed + // on top. If we push the ad route while any of that is still in flight, our route gets + // clobbered by the next replacement -- in debug this throws "_elements.contains + // (element)"; in release (asserts stripped) it silently wedges the Navigator and the + // app hangs on a black screen. So: wait for the current route name to stay the same + // across two consecutive polls (i.e. bootstrap has actually settled) before touching + // the Navigator or fetching anything. static Future _openAd(int adId) async { - final context = navigatorKey.currentContext; - if (context == null) return; + debugPrint("AdDeepLinkService: _openAd($adId) called"); + if (!await _waitUntilRouteSettles()) { + debugPrint("AdDeepLinkService: app never settled past bootstrap, aborting"); + return; + } + debugPrint("AdDeepLinkService: route settled at '$_currentTopRouteName'"); - Utils.showLoading(context); - final adDetails = await context.read().getAdDetailsById(adId); - Utils.hideLoading(context); + // Checking for a saved token here is NOT a reliable "is the user ready" signal -- + // confirmed on a real device: a stale token from before the app was killed survives + // on disk through the whole re-login flow (splash -> registerSelection -> silent + // relogin -> loginMethodSelection OTP screen), so it reads as "already logged in" + // while still sitting on the OTP screen, fetches too early with no real session + // ready yet, and silently fails. The dashboard route is the one signal that's only + // true once a real session is actually in place, however the user got there. + if (_currentTopRouteName != AppRoutes.dashboard) { + debugPrint("AdDeepLinkService: not on dashboard yet ('$_currentTopRouteName'), waiting for login to finish"); + if (!await _waitForRoute(AppRoutes.dashboard)) { + debugPrint("AdDeepLinkService: gave up waiting for dashboard, aborting"); + return; + } + debugPrint("AdDeepLinkService: reached dashboard"); + } else { + debugPrint("AdDeepLinkService: already on dashboard, proceeding directly"); + } + + debugPrint("AdDeepLinkService: fetching ad $adId"); + AdDetailsModel? adDetails; + try { + adDetails = await navigatorKey.currentContext?.read().getAdDetailsById(adId); + debugPrint("AdDeepLinkService: fetch result adDetails=${adDetails != null ? 'FOUND id=${adDetails.id}' : 'NULL'}"); + } catch (e) { + debugPrint("AdDeepLinkService: failed to fetch ad $adId - $e"); + } if (adDetails == null) { Utils.showToast(LocaleKeys.somethingWrong.tr()); return; } - navigateWithName(context, AppRoutes.adsDetailView, arguments: adDetails); + // Navigate via the NavigatorState directly (not a captured BuildContext) so this + // still works even if the route/context active when we started has since changed. + debugPrint("AdDeepLinkService: pushing ${AppRoutes.adsDetailView}"); + navigatorKey.currentState?.pushNamed(AppRoutes.adsDetailView, arguments: adDetails); + } + + static String? _currentRouteNameIgnoringPlaceholder() { + final name = _currentTopRouteName; + // The placeholder pops itself a frame after being pushed -- if this poll happens to + // land exactly then, pretend nothing was read rather than counting it as a route. + return name == ignoredPlaceholderRouteName ? null : name; + } + + static Future _waitUntilRouteSettles({int maxAttempts = 60, Duration retryDelay = const Duration(milliseconds: 300)}) async { + String? lastRouteName; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + final currentRouteName = _currentRouteNameIgnoringPlaceholder(); + + if (currentRouteName != null && currentRouteName != AppRoutes.splash && currentRouteName == lastRouteName) { + return true; + } + if (currentRouteName != null) lastRouteName = currentRouteName; + if (attempt < maxAttempts) await Future.delayed(retryDelay); + } + return false; + } + + // Used both for the short "did bootstrap settle" check and for waiting on the + // dashboard specifically, which can be user-paced (entering an OTP, picking a login + // method) -- default timeout is generous (5 minutes) to cover that case. + static Future _waitForRoute(String targetRoute, {int maxAttempts = 600, Duration retryDelay = const Duration(milliseconds: 500)}) async { + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + if (_currentRouteNameIgnoringPlaceholder() == targetRoute) return true; + if (attempt < maxAttempts) await Future.delayed(retryDelay); + } + return false; + } +} + +class _RouteNameTracker extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + AdDeepLinkService._currentTopRouteName = route.settings.name; + } + + @override + void didPop(Route route, Route? previousRoute) { + AdDeepLinkService._currentTopRouteName = previousRoute?.settings.name; + } + + @override + void didRemove(Route route, Route? previousRoute) { + AdDeepLinkService._currentTopRouteName = previousRoute?.settings.name; + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + AdDeepLinkService._currentTopRouteName = newRoute?.settings.name; } } diff --git a/lib/services/firebase_service.dart b/lib/services/firebase_service.dart index 5a16329..45192c4 100644 --- a/lib/services/firebase_service.dart +++ b/lib/services/firebase_service.dart @@ -12,6 +12,7 @@ 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/enums.dart'; import 'package:mc_common_app/utils/navigator.dart'; import 'package:mc_common_app/view_models/chat_view_model.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -270,8 +271,17 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { return; } - log("✅ Navigating to providers chat list for request: $requestId"); - navigateWithName(context, AppRoutes.providersChatListPage); + if (AppState().currentAppType == AppType.customer) { + // Customer notification tap: land on this request's offers list, matching + // the normal (non-notification) customer flow in requests_fragment.dart. + // ProvidersChatListPage below is provider-only (it reads the current + // user's own providerId), so routing a customer there fails. + log("✅ Navigating to offers list for request: $requestId"); + navigateWithName(context, AppRoutes.offersListPage, arguments: requestId); + } else { + 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 @@ -287,11 +297,33 @@ class FirebaseMessagingServiceImp implements FirebaseMessagingService { 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']; + + // The "other participant" for the chat we're about to open/keep messaging in is + // whoever SENT the message that triggered this notification — NOT whatever the + // payload calls the "receiver". Backend has repeatedly mixed up sender/receiver + // fields in this payload (see firebase_notification_payload_format.md); if we + // blindly trust a "receiver" field that turns out to be OUR OWN id, every message + // we send afterwards gets addressed back to ourselves, and we start getting + // pushed notifications for our own messages. Prefer sender_id; fall back to the + // old receiver fields only if sender fields are missing entirely. + String? receiverUserId = data['SenderUserID'] ?? data['sender_id'] ?? data['ReceiverUserID'] ?? data['receiver_id']; + + log("📦 Ad chat notification payload fields — " + "AdID: ${data['AdID']}, ad_id: ${data['ad_id']}, " + "SenderUserID: ${data['SenderUserID']}, sender_id: ${data['sender_id']}, " + "ReceiverUserID: ${data['ReceiverUserID']}, receiver_id: ${data['receiver_id']} " + "-> resolved receiverUserId: $receiverUserId"); + + final String? myUserId = AppState().getUser.data?.userInfo?.userId?.toString(); + if (myUserId != null && receiverUserId == myUserId) { + log("⚠️ Resolved receiverUserId ($receiverUserId) matches the current user's own id — " + "the notification payload is likely still misidentifying sender/receiver. " + "Proceeding anyway, but messages sent from this chat may misroute."); + } if (adId == 0 || receiverUserId == null) { - log("❌ Invalid AdID or ReceiverUserID in notification data"); - log(" AdID: ${data['AdID']}, ReceiverUserID: ${data['ReceiverUserID']}"); + log("❌ Invalid AdID or sender/receiver id in notification data"); + log(" AdID: ${data['AdID']}, resolved receiverUserId: $receiverUserId"); return; } diff --git a/lib/utils/enums.dart b/lib/utils/enums.dart index c6feccb..26fbb60 100644 --- a/lib/utils/enums.dart +++ b/lib/utils/enums.dart @@ -211,6 +211,7 @@ enum RequestOfferStatusEnum { accepted, rejected, cancel, + defaultStatus, } enum ChatTypeEnum { diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index d4578cc..39747e1 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -16,9 +16,11 @@ import 'package:mc_common_app/widgets/common_widgets/info_bottom_sheet.dart'; import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; import 'package:path/path.dart' as p; +import 'package:mc_common_app/classes/app_state.dart'; import 'package:mc_common_app/classes/consts.dart'; import 'package:mc_common_app/exceptions/api_exception.dart'; import 'package:mc_common_app/extensions/string_extensions.dart'; +import 'package:mc_common_app/models/advertisment_models/ad_details_model.dart'; import 'package:mc_common_app/theme/colors.dart'; import 'package:mc_common_app/utils/enums.dart'; import 'package:mc_common_app/widgets/loading_dialog.dart'; @@ -32,6 +34,16 @@ class Utils { static bool get isLoading => _isLoadingVisible; + /// Dynamic ad link - opens directly in the app if installed, otherwise falls back + /// to the store via the hosted web page for this path. The "app" query param tells + /// that page whether to target the customer or provider app, since both apps support + /// ad sharing. Used by both the Share button and the QR code sheet, so they always + /// point to the exact same destination. + static String buildAdShareLink(AdDetailsModel adDetails) { + final String appParam = AppState().currentAppType == AppType.customer ? 'customer' : 'provider'; + return 'https://ms.hmg.com/ad/${adDetails.id}?app=$appParam'; + } + static void showToast(String message) { toastification.show( type: ToastificationType.info, @@ -346,6 +358,9 @@ class Utils { static Color getChipColorByRequestOfferStatusEnum(RequestOfferStatusEnum requestOfferStatusEnum) { switch (requestOfferStatusEnum) { + case RequestOfferStatusEnum.defaultStatus: + return MyColors.lightTextColor; + case RequestOfferStatusEnum.offer: return MyColors.pendingColor; @@ -364,6 +379,8 @@ class Utils { static String getNameByRequestOfferStatusEnum(RequestOfferStatusEnum requestOfferStatusEnum) { switch (requestOfferStatusEnum) { + case RequestOfferStatusEnum.defaultStatus: + return ""; case RequestOfferStatusEnum.offer: return LocaleKeys.offer.tr(); case RequestOfferStatusEnum.negotiate: @@ -528,6 +545,9 @@ class Utils { Color chipColor = MyColors.greenColor, Color textColor = MyColors.white, }) { + if (text.isEmpty) { + return const SizedBox.shrink(); + } return Container( decoration: BoxDecoration( color: chipColor, diff --git a/lib/view_models/chat_view_model.dart b/lib/view_models/chat_view_model.dart index 70e3e3d..3a9d4bf 100644 --- a/lib/view_models/chat_view_model.dart +++ b/lib/view_models/chat_view_model.dart @@ -996,14 +996,14 @@ class ChatVM extends BaseVM { Future onNewMessageReceivedForAds({required List messages}) async { final myUserId = AppState().getUser.data?.userInfo?.userId?.toString(); - + for (var msg in messages) { // Skip if this is my own message echoed back from server if (myUserId != null && msg.senderUserID == myUserId) { log("Skipping own message echoed from server (sender: ${msg.senderUserID})"); continue; } - + // Check if message already exists to prevent duplicates bool messageExists = false; @@ -1061,13 +1061,13 @@ class ChatVM extends BaseVM { notifyListeners(); return false; } - + // Extra verification: ensure connection ID is available if (hubConnection!.connectionId == null) { logger.w("Connection ID not yet available, waiting a bit more..."); await Future.delayed(const Duration(milliseconds: 500)); } - + logger.i("Connection ready! ConnectionId: ${hubConnection!.connectionId}"); } @@ -1076,7 +1076,7 @@ class ChatVM extends BaseVM { final name = AppState().getUser.data!.userInfo!.firstName.toString(); final obj = { "ReceiverUserID": receiverId, - "SenderUserID": userId, // ✅ Send SenderUserID so backend knows who sent it + "SenderUserID": userId, // ✅ Send SenderUserID so backend knows who sent it "MessageType": chatMessageType.getIdFromChatMessageTypeEnum(), "ChatText": message, "AdsID": adId, 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 da086bc..eea5105 100644 --- a/lib/views/advertisement/ads_detail_view/ads_detail_view.dart +++ b/lib/views/advertisement/ads_detail_view/ads_detail_view.dart @@ -93,7 +93,6 @@ class _AdsDetailViewState extends State { Widget buildVehicleDetailsWidget() { bool isArabic = AppState().isArabic(context); - print("====================== ${widget.adDetails.vehicle!.currency }"); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -205,11 +204,12 @@ class _AdsDetailViewState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ widget.adDetails.vehicle!.demandAmount!.toInt().toString().toText(fontSize: 30, height: 1.2, isBold: true), - // (widget.adDetails.vehicle!.currency ?? LocaleKeys.sar.tr()).toText(fontSize: 15, color: MyColors.lightTextColor).paddingOnly(bottom: 5), - ( (isArabic ? widget.adDetails.vehicle!.currencyN : widget.adDetails.vehicle!.currency ) ?? '').toText( - fontSize: 15, - color: MyColors.lightTextColor, - ) + // (widget.adDetails.vehicle!.currency ?? LocaleKeys.sar.tr()).toText(fontSize: 15, color: MyColors.lightTextColor).paddingOnly(bottom: 5), + ((isArabic ? widget.adDetails.vehicle!.currencyN : widget.adDetails.vehicle!.currency) ?? '') + .toText( + fontSize: 15, + color: MyColors.lightTextColor, + ) .paddingOnly(bottom: 5), ], ), @@ -745,12 +745,7 @@ class ShareAdButton extends StatelessWidget { } shareMessage += '\n'; - // Dynamic ad link - opens directly in the app if installed, otherwise falls - // back to the store via the hosted web page for this path. The "app" query - // param tells that page whether to target the customer or provider app, - // since both apps support ad sharing. - final String appParam = AppState().currentAppType == AppType.customer ? 'customer' : 'provider'; - shareMessage += 'https://ms.hmg.com/ad/${adDetails.id}?app=$appParam\n'; + shareMessage += '${Utils.buildAdShareLink(adDetails)}\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 2933785..745e8c0 100644 --- a/lib/views/advertisement/ads_detail_view/components.dart +++ b/lib/views/advertisement/ads_detail_view/components.dart @@ -41,11 +41,26 @@ class BuildAdDetailsActionButtonForExploreAds extends StatelessWidget { isScrollControlled: true, enableDrag: true, builder: (BuildContext context) { + double carPrice = adDetailsModel.vehicle!.demandAmount ?? 0.0; + double specialServicesTotal = (adDetailsModel.specialservice ?? []).fold(0.0, (sum, service) => sum + (service.price ?? 0.0)); + double taxPrice = adDetailsModel.taxPrice ?? 0.0; + double reservePrice = adDetailsModel.reservePrice ?? 0.0; + double remainingAmount = carPrice + specialServicesTotal + taxPrice; + double totalAmount = reservePrice + remainingAmount; return InfoBottomSheet( title: LocaleKeys.reserveAd.tr().toText(fontSize: 24, isBold: true), description: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Every ad reaching this sheet is admin-created (Reserve Ad is only + // offered for admin/Mowater-owned ads), so this is always accurate. + Row( + children: [ + "${LocaleKeys.requester.tr()}: ".toText(fontSize: 14, color: MyColors.lightTextColor), + LocaleKeys.mowaterAdvertisement.tr().toText(fontSize: 14, isBold: true), + ], + ), + 12.height, // Show instructions if available if (adDetailsModel.reserveCustomerMessage != null && adDetailsModel.reserveCustomerMessage!.isNotEmpty) ...[ Container( @@ -77,7 +92,7 @@ class BuildAdDetailsActionButtonForExploreAds extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - "${adDetailsModel.reservePrice}".toText(fontSize: 19, isBold: true), + "$reservePrice".toText(fontSize: 19, isBold: true), 2.width, (adDetailsModel.vehicle!.currency ?? LocaleKeys.sar.tr()).toText(color: MyColors.lightTextColor, fontSize: 10, isBold: true).paddingOnly(bottom: 3), ], @@ -85,57 +100,61 @@ class BuildAdDetailsActionButtonForExploreAds extends StatelessWidget { ], ), const Divider(), - LocaleKeys.belowAmountPayLater.tr().toText(fontSize: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, children: [ - LocaleKeys.carPrice.tr().toText(fontSize: 16, isBold: true), + LocaleKeys.belowAmountPayLater.tr().toText(fontSize: 14, isBold: true), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - "${adDetailsModel.vehicle!.demandAmount ?? 0.0}".toText(fontSize: 19, isBold: true), + "$remainingAmount".toText(fontSize: 19, isBold: true), 2.width, (adDetailsModel.vehicle!.currency ?? LocaleKeys.sar.tr()).toText(color: MyColors.lightTextColor, fontSize: 10, isBold: true).paddingOnly(bottom: 3), ], ) ], ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - LocaleKeys.vatExcluded.tr().toText(fontSize: 10, isBold: true), - ], - ), - const Divider(), - LocaleKeys.specialService.tr().toText(fontSize: 16, isBold: true), - 5.height, + 8.height, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - LocaleKeys.carInsuranceService.tr().toText(fontSize: 14, color: MyColors.lightTextColor, fontWeight: FontWeight.w500), - LocaleKeys.toBeDecided.tr().toText(fontSize: 12, isBold: true), + LocaleKeys.carPrice.tr().toText(fontSize: 16, isBold: true), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + "$carPrice".toText(fontSize: 19, isBold: true), + 2.width, + (adDetailsModel.vehicle!.currency ?? LocaleKeys.sar.tr()).toText(color: MyColors.lightTextColor, fontSize: 10, isBold: true).paddingOnly(bottom: 3), + ], + ) ], ), - 5.height, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - LocaleKeys.registrationCarPlates.tr().toText(fontSize: 14, color: MyColors.lightTextColor), - LocaleKeys.toBeDecided.tr().toText(fontSize: 12, isBold: true), + if (adDetailsModel.specialservice != null && adDetailsModel.specialservice!.isNotEmpty) ...[ + const Divider(), + LocaleKeys.specialService.tr().toText(fontSize: 16, isBold: true), + 5.height, + for (var service in adDetailsModel.specialservice!) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: (service.name ?? "").toText(fontSize: 14, color: MyColors.lightTextColor, fontWeight: FontWeight.w500)), + "${service.price ?? 0.0} ${adDetailsModel.vehicle!.currency ?? LocaleKeys.sar.tr()}".toText(fontSize: 12, isBold: true), + ], + ), + 5.height, ], - ), - 5.height, + ], + 12.height, + LocaleKeys.specialServicechargesInsuranceDeliveryLocation.tr().toText(fontSize: 12, maxLines: 2), + 12.height, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - LocaleKeys.homeDeliveryService.tr().toText(fontSize: 14, color: MyColors.lightTextColor), - LocaleKeys.toBeDecided.tr().toText(fontSize: 12, isBold: true), + LocaleKeys.tax.tr().toText(fontSize: 14, color: MyColors.lightTextColor, fontWeight: FontWeight.w500), + "$taxPrice ${adDetailsModel.vehicle!.currency ?? LocaleKeys.sar.tr()}".toText(fontSize: 12, isBold: true), ], ), - 12.height, - LocaleKeys.specialServicechargesInsuranceDeliveryLocation.tr().toText(fontSize: 12, maxLines: 2), 30.height, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -144,7 +163,7 @@ class BuildAdDetailsActionButtonForExploreAds extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - "${(adDetailsModel.vehicle!.demandAmount ?? 0.0)}".toText(fontSize: 19, isBold: true), + "$totalAmount".toText(fontSize: 19, isBold: true), 2.width, (adDetailsModel.vehicle!.currency ?? LocaleKeys.sar.tr()).toText(color: MyColors.lightTextColor, fontSize: 10, isBold: true).paddingOnly(bottom: 3), ], diff --git a/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart b/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart index 3bc8a48..e144dfa 100644 --- a/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart +++ b/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart @@ -1,13 +1,12 @@ -import 'dart:developer'; - -import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:mc_common_app/extensions/int_extensions.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/models/advertisment_models/ad_details_model.dart'; +import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; +import 'package:qr_flutter/qr_flutter.dart'; class AdQrCodeSheet extends StatelessWidget { final AdDetailsModel adDetails; @@ -16,7 +15,9 @@ class AdQrCodeSheet extends StatelessWidget { @override Widget build(BuildContext context) { - log("dDetails.qrCodePath: ${adDetails.qrCodePath}"); + // Encode the exact same link the Share button sends, so scanning this QR from + // another phone lands on the same dynamic link and opens the app directly. + final String shareLink = Utils.buildAdShareLink(adDetails); return SizedBox( width: double.infinity, child: Column( @@ -28,15 +29,10 @@ class AdQrCodeSheet extends StatelessWidget { ], ), 20.height, - SizedBox( - height: 220, - width: 220, - child: CachedNetworkImage( - imageUrl: "${adDetails.qrCodePath}", - placeholder: (context, url) => const Center(child: CircularProgressIndicator()), - errorWidget: (context, url, error) => const Icon(Icons.error), - fit: BoxFit.contain, - ), + QrImageView( + data: shareLink, + size: 220, + padding: EdgeInsets.zero, ), 20.height, ], diff --git a/lib/views/chat/chat_view.dart b/lib/views/chat/chat_view.dart index f239962..c7f7bdb 100644 --- a/lib/views/chat/chat_view.dart +++ b/lib/views/chat/chat_view.dart @@ -15,6 +15,7 @@ import 'package:mc_common_app/view_models/dashboard_view_model_provider.dart'; import 'package:mc_common_app/view_models/payment_view_model.dart'; import 'package:mc_common_app/view_models/requests_view_model.dart'; import 'package:mc_common_app/views/advertisement/components/picked_images_container_widget.dart'; +import 'package:mc_common_app/widgets/bottom_sheet.dart'; import 'package:mc_common_app/views/chat/widgets/chat_bottom_sheets.dart'; import 'package:mc_common_app/views/chat/widgets/chat_message_widget.dart'; import 'package:mc_common_app/views/requests/request_bottomsheets.dart'; @@ -123,6 +124,102 @@ class _ChatViewState extends State { return status; } + void _openAttachmentOptions(BuildContext context, ChatVM chatVM, RequestsVM requestVM) { + final bool canProvideOffer = + AppState().currentAppType == AppType.provider && chatTypeEnum == ChatTypeEnum.requestOffer && requestVM.currentSelectedRequest!.requestStatus == RequestStatusEnum.submitted; + + showMyBottomSheet( + context, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 21, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LocaleKeys.selectOption.tr().toText(fontSize: 18, isBold: true), + 12.height, + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + if (canProvideOffer) + _buildAttachmentOptionTile( + icon: Icons.local_offer_rounded, + label: LocaleKeys.provideOffer.tr(), + backgroundColor: MyColors.darkPrimaryColor, + iconColor: MyColors.white, + onTap: () { + Navigator.pop(context); + requestVM.resetSendOfferBottomSheet(); + RequestDetailPageArguments requestDetailArguments = RequestDetailPageArguments( + requestIndex: chatViewArgumentsForRequest!.requestIndex, + requestModel: chatViewArgumentsForRequest!.requestModel!, + ); + context.read().checkUserSubscription(SubscriptionActionTypeEnum.subscription, context, callback: () { + buildSendOfferBottomSheet( + context: context, + requestDetailPageArguments: requestDetailArguments, + isFromChatScreen: true, + offerId: null, // null means creating new offer + ); + }); + }, + ), + _buildAttachmentOptionTile( + icon: Icons.photo_library_rounded, + label: LocaleKeys.attachImage.tr(), + backgroundColor: MyColors.darkPrimaryColor, + iconColor: MyColors.white, + onTap: () { + Navigator.pop(context); + chatVM.pickMultipleImages(); + }, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildAttachmentOptionTile({ + required IconData icon, + required String label, + required VoidCallback onTap, + Color backgroundColor = MyColors.greyButtonColor, + Color iconColor = MyColors.darkPrimaryColor, + }) { + return InkWell( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6), + decoration: BoxDecoration( + border: Border.all(color: MyColors.darkPrimaryColor, width: 1), + borderRadius: BorderRadius.zero, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 50, + width: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: backgroundColor, + ), + child: Icon(icon, color: iconColor, size: 24), + ), + 6.height, + label.toText(fontSize: 12, textAlign: TextAlign.center, maxLines: 2), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { Widget? appBarHeadlines; @@ -205,22 +302,28 @@ class _ChatViewState extends State { ], Expanded( child: chatMessages.isEmpty - ? Center(child: LocaleKeys.noChatMessage.tr().toText(fontSize: 16, color: MyColors.lightTextColor, textAlign: TextAlign.center)).paddingAll(22) + ? Center( + child: LocaleKeys.noChatMessage.tr().toText( + fontSize: 16, + color: MyColors.lightTextColor, + textAlign: TextAlign.center, + fontWeight: FontWeight.w500, + )).paddingAll(22) : ListView.separated( - controller: chatVM.scrollController, - itemCount: chatMessages.length, - separatorBuilder: (BuildContext context, int index) => 20.height, - itemBuilder: (BuildContext context, int index) { - ChatMessageModel chatMessageModel = chatMessages[index]; - return ChatMessageCustomWidget( - chatMessageModel: chatMessageModel, - requestModel: chatTypeEnum == ChatTypeEnum.requestOffer ? chatViewArgumentsForRequest!.requestModel! : null, - requestStatusEnum: requestVM.currentSelectedRequest?.requestStatus, - chatTypeEnum: chatTypeEnum, - requestsTypeEnum: chatTypeEnum == ChatTypeEnum.requestOffer ? chatViewArgumentsForRequest!.requestModel!.requestType.toRequestTypeEnum() : RequestsTypeEnum.specialCarRequest, - ); - }, - ).horPaddingMain(), + controller: chatVM.scrollController, + itemCount: chatMessages.length, + separatorBuilder: (BuildContext context, int index) => 20.height, + itemBuilder: (BuildContext context, int index) { + ChatMessageModel chatMessageModel = chatMessages[index]; + return ChatMessageCustomWidget( + chatMessageModel: chatMessageModel, + requestModel: chatTypeEnum == ChatTypeEnum.requestOffer ? chatViewArgumentsForRequest!.requestModel! : null, + requestStatusEnum: requestVM.currentSelectedRequest?.requestStatus, + chatTypeEnum: chatTypeEnum, + requestsTypeEnum: chatTypeEnum == ChatTypeEnum.requestOffer ? chatViewArgumentsForRequest!.requestModel!.requestType.toRequestTypeEnum() : RequestsTypeEnum.specialCarRequest, + ); + }, + ).horPaddingMain(), ), 10.height, Row( @@ -242,10 +345,10 @@ class _ChatViewState extends State { onPressed: requestVM.isRefreshingAfterAccept ? null : () { - context.read().updateRequestId(id: requestVM.currentSelectedRequest!.id); - requestVM.selectedDeliveryOptionEnum = null; - navigateWithName(context, AppRoutes.reviewRequestOffer, arguments: PaymentTypes.request); - }, + context.read().updateRequestId(id: requestVM.currentSelectedRequest!.id); + requestVM.selectedDeliveryOptionEnum = null; + navigateWithName(context, AppRoutes.reviewRequestOffer, arguments: PaymentTypes.request); + }, ), ), ] @@ -269,100 +372,73 @@ class _ChatViewState extends State { // ), // ] // - else ...[ - if (AppState().currentAppType == AppType.provider && - chatTypeEnum == ChatTypeEnum.requestOffer && - requestVM.currentSelectedRequest!.requestStatus == RequestStatusEnum.submitted && - chatVM.pickedImagesForMessage.isEmpty) ...[ - Expanded( - flex: 1, - child: const Icon( - Icons.local_offer_rounded, - color: MyColors.darkPrimaryColor, - size: 30, - ).onPress( - () { - requestVM.resetSendOfferBottomSheet(); - RequestDetailPageArguments requestDetailArguments = RequestDetailPageArguments( - requestIndex: chatViewArgumentsForRequest!.requestIndex, - requestModel: chatViewArgumentsForRequest!.requestModel!, - ); - context.read().checkUserSubscription(SubscriptionActionTypeEnum.subscription, context, callback: () { - buildSendOfferBottomSheet( - context: context, - requestDetailPageArguments: requestDetailArguments, - isFromChatScreen: true, - offerId: null, // null means creating new offer - ); - }); - }, + else + ...[ + if (chatVM.pickedImagesForMessage.isNotEmpty) ...[ + Expanded( + flex: 10, + child: PickedFilesContainer( + pickedFiles: chatVM.pickedImagesForMessage, + onCrossPressedPrimary: chatVM.removeImageFromList, + onAddFilePressed: () => chatVM.pickMultipleImages(), + ), ), - ), - ], - if (chatVM.pickedImagesForMessage.isNotEmpty) ...[ - Expanded( - flex: 8, - child: PickedFilesContainer( - pickedFiles: chatVM.pickedImagesForMessage, - onCrossPressedPrimary: chatVM.removeImageFromList, - onAddFilePressed: () => chatVM.pickMultipleImages(), + ] else + if (chatTypeEnum == ChatTypeEnum.requestOffer) ...[ + Expanded( + flex: 1, + child: const Icon( + Icons.add_circle_outline, + color: MyColors.darkPrimaryColor, + size: 30, + ).onPress(() => _openAttachmentOptions(context, chatVM, requestVM)), + ), + ], + if (chatVM.pickedImagesForMessage.isEmpty) ...[ + Expanded( + flex: 10, + child: TxtField( + isNeedLabelOnTop: false, + value: chatVM.chatMessageText, + hint: LocaleKeys.typeMessageHere.tr(), + keyboardType: TextInputType.text, + isNeedBorder: false, + onChanged: (v) => chatVM.updateChatMessageText(v), + ), ), - ), - ] else if (chatTypeEnum == ChatTypeEnum.requestOffer) ...[ + ], Expanded( flex: 1, - child: const Icon( - Icons.photo_library_rounded, - color: MyColors.darkPrimaryColor, - size: 30, - ).onPress(() => chatVM.pickMultipleImages()), - ), - ], - if (chatVM.pickedImagesForMessage.isEmpty) ...[ - Expanded( - flex: 8, - child: TxtField( - isNeedLabelOnTop: false, - value: chatVM.chatMessageText, - hint: LocaleKeys.typeMessageHere.tr(), - keyboardType: TextInputType.text, - isNeedBorder: false, - onChanged: (v) => chatVM.updateChatMessageText(v), - ), - ), - ], - Expanded( - flex: 1, - 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( + 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; + 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(); + } + } + }, + ), + ), + ], ], ).toContainer(isShadowEnabled: true) ], diff --git a/lib/views/chat/widgets/chat_message_widget.dart b/lib/views/chat/widgets/chat_message_widget.dart index 171bdb0..dea2cbf 100644 --- a/lib/views/chat/widgets/chat_message_widget.dart +++ b/lib/views/chat/widgets/chat_message_widget.dart @@ -252,18 +252,18 @@ class _ChatMessageCustomWidgetState extends State { chatVM.serviceProviderOffersList[index].requestOfferStatusEnum = chatMessageModel.reqOffer!.requestOfferStatusEnum; } setState(() {}); - + // Set loading state before refreshing requestVM.updateIsRefreshingAfterAccept(true); - + // Refresh requests list to get updated data await requestVM.getRequestsBasedOnFilters(); - + // Clear loading state after refresh completes requestVM.updateIsRefreshingAfterAccept(false); - + Utils.showToast(LocaleKeys.offerAccepted.tr()); - + return true; } else { return false; @@ -474,16 +474,16 @@ class _ChatMessageCustomWidgetState extends State { } setState(() {}); // Navigator.pop(context); - + // Set loading state before refreshing requestVM.updateIsRefreshingAfterAccept(true); - + // Refresh requests list to get updated data await requestVM.getRequestsBasedOnFilters(); - + // Clear loading state after refresh completes requestVM.updateIsRefreshingAfterAccept(false); - + Utils.showToast(LocaleKeys.offerAccepted.tr()); // navigateReplaceWithName(context, AppRoutes.dashboard); } @@ -559,6 +559,8 @@ class _ChatMessageCustomWidgetState extends State { } switch (requestOfferStatusEnum) { + case RequestOfferStatusEnum.defaultStatus: + return const SizedBox.shrink(); case RequestOfferStatusEnum.offer: return Column( children: [ diff --git a/lib/views/requests/offer_list_page.dart b/lib/views/requests/offer_list_page.dart index 060f9f7..5de6027 100644 --- a/lib/views/requests/offer_list_page.dart +++ b/lib/views/requests/offer_list_page.dart @@ -38,10 +38,32 @@ class _OfferListPageState extends State { _onRefresh() async { scheduleMicrotask(() async { ChatVM chatVM = context.read(); + RequestsVM requestsVM = context.read(); + await _ensureCurrentSelectedRequest(requestsVM); await chatVM.getOffersFromProvidersByRequest(requestId: widget.requestId); }); } + // Normally requests_fragment.dart sets currentSelectedRequest right before navigating + // here. When this page is opened directly (e.g. from a notification), that never + // happens, leaving it null/stale and crashing ChatView. Look it up ourselves instead, + // switching request-type filter and retrying if not found in the current one. + Future _ensureCurrentSelectedRequest(RequestsVM requestsVM) async { + if (requestsVM.currentSelectedRequest?.id == widget.requestId) return; + + int index = requestsVM.myFilteredRequests.indexWhere((request) => request.id == widget.requestId); + if (index == -1 && requestsVM.requestsTypeFilterOptions.isNotEmpty) { + RequestsTypeEnum currentType = requestsVM.requestsTypeFilterOptions.firstWhere((element) => element.isSelected).id.toRequestTypeEnum(); + RequestsTypeEnum otherType = currentType == RequestsTypeEnum.specialCarRequest ? RequestsTypeEnum.serviceRequest : RequestsTypeEnum.specialCarRequest; + await requestsVM.applyFilterOnRequestsVM(requestsTypeEnum: otherType); + index = requestsVM.myFilteredRequests.indexWhere((request) => request.id == widget.requestId); + } + + if (index != -1) { + requestsVM.updateCurrentSelectedRequest(requestsVM.myFilteredRequests[index]); + } + } + @override Widget build(BuildContext context) { return Consumer(builder: (context, ChatVM chatVM, Widget? child) { @@ -143,7 +165,7 @@ class _OfferListPageState extends State { chatVM.serviceProviderOffersList[index].offerCount = 0; navigateWithName(context, AppRoutes.chatView, arguments: chatViewArguments); }); - }).toContainer(isShadowEnabled: true); + }).toContainer(isShadowEnabled: true, paddingAll: 18); }, separatorBuilder: (context, index) => 16.height, ), diff --git a/lib/views/requests/request_detail_page.dart b/lib/views/requests/request_detail_page.dart index 17fe103..5714feb 100644 --- a/lib/views/requests/request_detail_page.dart +++ b/lib/views/requests/request_detail_page.dart @@ -340,19 +340,36 @@ class RequestDetailPage extends StatelessWidget { margin: const EdgeInsets.only(top: 21, right: 21, left: 21), ), if (!requestDetailPageArguments.requestModel.isChatted) ...[ - ShowFillButton( - maxWidth: double.infinity, - margin: const EdgeInsets.all(15), - maxHeight: 55, - title: LocaleKeys.provideOffer.tr(), - isBold: false, - fontSize: 18, - onPressed: () { - context.read().checkUserSubscription(SubscriptionActionTypeEnum.subscription, context, callback: () { - buildSendOfferBottomSheet(context: context, requestDetailPageArguments: requestDetailPageArguments, isFromChatScreen: false, offerId: null); - }); - }, - ), + Row( + children: [ + Expanded( + child: ShowFillButton( + maxHeight: 55, + title: LocaleKeys.chat.tr(), + isBold: false, + fontSize: 18, + isFilled: false, + borderColor: MyColors.darkPrimaryColor, + txtColor: MyColors.darkPrimaryColor, + onPressed: () => onViewChatTapped(context), + ), + ), + 12.width, + Expanded( + child: ShowFillButton( + maxHeight: 55, + title: LocaleKeys.provideOffer.tr(), + isBold: false, + fontSize: 18, + onPressed: () { + context.read().checkUserSubscription(SubscriptionActionTypeEnum.subscription, context, callback: () { + buildSendOfferBottomSheet(context: context, requestDetailPageArguments: requestDetailPageArguments, isFromChatScreen: false, offerId: null); + }); + }, + ), + ), + ], + ).paddingAll(15), ] else ...[ buildRequestDetailActionFooter( requestId: requestDetailPageArguments.requestModel.id, diff --git a/pubspec.lock b/pubspec.lock index d0f41f3..6b3c41d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1125,6 +1125,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cce50fe..87c9f6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: # huawei_fido: ^6.3.0+305 device_info_plus: ^10.1.0 app_links: ^7.2.1 + qr_flutter: ^4.1.0 dev_dependencies: