diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index 02ba950..adb08db 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -827,9 +827,12 @@ "distanceKm": "{} كم", "discountPercentPlus": "{}%+", "visitMerchantWebsite": "زيارة موقع التاجر", - "merchantWebsiteNotAvailable": "موقع التاجر غير متوفر", + "favoriteAddedSuccess": "تمت إضافة التاجر إلى المفضلة بنجاح", "favoriteRemovedSuccess": "تمت إزالة التاجر من المفضلة بنجاح", - "favoriteError": "حدث خطأ أثناء تحديث المفضلة" + "favoriteError": "حدث خطأ أثناء تحديث المفضلة", + "merchantWebsiteNotAvailable": "موقع التاجر غير متوفر", + "locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة", + "locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة" } diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 8dea94b..83bfeef 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -809,7 +809,7 @@ "microphoneAccessRequired": "The app needs microphone access to be able to record audio.", "shareAccessGuide": "Share Access Guide", "nationalIdOrIqamaNumber": "National ID or Iqama Number", - "multipleCities": "Multiple Cities", + "multipleCities": "Multi-city", "filters": "Filters", "availability": "Availability", "chooseOneOrBoth": "Choose one or both", @@ -828,6 +828,8 @@ "discountPercentPlus": "{}%+", "visitMerchantWebsite": "Visit Merchant Website", "merchantWebsiteNotAvailable": "Merchant website not available", + "locationServicesRequiredForNearestOffers": "You need to enable location services to show nearest offers", + "locationPermissionRequiredForNearestOffers": "You need to give location permission to show nearest offers", "favoriteAddedSuccess": "Added to favorites successfully", "favoriteRemovedSuccess": "Removed from favorites successfully", "favoriteError": "An error occurred while updating favorites" diff --git a/lib/api/offers_and_discounts_api_client.dart b/lib/api/offers_and_discounts_api_client.dart index 589110f..95e5029 100644 --- a/lib/api/offers_and_discounts_api_client.dart +++ b/lib/api/offers_and_discounts_api_client.dart @@ -257,6 +257,9 @@ class OffersAndDiscountsApiClient { int perPage = 10, String? geoLocation, String? offerType, + String? sortBy, + int? radius, + int? minDiscount, BuildContext? context, String? mobileNumber, String? query, @@ -286,6 +289,13 @@ class OffersAndDiscountsApiClient { if (query != null && query.trim().isNotEmpty) { postParams["WP_Query"] = query.trim(); } + if (sortBy != null && sortBy.trim().isNotEmpty) { + postParams["WP_Sort_By"] = sortBy.trim(); + } + // null (radius/minDiscount unset) means "Any"/"All" was selected - _cleanWalaPlusParams + // strips null entries below so the param is simply omitted from the request. + postParams["WP_Radius"] = radius; + postParams["WP_Min_Discount"] = minDiscount; if (geoLocation != null && geoLocation.isNotEmpty) { postParams["WP_Geo_Location"] = geoLocation; diff --git a/lib/extensions/string_extensions.dart b/lib/extensions/string_extensions.dart index 424fb78..14dca8f 100644 --- a/lib/extensions/string_extensions.dart +++ b/lib/extensions/string_extensions.dart @@ -82,7 +82,17 @@ extension EmailValidator on String { ), ); - Widget toText12({Color? color, FontWeight? weight, bool isUnderLine = false, Color? underlineColor, double underlineGap = 2, bool isBold = false, bool isCenter = false, int? maxLine, TextOverflow? overflow}) { + Widget toText12({ + Color? color, + FontWeight? weight, + bool isUnderLine = false, + Color? underlineColor, + double underlineGap = 2, + bool isBold = false, + bool isCenter = false, + int? maxLine, + TextOverflow? overflow, + }) { Widget text = Text( this, textAlign: isCenter ? TextAlign.center : null, diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index 40187ba..7a47054 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -839,7 +839,9 @@ class CodegenLoader extends AssetLoader{ "distanceKm": "{} كم", "discountPercentPlus": "{}%+", "visitMerchantWebsite": "زيارة موقع التاجر", - "merchantWebsiteNotAvailable": "موقع التاجر غير متوفر" + "merchantWebsiteNotAvailable": "موقع التاجر غير متوفر", + "locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة", + "locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة" }; static const Map _en_US = { "mohemm": "Mohemm", @@ -1648,7 +1650,7 @@ static const Map _en_US = { "microphoneAccessRequired": "The app needs microphone access to be able to record audio.", "shareAccessGuide": "Share Access Guide", "nationalIdOrIqamaNumber": "National ID or Iqama Number", - "multipleCities": "Multiple Cities", + "multipleCities": "Multi-city", "filters": "Filters", "availability": "Availability", "chooseOneOrBoth": "Choose one or both", @@ -1666,7 +1668,9 @@ static const Map _en_US = { "distanceKm": "{} km", "discountPercentPlus": "{}%+", "visitMerchantWebsite": "Visit Merchant Website", - "merchantWebsiteNotAvailable": "Merchant website not available" + "merchantWebsiteNotAvailable": "Merchant website not available", + "locationServicesRequiredForNearestOffers": "You need to enable location services to show nearest offers", + "locationPermissionRequiredForNearestOffers": "You need to give location permission to show nearest offers" }; static const Map> mapLocales = {"ar_SA": _ar_SA, "en_US": _en_US}; } diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 46087d0..c3d5b0f 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -815,4 +815,7 @@ abstract class LocaleKeys { static const favoriteAddedSuccess = 'favoriteAddedSuccess'; static const favoriteRemovedSuccess = 'favoriteRemovedSuccess'; static const favoriteError = 'favoriteError'; + static const locationServicesRequiredForNearestOffers = 'locationServicesRequiredForNearestOffers'; + static const locationPermissionRequiredForNearestOffers = 'locationPermissionRequiredForNearestOffers'; + } diff --git a/lib/models/offers_and_discounts/wala_plus_retailer_model.dart b/lib/models/offers_and_discounts/wala_plus_retailer_model.dart index a5cac49..6984226 100644 --- a/lib/models/offers_and_discounts/wala_plus_retailer_model.dart +++ b/lib/models/offers_and_discounts/wala_plus_retailer_model.dart @@ -601,18 +601,6 @@ class WalaPlusRetailerModel { return distinctCities.first; } - List getDistinctCities({bool isAr = false}) { - if (locations == null || locations!.isEmpty) return []; - Set distinctCities = {}; - for (var loc in locations!) { - String? resolved = _resolveLocationCity(loc, isAr); - if (resolved != null && resolved.trim().isNotEmpty && resolved != "null") { - distinctCities.add(resolved.trim()); - } - } - return distinctCities.toList(); - } - String get displayCity => getDisplayCity(isAr: false); String get displayUsage { diff --git a/lib/ui/landing/dashboard_screen_for_dependet.dart b/lib/ui/landing/dashboard_screen_for_dependet.dart index f84cf78..8648ea1 100644 --- a/lib/ui/landing/dashboard_screen_for_dependet.dart +++ b/lib/ui/landing/dashboard_screen_for_dependet.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:mohem_flutter_app/api/login_api_client.dart'; import 'package:mohem_flutter_app/api/offers_and_discounts_api_client.dart'; import 'package:mohem_flutter_app/app_state/app_state.dart'; @@ -20,6 +21,9 @@ import 'package:mohem_flutter_app/models/post_params_model.dart'; import 'package:mohem_flutter_app/widgets/bottom_sheets/confirm_bottom_sheet.dart'; import 'package:mohem_flutter_app/widgets/button/default_button.dart'; import 'package:mohem_flutter_app/widgets/info_chip_widget.dart'; +import 'package:mohem_flutter_app/widgets/location/Location.dart' as location; +import 'package:mohem_flutter_app/widgets/toast/app_toast.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:shimmer/shimmer.dart'; class DashboardScreenForDependet extends StatefulWidget { @@ -43,11 +47,12 @@ class _DashboardScreenForDependetState extends State bool isSubcategoriesLoading = false; String? _selectedSubcategoryId; - // Filters bottom sheet state. Availability is single-select and functional - it drives the - // same selectedTypeFilter/offerType param the old chip row used. Sort/Distance/Minimum - // discount still have no backend support, so those remain UI-only for now. Committed on - // "Apply filters"; sort/distance/discount are persisted here so reopening the sheet shows - // the last-applied selection (availability's source of truth is selectedTypeFilter itself). + // Filters bottom sheet state - all fully wired to the API now. Availability drives + // WP_Offer_Type (via selectedTypeFilter), _filterSortBy drives WP_Sort_By ('nearest' + // requires WP_Geo_Location, see _ensureLocationReadyForNearestSort), _filterDistance drives + // WP_Radius (null when "any"), _filterMinDiscount drives WP_Min_Discount (null when "any"). + // Committed on "Apply filters"/"Reset"; persisted here so reopening the sheet shows the + // last-applied selection (availability's source of truth is selectedTypeFilter itself). String _filterSortBy = 'nearest'; String _filterDistance = '5km'; String _filterMinDiscount = 'any'; @@ -293,6 +298,11 @@ class _DashboardScreenForDependetState extends State searchQuery = query; } + bool wantsNearest = _filterSortBy == 'nearest'; + if (wantsNearest && !await _ensureLocationReadyForNearestSort()) { + return; + } + setState(() { isLoading = true; currentPage = 1; @@ -303,6 +313,9 @@ class _DashboardScreenForDependetState extends State String catParam = (targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId; String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter); String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null; + int? radiusParam = _radiusFromFilterDistance(_filterDistance); + int? minDiscountParam = _filterMinDiscount == 'any' ? null : int.tryParse(_filterMinDiscount); + print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', sortBy='$_filterSortBy', radius=$radiusParam, minDiscount=$minDiscountParam, page=1, per_page=$pageSize"); String? userRef = isFavoriteOnly ? _getUserReferenceForFavorites() : null; print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', userReference='$userRef', page=1, per_page=$pageSize"); WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers( @@ -311,7 +324,10 @@ class _DashboardScreenForDependetState extends State perPage: pageSize, offerType: offerTypeParam, query: queryParam, - context: isNearestOffers ? context : null, + sortBy: _filterSortBy, + radius: radiusParam, + minDiscount: minDiscountParam, + context: wantsNearest ? context : null, userReference: userRef, ); @@ -341,6 +357,11 @@ class _DashboardScreenForDependetState extends State Future fetchNextPage() async { if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return; + bool wantsNearest = _filterSortBy == 'nearest'; + if (wantsNearest && !await _ensureLocationReadyForNearestSort()) { + return; + } + _isFetchingPage = true; setState(() { isLoadingMore = true; @@ -352,8 +373,10 @@ class _DashboardScreenForDependetState extends State String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter); String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null; String? userRef = isFavoriteOnly ? _getUserReferenceForFavorites() : null; + int? radiusParam = _radiusFromFilterDistance(_filterDistance); + int? minDiscountParam = _filterMinDiscount == 'any' ? null : int.tryParse(_filterMinDiscount); - print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam', userReference='$userRef'"); + print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam'"); WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers( categoryId: catParam, @@ -361,7 +384,10 @@ class _DashboardScreenForDependetState extends State perPage: pageSize, offerType: offerTypeParam, query: queryParam, - context: isNearestOffers ? context : null, + sortBy: _filterSortBy, + radius: radiusParam, + minDiscount: minDiscountParam, + context: wantsNearest ? context : null, userReference: userRef, ); @@ -396,6 +422,59 @@ class _DashboardScreenForDependetState extends State await Future.wait(>[fetchCategories(), fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId, query: _searchController.text)]); } + // '5km'/'10km'/'25km' -> 5/10/25 for WP_Radius; 'any' -> null (omit the param - no distance cap). + int? _radiusFromFilterDistance(String distance) { + if (distance == 'any') return null; + return int.tryParse(distance.replaceAll('km', '')); + } + + // WP_Sort_By=nearest requires WP_Geo_Location to be passed alongside it, so before fetching + // with "Nearest" selected we make sure location services + permission are actually available. + // Mirrors the existing attendance-flow location-permission pattern (see + // mark_attendance_widget.dart's checkHuaweiLocationPermission): services-off -> prompt to open + // settings, permission-not-granted -> request it, still denied -> toast. Never auto-retries the + // fetch - the user must grant access and trigger the fetch again themselves. + Future _ensureLocationReadyForNearestSort() async { + Completer enabledCompleter = Completer(); + location.Location.isEnabled((bool isEnabled) { + if (!enabledCompleter.isCompleted) enabledCompleter.complete(isEnabled); + }); + bool isEnabled = await enabledCompleter.future; + if (!isEnabled) { + if (mounted) { + ConfirmBottomSheet.show( + context, + message: LocaleKeys.locationServicesRequiredForNearestOffers.tr(), + confirmText: LocaleKeys.openSettings.tr(), + showCancelButton: false, + onConfirm: () async { + await Geolocator.openLocationSettings(); + }, + ); + } + return false; + } + + Completer permissionCompleter = Completer(); + location.Location.havePermission((bool hasPermission) { + if (!permissionCompleter.isCompleted) permissionCompleter.complete(hasPermission); + }); + bool hasPermission = await permissionCompleter.future; + if (!hasPermission) { + bool granted = await _requestLocationPermission(); + if (!granted) { + AppToast.show(LocaleKeys.locationPermissionRequiredForNearestOffers.tr()); + return false; + } + } + return true; + } + + Future _requestLocationPermission() async { + Map result = await [Permission.location].request(); + return result[Permission.location] == PermissionStatus.granted || result[Permission.locationAlways] == PermissionStatus.granted; + } + void _onSearchChanged(String query) { _debounceTimer?.cancel(); searchQuery = query; @@ -679,7 +758,16 @@ class _DashboardScreenForDependetState extends State GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, childAspectRatio: 0.7, crossAxisSpacing: 12, mainAxisSpacing: 16), + // Fixed pixel height instead of childAspectRatio - the card's content is + // fixed in absolute pixels, not proportional to screen width, so an aspect + // ratio made the computed cell height inconsistent across device sizes. + // _buildOfferCard now also standardizes every card's content height (the + // subtitle line and badges row are always reserved/fixed-height, not + // conditional/wrapping), so 235 is a real, stable fit - not a worst-case + // guess - with a non-scrollable SingleChildScrollView backstop in the card + // itself for the rare case a device still needs a bit more (e.g. large + // system text scaling), which clips instead of overflowing. + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16), itemCount: _filteredRetailersList.length, itemBuilder: (context, index) { return _buildOfferCard(_filteredRetailersList[index], isAr); @@ -901,9 +989,9 @@ class _DashboardScreenForDependetState extends State ); } - // Matches the shared Filters bottom sheet design. Availability is single-select and - // functional, driving selectedTypeFilter/offerType exactly like the old chip row did. - // Sort/Distance/Minimum discount still have no backend support, so those remain UI-only. + // Matches the shared Filters bottom sheet design. All four sections are fully wired to the + // API: Availability -> WP_Offer_Type (via selectedTypeFilter), Sort by -> WP_Sort_By, Distance + // -> WP_Radius, Minimum discount -> WP_Min_Discount. "Any"/"All" maps to null (param omitted). void _showFiltersBottomSheet() { String draftAvailability = selectedTypeFilter; String draftSortBy = _filterSortBy; @@ -1258,11 +1346,17 @@ class _DashboardScreenForDependetState extends State boxShadow: [BoxShadow(color: const Color(0xFF0D1720).withOpacity(0.0392), offset: const Offset(0, 5), blurRadius: 18, spreadRadius: 0)], ), padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Inset image with rounded corners - ClipRRect( + // Hard backstop against RenderFlex overflow: if content ever needs more height than + // mainAxisExtent provides (e.g. a device with large system text scaling), this clips + // the excess instead of showing a debug overflow error. Not meant to be scrolled by + // the user - just a safety net, hence NeverScrollableScrollPhysics. + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Inset image with rounded corners + ClipRRect( borderRadius: BorderRadius.circular(14), child: SizedBox( height: 120, @@ -1330,29 +1424,34 @@ class _DashboardScreenForDependetState extends State // Retailer Name / Bundle Title (item.name ?? "").toText14(isBold: true, maxlines: 1, overflow: TextOverflow.ellipsis, color: const Color(0xFF1E293B)), - if (offerSubtitle.isNotEmpty) ...[const SizedBox(height: 2), offerSubtitle.toText14(maxlines: 1, overflow: TextOverflow.ellipsis, isBold: true, color: const Color(0xFF1E293B))], - - // Badges Row: Type, Validity, Location & Usage - if (offerTypes.isNotEmpty || validityText.trim().isNotEmpty || cityText.trim().isNotEmpty || usageText.trim().isNotEmpty) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 5, - runSpacing: 4, + // Subtitle line is always rendered (even empty) so every card reserves the same + // vertical space here, instead of shorter-content cards ending up shorter overall. + const SizedBox(height: 2), + offerSubtitle.toText14(maxlines: 1, overflow: TextOverflow.ellipsis, isBold: true, color: const Color(0xFF1E293B)), + + // Badges row: Type, Location & Usage - always exactly one row, fixed height. + // Horizontally scrollable instead of wrapping to a 2nd row, so every card's badge + // section is the same height regardless of how many badges it has. + const SizedBox(height: 6), + SizedBox( + height: 28, + child: ListView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), children: [ // One Type Chip per distinct type - Online (green) and/or In Store (blue). - for (String offerType in offerTypes) + for (String offerType in offerTypes) ...[ InfoChipWidget( text: _getTypeFilterTitle(offerType), icon: _getTypeFilterIcon(offerType), backgroundColor: offerType == 'online' ? const Color(0xFFE6F7F0) : const Color(0xFFEFF6FF), textColor: offerType == 'online' ? const Color(0xFF00A651) : const Color(0xFF2563EB), ), - - // Validity badge - // if (validityText.trim().isNotEmpty) InfoChipWidget(text: validityText, backgroundColor: const Color(0xFFE6F7F0), textColor: const Color(0xFF00A651)), + const SizedBox(width: 5), + ], // Location Badge - if (cityText.trim().isNotEmpty) + if (cityText.trim().isNotEmpty) ...[ InfoChipWidget( text: cityText, svgIcon: "assets/icons/location.svg", @@ -1362,15 +1461,18 @@ class _DashboardScreenForDependetState extends State iconColor: const Color(0xFF64748B), iconTextGap: 2.5, ), + const SizedBox(width: 5), + ], // Usage badge if (usageText.trim().isNotEmpty) InfoChipWidget(text: usageText, backgroundColor: const Color(0xFFFEF3C7), textColor: const Color(0xFFB45309)), ], ), - ], + ), ], ), ), + ), ); } @@ -1381,7 +1483,7 @@ class _DashboardScreenForDependetState extends State child: GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, childAspectRatio: 0.7, crossAxisSpacing: 12, mainAxisSpacing: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16), itemCount: 4, itemBuilder: (_, __) { return Container( @@ -1390,19 +1492,17 @@ class _DashboardScreenForDependetState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(height: 105, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))), + Container(height: 120, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))), const SizedBox(height: 8), Container(height: 14, width: 110, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))), - const SizedBox(height: 4), - Container(height: 12, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))), const SizedBox(height: 6), - Wrap( - spacing: 5, - runSpacing: 4, + Container(height: 14, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))), + const SizedBox(height: 6), + Row( children: [ - Container(height: 16, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10))), - Container(height: 16, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10))), - Container(height: 16, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10))), + Container(height: 28, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))), + const SizedBox(width: 5), + Container(height: 28, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))), ], ), ], diff --git a/lib/ui/landing/wala_plus_retailer_details_screen.dart b/lib/ui/landing/wala_plus_retailer_details_screen.dart index 61588c2..5cb33f3 100644 --- a/lib/ui/landing/wala_plus_retailer_details_screen.dart +++ b/lib/ui/landing/wala_plus_retailer_details_screen.dart @@ -705,7 +705,7 @@ class _WalaPlusRetailerDetailsScreenState extends State offerTypes = _retailer?.normalizedTypes ?? []; - List cities = _retailer?.getDistinctCities(isAr: isAr) ?? []; + // Single aggregated chip - same as the dashboard card: the actual city when there's only + // one, or the localized "Multi-city" label when locations span more than one distinct city. + String cityText = _retailer?.getDisplayCity(isAr: isAr) ?? ''; return Wrap( spacing: 8, @@ -884,9 +886,9 @@ class _WalaPlusRetailerDetailsScreenState extends State