You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mohemm-flutter-app/lib/ui/mazaya/retailers_list_screen.dart

2602 lines
92 KiB
Dart

import 'dart:async';
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';
import 'package:mohem_flutter_app/classes/colors.dart';
import 'package:mohem_flutter_app/classes/consts.dart';
import 'package:mohem_flutter_app/classes/utils.dart';
import 'package:mohem_flutter_app/config/routes.dart';
import 'package:mohem_flutter_app/extensions/int_extensions.dart';
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
import 'package:mohem_flutter_app/models/generic_response_model.dart';
import 'package:mohem_flutter_app/models/offers_and_discounts/get_offers_list.dart';
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_category_model.dart';
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart';
import 'package:mohem_flutter_app/models/post_params_model.dart';
import 'package:mohem_flutter_app/ui/mazaya/widgets/mazaya_city_selector.dart';
import 'package:mohem_flutter_app/ui/mazaya/widgets/wala_plus_retailer_grid.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:permission_handler/permission_handler.dart';
import 'package:shimmer/shimmer.dart';
class RetailersListScreen extends StatefulWidget {
const RetailersListScreen({Key? key}) : super(key: key);
@override
State<RetailersListScreen> createState() => _RetailersListScreenState();
}
class _RetailersListScreenState extends State<RetailersListScreen> {
List<WalaPlusRetailerModel> retailersList = [];
List<WalaPlusRetailerModel> _filteredRetailersList = [];
List<WalaPlusCategoryModel> categoriesList = [];
WalaPlusCategoryModel? selectedCategory;
String currentCategoryId = "";
String selectedTypeFilter = "all";
String selectedCity = "All Cities";
bool _isInitRouteArgs = false;
// Reached from Discover's "Exclusive offers" See all - reuses the legacy Offers & Discounts
// (ITG) API/implementation (lib/ui/screens/offers_and_discounts) instead of a WalaPlus
// endpoint, so fetchCategories/fetchOffers/fetchSubcategories all short-circuit to
// fetchExclusiveOffers below rather than touching any WalaPlus-specific state or UI.
bool isExclusiveMode = false;
List<WalaPlusRetailerModel> _exclusiveOffers = <WalaPlusRetailerModel>[];
// Subcategories of whichever main category is currently selected - fetched via
// getWalaPlusCategories(parentId: ...) whenever a main category card is tapped. Empty/cleared
// when "All Categories" (or nothing) is selected, since there's no specific parent to ask for.
List<WalaPlusCategoryModel> subcategoriesList = <WalaPlusCategoryModel>[];
bool isSubcategoriesLoading = false;
String? _selectedSubcategoryId;
// 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 = 'newest';
String _filterDistance = 'any';
String _filterMinDiscount = 'any';
bool isNearestOffers = true;
bool isLoading = true;
bool isLoadingMore = false;
bool isCategoriesLoading = true;
bool hasMore = true;
bool _isFetchingPage = false;
int currentPage = 1;
int totalPages = 1;
final int pageSize = 10;
String searchQuery = "";
Timer? _debounceTimer;
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _hasRegisteredMobileLoginInfo = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
_loadDependentName();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await _saveDependentSessionToPrefs();
await _registerMobileLoginInfo();
await fetchCategories();
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInitRouteArgs) {
_isInitRouteArgs = true;
Object? args = ModalRoute.of(context)?.settings.arguments;
if (args is Map && args['exclusiveMode'] == true) {
isExclusiveMode = true;
} else if (args is WalaPlusCategoryModel) {
selectedCategory = args;
currentCategoryId = args.id ?? "";
} else if (args is Map && args['category'] is WalaPlusCategoryModel) {
selectedCategory = args['category'] as WalaPlusCategoryModel;
currentCategoryId = selectedCategory?.id ?? "";
}
}
}
Future<void> _registerMobileLoginInfo() async {
if (_hasRegisteredMobileLoginInfo) return;
if (!ApiConsts.isDependetLogin) return;
_hasRegisteredMobileLoginInfo = true;
try {
int loginType = AppState().dependentLoginType ?? 1;
String nationalId = AppState().getUserName ?? "";
String dob = AppState().dependentDob ?? "";
String name = AppState().dependentName ?? "";
String mobile = AppState().postParamsObject?.mobileNumber ?? "";
String tokenID =
(AppState().postParamsObject?.tokenID != null &&
AppState().postParamsObject!.tokenID!.isNotEmpty)
? AppState().postParamsObject!.tokenID!
: (AppState().authenticationTokenID ?? "");
if (tokenID.isEmpty) {
tokenID = await Utils.getStringFromPrefs("P_DEP_TOKEN_ID");
}
int sessionId = AppState().postParamsObject?.pSessionId ?? 0;
String email = AppState().postParamsObject?.pEmailAddress ?? "";
String deviceToken =
AppState().getIsHuawei
? AppState().getHuaweiPushToken
: ((AppState().getDeviceToken != null &&
AppState().getDeviceToken!.isNotEmpty &&
AppState().getDeviceToken != "null")
? AppState().getDeviceToken!
: "");
if (deviceToken.isEmpty || deviceToken == "null") {
String savedToken = await Utils.getStringFromPrefs(
"P_DEP_DEVICE_TOKEN",
);
if (savedToken.isNotEmpty && savedToken != "null") {
deviceToken = savedToken;
}
}
await LoginApiClient().insertMobileLoginInfoDependent(
loginType: loginType,
nationalId: nationalId,
dob: dob,
dependentName: name,
mobileNumber: mobile,
tokenID: tokenID,
sessionId: sessionId,
email: email,
deviceToken: deviceToken,
context: context,
);
} catch (e) {
if (kDebugMode) {
print("Error calling insertMobileLoginInfoDependent: $e");
}
}
}
void _loadDependentName() {
if (AppState().dependentName != null &&
AppState().dependentName!.isNotEmpty &&
mounted) {
setState(() {});
}
}
String _cleanDependentName(String name) {
return name
.replaceAll(RegExp(r'\s*-\s*'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _scrollListener() {
if (!_scrollController.hasClients) return;
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
// Trigger only when user scrolls to within 35px of the very bottom
if (currentScroll >= maxScroll - 35) {
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
fetchNextPage();
}
}
}
// Reuses the legacy Offers & Discounts (ITG) API/implementation
// (lib/ui/screens/offers_and_discounts) rather than a WalaPlus endpoint - categoryID 0 is the
// same "all categories" value that screen's own initial load uses. There's no query param on
// getOffersList, so search filters the already-fetched list client-side instead of re-fetching.
// Each result is mapped onto WalaPlusRetailerModel (see fromExclusiveOffer) so it renders
// through the exact same grid/card used everywhere else, flagged via isExclusive.
Future<void> fetchExclusiveOffers({
String? query,
bool forceRefresh = false,
}) async {
if (query != null) searchQuery = query;
if (forceRefresh) _exclusiveOffers = <WalaPlusRetailerModel>[];
setState(() {
isLoading = true;
hasMore = false;
});
try {
if (_exclusiveOffers.isEmpty) {
bool isAr = AppState().isArabic(context);
List<OffersListModel> offers = await OffersAndDiscountsApiClient()
.getOffersList(0, 100);
_exclusiveOffers =
offers
.map(
(o) =>
WalaPlusRetailerModel.fromExclusiveOffer(o, isAr: isAr),
)
.toList();
}
retailersList = _exclusiveOffers;
String q = searchQuery.trim().toLowerCase();
_filteredRetailersList =
q.isEmpty
? _exclusiveOffers
: _exclusiveOffers
.where((o) => (o.name ?? '').toLowerCase().contains(q))
.toList();
} catch (e) {
debugPrint("RetailersListScreen: error fetching exclusive offers: $e");
_filteredRetailersList = <WalaPlusRetailerModel>[];
} finally {
if (mounted) {
setState(() {
isLoading = false;
isCategoriesLoading = false;
});
}
}
}
Future<void> fetchCategories() async {
if (isExclusiveMode) {
await fetchExclusiveOffers();
return;
}
setState(() {
isCategoriesLoading = true;
});
try {
List<WalaPlusCategoryModel> list = await OffersAndDiscountsApiClient()
.getWalaPlusCategories(context: context);
if (list.isEmpty) {
await Future.delayed(const Duration(milliseconds: 600));
list = await OffersAndDiscountsApiClient().getWalaPlusCategories(
context: context,
);
}
if (mounted) {
categoriesList = list;
if (selectedCategory != null) {
for (WalaPlusCategoryModel cat in list) {
if (cat.id == selectedCategory!.id) {
selectedCategory = cat;
break;
}
}
} else {
WalaPlusCategoryModel? defaultCat;
for (WalaPlusCategoryModel cat in list) {
String n = (cat.name ?? "").toLowerCase();
if (n.contains("travel") ||
n.contains("entertain") ||
n.contains("سفر") ||
n.contains("ترفيه")) {
defaultCat = cat;
break;
}
}
defaultCat ??= list.firstWhere(
(c) =>
c.id != "all" &&
c.id != "0" &&
(c.name ?? "").toLowerCase() != "all" &&
c.name != "الكل" &&
c.name != "الجميع",
orElse:
() =>
list.isNotEmpty
? list.first
: WalaPlusCategoryModel(
id: "1",
name: "Travel & Entertainment",
),
);
selectedCategory = defaultCat;
}
currentCategoryId = selectedCategory?.id ?? "";
setState(() {});
if (currentCategoryId.isNotEmpty) {
await fetchSubcategories(currentCategoryId);
await fetchOffers(categoryId: currentCategoryId);
}
}
} catch (e) {
debugPrint("Error fetching WalaPlus categories (attempt 1): $e");
try {
await Future.delayed(const Duration(milliseconds: 600));
List<WalaPlusCategoryModel> retryList =
await OffersAndDiscountsApiClient().getWalaPlusCategories(
context: context,
);
if (mounted && retryList.isNotEmpty) {
categoriesList = retryList;
if (selectedCategory != null) {
for (WalaPlusCategoryModel cat in retryList) {
if (cat.id == selectedCategory!.id) {
selectedCategory = cat;
break;
}
}
} else {
WalaPlusCategoryModel? defaultCat;
for (WalaPlusCategoryModel cat in retryList) {
String n = (cat.name ?? "").toLowerCase();
if (n.contains("travel") ||
n.contains("entertain") ||
n.contains("سفر") ||
n.contains("ترفيه")) {
defaultCat = cat;
break;
}
}
defaultCat ??= retryList.firstWhere(
(c) =>
c.id != "all" &&
c.id != "0" &&
(c.name ?? "").toLowerCase() != "all" &&
c.name != "الكل" &&
c.name != "الجميع",
orElse: () => retryList.first,
);
selectedCategory = defaultCat;
}
currentCategoryId = selectedCategory?.id ?? "";
setState(() {});
if (currentCategoryId.isNotEmpty) {
await fetchSubcategories(currentCategoryId);
await fetchOffers(categoryId: currentCategoryId);
}
return;
}
} catch (retryEx) {
debugPrint("Error fetching WalaPlus categories (attempt 2): $retryEx");
}
if (mounted) {
if (selectedCategory == null) {
selectedCategory = WalaPlusCategoryModel(
id: "1",
name: "Travel & Entertainment",
);
currentCategoryId = "1";
}
setState(() {});
await fetchOffers(categoryId: currentCategoryId);
}
} finally {
if (mounted) {
setState(() {
isCategoriesLoading = false;
});
}
}
}
// Fetches the subcategories under a main category (WP_ParentId). "All Categories" (or no
// category) has no specific parent, so that just clears the list instead of calling the API.
Future<void> fetchSubcategories(String? parentId) async {
if (isExclusiveMode) return;
if (parentId == null || parentId.isEmpty || parentId == "all") {
setState(() {
subcategoriesList = <WalaPlusCategoryModel>[];
_selectedSubcategoryId = null;
});
return;
}
setState(() {
isSubcategoriesLoading = true;
});
try {
List<WalaPlusCategoryModel> list = await OffersAndDiscountsApiClient()
.getWalaPlusCategories(context: context, parentId: parentId);
if (mounted) {
setState(() {
subcategoriesList = list;
_selectedSubcategoryId = null;
});
}
} catch (e) {
debugPrint("Error fetching WalaPlus subcategories: $e");
if (mounted) {
setState(() {
subcategoriesList = <WalaPlusCategoryModel>[];
});
}
} finally {
if (mounted) {
setState(() {
isSubcategoriesLoading = false;
});
}
}
}
String? _getUserReferenceForFavorites() {
String ref = OffersAndDiscountsApiClient().resolveUserReference();
if (ref.trim().isNotEmpty) return ref.trim();
String? userName = AppState().getUserName;
if (userName != null && userName.trim().isNotEmpty) return userName.trim();
return null;
}
Future<void> fetchOffers({String? categoryId, String? query}) async {
if (isExclusiveMode) {
await fetchExclusiveOffers(query: query);
return;
}
String targetCategoryId =
categoryId ??
_selectedSubcategoryId ??
(currentCategoryId.isEmpty
? (selectedCategory?.id ?? "")
: currentCategoryId);
if (query != null) {
searchQuery = query;
}
bool wantsNearest = _filterSortBy == 'nearest';
if (wantsNearest && !await _ensureLocationReadyForNearestSort()) {
return;
}
setState(() {
isLoading = true;
currentPage = 1;
hasMore = true;
});
try {
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', page=1, per_page=$pageSize",
);
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient()
.getWalaPlusRetailers(
categoryId: catParam,
page: 1,
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
context: wantsNearest ? context : null,
userReference: null,
);
retailersList = response?.retailers ?? <WalaPlusRetailerModel>[];
_filteredRetailersList = retailersList;
currentPage = 1;
totalPages = response?.pagesCount ?? 1;
hasMore =
currentPage < totalPages &&
(response?.retailers?.isNotEmpty ?? false);
} catch (e) {
print("Error fetching WalaPlus retailers: $e");
retailersList = <WalaPlusRetailerModel>[];
_filteredRetailersList = <WalaPlusRetailerModel>[];
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> fetchNextPage() async {
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
bool wantsNearest = _filterSortBy == 'nearest';
if (wantsNearest && !await _ensureLocationReadyForNearestSort()) {
return;
}
_isFetchingPage = true;
setState(() {
isLoadingMore = true;
});
try {
int nextPage = currentPage + 1;
String effectiveCategoryId = _selectedSubcategoryId ?? currentCategoryId;
String catParam =
(effectiveCategoryId == "all" || effectiveCategoryId == "0")
? ""
: effectiveCategoryId;
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'",
);
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient()
.getWalaPlusRetailers(
categoryId: catParam,
page: nextPage,
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
context: wantsNearest ? context : null,
userReference: null,
);
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? [];
if (newRetailers.isNotEmpty) {
currentPage = nextPage;
totalPages = response?.pagesCount ?? totalPages;
hasMore = currentPage < totalPages;
retailersList.addAll(newRetailers);
_filteredRetailersList = retailersList;
} else {
hasMore = false;
}
} catch (e) {
print("Error fetching next page of WalaPlus retailers: $e");
} finally {
_isFetchingPage = false;
if (mounted) {
setState(() {
isLoadingMore = false;
});
}
}
}
Future<void> _onRefresh() async {
if (isExclusiveMode) {
await fetchExclusiveOffers(
query: _searchController.text,
forceRefresh: true,
);
return;
}
String effectiveCategoryId =
_selectedSubcategoryId ??
(currentCategoryId.isEmpty
? (selectedCategory?.id ?? "")
: currentCategoryId);
await Future.wait<dynamic>(<Future<void>>[
fetchCategories(),
fetchOffers(
categoryId: effectiveCategoryId,
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) {
Utils.showToast(
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;
String effectiveCategoryId = _selectedSubcategoryId ?? currentCategoryId;
if (query.trim().isEmpty) {
fetchOffers(categoryId: effectiveCategoryId, query: "");
} else {
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
fetchOffers(categoryId: effectiveCategoryId, query: query.trim());
});
}
}
Future<void> _toggleFavorite(WalaPlusRetailerModel item) async {
String retailerId = item.id ?? "";
if (retailerId.isEmpty) return;
bool currentFav = item.isFavorite ?? false;
bool targetFav = !currentFav;
setState(() {
item.isFavorite = targetFav;
});
Utils.showLoading(context);
try {
GenericResponseModel? response;
String? userRef = _getUserReferenceForFavorites();
if (targetFav) {
response = await OffersAndDiscountsApiClient().addWalaPlusFavorite(
retailerId: retailerId,
context: context,
userReference: userRef,
);
} else {
response = await OffersAndDiscountsApiClient().removeWalaPlusFavorite(
retailerId: retailerId,
context: context,
userReference: userRef,
);
}
Utils.hideLoading(context);
if (response != null && response.errorMessage == null) {
bool isAr = AppState().isArabic(context);
String msg;
if (isAr) {
msg =
targetFav
? LocaleKeys.favoriteAddedSuccess.tr()
: LocaleKeys.favoriteRemovedSuccess.tr();
} else {
msg = response.wpFavoritesAddRemoveMessage ?? "";
if (msg.trim().isEmpty) {
msg =
targetFav
? LocaleKeys.favoriteAddedSuccess.tr()
: LocaleKeys.favoriteRemovedSuccess.tr();
}
}
Utils.showToast(msg, isSuccess: true);
} else {
if (mounted) {
setState(() {
item.isFavorite = currentFav;
});
}
String error =
response?.errorEndUserMessage ??
response?.errorMessage ??
LocaleKeys.favoriteError.tr();
Utils.showToast(error);
}
} catch (e) {
Utils.hideLoading(context);
debugPrint("Error toggling favorite: $e");
if (mounted) {
setState(() {
item.isFavorite = currentFav;
});
}
Utils.showToast(LocaleKeys.favoriteError.tr());
}
}
void _openRetailer(WalaPlusRetailerModel item) async {
if (item.categories == null || item.categories!.isEmpty) {
if (selectedCategory != null) {
item.categories = [selectedCategory!];
} else if (currentCategoryId.isNotEmpty && currentCategoryId != "all") {
for (var c in categoriesList) {
if (c.id?.toString() == currentCategoryId) {
item.categories = [c];
break;
}
}
}
}
await Navigator.pushNamed(
context,
AppRoutes.retailerDetailsScreen,
arguments: item,
);
if (mounted) {
setState(() {});
}
}
Future<void> _saveDependentSessionToPrefs() async {
if (!ApiConsts.isDependetLogin) return;
try {
await Utils.saveStringFromPrefs("P_DEP_IS_LOGGED_IN", "true");
String nationalId = AppState().getUserName ?? "";
if (nationalId.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_NATIONAL_ID", nationalId);
}
String dob = AppState().dependentDob ?? "";
if (dob.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_BIRTH_DATE", dob);
}
String name = AppState().dependentName ?? "";
if (name.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEPENDENT_NAME", name);
}
String mobile = AppState().postParamsObject?.mobileNumber ?? "";
if (mobile.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_MOBILE_NUMBER", mobile);
}
String tokenID =
(AppState().postParamsObject?.tokenID != null &&
AppState().postParamsObject!.tokenID!.isNotEmpty)
? AppState().postParamsObject!.tokenID!
: (AppState().authenticationTokenID ?? "");
if (tokenID.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_TOKEN_ID", tokenID);
}
String logInTokenID = AppState().postParamsObject?.logInTokenID ?? "";
if (logInTokenID.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_LOGIN_TOKEN_ID", logInTokenID);
}
String authTokenID = AppState().authenticationTokenID ?? "";
if (authTokenID.isNotEmpty) {
await Utils.saveStringFromPrefs(
"P_DEP_AUTHENTICATION_TOKEN_ID",
authTokenID,
);
}
int loginType = AppState().dependentLoginType ?? 1;
await Utils.saveStringFromPrefs("P_DEP_LOGIN_TYPE", loginType.toString());
String deviceToken =
AppState().getIsHuawei
? AppState().getHuaweiPushToken
: (AppState().getDeviceToken ?? "");
if (deviceToken.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_DEVICE_TOKEN", deviceToken);
}
} catch (e) {
if (kDebugMode) print("Error saving dependent session to prefs: $e");
}
}
Future<void> _clearDependentPrefs() async {
await Utils.removeStringFromPrefs("P_DEP_IS_LOGGED_IN");
await Utils.removeStringFromPrefs("P_DEP_NATIONAL_ID");
await Utils.removeStringFromPrefs("P_DEP_BIRTH_DATE");
await Utils.removeStringFromPrefs("P_DEP_TOKEN_ID");
await Utils.removeStringFromPrefs("P_DEP_LOGIN_TOKEN_ID");
await Utils.removeStringFromPrefs("P_DEP_AUTHENTICATION_TOKEN_ID");
await Utils.removeStringFromPrefs("P_DEP_MOBILE_NUMBER");
await Utils.removeStringFromPrefs("P_DEPENDENT_NAME");
await Utils.removeStringFromPrefs("P_DEP_LOGIN_TYPE");
}
void _performLogout() {
ConfirmBottomSheet.show(
context,
message: LocaleKeys.areYouSureWantToLogOut.tr(),
confirmText: LocaleKeys.yes.tr(),
cancelText: LocaleKeys.no.tr(),
isDestructive: false,
onConfirm: () async {
if (ApiConsts.isDependetLogin) {
await _clearDependentPrefs();
}
ApiConsts.selectedMemberType = MemberType.employee;
ApiConsts.ISDependetLogin = false;
AppState().setDependentName = null;
AppState().setDependentDob = null;
AppState().setDependentLoginType = null;
AppState().setWalaplusDepInfoModel = null;
AppState().setPostParamsInitConfig();
if (mounted) {
Navigator.pushNamedAndRemoveUntil(
context,
AppRoutes.login,
(Route<dynamic> route) => false,
);
}
},
);
}
Future<void> _changeLanguage(Locale newLocale) async {
if (context.locale == newLocale) return;
await context.setLocale(newLocale);
PostParamsModel? obj = AppState().postParamsObject;
if (obj != null) {
obj.languageID = (newLocale.languageCode == "ar" ? 1 : 2);
AppState().setPostParamsModel(obj);
}
if (mounted) {
setState(() {});
await fetchCategories();
await fetchOffers(
categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId,
);
}
}
Widget _buildLanguageIconButton(bool isAr) {
return Tooltip(
message: isAr ? 'English' : 'العربية',
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
if (isAr) {
_changeLanguage(const Locale("en", "US"));
} else {
_changeLanguage(const Locale("ar", "SA"));
}
},
child: const Padding(
padding: EdgeInsets.all(3),
child: Icon(
Icons.language_rounded,
size: 24,
color: Color(0xFF1E293B),
),
),
),
);
}
@override
Widget build(BuildContext context) {
bool isAr = AppState().isArabic(context);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else if (ApiConsts.isDependetLogin) {
_performLogout();
}
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Icon(
Icons.arrow_back_ios_new_rounded,
size: 20,
color: Color(0xFF1E293B),
),
),
),
const SizedBox(width: 8),
LocaleKeys.mazayaDeals.tr().toText22(
isBold: true,
color: const Color(0xFF202F31),
),
],
),
),
actions: [
if (ApiConsts.isDependetLogin) ...[
_buildLanguageIconButton(isAr),
const SizedBox(width: 4),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _performLogout,
child: Padding(
padding: const EdgeInsets.all(6),
child: SvgPicture.asset(
'assets/images/drawer/logout.svg',
height: 20,
width: 20,
),
),
),
const SizedBox(width: 12),
],
],
),
body: SafeArea(
child: RefreshIndicator(
onRefresh: _onRefresh,
color: MyColors.textMixColor,
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Sub-header: "Explore your benefits" | "📍 Riyadh"
_buildSubHeaderRow(isAr),
// 2. Category Hero Section: Title, Subtitle, Category image
_buildCategoryHero(isAr),
// 3. Search Bar: "Find an escape or an experience"
_buildSearchBar(isAr),
// 4. Filters & Subcategories Row - the filters (sort/distance/discount) and
// subcategories are WalaPlus-specific concepts that don't apply to exclusive
// offers (legacy Offers & Discounts API), so this row is skipped in that mode.
if (!isExclusiveMode) _buildSubcategoriesAndFilterRow(isAr),
const SizedBox(height: 16),
// 5. Offers Grid
WalaPlusRetailerGrid(
isLoading: isLoading,
retailers: _filteredRetailersList,
isAr: isAr,
isLoadingMore: isLoadingMore,
onTapRetailer: _openRetailer,
onFavoriteTap: _toggleFavorite,
),
],
),
),
),
),
);
}
Widget _buildSubHeaderRow(bool isAr) {
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
LocaleKeys.exploreYourBenefits.tr().toText13(
color: const Color(0xFF79807F),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _showCitySelectionBottomSheet,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.location_on_outlined,
size: 16,
color: Color(0xFF157D6B),
),
const SizedBox(width: 4),
selectedCity.toText13(
isBold: true,
color: const Color(0xFF157D6B),
),
],
),
),
),
],
),
);
}
Widget _buildCategoryHero(bool isAr) {
bool isLatest = selectedCategory?.id == 'all';
String categoryName =
isExclusiveMode
? LocaleKeys.exclusivelyYours.tr()
: isLatest
? LocaleKeys.latestOnMazaya.tr()
: selectedCategory?.name ??
(isAr ? "السفر والترفيه" : "Travel & Entertainment");
String subtitle =
isExclusiveMode
? LocaleKeys.aLittleExtraJustForMazayaMembers.tr()
: isLatest
? LocaleKeys.goodFoodGreatCompanyEvenBetterPerks.tr()
: (isAr
? "مغامرتك القادمة، مع القليل من التميز."
: "Your next adventure, with a little extra.");
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
categoryName.toText24(
isBold: true,
color: const Color(0xFF202F31),
),
const SizedBox(height: 6),
subtitle.toText12(color: const Color(0xFF7A8180)),
],
),
),
const SizedBox(width: 16),
_buildCategoryHeroImage(
selectedCategory,
isLatest: isLatest,
isExclusive: isExclusiveMode,
),
],
),
);
}
Widget _buildCategoryHeroImage(
WalaPlusCategoryModel? category, {
bool isLatest = false,
bool isExclusive = false,
}) {
const double imgWidth = 84.0;
const double imgHeight = 84.0;
const double radius = 18.0;
if (isExclusive) {
return _buildCategoryHeroFallback(
category,
imgWidth,
imgHeight,
radius,
isExclusive: true,
);
}
if (isLatest) {
return _buildCategoryHeroFallback(
category,
imgWidth,
imgHeight,
radius,
isLatest: true,
);
}
String? imgUrl = category?.categoryImageUrl;
if (imgUrl != null && imgUrl.isNotEmpty) {
if (imgUrl.toLowerCase().endsWith('.svg') || (category?.isSvg ?? false)) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: SizedBox(
width: imgWidth,
height: imgHeight,
child: SvgPicture.network(
imgUrl,
fit: BoxFit.cover,
placeholderBuilder:
(_) => _buildCategoryHeroFallback(
category,
imgWidth,
imgHeight,
radius,
),
),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: SizedBox(
width: imgWidth,
height: imgHeight,
child: Image.network(
imgUrl,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => _buildCategoryHeroFallback(
category,
imgWidth,
imgHeight,
radius,
),
),
),
);
}
return _buildCategoryHeroFallback(category, imgWidth, imgHeight, radius);
}
Widget _buildCategoryHeroFallback(
WalaPlusCategoryModel? category,
double w,
double h,
double r, {
bool isLatest = false,
bool isExclusive = false,
}) {
return ClipRRect(
borderRadius: BorderRadius.circular(r),
child: Container(
width: w,
height: h,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors:
isExclusive
? const [Color(0xFF143F36), Color(0xFF0B2A23)]
: isLatest
? const [Color(0xFF1FA98D), Color(0xFF147D6B)]
: const [Color(0xFF38BDF8), Color(0xFF0284C7)],
),
),
child: Center(
child: Icon(
isExclusive
? Icons.workspace_premium_rounded
: isLatest
? Icons.local_offer_rounded
: Icons.flight_takeoff_rounded,
size: 36,
color: isExclusive ? const Color(0xFFDCC58F) : Colors.white,
),
),
),
);
}
Widget _buildSearchBar(bool isAr) {
return Container(
width: double.infinity,
height: 46,
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8EAE6), width: 1.0),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.search_rounded, size: 20, color: Color(0xFF79807F)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
onSubmitted: (String val) {
_debounceTimer?.cancel();
fetchOffers(query: val.trim());
},
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
color: Color(0xFF202F31),
fontSize: 13,
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
hintText: LocaleKeys.findAnEscapeOrAnExperience.tr(),
hintStyle: const TextStyle(
color: Color(0xFF7A8180),
fontSize: 13,
fontWeight: FontWeight.w400,
),
border: InputBorder.none,
),
),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (
BuildContext context,
TextEditingValue value,
Widget? child,
) {
if (value.text.isEmpty) return const SizedBox.shrink();
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
_searchController.clear();
_onSearchChanged("");
},
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.clear, size: 18, color: Color(0xFF848486)),
),
);
},
),
],
),
);
}
int _activeFilterCount() {
int count = 0;
if (selectedTypeFilter != 'all') count++;
if (_filterSortBy != 'newest') count++;
if (_filterDistance != 'any') count++;
if (_filterMinDiscount != 'any') count++;
return count;
}
Widget _buildSubcategoriesAndFilterRow(bool isAr) {
bool showSubcategoriesRow =
currentCategoryId.isNotEmpty &&
currentCategoryId != "all" &&
(isSubcategoriesLoading || subcategoriesList.isNotEmpty);
int activeFilterCount = _activeFilterCount();
return Padding(
padding: const EdgeInsets.only(top: 14, bottom: 2),
child: SizedBox(
height: 38,
child: Row(
children: <Widget>[
Stack(
clipBehavior: Clip.none,
children: <Widget>[
InkWell(
borderRadius: BorderRadius.circular(6),
onTap: _showFiltersBottomSheet,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: MyColors.lightGreyEAColor,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SvgPicture.asset(
"assets/icons/filter_icon.svg",
width: 14,
height: 14,
colorFilter: const ColorFilter.mode(
MyColors.black,
BlendMode.srcIn,
),
),
const SizedBox(width: 5),
LocaleKeys.filters.tr().toText12(color: MyColors.black),
],
),
),
),
if (activeFilterCount > 0)
PositionedDirectional(
top: -5,
end: -5,
child: Container(
width: 18,
height: 18,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: MyColors.darkIconColor,
shape: BoxShape.circle,
),
child: '$activeFilterCount'.toText10(
isBold: true,
color: Colors.white,
),
),
),
],
),
if (showSubcategoriesRow) ...<Widget>[
Container(
margin: const EdgeInsets.symmetric(horizontal: 10),
width: 1,
height: 20,
color: const Color(0xFFE2E8F0),
),
Expanded(
child:
isSubcategoriesLoading
? Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder:
(BuildContext _, int __) =>
const SizedBox(width: 8),
itemBuilder:
(BuildContext _, int __) => Container(
width: 70,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
),
),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
itemCount: subcategoriesList.length + 1,
separatorBuilder:
(BuildContext _, int __) =>
const SizedBox(width: 8),
itemBuilder: (BuildContext context, int index) {
bool isAllChip = index == 0;
WalaPlusCategoryModel? subcategory =
isAllChip ? null : subcategoriesList[index - 1];
bool isSelected =
isAllChip
? _selectedSubcategoryId == null
: _selectedSubcategoryId == subcategory!.id;
String label =
isAllChip
? LocaleKeys.allCategories.tr()
: (subcategory!.name ?? "");
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () {
setState(() {
_selectedSubcategoryId =
isAllChip
? null
: (isSelected
? null
: subcategory!.id);
});
fetchOffers(
categoryId:
_selectedSubcategoryId ??
currentCategoryId,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color:
isSelected
? MyColors.darkIconColor
: MyColors.lightGreyEAColor,
),
child: label.toText12(
color:
isSelected
? MyColors.white
: MyColors.black,
),
),
);
},
),
),
],
],
),
),
);
}
void _showCitySelectionBottomSheet() {
showMazayaCitySelectionBottomSheet(
context,
selectedCity: selectedCity,
onCitySelected: (city) => setState(() => selectedCity = city),
);
}
// 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;
String draftDistance = _filterDistance;
String draftMinDiscount = _filterMinDiscount;
const Color darkBg = Color(0xFF202B33);
const Color borderColor = Color(0xFFE2E8F0);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext sheetContext) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setSheetState) {
Widget buildOption(
String label,
bool isSelected,
VoidCallback onTap, {
IconData? icon,
}) {
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color:
isSelected ? MyColors.lightGreyEAColor : Colors.white,
border: Border.all(
color:
isSelected
? MyColors.lightGreyEAColor
: borderColor,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (icon != null) ...<Widget>[
Icon(
icon,
size: 14,
color:
isSelected
? MyColors.black
: const Color(0xFF64748B),
),
const SizedBox(width: 5),
],
Flexible(
child: label.toText13(
isBold: true,
color: isSelected ? MyColors.black : darkBg,
maxlines: 1,
),
),
if (isSelected) ...<Widget>[
const SizedBox(width: 5),
const Icon(
Icons.check,
size: 14,
color: MyColors.black,
),
],
],
),
),
),
);
}
Widget sectionHeader(String title, {String? hint}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
title.toText14(isBold: true, color: darkBg),
if (hint != null)
hint.toText12(color: const Color(0xFF94A3B8)),
],
);
}
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Container(
width: 44,
height: 4.5,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: const Color(0xFFCBD5E1),
borderRadius: BorderRadius.circular(2.5),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
LocaleKeys.filters.tr().toText20(
isBold: true,
color: darkBg,
),
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(sheetContext),
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFF1F5F9),
),
child: const Icon(
Icons.close,
size: 18,
color: darkBg,
),
),
),
],
),
20.height,
// Availability - single select, drives the real offerType filter.
LocaleKeys.availability.tr().toText14(
isBold: true,
color: darkBg,
),
10.height,
Row(
children: <Widget>[
buildOption(
LocaleKeys.allCategories.tr(),
draftAvailability == 'all',
() => setSheetState(() => draftAvailability = 'all'),
),
const SizedBox(width: 10),
buildOption(
LocaleKeys.online.tr(),
draftAvailability == 'online',
() =>
setSheetState(() => draftAvailability = 'online'),
),
const SizedBox(width: 10),
buildOption(
LocaleKeys.inStore.tr(),
draftAvailability == 'instore',
() => setSheetState(
() => draftAvailability = 'instore',
),
),
],
),
20.height,
// Sort by - single select, Nearest and Newest only.
LocaleKeys.sortBy.tr().toText14(
isBold: true,
color: darkBg,
),
10.height,
Row(
children: <Widget>[
buildOption(
LocaleKeys.nearest.tr(),
draftSortBy == 'nearest',
() => setSheetState(() => draftSortBy = 'nearest'),
icon: Icons.location_on_outlined,
),
const SizedBox(width: 10),
buildOption(
LocaleKeys.newest.tr(),
draftSortBy == 'newest',
() => setSheetState(() => draftSortBy = 'newest'),
),
],
),
20.height,
// Distance - single select.
sectionHeader(
LocaleKeys.distance.tr(),
hint: LocaleKeys.fromYourCurrentLocation.tr(),
),
10.height,
Row(
children: <Widget>[
buildOption(
LocaleKeys.any.tr(),
draftDistance == 'any',
() => setSheetState(() => draftDistance = 'any'),
),
const SizedBox(width: 8),
buildOption(
LocaleKeys.distanceKm.tr(args: <String>['5']),
draftDistance == '5km',
() => setSheetState(() => draftDistance = '5km'),
),
const SizedBox(width: 8),
buildOption(
LocaleKeys.distanceKm.tr(args: <String>['10']),
draftDistance == '10km',
() => setSheetState(() => draftDistance = '10km'),
),
const SizedBox(width: 8),
buildOption(
LocaleKeys.distanceKm.tr(args: <String>['25']),
draftDistance == '25km',
() => setSheetState(() => draftDistance = '25km'),
),
],
),
20.height,
// Minimum discount - single select.
LocaleKeys.minimumDiscount.tr().toText14(
isBold: true,
color: darkBg,
),
10.height,
Row(
children: <Widget>[
buildOption(
LocaleKeys.any.tr(),
draftMinDiscount == 'any',
() => setSheetState(() => draftMinDiscount = 'any'),
),
const SizedBox(width: 8),
buildOption(
LocaleKeys.discountPercentPlus.tr(
args: <String>['10'],
),
draftMinDiscount == '10',
() => setSheetState(() => draftMinDiscount = '10'),
),
const SizedBox(width: 8),
buildOption(
LocaleKeys.discountPercentPlus.tr(
args: <String>['20'],
),
draftMinDiscount == '20',
() => setSheetState(() => draftMinDiscount = '20'),
),
const SizedBox(width: 8),
buildOption(
LocaleKeys.discountPercentPlus.tr(
args: <String>['30'],
),
draftMinDiscount == '30',
() => setSheetState(() => draftMinDiscount = '30'),
),
],
),
20.height,
const Divider(height: 1, thickness: 1, color: borderColor),
16.height,
Row(
children: <Widget>[
Expanded(
child: DefaultButton(
LocaleKeys.reset.tr(),
() {
setSheetState(() {
draftAvailability = 'all';
draftSortBy = 'newest';
draftDistance = 'any';
draftMinDiscount = 'any';
});
setState(() {
selectedTypeFilter = draftAvailability;
_filterSortBy = draftSortBy;
_filterDistance = draftDistance;
_filterMinDiscount = draftMinDiscount;
});
Navigator.pop(sheetContext);
fetchOffers(
categoryId:
currentCategoryId.isEmpty
? "all"
: currentCategoryId,
);
},
colors: const <Color>[Colors.white, Colors.white],
border: Border.all(color: borderColor),
textColor: darkBg,
),
),
const SizedBox(width: 12),
Expanded(
child: DefaultButton(
LocaleKeys.applyFilters.tr(),
() {
setState(() {
selectedTypeFilter = draftAvailability;
_filterSortBy = draftSortBy;
_filterDistance = draftDistance;
_filterMinDiscount = draftMinDiscount;
});
Navigator.pop(sheetContext);
fetchOffers(
categoryId:
_selectedSubcategoryId ?? currentCategoryId,
);
},
),
),
],
),
],
),
),
);
},
);
},
);
}
void showFavoritesBottomSheet() async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
elevation: 0,
builder: (BuildContext sheetContext) {
return FavoriteMerchantsBottomSheet(categoriesList: categoriesList);
},
);
if (mounted) {
setState(() {});
}
}
}
class WalaPlusRetailerCard extends StatelessWidget {
final WalaPlusRetailerModel item;
final bool isAr;
final VoidCallback onTap;
final VoidCallback? onFavoriteTap;
const WalaPlusRetailerCard({
Key? key,
required this.item,
required this.isAr,
required this.onTap,
this.onFavoriteTap,
}) : super(key: key);
static String getDiscountBadge(WalaPlusRetailerModel item) {
if (item.offerTitle != null) {
RegExp percentRegex = RegExp(r'(\d+%)');
Match? match = percentRegex.firstMatch(item.offerTitle!);
if (match != null) {
return match.group(1)!;
}
}
if (item.name != null) {
RegExp percentRegex = RegExp(r'(\d+%)');
Match? match = percentRegex.firstMatch(item.name!);
if (match != null) {
return match.group(1)!;
}
}
return "";
}
static String getTypeFilterTitle(String type) {
if (type == 'all') {
return LocaleKeys.allCategories.tr();
} else if (type == 'instore') {
return LocaleKeys.inStore.tr();
} else if (type == 'online') {
return LocaleKeys.online.tr();
}
return type.isNotEmpty ? (type[0].toUpperCase() + type.substring(1)) : type;
}
static IconData getTypeFilterIcon(String type) {
if (type == 'instore') {
return Icons.storefront_rounded;
} else if (type == 'online') {
return Icons.language_rounded;
}
return Icons.tune_rounded;
}
@override
Widget build(BuildContext context) {
String? bannerUrl =
(item.imageThumb != null && item.imageThumb!.isNotEmpty)
? item.imageThumb
: item.image;
String discountText = getDiscountBadge(item);
String offerSubtitle = "";
if (discountText.isNotEmpty) {
if (item.offerTitle != null &&
item.offerTitle!.toLowerCase().contains("off")) {
offerSubtitle = item.offerTitle!;
} else {
offerSubtitle = LocaleKeys.offDiscount.tr(args: [discountText]);
}
} else if (item.offerTitle != null && item.offerTitle!.trim().isNotEmpty) {
offerSubtitle = item.offerTitle!.trim();
}
List<String> offerTypes = item.normalizedTypes;
String usageText = "";
if (item.displayUsage.isNotEmpty) {
usageText = item.displayUsage
.replaceAll('usage:', LocaleKeys.usageLabel.tr())
.replaceAll('Usage :', LocaleKeys.usageLabel.tr());
}
String cityText = item.getDisplayCity(isAr: isAr);
if (cityText.trim().isEmpty) {
cityText = item.displayCity;
}
return InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 24,
spreadRadius: 0,
),
],
),
padding: const EdgeInsets.all(10),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Figma redesign (node 927:496): shorter image, no bottom gradient/logo overlay
// (brand moved into the card body below), no discount ribbon - the percentage now
// lives inline in the teal offer subtitle text instead. Image height trimmed from
// 120->110 (not Figma's 82) and padding/gaps left untouched on purpose: this card's
// overall size is mainAxisExtent-driven (see the grid's gridDelegate) and was
// already tuned against real devices - copying Figma's absolute pixels here would
// reintroduce the overflow/empty-space problems that tuning fixed.
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
height: 110,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
(bannerUrl != null && bannerUrl.isNotEmpty)
? Image.network(
bannerUrl,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => Container(
color: const Color(0xFFF1F5F9),
child: const Icon(
Icons.image_outlined,
color: Color(0xFF94A3B8),
size: 32,
),
),
)
: Container(
color: const Color(0xFFF1F5F9),
child: const Icon(
Icons.image_outlined,
color: Color(0xFF94A3B8),
size: 32,
),
),
// Exclusive offers (mapped from the legacy Offers & Discounts API) have no
// real WalaPlus retailer id, so there's nothing for add/removeWalaPlusFavorite
// to act on - hide the heart rather than wiring it to a call that can't work.
if (item.isExclusive != true)
PositionedDirectional(
top: 7,
end: 7,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onFavoriteTap,
child: Container(
width: 27,
height: 27,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Icon(
item.isFavorite == true
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 15,
color:
item.isFavorite == true
? Colors.black
: const Color(0xFF1E293B),
),
),
),
),
if (item.isExclusive == true)
PositionedDirectional(
bottom: 7,
start: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF143F36),
borderRadius: BorderRadius.circular(6),
),
child: LocaleKeys.mazayaExclusive.tr().toText10(
color: const Color(0xFFF1EBDD),
),
),
),
],
),
),
),
const SizedBox(height: 8),
// Brand name + "view details" affordance arrow, replacing the old image-overlay name.
Row(
children: <Widget>[
Expanded(
child: (item.name ?? "").toText13(
isBold: true,
maxlines: 1,
overflow: TextOverflow.ellipsis,
color: const Color(0xFF202F32),
),
),
const SizedBox(width: 4),
const Icon(
Icons.north_east_rounded,
size: 16,
color: Color(0xFF147D6B),
),
],
),
const SizedBox(height: 2),
offerSubtitle.toText14(
maxlines: 1,
overflow: TextOverflow.ellipsis,
isBold: true,
color: const Color(0xFF147D6B),
),
const SizedBox(height: 6),
// Fully-rounded pill badges (no icon, no border) matching the new design - type
// chips in blue (In-store) / green (Online), city in grey.
SizedBox(
height: 28,
child: ListView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
children: <Widget>[
for (String offerType in offerTypes) ...<Widget>[
InfoChipWidget(
text: getTypeFilterTitle(offerType),
backgroundColor:
offerType == 'online'
? const Color(0xFFEAF3EE)
: const Color(0x14062CFF),
textColor:
offerType == 'online'
? const Color(0xFF147D6B)
: const Color(0xFF2A56B6),
borderColor: Colors.transparent,
borderWidth: 0,
isBold: false,
horizontalPadding: 10,
verticalPadding: 6,
borderRadius: 99,
),
const SizedBox(width: 5),
],
if (cityText.trim().isNotEmpty) ...<Widget>[
InfoChipWidget(
text: cityText,
backgroundColor: const Color(0xFFEFF2EF),
textColor: const Color(0xFF464746),
borderColor: Colors.transparent,
borderWidth: 0,
isBold: false,
horizontalPadding: 10,
verticalPadding: 6,
borderRadius: 99,
),
const SizedBox(width: 5),
],
if (usageText.trim().isNotEmpty)
InfoChipWidget(
text: usageText,
backgroundColor: const Color(0xFFFEF3C7),
textColor: const Color(0xFFB45309),
borderColor: Colors.transparent,
borderWidth: 0,
isBold: false,
horizontalPadding: 10,
verticalPadding: 6,
borderRadius: 99,
),
],
),
),
],
),
),
),
);
}
}
class FavoriteMerchantsBottomSheet extends StatefulWidget {
final List<WalaPlusCategoryModel>? categoriesList;
const FavoriteMerchantsBottomSheet({Key? key, this.categoriesList})
: super(key: key);
@override
State<FavoriteMerchantsBottomSheet> createState() =>
_FavoriteMerchantsBottomSheetState();
}
class _FavoriteMerchantsBottomSheetState
extends State<FavoriteMerchantsBottomSheet> {
List<WalaPlusRetailerModel> favoriteRetailers = [];
bool isLoading = true;
bool isLoadingMore = false;
bool hasMore = true;
bool _isFetchingPage = false;
int currentPage = 1;
int totalPages = 1;
final int pageSize = 10;
String searchQuery = "";
Timer? _debounceTimer;
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
fetchFavorites();
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _scrollListener() {
if (!_scrollController.hasClients) return;
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
if (currentScroll >= maxScroll - 35) {
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
fetchNextPage();
}
}
}
String? _getUserReferenceForFavorites() {
String ref = OffersAndDiscountsApiClient().resolveUserReference();
if (ref.trim().isNotEmpty) return ref.trim();
String? userName = AppState().getUserName;
if (userName != null && userName.trim().isNotEmpty) return userName.trim();
return null;
}
Future<void> fetchFavorites({String? query}) async {
if (query != null) {
searchQuery = query;
}
setState(() {
isLoading = true;
currentPage = 1;
hasMore = true;
});
try {
String? queryParam =
searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = _getUserReferenceForFavorites();
debugPrint(
"Fetching favorite retailers: query='$queryParam', userReference='$userRef', page=1, per_page=$pageSize",
);
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient()
.getWalaPlusRetailers(
categoryId: "",
page: 1,
perPage: pageSize,
offerType: null,
query: queryParam,
sortBy: null,
radius: null,
minDiscount: null,
context: null,
userReference: userRef,
);
favoriteRetailers = response?.retailers ?? <WalaPlusRetailerModel>[];
for (WalaPlusRetailerModel r in favoriteRetailers) {
r.isFavorite = true;
}
currentPage = 1;
totalPages = response?.pagesCount ?? 1;
hasMore =
currentPage < totalPages &&
(response?.retailers?.isNotEmpty ?? false);
} catch (e) {
debugPrint("Error fetching favorite retailers: $e");
favoriteRetailers = <WalaPlusRetailerModel>[];
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> fetchNextPage() async {
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
_isFetchingPage = true;
setState(() {
isLoadingMore = true;
});
try {
int nextPage = currentPage + 1;
String? queryParam =
searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = _getUserReferenceForFavorites();
debugPrint(
"Fetching favorite retailers next page: $nextPage of $totalPages, query: '$queryParam', userReference='$userRef'",
);
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient()
.getWalaPlusRetailers(
categoryId: "",
page: nextPage,
perPage: pageSize,
offerType: null,
query: queryParam,
sortBy: null,
radius: null,
minDiscount: null,
context: null,
userReference: userRef,
);
List<WalaPlusRetailerModel> newRetailers =
response?.retailers ?? <WalaPlusRetailerModel>[];
if (newRetailers.isNotEmpty) {
for (WalaPlusRetailerModel r in newRetailers) {
r.isFavorite = true;
}
currentPage = nextPage;
totalPages = response?.pagesCount ?? totalPages;
hasMore = currentPage < totalPages;
favoriteRetailers.addAll(newRetailers);
} else {
hasMore = false;
}
} catch (e) {
debugPrint("Error fetching next page of favorite retailers: $e");
} finally {
_isFetchingPage = false;
if (mounted) {
setState(() {
isLoadingMore = false;
});
}
}
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
searchQuery = query;
if (query.trim().isEmpty) {
fetchFavorites(query: "");
} else {
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
fetchFavorites(query: query.trim());
});
}
}
Future<void> _toggleFavorite(WalaPlusRetailerModel item) async {
String retailerId = item.id ?? "";
if (retailerId.isEmpty) return;
bool currentFav = item.isFavorite ?? true;
bool targetFav = !currentFav;
setState(() {
item.isFavorite = targetFav;
if (!targetFav) {
favoriteRetailers.removeWhere((r) => r.id == item.id);
}
});
Utils.showLoading(context);
try {
GenericResponseModel? response;
String? userRef = _getUserReferenceForFavorites();
if (targetFav) {
response = await OffersAndDiscountsApiClient().addWalaPlusFavorite(
retailerId: retailerId,
context: context,
userReference: userRef,
);
} else {
response = await OffersAndDiscountsApiClient().removeWalaPlusFavorite(
retailerId: retailerId,
context: context,
userReference: userRef,
);
}
Utils.hideLoading(context);
if (response != null && response.errorMessage == null) {
bool isAr = AppState().isArabic(context);
String msg =
isAr
? (targetFav
? LocaleKeys.favoriteAddedSuccess.tr()
: LocaleKeys.favoriteRemovedSuccess.tr())
: (response.wpFavoritesAddRemoveMessage ??
(targetFav
? LocaleKeys.favoriteAddedSuccess.tr()
: LocaleKeys.favoriteRemovedSuccess.tr()));
Utils.showToast(msg, isSuccess: true);
} else {
Utils.showToast(
response?.errorEndUserMessage ??
response?.errorMessage ??
LocaleKeys.favoriteError.tr(),
);
await fetchFavorites(query: _searchController.text);
}
} catch (e) {
Utils.hideLoading(context);
debugPrint("Error toggling favorite in bottom sheet: $e");
Utils.showToast(LocaleKeys.favoriteError.tr());
await fetchFavorites(query: _searchController.text);
}
}
void _openRetailer(WalaPlusRetailerModel item) async {
if ((item.categories == null || item.categories!.isEmpty) &&
widget.categoriesList != null &&
item.categoryId != null) {
for (var c in widget.categoriesList!) {
if (c.id?.toString() == item.categoryId?.toString()) {
item.categories = [c];
break;
}
}
}
await Navigator.pushNamed(
context,
AppRoutes.retailerDetailsScreen,
arguments: item,
);
if (mounted) {
await fetchFavorites(query: _searchController.text);
}
}
@override
Widget build(BuildContext context) {
bool isAr = AppState().isArabic(context);
return Container(
height: MediaQuery.of(context).size.height * 0.85,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4.5,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: const Color(0xFFCBD5E1),
borderRadius: BorderRadius.circular(2.5),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
LocaleKeys.favoriteOffers.tr().toText18(
weight: FontWeight.w600,
color: const Color(0xFF1E293B),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(context),
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFF1F5F9),
),
child: const Icon(
Icons.close_rounded,
size: 18,
color: Color(0xFF1E293B),
),
),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Container(
width: double.infinity,
height: 70,
padding: const EdgeInsets.only(
top: 14,
right: 16,
bottom: 14,
left: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color(0xFFE8E8E8),
width: 1.0,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withOpacity(0.0314),
offset: const Offset(0, 2),
blurRadius: 5,
spreadRadius: 0,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LocaleKeys.search.tr().toText12(
isBold: true,
color: const Color(0xFF2A333C),
),
const SizedBox(height: 2),
TextField(
controller: _searchController,
onChanged: _onSearchChanged,
onSubmitted: (String val) {
_debounceTimer?.cancel();
fetchFavorites(query: val.trim());
},
textInputAction: TextInputAction.search,
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
hintText: LocaleKeys.searchItems.tr(),
hintStyle: const TextStyle(
color: Color(0xFFACAFB1),
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
),
border: InputBorder.none,
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (
BuildContext context,
TextEditingValue value,
Widget? child,
) {
if (value.text.isEmpty)
return const SizedBox.shrink();
return InkWell(
child: const Icon(
Icons.clear,
size: 18,
color: Color(0xFF848486),
),
onTap: () {
_searchController.clear();
_onSearchChanged("");
},
);
},
),
suffixIconConstraints: const BoxConstraints(
maxWidth: 24,
maxHeight: 28,
),
),
style: const TextStyle(
color: Color(0xFF2A333C),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh:
() => fetchFavorites(query: _searchController.text),
color: MyColors.textMixColor,
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Column(
children: <Widget>[
WalaPlusRetailerGrid(
isLoading: isLoading,
retailers: favoriteRetailers,
isAr: isAr,
isLoadingMore: isLoadingMore,
onTapRetailer: _openRetailer,
onFavoriteTap: _toggleFavorite,
),
const SizedBox(height: 20),
],
),
),
),
),
],
),
),
),
);
}
}