Added filters

wala_plus_with_dialog_removal
FaizHashmi 2 weeks ago
parent 59bba20e20
commit 6d4bddd42d

@ -827,6 +827,8 @@
"distanceKm": "{} كم",
"discountPercentPlus": "{}%+",
"visitMerchantWebsite": "زيارة موقع التاجر",
"merchantWebsiteNotAvailable": "موقع التاجر غير متوفر"
"merchantWebsiteNotAvailable": "موقع التاجر غير متوفر",
"locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة",
"locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة"
}

@ -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",
@ -827,6 +827,8 @@
"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"
}

@ -256,6 +256,9 @@ class OffersAndDiscountsApiClient {
int perPage = 10,
String? geoLocation,
String? offerType,
String? sortBy,
int? radius,
int? minDiscount,
BuildContext? context,
String? mobileNumber,
String? query,
@ -284,6 +287,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;

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

@ -839,7 +839,9 @@ class CodegenLoader extends AssetLoader{
"distanceKm": "{} كم",
"discountPercentPlus": "{}%+",
"visitMerchantWebsite": "زيارة موقع التاجر",
"merchantWebsiteNotAvailable": "موقع التاجر غير متوفر"
"merchantWebsiteNotAvailable": "موقع التاجر غير متوفر",
"locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة",
"locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة"
};
static const Map<String,dynamic> _en_US = {
"mohemm": "Mohemm",
@ -1648,7 +1650,7 @@ static const Map<String,dynamic> _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<String,dynamic> _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<String, Map<String,dynamic>> mapLocales = {"ar_SA": _ar_SA, "en_US": _en_US};
}

@ -812,5 +812,7 @@ abstract class LocaleKeys {
static const discountPercentPlus = 'discountPercentPlus';
static const visitMerchantWebsite = 'visitMerchantWebsite';
static const merchantWebsiteNotAvailable = 'merchantWebsiteNotAvailable';
static const locationServicesRequiredForNearestOffers = 'locationServicesRequiredForNearestOffers';
static const locationPermissionRequiredForNearestOffers = 'locationPermissionRequiredForNearestOffers';
}

@ -570,18 +570,6 @@ class WalaPlusRetailerModel {
return distinctCities.first;
}
List<String> getDistinctCities({bool isAr = false}) {
if (locations == null || locations!.isEmpty) return <String>[];
Set<String> distinctCities = <String>{};
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 {

@ -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,15 +47,15 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
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';
bool isNearestOffers = true;
bool isLoading = true;
bool isLoadingMore = false;
bool isCategoriesLoading = true;
@ -284,6 +288,11 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
searchQuery = query;
}
bool wantsNearest = _filterSortBy == 'nearest';
if (wantsNearest && !await _ensureLocationReadyForNearestSort()) {
return;
}
setState(() {
isLoading = true;
currentPage = 1;
@ -294,14 +303,19 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
String catParam = (targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId;
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', page=1, per_page=$pageSize");
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");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: catParam,
page: 1,
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
context: isNearestOffers ? context : null,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
context: wantsNearest ? context : null,
);
retailersList = response?.retailers ?? <WalaPlusRetailerModel>[];
@ -325,6 +339,11 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
Future<void> fetchNextPage() async {
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
bool wantsNearest = _filterSortBy == 'nearest';
if (wantsNearest && !await _ensureLocationReadyForNearestSort()) {
return;
}
_isFetchingPage = true;
setState(() {
isLoadingMore = true;
@ -335,8 +354,10 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
String catParam = (currentCategoryId == "all" || currentCategoryId == "0") ? "" : currentCategoryId;
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 next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam'");
print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam', sortBy: '$_filterSortBy'");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: catParam,
@ -344,7 +365,10 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
context: isNearestOffers ? context : null,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
context: wantsNearest ? context : null,
);
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? [];
@ -373,6 +397,59 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
await Future.wait<dynamic>(<Future<void>>[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<bool> _ensureLocationReadyForNearestSort() async {
Completer<bool> enabledCompleter = Completer<bool>();
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<bool> permissionCompleter = Completer<bool>();
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<bool> _requestLocationPermission() async {
Map<Permission, PermissionStatus> result = await <Permission>[Permission.location].request();
return result[Permission.location] == PermissionStatus.granted || result[Permission.locationAlways] == PermissionStatus.granted;
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
searchQuery = query;
@ -642,7 +719,16 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
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);
@ -864,9 +950,9 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
);
}
// 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;
@ -1221,11 +1307,17 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
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,
@ -1293,29 +1385,34 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
// 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: <Widget>[
// One Type Chip per distinct type - Online (green) and/or In Store (blue).
for (String offerType in offerTypes)
for (String offerType in offerTypes) ...<Widget>[
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) ...<Widget>[
InfoChipWidget(
text: cityText,
svgIcon: "assets/icons/location.svg",
@ -1325,15 +1422,18 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
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)),
],
),
],
),
],
),
),
),
);
}
@ -1344,7 +1444,7 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
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(
@ -1353,19 +1453,17 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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))),
],
),
],

@ -608,7 +608,7 @@ class _WalaPlusRetailerDetailsScreenState extends State<WalaPlusRetailerDetailsS
14.height,
InkWell(
onTap: _openMerchantWebsite,
child: LocaleKeys.visitMerchantWebsite.tr().toText14(color: const Color(0xFF0D9488), isUnderLine: true, weight: FontWeight.w600),
child: LocaleKeys.visitMerchantWebsite.tr().toText14(color: const Color(0xFF0D9488), isUnderLine: true, weight: FontWeight.w600),
),
],
10.height,
@ -765,7 +765,9 @@ class _WalaPlusRetailerDetailsScreenState extends State<WalaPlusRetailerDetailsS
// that into its distinct parts, so a retailer offering both ways gets a chip each, same
// as the dashboard card.
List<String> offerTypes = _retailer?.normalizedTypes ?? <String>[];
List<String> cities = _retailer?.getDistinctCities(isAr: isAr) ?? <String>[];
// 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,
@ -787,9 +789,9 @@ class _WalaPlusRetailerDetailsScreenState extends State<WalaPlusRetailerDetailsS
borderRadius: 12,
iconSize: 14,
),
for (String city in cities)
if (cityText.trim().isNotEmpty)
InfoChipWidget(
text: city,
text: cityText,
icon: Icons.location_on_outlined,
backgroundColor: const Color(0xFFF8FAFC),
textColor: const Color(0xFF475569),

Loading…
Cancel
Save