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.
2637 lines
94 KiB
Dart
2637 lines
94 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_city_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";
|
|
WalaPlusCityModel? selectedCity;
|
|
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>[];
|
|
|
|
// Reached by tapping Discover's search field - same "all categories / newest" data as the
|
|
// "Latest on Mazaya" See all, but the category hero is hidden (there's no specific category
|
|
// context, it's a pure search entry point) and the search bar auto-focuses with the keyboard
|
|
// open instead.
|
|
bool isSearchMode = false;
|
|
final FocusNode _searchFocusNode = FocusNode();
|
|
|
|
// 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 ?? "";
|
|
}
|
|
if (args is Map && args['searchMode'] == true) {
|
|
isSearchMode = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _searchFocusNode.requestFocus();
|
|
});
|
|
}
|
|
// Preserves whichever city was already selected on Discover - starts here instead of
|
|
// resetting back to "All Cities" until the user explicitly changes it on this page.
|
|
if (args is Map && args['city'] is WalaPlusCityModel) {
|
|
selectedCity = args['city'] as WalaPlusCityModel;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
_searchFocusNode.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,
|
|
cityId: selectedCity?.cityId,
|
|
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,
|
|
cityId: selectedCity?.cityId,
|
|
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 - hidden when
|
|
// reached from Discover's search field, since there's no specific category
|
|
// context there and the search bar should be the first thing on screen.
|
|
if (!isSearchMode) _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?.getDisplayName(isAr) ??
|
|
LocaleKeys.allCitiesOption.tr())
|
|
.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,
|
|
focusNode: _searchFocusNode,
|
|
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,
|
|
selectedCityId: selectedCity?.cityId,
|
|
onCitySelected: (city) {
|
|
setState(() => selectedCity = city);
|
|
String effectiveCategoryId =
|
|
_selectedSubcategoryId ??
|
|
(currentCategoryId.isEmpty
|
|
? (selectedCategory?.id ?? "")
|
|
: currentCategoryId);
|
|
fetchOffers(
|
|
categoryId: effectiveCategoryId,
|
|
query: _searchController.text,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// 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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|