diff --git a/.gitignore b/.gitignore index a8e938c..5bf292d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + # Miscellaneous *.class *.log diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index 6acd74e..2922dc4 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -150,6 +150,7 @@ "my_service_providers": "مزودي الخدمة الجدد", "appointment_details": "تفاصيل الموعد", "no_city_available": "لا توجد مدينة متاحة لهذا البلد", + "noDataAvailable": "لا توجد بيانات متاحة", "branch_created": "تم إنشاء الفرع بنجاح", "branch_updated": "تم تحديث الفرع بنجاح", "branch_deleted": "تم حذف الفرع بنجاح", @@ -334,6 +335,7 @@ "vehiclePictures": "صور المركبة", "vehiclePart": "جزء المركبة", "damagePartPictures": "صور الأجزاء المتضررة", + "qrCode": "رمز الاستجابة السريعة", "vehicleDamagePart": "جزء المركبة المتضرر", "duration": "المدة", "specialServices": "الخدمات الخاصة", @@ -385,6 +387,7 @@ "error": "خطأ", "appointmentBookedSuccessfully": "تم حجز موعدك بنجاح", "appointmentStatusUpdated": "تم تحديث حالة الموعد", + "appointmentStatusLabel": "حالة الموعد", "paymentStatusUpdated": "تم تحديث حالة الدفع", "serviceStatusUpdated": "تم تحديث حالة الخدمة", "selectOneSubService": "يرجى اختيار خدمة فرعية واحدة على الأقل", @@ -664,6 +667,7 @@ "searchByRequestedDate": "البحث حسب تاريخ الطلب", "selectDate": "اختر التاريخ", "searchByVehicleModel": "البحث حسب طراز المركبة", + "selectVehicleModel": "اختر طراز المركبة", "customerCarePrompt": "هل لديك أي استفسار؟ نحن هنا لخدمتك. انقر هنا للتحدث مع خدمة العملاء لدينا الآن.", "callNow": "اتصل الآن", "condition": "الحالة", diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 94feba2..02cff39 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -153,6 +153,7 @@ "my_service_providers": "My Recent Service Providers", "appointment_details": "Appointment Details", "no_city_available": "No City Available for this country", + "noDataAvailable": "No Data Available", "branch_created": "Branch is successfully created", "branch_updated": "Branch is successfully Updated", "branch_deleted": "Branch is successfully Deleted", @@ -337,6 +338,7 @@ "vehiclePictures": "Vehicle Pictures", "vehiclePart": "Vehicle Part", "damagePartPictures": "Damage Part Pictures", + "qrCode": "QR Code", "vehicleDamagePart": "Vehicle Damage Part", "duration": "Duration", "specialServices": "Special Services", @@ -388,6 +390,7 @@ "error": "Error", "appointmentBookedSuccessfully": "Your appointment has been booked successfully", "appointmentStatusUpdated": "Appointment Status Updated", + "appointmentStatusLabel": "Appointment Status", "paymentStatusUpdated": "Payment Status Updated", "serviceStatusUpdated": "Service Status Updated", "selectOneSubService": "Please select at least one sub service", @@ -668,6 +671,7 @@ "searchByRequestedDate": "Search by Requested Date", "selectDate": "Select Date", "searchByVehicleModel": "Search By Vehicle Model", + "selectVehicleModel": "Select Vehicle Model", "customerCarePrompt": "Got any question? We are here to service. Click here to talk to our customer care right now.", "callNow": "Call Now", "condition": "Condition", diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 54aeebb..65a3218 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -124,13 +124,14 @@ class ApiClientImp implements ApiClient { return await _postForResponse(url, requestBody, token: token, queryParameters: queryParameters, headers: headers, retryTimes: retryTimes); } - Future _postForResponse(String url, - requestBody, { - String? token, - Map? queryParameters, - Map? headers, - int retryTimes = 0, - }) async { + Future _postForResponse( + String url, + requestBody, { + String? token, + Map? queryParameters, + Map? headers, + int retryTimes = 0, + }) async { try { var headers0 = {}; @@ -158,7 +159,7 @@ class ApiClientImp implements ApiClient { logger.i(url); logger.d("------Payload------"); logger.i(jsonDecode(requestBody)); - // logger.d("------Response------"); + logger.d("------Response------"); logger.i(jsonDecode(response.body)); } if (response.statusCode >= 200 && response.statusCode < 500) { @@ -210,8 +211,7 @@ class ApiClientImp implements ApiClient { bool _certificateCheck(X509Certificate cert, String host, int port) => true; Future _withClient(Future Function(Client) fn) async { - var httpClient = HttpClient() - ..badCertificateCallback = _certificateCheck; + var httpClient = HttpClient()..badCertificateCallback = _certificateCheck; var client = IOClient(httpClient); try { return await fn(client); @@ -271,8 +271,10 @@ class ApiClientImp implements ApiClient { } if (!kReleaseMode) { - logger.i(url); - logger.i("$queryParameters"); + log("---URL---"); + log(url); + log("---Params---"); + log("$queryParameters"); } var response = await _get(Uri.parse(url), headers: headers0).timeout(const Duration(seconds: 120)); diff --git a/lib/extensions/string_extensions.dart b/lib/extensions/string_extensions.dart index 3c5f9b7..fea613e 100644 --- a/lib/extensions/string_extensions.dart +++ b/lib/extensions/string_extensions.dart @@ -169,12 +169,9 @@ extension FormatDate on String { String date = split("T")[0]; var dates = date.split("-"); - final isArabic = - Localizations.localeOf(context).languageCode == 'ar'; + final isArabic = Localizations.localeOf(context).languageCode == 'ar'; - final month = isArabic - ? getArabicMonth(int.parse(dates[1])) - : getMonth(int.parse(dates[1])); + final month = isArabic ? getArabicMonth(int.parse(dates[1])) : getMonth(int.parse(dates[1])); return "${dates[2]} $month, ${dates[0]}"; } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index a299a86..86034ac 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -166,6 +166,7 @@ class CodegenLoader extends AssetLoader{ "my_service_providers": "مزودي الخدمة الجدد", "appointment_details": "تفاصيل الموعد", "no_city_available": "لا توجد مدينة متاحة لهذا البلد", + "noDataAvailable": "لا توجد بيانات متاحة", "branch_created": "تم إنشاء الفرع بنجاح", "branch_updated": "تم تحديث الفرع بنجاح", "branch_deleted": "تم حذف الفرع بنجاح", @@ -350,6 +351,7 @@ class CodegenLoader extends AssetLoader{ "vehiclePictures": "صور المركبة", "vehiclePart": "جزء المركبة", "damagePartPictures": "صور الأجزاء المتضررة", + "qrCode": "رمز الاستجابة السريعة", "vehicleDamagePart": "جزء المركبة المتضرر", "duration": "المدة", "specialServices": "الخدمات الخاصة", @@ -401,6 +403,7 @@ class CodegenLoader extends AssetLoader{ "error": "خطأ", "appointmentBookedSuccessfully": "تم حجز موعدك بنجاح", "appointmentStatusUpdated": "تم تحديث حالة الموعد", + "appointmentStatusLabel": "حالة الموعد", "paymentStatusUpdated": "تم تحديث حالة الدفع", "serviceStatusUpdated": "تم تحديث حالة الخدمة", "selectOneSubService": "يرجى اختيار خدمة فرعية واحدة على الأقل", @@ -680,6 +683,7 @@ class CodegenLoader extends AssetLoader{ "searchByRequestedDate": "البحث حسب تاريخ الطلب", "selectDate": "اختر التاريخ", "searchByVehicleModel": "البحث حسب طراز المركبة", + "selectVehicleModel": "اختر طراز المركبة", "customerCarePrompt": "هل لديك أي استفسار؟ نحن هنا لخدمتك. انقر هنا للتحدث مع خدمة العملاء لدينا الآن.", "callNow": "اتصل الآن", "condition": "الحالة", @@ -1106,6 +1110,7 @@ static const Map _en_US = { "my_service_providers": "My Recent Service Providers", "appointment_details": "Appointment Details", "no_city_available": "No City Available for this country", + "noDataAvailable": "No Data Available", "branch_created": "Branch is successfully created", "branch_updated": "Branch is successfully Updated", "branch_deleted": "Branch is successfully Deleted", @@ -1290,6 +1295,7 @@ static const Map _en_US = { "vehiclePictures": "Vehicle Pictures", "vehiclePart": "Vehicle Part", "damagePartPictures": "Damage Part Pictures", + "qrCode": "QR Code", "vehicleDamagePart": "Vehicle Damage Part", "duration": "Duration", "specialServices": "Special Services", @@ -1341,6 +1347,7 @@ static const Map _en_US = { "error": "Error", "appointmentBookedSuccessfully": "Your appointment has been booked successfully", "appointmentStatusUpdated": "Appointment Status Updated", + "appointmentStatusLabel": "Appointment Status", "paymentStatusUpdated": "Payment Status Updated", "serviceStatusUpdated": "Service Status Updated", "selectOneSubService": "Please select at least one sub service", @@ -1621,6 +1628,7 @@ static const Map _en_US = { "searchByRequestedDate": "Search by Requested Date", "selectDate": "Select Date", "searchByVehicleModel": "Search By Vehicle Model", + "selectVehicleModel": "Select Vehicle Model", "customerCarePrompt": "Got any question? We are here to service. Click here to talk to our customer care right now.", "callNow": "Call Now", "condition": "Condition", diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 47d88ac..8950b65 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -132,6 +132,7 @@ abstract class LocaleKeys { static const my_service_providers = 'my_service_providers'; static const appointment_details = 'appointment_details'; static const no_city_available = 'no_city_available'; + static const noDataAvailable = 'noDataAvailable'; static const branch_created = 'branch_created'; static const branch_updated = 'branch_updated'; static const branch_deleted = 'branch_deleted'; @@ -316,6 +317,7 @@ abstract class LocaleKeys { static const vehiclePictures = 'vehiclePictures'; static const vehiclePart = 'vehiclePart'; static const damagePartPictures = 'damagePartPictures'; + static const qrCode = 'qrCode'; static const vehicleDamagePart = 'vehicleDamagePart'; static const duration = 'duration'; static const specialServices = 'specialServices'; @@ -366,6 +368,7 @@ abstract class LocaleKeys { static const error = 'error'; static const appointmentBookedSuccessfully = 'appointmentBookedSuccessfully'; static const appointmentStatusUpdated = 'appointmentStatusUpdated'; + static const appointmentStatusLabel = 'appointmentStatusLabel'; static const paymentStatusUpdated = 'paymentStatusUpdated'; static const serviceStatusUpdated = 'serviceStatusUpdated'; static const selectOneSubService = 'selectOneSubService'; @@ -645,6 +648,7 @@ abstract class LocaleKeys { static const searchByRequestedDate = 'searchByRequestedDate'; static const selectDate = 'selectDate'; static const searchByVehicleModel = 'searchByVehicleModel'; + static const selectVehicleModel = 'selectVehicleModel'; static const customerCarePrompt = 'customerCarePrompt'; static const callNow = 'callNow'; static const condition = 'condition'; diff --git a/lib/models/advertisment_models/ad_details_model.dart b/lib/models/advertisment_models/ad_details_model.dart index c33d492..7ab4664 100644 --- a/lib/models/advertisment_models/ad_details_model.dart +++ b/lib/models/advertisment_models/ad_details_model.dart @@ -14,6 +14,8 @@ class AdDetailsModel { // List? reserved; int? statusID; + + //TODO: Overwrite these with StatusLabelDisplay and StatusLabelDisplayN String? statuslabel; String? statuslabelN; double? adsDurationPrice; @@ -137,8 +139,8 @@ class AdDetailsModel { }); } statusID = json['statusID']; - statuslabel = json['statuslabel']; - statuslabelN = json['statuslabelN']; + statuslabel = json['statuslabelDisplay'] ?? json['statuslabel']; + statuslabelN = json['statuslabelDisplayN'] ?? json['statuslabelN'] ?? json['statuslabelDisplay'] ?? json['statuslabel']; adsDurationPrice = json['adsDurationPrice']; adsDurationDiscount = json['adsDurationDiscount']; adsDurationDiscountPrice = json['adsDurationDiscountPrice']; diff --git a/lib/models/general_models/enums_model.dart b/lib/models/general_models/enums_model.dart index 5413d41..6d792cf 100644 --- a/lib/models/general_models/enums_model.dart +++ b/lib/models/general_models/enums_model.dart @@ -20,8 +20,10 @@ class EnumsModel { factory EnumsModel.fromJson(Map json) => EnumsModel( id: json["id"], enumTypeId: json["enumTypeID"], - enumValueStr: json["enumValueStr"], - enumValueStrN: json["enumValueStrN"] ?? json["enumValueStr"], + // enumValueStr: json["enumValueStr"], + // enumValueStrN: json["enumValueStrN"] ?? json["enumValueStr"], + enumValueStr: json["enumValueDisplay"] ?? json["enumValueStr"], + enumValueStrN: json["enumValueDisplayN"] ?? json["enumValueDisplay"] ?? json["enumValueStrN"] ?? json["enumValueStr"], enumValueStrDes: json["enumValueStrDes"] ?? json["enumValueStr"], enumValue: json["enumValue"], isActive: json["isActive"], diff --git a/lib/repositories/ads_repo.dart b/lib/repositories/ads_repo.dart index 4f6a2d1..332c82a 100644 --- a/lib/repositories/ads_repo.dart +++ b/lib/repositories/ads_repo.dart @@ -44,6 +44,7 @@ abstract class AdsRepo { List? vehicleModelYearIdsList, List? vehicleTypeIdsList, List? vehicleBrandIdsList, + List? vehicleModelIdsList, List? createdByRolesIdsList, List? vehicleAdConditionIdsList, List? vehicleAdCreatedDateList, @@ -66,6 +67,8 @@ abstract class AdsRepo { Future toggleAdFavorite({required int adsID, required bool isFavorite}); + Future getAdDetailsById({required int adId}); + Future getAdBankingAccountInfo({required int adId}); Future updateAdStatus({required int adId, required AdPostStatus adStatusToUpdate, String? comment}); @@ -398,6 +401,7 @@ class AdsRepoImp implements AdsRepo { List? vehicleModelYearIdsList, List? vehicleTypeIdsList, List? vehicleBrandIdsList, + List? vehicleModelIdsList, List? createdByRolesIdsList, List? vehicleAdConditionIdsList, List? vehicleAdCreatedDateList, @@ -412,6 +416,7 @@ class AdsRepoImp implements AdsRepo { "CityIDs": cityIdsList ?? [], "VehicleTypeIDs": vehicleTypeIdsList ?? [], "VehicleBrandIDs": vehicleBrandIdsList ?? [], + "VehicleModelIDs": vehicleModelIdsList ?? [], "VehicleModelYearIDs": vehicleModelYearIdsList ?? [], "CreatedByRoles": createdByRolesIdsList ?? [], "AdsStatuses": ["${AdPostStatus.active.getIdFromAdPostStatusEnum()}"], //only Active ADS @@ -561,11 +566,7 @@ class AdsRepoImp implements AdsRepo { } @override - Future> getFavouriteAds({ - AdPostStatus? adPostStatus, - CreatedByRoleEnum? createdByRoleEnum, - int? vehicleBrandId, - }) async { + Future> getFavouriteAds({AdPostStatus? adPostStatus, CreatedByRoleEnum? createdByRoleEnum, int? vehicleBrandId}) async { // Build filter parameters similar to getAllAds Map params = { "PageSize": "30", @@ -605,9 +606,15 @@ class AdsRepoImp implements AdsRepo { @override Future toggleAdFavorite({required int adsID, required bool isFavorite}) async { var params = { - "ID": adsID.toString(), + "adsID": adsID.toString(), }; + if (isFavorite) { + params = { + "ID": adsID.toString(), + }; + } + String endpoint = isFavorite ? ApiConsts.unFavouriteAds : ApiConsts.favouriteAdsCreate; GenericRespModel genericRespModel = await apiClient.postJsonForObject( @@ -620,6 +627,18 @@ class AdsRepoImp implements AdsRepo { return genericRespModel; } + @override + Future getAdDetailsById({required int adId}) async { + try { + List ads = await getAdsPerSpecificIds(ids: [adId.toString()], reservedAds: []); + if (ads.isEmpty) return null; + return ads.first; + } catch (e) { + logger.e(e.toString()); + return null; + } + } + @override Future getAdBankingAccountInfo({required int adId}) async { var params = { diff --git a/lib/repositories/appointment_repo.dart b/lib/repositories/appointment_repo.dart index e5b2808..7c5f3ef 100644 --- a/lib/repositories/appointment_repo.dart +++ b/lib/repositories/appointment_repo.dart @@ -304,8 +304,7 @@ class AppointmentRepoImp implements AppointmentRepo { queryParameters: params, ApiConsts.customersAppointmentGetByFilters, ); - print("genericRespModel.data ......."); - print(genericRespModel.data); + List appointmentList = List.generate(genericRespModel.data.length, (index) => AppointmentListModel.fromJson(genericRespModel.data[index])); return appointmentList; } diff --git a/lib/repositories/common_repo.dart b/lib/repositories/common_repo.dart index 4169dba..55dd118 100644 --- a/lib/repositories/common_repo.dart +++ b/lib/repositories/common_repo.dart @@ -257,7 +257,7 @@ class CommonRepoImp implements CommonRepo { token: appState.getUser.data!.accessToken, (json) => GenericRespModel.fromJson(json), ApiConsts.vehicleModelGet, - queryParameters: postParams, + queryParameters: vehicleTypeId != -1 ? postParams : null, ); List vehicleModels = List.generate(adsGenericModel.data.length, (index) => VehicleModel.fromJson(adsGenericModel.data[index])); return vehicleModels; diff --git a/lib/services/ad_deep_link_service.dart b/lib/services/ad_deep_link_service.dart new file mode 100644 index 0000000..a2fdf74 --- /dev/null +++ b/lib/services/ad_deep_link_service.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:app_links/app_links.dart'; +import 'package:easy_localization/easy_localization.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/utils/utils.dart'; +import 'package:mc_common_app/view_models/ad_view_model.dart'; +import 'package:provider/provider.dart'; + +/// Handles opening the ad detail screen directly when the app is launched or +/// resumed via a shared ad link, for Android App Links / iOS Universal Links +/// (https://ms.hmg.com/ad/1541) AND the custom-scheme fallback used by that +/// same web page when App Links verification isn't active +/// (mowatercustomer://ad/1541, mowaterprovider://ad/1541). +class AdDeepLinkService { + static final AppLinks _appLinks = AppLinks(); + static StreamSubscription? _subscription; + + /// Call once, after the app's first frame (so [navigatorKey] is attached to a Navigator). + static Future init() async { + try { + final Uri? initialUri = await _appLinks.getInitialLink(); + if (initialUri != null) { + _handleUri(initialUri); + } + } catch (e) { + log("AdDeepLinkService: failed to read initial link - $e"); + } + + _subscription?.cancel(); + _subscription = _appLinks.uriLinkStream.listen( + _handleUri, + onError: (Object e) => log("AdDeepLinkService: stream error - $e"), + ); + } + + static void _handleUri(Uri uri) { + log("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"] + int? adId; + if (uri.scheme == 'https') { + final segments = uri.pathSegments; + if (segments.length >= 2 && segments[0] == 'ad') { + adId = int.tryParse(segments[1]); + } + } else if (uri.host == 'ad' && uri.pathSegments.isNotEmpty) { + adId = int.tryParse(uri.pathSegments.first); + } + + if (adId == null) return; + _openAd(adId); + } + + static Future _openAd(int adId) async { + final context = navigatorKey.currentContext; + if (context == null) return; + + Utils.showLoading(context); + final adDetails = await context.read().getAdDetailsById(adId); + Utils.hideLoading(context); + + if (adDetails == null) { + Utils.showToast(LocaleKeys.somethingWrong.tr()); + return; + } + + navigateWithName(context, AppRoutes.adsDetailView, arguments: adDetails); + } +} diff --git a/lib/utils/date_helper.dart b/lib/utils/date_helper.dart index e332e15..9717797 100644 --- a/lib/utils/date_helper.dart +++ b/lib/utils/date_helper.dart @@ -11,6 +11,16 @@ class DateHelper { return formatter.parse(dateString); } + /// True when [dateString] is at most [days] away from now, or already in the past + /// (e.g. an already-expired subscription still qualifies as "within 7 days"). + static bool isWithinDays(String? dateString, int days) { + if (dateString == null || dateString.isEmpty) return false; + final DateTime date = parseStringToDate(formatDateT(dateString)); + return date + .difference(DateTime.now()) + .inDays <= days; + } + static String formatAsShortDate(DateTime date) { final formatter = DateFormat.yMd(); return formatter.format(date); diff --git a/lib/view_models/ad_view_model.dart b/lib/view_models/ad_view_model.dart index 86f5806..acf39b1 100644 --- a/lib/view_models/ad_view_model.dart +++ b/lib/view_models/ad_view_model.dart @@ -378,6 +378,15 @@ class AdVM extends BaseVM { } } + Future getAdDetailsById(int adId) async { + try { + return await adsRepo.getAdDetailsById(adId: adId); + } catch (e) { + logger.e(e.toString()); + return null; + } + } + Future getExploreAds() async { pageIndexForExploreAds = 1; hasMoreDataForExploreAds = true; @@ -727,6 +736,7 @@ class AdVM extends BaseVM { if (!ifAlreadyExist(list: vehicleBrandsAdSearchHistory, value: brandValue)) { addToVehicleBrandsAdSearchHistory(value: brandValue); } + pruneVehicleModelsAdSearchHistoryForRemovedBrands(); notifyListeners(); return; } @@ -739,7 +749,16 @@ class AdVM extends BaseVM { SelectionModel vehicleModelId = SelectionModel(selectedOption: "", selectedId: -1, errorValue: ""); - void updateSelectionVehicleModelId(SelectionModel id) { + void updateSelectionVehicleModelId(SelectionModel id, {bool isForSearch = false}) { + if (isForSearch) { + VehicleModel model = vehicleModelsForSelectedBrandsFilter.firstWhere((element) => element.id == id.selectedId); + DropValue modelValue = DropValue(model.id ?? 0, model.model ?? "", ""); + if (!ifAlreadyExist(list: vehicleModelsAdSearchHistory, value: modelValue)) { + addToVehicleModelsAdSearchHistory(value: modelValue); + } + notifyListeners(); + return; + } vehicleModelId = id; notifyListeners(); } @@ -1250,7 +1269,7 @@ class AdVM extends BaseVM { isInUsedError = ""; } - if ((pickedPostingImages.isEmpty || pickedPostingImages.length < 3) && !isAdEditEnabled) { + if (pickedPostingImages.isEmpty || (pickedPostingImages.length < 3 && !isAdEditEnabled)) { vehicleImageError = GlobalConsts.attachImageError; isValidated = false; } else { @@ -2324,6 +2343,15 @@ class AdVM extends BaseVM { List vehicleTypesForFilters = []; + List vehicleModelsForFilters = []; + + // Cascading list: only models belonging to the brand(s) currently selected in vehicleBrandsAdSearchHistory. + List get vehicleModelsForSelectedBrandsFilter { + if (vehicleBrandsAdSearchHistory.isEmpty) return []; + Set selectedBrandIds = vehicleBrandsAdSearchHistory.map((e) => e.id).toSet(); + return vehicleModelsForFilters.where((model) => selectedBrandIds.contains(model.vehicleBrandID)).toList(); + } + Future populateDataForAdFilter() async { setState(ViewState.busy); pageIndexForExploreAds = 1; @@ -2352,6 +2380,10 @@ class AdVM extends BaseVM { vehicleModelYears = await commonRepo.getVehicleModelYears(vehicleTypeId: -1); } + if (vehicleModelsForFilters.isEmpty) { + vehicleModelsForFilters = await commonRepo.getVehicleModels(vehicleTypeId: -1); // to get all the models + } + vehicleCities = await commonRepo.getVehicleCities(countryId: -1); // fetch all the cities setState(ViewState.idle); } @@ -2495,6 +2527,7 @@ class AdVM extends BaseVM { void removeVehicleBrandsAdSearchHistory({bool isClear = false, required int index}) { if (isClear) { vehicleBrandsAdSearchHistory.clear(); + pruneVehicleModelsAdSearchHistoryForRemovedBrands(); notifyListeners(); return; } @@ -2502,6 +2535,7 @@ class AdVM extends BaseVM { if (vehicleBrandsAdSearchHistory.isEmpty) { updateAdsFiltersCounter(adsFiltersCounter - 1); } + pruneVehicleModelsAdSearchHistoryForRemovedBrands(); notifyListeners(); } @@ -2513,6 +2547,42 @@ class AdVM extends BaseVM { notifyListeners(); } + // MODELS (cascades from vehicleBrandsAdSearchHistory) + List vehicleModelsAdSearchHistory = []; + + void removeVehicleModelsAdSearchHistory({bool isClear = false, required int index}) { + if (isClear) { + vehicleModelsAdSearchHistory.clear(); + notifyListeners(); + return; + } + vehicleModelsAdSearchHistory.removeAt(index); + if (vehicleModelsAdSearchHistory.isEmpty) { + updateAdsFiltersCounter(adsFiltersCounter - 1); + } + notifyListeners(); + } + + void addToVehicleModelsAdSearchHistory({required DropValue value}) { + if (vehicleModelsAdSearchHistory.isEmpty) { + updateAdsFiltersCounter(adsFiltersCounter + 1); + } + vehicleModelsAdSearchHistory.add(value); + notifyListeners(); + } + + // Removes any previously selected models whose brand is no longer present in vehicleBrandsAdSearchHistory, + // keeping the Model filter consistent whenever the Brand selection changes (add or remove). + void pruneVehicleModelsAdSearchHistoryForRemovedBrands() { + if (vehicleModelsAdSearchHistory.isEmpty) return; + Set validModelIds = vehicleModelsForSelectedBrandsFilter.map((e) => e.id ?? -1).toSet(); + int previousLength = vehicleModelsAdSearchHistory.length; + vehicleModelsAdSearchHistory.removeWhere((element) => !validModelIds.contains(element.id)); + if (vehicleModelsAdSearchHistory.isEmpty && previousLength > 0) { + updateAdsFiltersCounter(adsFiltersCounter - 1); + } + } + // LOCATION List vehicleLocationAdSearchHistory = []; @@ -2678,6 +2748,7 @@ class AdVM extends BaseVM { vehicleAdConditionSearchHistory.clear(); adFilterMobilePhoneSearchHistory.clear(); vehicleBrandsAdSearchHistory.clear(); + vehicleModelsAdSearchHistory.clear(); adFilterDemandStartPrice = ''; adFilterDemandEndPrice = ''; clearAdFilterSelections(); // Clear dropdown selections @@ -2733,6 +2804,13 @@ class AdVM extends BaseVM { } } + List modelsIdsList = []; + if (vehicleModelsAdSearchHistory.isNotEmpty) { + for (var element in vehicleModelsAdSearchHistory) { + modelsIdsList.add(element.id.toString()); + } + } + List vehicleYearIdsList = []; if (vehicleYearAdSearchHistory.isNotEmpty) { for (var element in vehicleYearAdSearchHistory) { @@ -2797,6 +2875,7 @@ class AdVM extends BaseVM { createdByRolesIdsList: adOwnerIdsList, vehicleTypeIdsList: typesIdsList, vehicleBrandIdsList: brandsIdsList, + vehicleModelIdsList: modelsIdsList, vehicleModelYearIdsList: vehicleYearIdsList, vehicleAdConditionIdsList: conditionsIdsList, vehicleAdCreatedDateList: createdDatesList, diff --git a/lib/view_models/appointments_view_model.dart b/lib/view_models/appointments_view_model.dart index a71476c..851cc28 100644 --- a/lib/view_models/appointments_view_model.dart +++ b/lib/view_models/appointments_view_model.dart @@ -308,8 +308,23 @@ class AppointmentsVM extends BaseVM { notifyListeners(); } + /// Used when editing a service already added to [servicesInCurrentAppointment] (e.g. to add/remove items). + /// Points [currentServiceSelection] at the SAME object already holding this service's real state + /// (isHomeSelected, servicelocationInfo, accumulated items/price) instead of a fresh, pristine copy + /// looked up from [branchServices] — which would lose that state and desync item-level isHomeSelected. + void editServiceInCurrentAppointment(ServiceModel serviceData) { + currentServiceSelection = serviceData; + branchSelectedServiceId = SelectionModel( + selectedId: serviceData.serviceProviderServiceId ?? -1, + selectedOption: serviceData.serviceDescription ?? "", + ); + notifyListeners(); + } + void removeServiceInCurrentAppointment(int index) { - int serviceId = servicesInCurrentAppointment.elementAt(index).serviceProviderServiceId ?? -1; + int serviceId = servicesInCurrentAppointment + .elementAt(index) + .serviceProviderServiceId ?? -1; allSelectedItemsInAppointments.removeWhere((element) => element.serviceProviderServiceId == serviceId); servicesInCurrentAppointment[index].serviceItems!.clear(); servicesInCurrentAppointment.removeAt(index); @@ -372,8 +387,8 @@ class AppointmentsVM extends BaseVM { if (shouldPopulateUpcoming) { myUpComingAppointments = myFilteredAppointmentsForProvider .where((element) => - (element.appointmentStatusEnum == AppointmentStatusEnum.booked || element.appointmentStatusEnum == AppointmentStatusEnum.confirmed) && - (DateHelper.parseStringToDate(element.appointmentDate!).isAfter(DateTime.now()))) + (element.appointmentStatusEnum == AppointmentStatusEnum.booked || element.appointmentStatusEnum == AppointmentStatusEnum.confirmed) && + (DateHelper.parseStringToDate(element.appointmentDate!).isAfter(DateTime.now()))) .toList(); log("myUpComingAppointments: ${myUpComingAppointments.length}"); } @@ -402,8 +417,8 @@ class AppointmentsVM extends BaseVM { if (shouldPopulateUpcoming) { myUpComingAppointments = myFilteredAppointmentsForCustomers .where((element) => - (element.appointmentStatusEnum == AppointmentStatusEnum.booked || element.appointmentStatusEnum == AppointmentStatusEnum.confirmed) && - (DateHelper.parseStringToDate(element.appointmentDate!).isAfter(DateTime.now()))) + (element.appointmentStatusEnum == AppointmentStatusEnum.booked || element.appointmentStatusEnum == AppointmentStatusEnum.confirmed) && + (DateHelper.parseStringToDate(element.appointmentDate!).isAfter(DateTime.now()))) .toList(); } @@ -418,9 +433,13 @@ class AppointmentsVM extends BaseVM { List groupedList = uniqueCustomerIDs.map((id) { List list = appointments.where((item) => item.customerID == id).toList(); - list.sort((a, b) => DateHelper.parseStringToDate(DateHelper.formatDateT(b.appointmentDate!)) - .millisecondsSinceEpoch - .compareTo(DateHelper.parseStringToDate(DateHelper.formatDateT(a.appointmentDate!)).millisecondsSinceEpoch)); + list.sort((a, b) => + DateHelper + .parseStringToDate(DateHelper.formatDateT(b.appointmentDate!)) + .millisecondsSinceEpoch + .compareTo(DateHelper + .parseStringToDate(DateHelper.formatDateT(a.appointmentDate!)) + .millisecondsSinceEpoch)); AppointmentListModel model = list.first; model.customerAppointmentList = list; return model; @@ -429,6 +448,35 @@ class AppointmentsVM extends BaseVM { return groupedList; } + /// Re-fetches upcoming appointments for the tapped appointment's own branch and + /// returns the index of its customer group in [myFilteredAppointmentsForProvider], + /// so the Home slider always opens the correct, up-to-date appointment detail + /// instead of relying on whatever branch/index was last used elsewhere. + Future refreshUpcomingAppointmentAndGetIndex(AppointmentListModel tappedAppointment) async { + int branchId = tappedAppointment.branchId ?? 0; + List freshAppointments = await getAppointmentsBasedOnFiltersForProviders( + branchID: branchId, + appointmentStatus: AppointmentStatusEnum.allAppointments, + ); + + List upcomingOnly = freshAppointments + .where((element) => + (element.appointmentStatusEnum == AppointmentStatusEnum.booked || element.appointmentStatusEnum == AppointmentStatusEnum.confirmed) && + (DateHelper.parseStringToDate(element.appointmentDate!).isAfter(DateTime.now()))) + .toList(); + + List groupedList = findAppointmentsBasedOnCustomers(upcomingOnly); + + int index = groupedList.indexWhere((element) => element.customerID == tappedAppointment.customerID); + if (index == -1) return null; + + myFilteredAppointmentsForProvider = groupedList; + selectedBranchIdForAppointments = branchId; + selectedAppointmentIndex = index; + notifyListeners(); + return index; + } + AppointmentSlots? appointmentSlots; bool isSlotsLoading = true; @@ -507,7 +555,7 @@ class AppointmentsVM extends BaseVM { void updateCheckBoxInMergeRequest(int currentIndex) { myFilteredAppointmentsForProvider[selectedAppointmentIndex].customerAppointmentList![currentIndex].isSelected = - !(myFilteredAppointmentsForProvider[selectedAppointmentIndex].customerAppointmentList?[currentIndex].isSelected ?? false); + !(myFilteredAppointmentsForProvider[selectedAppointmentIndex].customerAppointmentList?[currentIndex].isSelected ?? false); int count = countSelected(myFilteredAppointmentsForProvider[selectedAppointmentIndex].customerAppointmentList ?? []); if (count > 1) { @@ -519,7 +567,10 @@ class AppointmentsVM extends BaseVM { } int countSelected(List appointments) { - return appointments.where((appointment) => appointment.isSelected == true).toList().length; + return appointments + .where((appointment) => appointment.isSelected == true) + .toList() + .length; } updateSelectedAppointmentDate({required int dateIndex, required int scheduleIndex}) { @@ -564,7 +615,7 @@ class AppointmentsVM extends BaseVM { int serviceIndex = servicesInCurrentAppointment.indexWhere((element) => element.serviceId == currentServiceSelection!.serviceId!); serviceItemsFromApi[index].isUpdateOrSelected = selected; - serviceItemsFromApi[index].isHomeSelected = isHomeTapped; + serviceItemsFromApi[index].isHomeSelected = currentServiceSelection?.isHomeSelected ?? isHomeTapped; log(" serviceIndex: $serviceIndex"); if (selected) { @@ -710,13 +761,14 @@ class AppointmentsVM extends BaseVM { Column( children: List.generate( selectedService.serviceItems!.length, - (index) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - "${selectedService.serviceItems![index].name}".toText(fontSize: 12, color: MyColors.lightTextColor, isBold: true), - "${selectedService.serviceItems![index].price} ${LocaleKeys.sar.tr()}".toText(fontSize: 12, isBold: true), - ], - ), + (index) => + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + "${selectedService.serviceItems![index].name}".toText(fontSize: 12, color: MyColors.lightTextColor, isBold: true), + "${selectedService.serviceItems![index].price} ${LocaleKeys.sar.tr()}".toText(fontSize: 12, isBold: true), + ], + ), ), ), Row( @@ -935,7 +987,10 @@ class AppointmentsVM extends BaseVM { return InfoBottomSheet( title: LocaleKeys.reportComplaint.tr().toText(fontSize: 28, isBold: true, letterSpacing: -1.44), description: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + padding: EdgeInsets.only(bottom: MediaQuery + .of(context) + .viewInsets + .bottom), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/view_models/service_view_model.dart b/lib/view_models/service_view_model.dart index 67917f5..ba36db2 100644 --- a/lib/view_models/service_view_model.dart +++ b/lib/view_models/service_view_model.dart @@ -652,8 +652,12 @@ class ServiceVM extends BaseVM { } } - Future cannotDeactivateServiceOrCategoryBottomSheet( - {required BuildContext mainContext, required List appointments, required String branchName, required bool isService}) async { + Future cannotDeactivateServiceOrCategoryBottomSheet({ + required BuildContext mainContext, + required List appointments, + required String branchName, + required bool isService, + }) async { return actionConfirmationBottomSheet( isOnlyOneButton: true, context: mainContext, @@ -667,21 +671,20 @@ class ServiceVM extends BaseVM { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // "${appointmentBasicDetailsModel.customerID}".toText(fontSize: 16, letterSpacing: -0.64), + showItem("${LocaleKeys.branchName.tr()}:", branchName), if (appointmentBasicDetailsModel.appointmentStatusText != null && appointmentBasicDetailsModel.appointmentStatusText!.isNotEmpty) ...[ - Utils.statusContainerChip(text: appointmentBasicDetailsModel.appointmentStatusText!, chipColor: MyColors.black), + showItem("${LocaleKeys.appointmentStatusLabel.tr()}:", appointmentBasicDetailsModel.appointmentStatusText!), ], - 6.height, - "${appointmentBasicDetailsModel.customerID}".toText(fontSize: 16, letterSpacing: -0.64), - showItem("${LocaleKeys.branchName.tr()}:", "${appointmentBasicDetailsModel.slotDate}"), showItem("${LocaleKeys.date.tr()}:", "${appointmentBasicDetailsModel.slotDate}"), - showItem("${LocaleKeys.time.tr()}:", "${appointmentBasicDetailsModel.startTime} - ${appointmentBasicDetailsModel.startTime}"), + showItem("${LocaleKeys.time.tr()}:", "${appointmentBasicDetailsModel.startTime} - ${appointmentBasicDetailsModel.endTime}"), ], ).toContainer(isShadowEnabled: true); }), actionButtonYes: Expanded( child: ShowFillButton( maxHeight: 55, - title: LocaleKeys.done.tr(), + title: LocaleKeys.ok.tr(), fontSize: 15, onPressed: () => Navigator.pop(mainContext), ), @@ -689,7 +692,7 @@ class ServiceVM extends BaseVM { actionButtonNo: Expanded( child: ShowFillButton( maxHeight: 55, - title: LocaleKeys.done.tr(), + title: LocaleKeys.ok.tr(), fontSize: 15, onPressed: () => Navigator.pop(mainContext), ), diff --git a/lib/view_models/subscriptions_view_model.dart b/lib/view_models/subscriptions_view_model.dart index 642492b..73e88f2 100644 --- a/lib/view_models/subscriptions_view_model.dart +++ b/lib/view_models/subscriptions_view_model.dart @@ -148,11 +148,18 @@ class SubscriptionsVM extends BaseVM { } String _convertDaysToMonths(int days) { - final int months = days ~/ 30; - final int remainingDays = days % 30; + const int daysPerMonth = 30; + const int roundingToleranceDays = 6; // absorbs real calendar month/year variance vs. the flat 30-day assumption (e.g. 365 vs 360 for "12 Months") + + final int months = days ~/ daysPerMonth; + int remainingDays = days % daysPerMonth; + if (months > 0 && remainingDays <= roundingToleranceDays) { + remainingDays = 0; + } String result = months > 0 ? '$months Month${months > 1 ? 's' : ''}${remainingDays > 0 ? ' & ' : ''}' : ''; result += remainingDays > 0 ? '$remainingDays Day${remainingDays > 1 ? 's' : ''}' : ''; + if (result.isEmpty) result = '$days Day${days > 1 ? 's' : ''}'; return result; } 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 3e9f7d2..da086bc 100644 --- a/lib/views/advertisement/ads_detail_view/ads_detail_view.dart +++ b/lib/views/advertisement/ads_detail_view/ads_detail_view.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:developer'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:mc_common_app/classes/app_state.dart'; import 'package:mc_common_app/classes/consts.dart'; @@ -18,6 +17,7 @@ import 'package:mc_common_app/view_models/ad_view_model.dart'; import 'package:mc_common_app/view_models/payment_view_model.dart'; import 'package:mc_common_app/views/advertisement/ads_detail_view/components.dart'; import 'package:mc_common_app/views/advertisement/bottom_sheets/ad_duration_selection_sheet.dart'; +import 'package:mc_common_app/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart'; import 'package:mc_common_app/views/advertisement/bottom_sheets/ads_damage_part_pictures_sheet.dart'; import 'package:mc_common_app/views/advertisement/components/ads_images_corousel_widget.dart'; import 'package:mc_common_app/widgets/bottom_sheet.dart'; @@ -107,8 +107,10 @@ class _AdsDetailViewState extends State { child: Row( children: [ ShareAdButton(adDetails: widget.adDetails), - 8.width, - FavoriteAdButton(adDetails: widget.adDetails), + if (!(widget.adDetails.isMyAd ?? false) && widget.adDetails.adPostStatus == AdPostStatus.active) ...[ + 8.width, + FavoriteAdButton(adDetails: widget.adDetails), + ], ], ), ), @@ -358,6 +360,25 @@ class _AdsDetailViewState extends State { }).toWhiteContainer(width: double.infinity, allPading: 12); } + Widget buildQrCodeSection() { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.qr_code_2, color: MyColors.darkTextColor), + 10.width, + LocaleKeys.qrCode.tr().toText(fontSize: 14), + ], + ), + const Icon(Icons.arrow_forward_ios_rounded, size: 20) + ], + ).paddingOnly(top: 5, bottom: 5).onPress(() { + showMyBottomSheet(context, child: AdQrCodeSheet(adDetails: widget.adDetails)); + }).toWhiteContainer(width: double.infinity, allPading: 12); + } + Future onExtendAdPressed() async { final AdVM adVM = context.read(); return actionConfirmationBottomSheet( @@ -541,6 +562,10 @@ class _AdsDetailViewState extends State { if (widget.adDetails.vehicle!.damagereport != null && widget.adDetails.vehicle!.damagereport!.isNotEmpty) ...[ buildDamagePartDetails(), ], + if ((widget.adDetails.qrCodePath ?? "").isNotEmpty) ...[ + 12.height, + buildQrCodeSection(), + ], if ((!(widget.adDetails.isMyAd ?? false) && (widget.adDetails.showContactDetail ?? false))) ...[ 12.height, buildPersonalInformationCard(context: context), @@ -720,18 +745,12 @@ class ShareAdButton extends StatelessWidget { } shareMessage += '\n'; - // App download links - shareMessage += '${LocaleKeys.downloadApp.tr()}\n'; - - if (Platform.isAndroid) { - shareMessage += '${LocaleKeys.androidApp.tr()}: https://play.google.com/store/apps/details?id=com.mowater.app\n'; - } else if (Platform.isIOS) { - shareMessage += '${LocaleKeys.iosApp.tr()}: https://apps.apple.com/app/mowater/id123456789\n'; - } else { - // Include both if platform detection fails - shareMessage += '${LocaleKeys.androidApp.tr()}: https://play.google.com/store/apps/details?id=com.mowater.app\n'; - shareMessage += '${LocaleKeys.iosApp.tr()}: https://apps.apple.com/app/mowater/id123456789\n'; - } + // 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'; // 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 cfa86d0..2933785 100644 --- a/lib/views/advertisement/ads_detail_view/components.dart +++ b/lib/views/advertisement/ads_detail_view/components.dart @@ -109,7 +109,7 @@ class BuildAdDetailsActionButtonForExploreAds extends StatelessWidget { ], ), const Divider(), - LocaleKeys.specialService.toText(fontSize: 16, isBold: true), + LocaleKeys.specialService.tr().toText(fontSize: 16, isBold: true), 5.height, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -245,8 +245,8 @@ class BuildAdDetailsActionButtonForExploreAds extends StatelessWidget { } Widget defaultActionForProviderAndCustomer(BuildContext context, AdDetailsModel adDetailsModel) { - return (adDetailsModel.phoneNo != null && adDetailsModel.phoneNo!.isNotEmpty) - ? Row( + return (adDetailsModel.showContactDetail ?? false) + ? Row( children: [ Expanded( child: ShowFillButton( diff --git a/lib/views/advertisement/ads_filter_view.dart b/lib/views/advertisement/ads_filter_view.dart index b11171f..b3d4bbf 100644 --- a/lib/views/advertisement/ads_filter_view.dart +++ b/lib/views/advertisement/ads_filter_view.dart @@ -190,6 +190,28 @@ class _AdsFilterViewState extends State { onHistoryItemDeleted: (index) => adVM.removeVehicleBrandsAdSearchHistory(index: index), onHistoryItemTapped: (DropValue value) => null, ), + if (adVM.vehicleBrandsAdSearchHistory.isNotEmpty) ...[ + const Divider(thickness: 1.2).paddingOnly(top: 7, bottom: 7), + SearchEntityWidget( + title: LocaleKeys.searchByVehicleModel.tr(), + actionWidget: Builder(builder: (context) { + List vehicleModelsDrop = []; + for (var element in adVM.vehicleModelsForSelectedBrandsFilter) { + vehicleModelsDrop.add(DropValue(element.id?.toInt() ?? 0, element.model ?? "", "")); + } + return DropdownField( + (DropValue value) => adVM.updateSelectionVehicleModelId(SelectionModel(selectedId: value.id, selectedOption: value.value), isForSearch: true), + list: vehicleModelsDrop, + dropdownValue: adVM.vehicleModelId.selectedId != -1 ? DropValue(adVM.vehicleModelId.selectedId, adVM.vehicleModelId.selectedOption, "") : null, + hint: LocaleKeys.selectVehicleModel.tr(), + errorValue: adVM.vehicleModelId.errorValue, + ); + }), + historyContent: adVM.vehicleModelsAdSearchHistory, + onHistoryItemDeleted: (index) => adVM.removeVehicleModelsAdSearchHistory(index: index), + onHistoryItemTapped: (DropValue value) => null, + ), + ], const Divider(thickness: 1.2).paddingOnly(top: 7, bottom: 7), SearchEntityWidget( title: LocaleKeys.searchByVehicleYear.tr(), diff --git a/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart b/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart new file mode 100644 index 0000000..3bc8a48 --- /dev/null +++ b/lib/views/advertisement/bottom_sheets/ad_qr_code_sheet.dart @@ -0,0 +1,46 @@ +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/widgets/extensions/extensions_widget.dart'; + +class AdQrCodeSheet extends StatelessWidget { + final AdDetailsModel adDetails; + + const AdQrCodeSheet({super.key, required this.adDetails}); + + @override + Widget build(BuildContext context) { + log("dDetails.qrCodePath: ${adDetails.qrCodePath}"); + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + LocaleKeys.qrCode.tr().toText(fontSize: 24, isBold: true), + ], + ), + 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, + ), + ), + 20.height, + ], + ), + ).horPaddingMain(); + } +} diff --git a/lib/views/advertisement/components/ads_list_widget.dart b/lib/views/advertisement/components/ads_list_widget.dart index 3b32f17..f1d5318 100644 --- a/lib/views/advertisement/components/ads_list_widget.dart +++ b/lib/views/advertisement/components/ads_list_widget.dart @@ -28,6 +28,7 @@ class AdsListWidget extends StatelessWidget { final bool hasMoreData; final bool isDraftAds; final Function()? onFetchMoreAds; + final Function()? onReturnFromAdDetail; const AdsListWidget({ super.key, @@ -36,6 +37,7 @@ class AdsListWidget extends StatelessWidget { required this.hasMoreData, required this.isDraftAds, this.onFetchMoreAds, + this.onReturnFromAdDetail, this.scrollPhysics, this.isAdsFragment = false, }); @@ -97,7 +99,9 @@ class AdsListWidget extends StatelessWidget { isForDraft: true, ); } else { - navigateWithName(context, AppRoutes.adsDetailView, arguments: adsList[index]); + Navigator.pushNamed(context, AppRoutes.adsDetailView, arguments: adsList[index]).then((_) { + onReturnFromAdDetail?.call(); + }); } }, ).toViewOnly(context, onTap: () => navigateWithName(context, AppRoutes.loginWithPassword, arguments: false)); diff --git a/lib/views/advertisement/favourite_ads_view.dart b/lib/views/advertisement/favourite_ads_view.dart index 54a3968..ca938b6 100644 --- a/lib/views/advertisement/favourite_ads_view.dart +++ b/lib/views/advertisement/favourite_ads_view.dart @@ -1,7 +1,6 @@ import 'dart:async'; 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/view_models/ad_view_model.dart'; import 'package:flutter/material.dart'; import 'package:mc_common_app/extensions/int_extensions.dart'; @@ -28,11 +27,19 @@ class _FavouriteAdsViewState extends State { void initState() { scheduleMicrotask(() async { final adVM = context.read(); - await adVM.getFavouriteAds(); // Load filter options if not already loaded if (adVM.exploreAdsFilterOptions.isEmpty) { await adVM.populateAdsFilterList(); } + // exploreAdsFilterOptions is shared with the Explore Ads screen, so its selection + // may be left on a different brand from there — reset to "All" for this screen. + for (var filter in adVM.exploreAdsFilterOptions) { + filter.isSelected = filter.id == 0; + } + setState(() { + selectedBrandId = 0; + }); + await adVM.getFavouriteAds(); }); super.initState(); } @@ -69,7 +76,7 @@ class _FavouriteAdsViewState extends State { filter.isSelected = false; } adVM.exploreAdsFilterOptions[index].isSelected = true; - + // Call API with filter (0 = all brands) await adVM.getFavouriteAds( vehicleBrandId: selectedFilterId == 0 ? null : selectedFilterId, @@ -99,8 +106,12 @@ class _FavouriteAdsViewState extends State { isAdsFragment: false, isDraftAds: false, shouldShowAdStatus: false, - adsList: adVM.favouriteAds, // Use API-filtered list directly + adsList: adVM.favouriteAds, + // Use API-filtered list directly hasMoreData: false, + onReturnFromAdDetail: () => adVM.getFavouriteAds( + vehicleBrandId: selectedBrandId == 0 ? null : selectedBrandId, + ), ), ), ), diff --git a/lib/views/appointments/book_appointment_services_view.dart b/lib/views/appointments/book_appointment_services_view.dart index cd1940e..097c42b 100644 --- a/lib/views/appointments/book_appointment_services_view.dart +++ b/lib/views/appointments/book_appointment_services_view.dart @@ -5,7 +5,6 @@ import 'package:mc_common_app/config/routes.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/general_models/widgets_models.dart'; import 'package:mc_common_app/models/services_models/item_model.dart'; import 'package:mc_common_app/models/services_models/service_model.dart'; import 'package:mc_common_app/theme/colors.dart'; @@ -64,13 +63,7 @@ class BookAppointmentServicesView extends StatelessWidget { Row( children: [ MyAssets.icEdit.buildSvg(height: 17, width: 17).onPress(() { - appointmentsVM.updateBranchServiceId( - SelectionModel( - selectedId: serviceData.serviceProviderServiceId!, - selectedOption: serviceData.serviceDescription ?? "", - itemPrice: "", - ), - ); + appointmentsVM.editServiceInCurrentAppointment(serviceData); appointmentsVM.getServiceItems(serviceId: serviceData.serviceProviderServiceId!); navigateWithName(context, AppRoutes.bookAppointmentsItemView); }), @@ -110,8 +103,7 @@ class BookAppointmentServicesView extends StatelessWidget { Builder(builder: (context) { double totalKms = double.parse(serviceData.servicelocationInfo.distanceToBranch.toStringAsFixed(2)); double pricePerKm = double.parse(serviceData.servicelocationInfo.homeChargesInCurrentService.toStringAsFixed(2)); - return ((serviceData.currentTotalServicePrice + (pricePerKm * totalKms)).toStringAsFixed(2)) - .toText(fontSize: 32, isBold: true); + return ((serviceData.currentTotalServicePrice + (pricePerKm * totalKms)).toStringAsFixed(2)).toText(fontSize: 32, isBold: true); }), ] else ...[ ((serviceData.currentTotalServicePrice).toString()).toText(fontSize: 32, isBold: true), @@ -124,8 +116,8 @@ class BookAppointmentServicesView extends StatelessWidget { ], ], ).toWhiteContainer( - width: double.infinity, - allPading: 12, + width: double.infinity, + allPading: 12, margin: const EdgeInsets.symmetric(horizontal: 21, vertical: 10), onTap: () => appointmentsVM.priceBreakDownClicked(context, serviceData), ); diff --git a/lib/views/appointments/widgets/common_appointment_slider_widget.dart b/lib/views/appointments/widgets/common_appointment_slider_widget.dart index b064484..37ed270 100644 --- a/lib/views/appointments/widgets/common_appointment_slider_widget.dart +++ b/lib/views/appointments/widgets/common_appointment_slider_widget.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:carousel_slider/carousel_slider.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -169,7 +171,8 @@ class AppointmentHomeTileWidget extends StatelessWidget { height: 19 / 12, ) : const SizedBox(), - (AppState().currentAppType == AppType.provider ? appointmentListModel!.customerName ?? "" : appointmentListModel!.branchName ?? "").toText(color: MyColors.black, fontSize: 16, letterSpacing: -0.64, height: 24 / 16), + (AppState().currentAppType == AppType.provider ? appointmentListModel!.customerName ?? "" : appointmentListModel!.branchName ?? "") + .toText(color: MyColors.black, fontSize: 16, letterSpacing: -0.64, height: 24 / 16), Row( children: [ !isForProvider ? MyAssets.miniClock.buildSvg(height: 12) : const SizedBox(), @@ -313,24 +316,21 @@ class AppointmentFragmentTileWidget extends StatelessWidget { (appointmentListModel!.branchName ?? "").toText(color: MyColors.darkTextColor, fontSize: 12, letterSpacing: -0.48, height: 18 / 12), ], ), - // Row( - // children: [ - // MyAssets.miniClock.buildSvg(height: 12), - // 2.width, - // " ${appointmentListModel!.duration ?? ""} ${appointmentListModel!.appointmentDate!.toFormattedDateWithoutTime()}".toText(color: MyColors.lightTextColor, fontSize: 12, letterSpacing: -0.48, height: 19 / 12), - // ], - // ), + if (appointmentListModel!.appointmentType != null) + Row( + children: [ + ("${LocaleKeys.serviceLocation.tr()}:").toText(color: MyColors.greyColor, fontSize: 12, letterSpacing: -0.48, height: 18 / 12), + 2.width, + (appointmentListModel!.appointmentType == 1 ? LocaleKeys.companyLocation.tr() : LocaleKeys.customerLocation.tr()) + .toText(color: MyColors.darkTextColor, fontSize: 12, letterSpacing: -0.48, height: 18 / 12), + ], + ), Row( children: [ MyAssets.miniClock.buildSvg(height: 12), 2.width, - " ${appointmentListModel!.duration ?? ""} ${appointmentListModel!.appointmentDate!.toLocalizedFormattedDateWithoutTime(context)}" - .toText( - color: MyColors.lightTextColor, - fontSize: 12, - letterSpacing: -0.48, - height: 19 / 12, - ), + " ${appointmentListModel!.duration ?? ""} ${appointmentListModel!.appointmentDate!.toFormattedDateWithoutTime()}" + .toText(color: MyColors.lightTextColor, fontSize: 12, letterSpacing: -0.48, height: 19 / 12), ], ), 5.height, diff --git a/lib/views/common_fragments/ads_fragment.dart b/lib/views/common_fragments/ads_fragment.dart index b2a00c3..e0d7b08 100644 --- a/lib/views/common_fragments/ads_fragment.dart +++ b/lib/views/common_fragments/ads_fragment.dart @@ -38,6 +38,7 @@ class AdsFragment extends StatelessWidget { @override Widget build(BuildContext context) { + final isArabic = AppState().isArabic(context); return Consumer( builder: (BuildContext context, AdVM adVM, Widget? child) { return Scaffold( @@ -46,8 +47,7 @@ class AdsFragment extends StatelessWidget { isRemoveBackButton: true, actions: [ Padding( - padding: EdgeInsets.only( - top: adVM.adsFiltersCounter > 0 ? 20 : 0, right: 21), + padding: EdgeInsets.only(top: adVM.adsFiltersCounter > 0 ? 20 : 0, right: isArabic ? 0 : 16, left: isArabic ? 16 : 0), child: Badge( isLabelVisible: adVM.adsFiltersCounter > 0, largeSize: 15, @@ -61,8 +61,7 @@ class AdsFragment extends StatelessWidget { }) ], ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, - arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); }), body: Stack( children: [ @@ -73,8 +72,7 @@ class AdsFragment extends StatelessWidget { children: [ 16.height, Consumer( - builder: - (BuildContext context, AdVM adVM, Widget? child) { + builder: (BuildContext context, AdVM adVM, Widget? child) { return Column( children: [ Row( @@ -85,9 +83,7 @@ class AdsFragment extends StatelessWidget { maxHeight: 55, fontSize: 15, title: LocaleKeys.exploreAds.tr(), - txtColor: adVM.isExploreAdsTapped - ? MyColors.white - : MyColors.darkTextColor, + txtColor: adVM.isExploreAdsTapped ? MyColors.white : MyColors.darkTextColor, onPressed: () { log("accessToken: ${AppState().getUser.data!.accessToken}"); log("AppState().getUser.data!.userInfo!.userId;: ${AppState().getUser.data!.userInfo!.userId}"); @@ -103,9 +99,7 @@ class AdsFragment extends StatelessWidget { Expanded( child: ShowFillButton( isFilled: !adVM.isExploreAdsTapped, - txtColor: !adVM.isExploreAdsTapped - ? MyColors.white - : MyColors.darkTextColor, + txtColor: !adVM.isExploreAdsTapped ? MyColors.white : MyColors.darkTextColor, maxHeight: 55, fontSize: 15, title: LocaleKeys.myAds.tr(), @@ -113,9 +107,7 @@ class AdsFragment extends StatelessWidget { adVM.updateIsExploreAds(false); }, ).toViewOnly(context, onTap: () { - navigateWithName( - context, AppRoutes.loginWithPassword, - arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); }), ), ], @@ -127,8 +119,7 @@ class AdsFragment extends StatelessWidget { showImages: true, filterList: adVM.exploreAdsFilterOptions, onFilterTapped: (index, selectedFilterId) { - adVM.applyFilterOnExploreAds( - vehicleBrandId: selectedFilterId); + adVM.applyFilterOnExploreAds(vehicleBrandId: selectedFilterId); }, needLeftPadding: false, ).paddingOnly(left: 21), @@ -138,9 +129,7 @@ class AdsFragment extends StatelessWidget { FiltersList( filterList: adVM.myAdsFilterOptions, onFilterTapped: (index, selectedFilterId) { - adVM.applyFilterOnMyAds( - adPostStatusEnum: - selectedFilterId.toAdPostEnum()); + adVM.applyFilterOnMyAds(adPostStatusEnum: selectedFilterId.toAdPostEnum()); }, needLeftPadding: false, ).paddingOnly(left: 21), @@ -154,19 +143,11 @@ class AdsFragment extends StatelessWidget { child: RefreshIndicator( onRefresh: () async { if (adVM.isExploreAdsTapped) { - int vehicleBrandId = adVM.exploreAdsFilterOptions - .firstWhere((element) => element.isSelected) - .id; - adVM.applyFilterOnExploreAds( - vehicleBrandId: vehicleBrandId); + int vehicleBrandId = adVM.exploreAdsFilterOptions.firstWhere((element) => element.isSelected).id; + adVM.applyFilterOnExploreAds(vehicleBrandId: vehicleBrandId); } else { - AdPostStatus adPostStatusEnum = adVM - .myAdsFilterOptions - .firstWhere((element) => element.isSelected) - .id - .toAdPostEnum(); - adVM.applyFilterOnMyAds( - adPostStatusEnum: adPostStatusEnum); + AdPostStatus adPostStatusEnum = adVM.myAdsFilterOptions.firstWhere((element) => element.isSelected).id.toAdPostEnum(); + adVM.applyFilterOnMyAds(adPostStatusEnum: adPostStatusEnum); } }, child: adVM.state == ViewState.busy @@ -176,22 +157,14 @@ class AdsFragment extends StatelessWidget { isDraftAds: false, shouldShowAdStatus: !adVM.isExploreAdsTapped, adsList: getAdsList(adVM), - hasMoreData: adVM.isExploreAdsTapped - ? adVM.hasMoreDataForExploreAds - : adVM.hasMoreDataForMyAds, + hasMoreData: adVM.isExploreAdsTapped ? adVM.hasMoreDataForExploreAds : adVM.hasMoreDataForMyAds, onFetchMoreAds: () async { - AdPostStatus adPostStatusEnum = adVM - .myAdsFilterOptions - .firstWhere( - (element) => element.isSelected) - .id - .toAdPostEnum(); + AdPostStatus adPostStatusEnum = adVM.myAdsFilterOptions.firstWhere((element) => element.isSelected).id.toAdPostEnum(); if (adVM.isExploreAdsTapped) { await adVM.fetchMoreExploreAds(); } else { - await adVM.fetchMoreMyAds( - adsStatus: adPostStatusEnum); + await adVM.fetchMoreMyAds(adsStatus: adPostStatusEnum); } }, ), @@ -215,8 +188,7 @@ class AdsFragment extends StatelessWidget { color: Colors.black.withOpacity(0.3), child: const Center( child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - MyColors.darkPrimaryColor), + valueColor: AlwaysStoppedAnimation(MyColors.darkPrimaryColor), ), ), ), @@ -233,31 +205,23 @@ class AdsFragment extends StatelessWidget { } if (AppState().userType != UserType.providerDealer) { - if (adVM.vehicleAdDurationId.selectedId == -1 && - adVM.vehicleAdsDurations.isNotEmpty) { + if (adVM.vehicleAdDurationId.selectedId == -1 && adVM.vehicleAdsDurations.isNotEmpty) { SelectionModel selection = SelectionModel( selectedId: adVM.vehicleAdsDurations.first.id ?? 0, - selectedOption: - ("${adVM.vehicleAdsDurations.first.days} ${LocaleKeys.daysVar.tr()}"), + selectedOption: ("${adVM.vehicleAdsDurations.first.days} ${LocaleKeys.daysVar.tr()}"), itemPrice: adVM.vehicleAdsDurations.first.price!.toString(), - currency: - adVM.vehicleAdsDurations.first.currency!.toString(), + currency: adVM.vehicleAdsDurations.first.currency!.toString(), ); adVM.updateVehicleAdDurationId(selection); } } - navigateWithName(context, AppRoutes.selectAdTypeView, arguments: [ - injector.get().currentAppType == AppType.provider, - false, - -1 - ]); + navigateWithName(context, AppRoutes.selectAdTypeView, arguments: [injector.get().currentAppType == AppType.provider, false, -1]); }, backgroundColor: MyColors.darkPrimaryColor, child: const Icon(Icons.add, color: MyColors.white), ).toViewOnly(context, onTap: () { - navigateWithName(context, AppRoutes.loginWithPassword, - arguments: false); + navigateWithName(context, AppRoutes.loginWithPassword, arguments: false); }), ); }, diff --git a/lib/views/user/update_user_details.dart b/lib/views/user/update_user_details.dart index 1a51d29..1088be8 100644 --- a/lib/views/user/update_user_details.dart +++ b/lib/views/user/update_user_details.dart @@ -26,6 +26,8 @@ class _UpdateUserDetailsState extends State { @override void initState() { userVM = Provider.of(context, listen: false); + firstname = AppState().getUser.data?.userInfo?.firstName ?? ""; + lastname = AppState().getUser.data?.userInfo?.lastName ?? ""; super.initState(); } @@ -44,11 +46,13 @@ class _UpdateUserDetailsState extends State { children: [ TxtField( hint: LocaleKeys.enterNewFirstName.tr(), + value: firstname, onChanged: (v) => firstname = v, ), 12.height, TxtField( hint: LocaleKeys.enterNewLastName.tr(), + value: lastname, onChanged: (v) => lastname = v, ), 40.height, diff --git a/lib/widgets/common_widgets/app_bar.dart b/lib/widgets/common_widgets/app_bar.dart index aa9314f..064cb6a 100644 --- a/lib/widgets/common_widgets/app_bar.dart +++ b/lib/widgets/common_widgets/app_bar.dart @@ -48,6 +48,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { + final isArabic = AppState().isArabic(context); return Column( children: [ AppBar( @@ -101,7 +102,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { 10.width, SvgPicture.asset(MyAssets.dashboardDrawerIcon), ], - ).paddingOnly(left: 21), + ).paddingOnly(left: isArabic ? 0 : 21, right: isArabic ? 21 : 0), ) : isRemoveBackButton ? null @@ -110,7 +111,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { 21.width, const Icon(Icons.arrow_back_ios, color: Colors.black, size: 16) .toContainer( - padding: const EdgeInsets.only(left: 5), + padding: EdgeInsets.only(left: isArabic ? 0 : 5, right: isArabic ? 5 : 0), borderRadius: 100, borderColor: MyColors.lightGreyEFColor, isEnabledBorder: true, diff --git a/lib/widgets/dropdown/dropdow_field.dart b/lib/widgets/dropdown/dropdow_field.dart index 7bf7f65..55c4c7d 100644 --- a/lib/widgets/dropdown/dropdow_field.dart +++ b/lib/widgets/dropdown/dropdow_field.dart @@ -1,8 +1,10 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:mc_common_app/classes/consts.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/theme/colors.dart'; import 'package:mc_common_app/utils/utils.dart'; import 'package:mc_common_app/widgets/extensions/extensions_widget.dart'; @@ -24,6 +26,24 @@ class DropValue { } } +/// Resolves which label to display for a [DropValue], matching the localization +/// rules: Arabic + useValueNForArabic -> valueN (falls back to value), Arabic + +/// useSubValueForArabic -> subValue, otherwise -> value. +String _dropdownDisplayLabel( + DropValue value, { + required bool isArabic, + required bool useSubValueForArabic, + required bool useValueNForArabic, +}) { + if (isArabic && useValueNForArabic) { + return value.valueN ?? value.value; + } + if (isArabic && useSubValueForArabic) { + return value.subValue; + } + return value.value; +} + class DropdownField extends StatefulWidget { final String? hint; final String errorValue; @@ -38,10 +58,19 @@ class DropdownField extends StatefulWidget { final bool useSubValueForArabic; final bool useValueNForArabic; - const DropdownField(this.onSelect, - {super.key, this.hint, this.list, this.dropdownValue, this.onTap, this.errorValue = "", this.showAppointmentPickerVariant = false, this.textStyle, this.isSelectAble = true, - this.isArabic = false , this.useSubValueForArabic = false, this.useValueNForArabic = false}); + {super.key, + this.hint, + this.list, + this.dropdownValue, + this.onTap, + this.errorValue = "", + this.showAppointmentPickerVariant = false, + this.textStyle, + this.isSelectAble = true, + this.isArabic = false, + this.useSubValueForArabic = false, + this.useValueNForArabic = false}); @override State createState() => _DropdownFieldState(); @@ -62,11 +91,6 @@ class _DropdownFieldState extends State { @override Widget build(BuildContext context) { dropdownValue = widget.dropdownValue; - - print("========== DROPDOWN VALUE =========="); - print("IsArabic: ${widget.isSelectAble}"); - print("listll: ${widget.list?.length}"); - print("list: ${widget.list}"); return Column( children: [ // Always show hint as label when provided @@ -92,7 +116,15 @@ class _DropdownFieldState extends State { decoration: Utils.containerColorRadiusBorderWidth(MyColors.greyShadowColor, 0, MyColors.greyACColor, 2), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), width: double.infinity, - child: (dropdownValue?.value ?? "").toText( + child: (dropdownValue == null + ? "" + : _dropdownDisplayLabel( + dropdownValue!, + isArabic: widget.isArabic, + useSubValueForArabic: widget.useSubValueForArabic, + useValueNForArabic: widget.useValueNForArabic, + )) + .toText( color: MyColors.darkTextColor, fontSize: 15, fontWeight: MyFonts.Medium, @@ -100,56 +132,55 @@ class _DropdownFieldState extends State { ) else // Show normal dropdown when selectable - Container( - decoration: widget.showAppointmentPickerVariant ? null : Utils.containerColorRadiusBorderWidth(MyColors.white, 0, MyColors.darkPrimaryColor, 2), - margin: const EdgeInsets.all(0), - padding: const EdgeInsets.only(left: 8, right: 8), - width: widget.showAppointmentPickerVariant ? 170 : null, - child: DropdownButton( - value: dropdownValue, - icon: const Icon( - Icons.keyboard_arrow_down_sharp, - size: 21, - ), - elevation: 16, - iconSize: widget.showAppointmentPickerVariant ? 26 : 16, - iconEnabledColor: borderColor, - iconDisabledColor: borderColor, - isExpanded: true, - style: const TextStyle( - color: borderColor, - fontSize: 15, - ), - underline: Container(height: 0), - onChanged: (DropValue? newValue) { - setState(() { - dropdownValue = newValue!; - widget.onSelect(newValue); - }); - }, - onTap: widget.onTap, - items: (widget.list ?? defaultV).map>( - (DropValue value) { - return DropdownMenuItem( - value: value, - enabled: value.isEnabled ?? true, - // child: value.value.toText(fontSize: 15, color: value.isEnabled == false ? MyColors.darkTextColor : null, fontWeight: MyFonts.Medium), - child: (widget.isArabic && widget.useValueNForArabic - ? (value.valueN ?? value.value) - : widget.isArabic && widget.useSubValueForArabic - ? value.subValue - : value.value) - .toText( - fontSize: 15, - color: value.isEnabled == false - ? MyColors.darkTextColor - : null, + Builder(builder: (context) { + // Distinguish "no list passed" (use placeholder defaultV) from "list explicitly empty" + // (real data fetched but nothing available) so we can show a meaningful empty-state. + final List items = widget.list ?? defaultV; + final bool hasNoData = widget.list != null && widget.list!.isEmpty; + return InkWell( + onTap: hasNoData + ? null + : () { + widget.onTap?.call(); + _openSearchablePicker(context, items); + }, + child: Container( + decoration: widget.showAppointmentPickerVariant ? null : Utils.containerColorRadiusBorderWidth(MyColors.white, 0, MyColors.darkPrimaryColor, 2), + margin: const EdgeInsets.all(0), + padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 2), + width: widget.showAppointmentPickerVariant ? 170 : null, + child: Row( + mainAxisSize: widget.showAppointmentPickerVariant ? MainAxisSize.min : MainAxisSize.max, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: (dropdownValue == null + ? (hasNoData ? LocaleKeys.noDataAvailable.tr() : "") + : _dropdownDisplayLabel( + dropdownValue!, + isArabic: widget.isArabic, + useSubValueForArabic: widget.useSubValueForArabic, + useValueNForArabic: widget.useValueNForArabic, + )) + .toText( + fontSize: 15, + color: hasNoData ? MyColors.greyACColor : MyColors.darkTextColor, + fontWeight: MyFonts.Medium, + maxLines: 1, + ), + ), ), - ); - }, - ).toList(), - ), - ), + Icon( + Icons.keyboard_arrow_down_sharp, + size: widget.showAppointmentPickerVariant ? 26 : 16, + color: hasNoData ? MyColors.greyACColor : MyColors.darkTextColor, + ), + ], + ), + ), + ); + }), if (widget.errorValue != "") Row( mainAxisAlignment: MainAxisAlignment.end, @@ -160,4 +191,186 @@ class _DropdownFieldState extends State { ], ); } + + void _openSearchablePicker(BuildContext context, List items) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return _SearchableDropdownSheet( + items: items, + selectedValue: dropdownValue, + isArabic: widget.isArabic, + useSubValueForArabic: widget.useSubValueForArabic, + useValueNForArabic: widget.useValueNForArabic, + onSelected: (value) { + setState(() { + dropdownValue = value; + }); + widget.onSelect(value); + }, + ); + }, + ); + } +} + +class _SearchableDropdownSheet extends StatefulWidget { + final List items; + final DropValue? selectedValue; + final Function(DropValue) onSelected; + final bool isArabic; + final bool useSubValueForArabic; + final bool useValueNForArabic; + + const _SearchableDropdownSheet({ + required this.items, + required this.selectedValue, + required this.onSelected, + this.isArabic = false, + this.useSubValueForArabic = false, + this.useValueNForArabic = false, + }); + + @override + State<_SearchableDropdownSheet> createState() => _SearchableDropdownSheetState(); +} + +class _SearchableDropdownSheetState extends State<_SearchableDropdownSheet> { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + String _labelFor(DropValue value) => _dropdownDisplayLabel( + value, + isArabic: widget.isArabic, + useSubValueForArabic: widget.useSubValueForArabic, + useValueNForArabic: widget.useValueNForArabic, + ); + + List get _filteredItems { + if (_searchQuery.isEmpty) return widget.items; + return widget.items.where((element) => _labelFor(element).toLowerCase().contains(_searchQuery.toLowerCase())).toList(); + } + + Widget _buildSearchField() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + height: 55, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!, width: 1), + ), + child: TextField( + controller: _searchController, + autofocus: false, + style: const TextStyle(fontSize: 16), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + decoration: InputDecoration( + hintStyle: TextStyle(fontSize: 16, color: Colors.grey[600]), + prefixIcon: Icon(Icons.search, size: 26, color: Colors.grey[700]), + suffixIcon: _searchQuery.isEmpty + ? null + : InkWell( + onTap: () { + setState(() { + _searchController.clear(); + _searchQuery = ''; + }); + }, + child: Icon(Icons.close, size: 22, color: Colors.grey[700]), + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final filtered = _filteredItems; + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: MyColors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.7), + child: SafeArea( + top: false, + // Material ancestor so ListTile ink splashes render on top of the white background above, + // instead of being hidden behind it. + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 12.height, + Container( + width: 40, + height: 4, + decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)), + ), + 14.height, + _buildSearchField(), + 10.height, + Flexible( + child: filtered.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: LocaleKeys.noDataAvailable.tr().toText(fontSize: 15, color: MyColors.greyACColor), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: filtered.length, + separatorBuilder: (context, index) => Divider(height: 1, thickness: 1, color: Colors.grey[300]), + itemBuilder: (context, index) { + DropValue value = filtered[index]; + bool isEnabled = value.isEnabled ?? true; + bool isSelected = widget.selectedValue?.id == value.id; + return ListTile( + enabled: isEnabled, + selected: isSelected, + dense: false, + selectedTileColor: MyColors.darkPrimaryColor.withValues(alpha: 0.08), + title: _labelFor(value).toText( + fontSize: 15, + color: !isEnabled ? MyColors.greyACColor : (isSelected ? MyColors.darkPrimaryColor : MyColors.darkTextColor), + fontWeight: isSelected ? MyFonts.Bold : MyFonts.Medium, + ), + trailing: isSelected ? const Icon(Icons.check, color: MyColors.darkPrimaryColor) : null, + onTap: !isEnabled + ? null + : () { + widget.onSelected(value); + Navigator.pop(context); + }, + ); + }, + ), + ), + 8.height, + ], + ), + ), + ), + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 0f627d3..d0f41f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.41" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" args: dependency: transitive description: @@ -653,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.12" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hexcolor: dependency: "direct main" description: @@ -1539,5 +1579,5 @@ packages: source: hosted version: "6.5.0" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index a5071ba..cce50fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: local_auth: ^2.2.0 # huawei_fido: ^6.3.0+305 device_info_plus: ^10.1.0 + app_links: ^7.2.1 dev_dependencies: