fix product page
This commit is contained in:
@@ -11,10 +11,9 @@ import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/router/app_router.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/categories_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/category_filter_chips.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/brand_filter_chips.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/product_filter_drawer.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/product_grid.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
|
||||
@@ -34,7 +33,6 @@ class ProductsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
final cartItemCount = ref.watch(cartItemCountProvider);
|
||||
|
||||
@@ -75,46 +73,35 @@ class ProductsPage extends ConsumerWidget {
|
||||
children: [
|
||||
// Search Bar with Filter Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search Bar (Expanded)
|
||||
const Expanded(child: ProductSearchBar()),
|
||||
const SizedBox(width: 8),
|
||||
// Filter Button
|
||||
SizedBox(
|
||||
Container(
|
||||
height: InputFieldSpecs.height,
|
||||
child: OutlinedButton.icon(
|
||||
width: InputFieldSpecs.height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
// Open filter drawer from right
|
||||
Scaffold.of(scaffoldContext).openEndDrawer();
|
||||
},
|
||||
icon: const FaIcon(FontAwesomeIcons.sliders, size: 18),
|
||||
label: const Text('Lọc', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.grey900,
|
||||
side: const BorderSide(color: AppColors.white, width: 0),
|
||||
backgroundColor: AppColors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
),
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Category Filter Chips
|
||||
categoriesAsync.when(
|
||||
data: (categories) => CategoryFilterChips(categories: categories),
|
||||
loading: () =>
|
||||
const SizedBox(height: 48.0, child: Center(child: CircularProgressIndicator(strokeWidth: 2.0))),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
// Brand Filter Chips
|
||||
const BrandFilterChips(),
|
||||
|
||||
// Products Grid
|
||||
Expanded(
|
||||
@@ -124,8 +111,16 @@ class ProductsPage extends ConsumerWidget {
|
||||
return _buildEmptyState(context, l10n);
|
||||
}
|
||||
|
||||
final productsNotifier = ref.read(productsProvider.notifier);
|
||||
final hasMore = productsNotifier.hasMore;
|
||||
|
||||
return ProductGrid(
|
||||
products: products,
|
||||
hasMore: hasMore,
|
||||
isLoadingMore: false,
|
||||
onLoadMore: () async {
|
||||
await productsNotifier.loadMore();
|
||||
},
|
||||
onProductTap: (product) {
|
||||
// Navigate to product detail page
|
||||
context.push('/products/${product.productId}');
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'package:worker/features/products/domain/repositories/products_repository
|
||||
import 'package:worker/features/products/domain/usecases/get_products.dart';
|
||||
import 'package:worker/features/products/domain/usecases/search_products.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_product_detail.dart';
|
||||
import 'package:worker/features/products/presentation/providers/selected_category_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/selected_brand_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
|
||||
|
||||
part 'products_provider.g.dart';
|
||||
@@ -50,7 +50,7 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -64,38 +64,108 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
|
||||
/// ```
|
||||
@riverpod
|
||||
class Products extends _$Products {
|
||||
static const int pageSize = 12;
|
||||
int _currentPage = 0;
|
||||
bool _hasMore = true;
|
||||
|
||||
@override
|
||||
Future<List<Product>> build() async {
|
||||
// Reset pagination when dependencies change
|
||||
_currentPage = 0;
|
||||
_hasMore = true;
|
||||
|
||||
// Watch dependencies
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
|
||||
// Get repository with injected data sources
|
||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||
|
||||
// Apply filters
|
||||
// Fetch first page of products
|
||||
List<Product> products;
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
// Search takes precedence over category filter
|
||||
// Search takes precedence over brand filter
|
||||
final searchUseCase = SearchProducts(repository);
|
||||
products = await searchUseCase(searchQuery);
|
||||
|
||||
// If a category is selected, filter search results by category
|
||||
if (selectedCategory != 'all') {
|
||||
// If a brand is selected, filter search results by brand
|
||||
if (selectedBrand != 'all') {
|
||||
products = products
|
||||
.where((product) => product.categoryId == selectedCategory)
|
||||
.where((product) => product.brand == selectedBrand)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// For search, we fetch all results at once, so no more pages
|
||||
_hasMore = false;
|
||||
} else {
|
||||
// No search query, use category filter
|
||||
// No search query, fetch all products with pagination
|
||||
final getProductsUseCase = GetProducts(repository);
|
||||
products = await getProductsUseCase(categoryId: selectedCategory);
|
||||
products = await getProductsUseCase(
|
||||
limitStart: 0,
|
||||
limitPageLength: pageSize,
|
||||
);
|
||||
|
||||
// Filter by brand if not 'all'
|
||||
if (selectedBrand != 'all') {
|
||||
products = products
|
||||
.where((product) => product.brand == selectedBrand)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// If we got less than pageSize, there are no more products
|
||||
_hasMore = products.length >= pageSize;
|
||||
}
|
||||
|
||||
_currentPage = 1;
|
||||
return products;
|
||||
}
|
||||
|
||||
/// Load more products (next page)
|
||||
Future<void> loadMore() async {
|
||||
if (!_hasMore) return;
|
||||
|
||||
// Watch dependencies to get current filters
|
||||
final selectedBrand = ref.read(selectedBrandProvider);
|
||||
final searchQuery = ref.read(searchQueryProvider);
|
||||
|
||||
// Don't paginate search results (already fetched all)
|
||||
if (searchQuery.isNotEmpty) return;
|
||||
|
||||
// Get repository
|
||||
final repository = await ref.read(productsRepositoryProvider.future);
|
||||
|
||||
// Calculate pagination parameters
|
||||
final limitStart = _currentPage * pageSize;
|
||||
|
||||
// Fetch next page from API
|
||||
final getProductsUseCase = GetProducts(repository);
|
||||
var newProducts = await getProductsUseCase(
|
||||
limitStart: limitStart,
|
||||
limitPageLength: pageSize,
|
||||
);
|
||||
|
||||
// Filter by brand if not 'all'
|
||||
if (selectedBrand != 'all') {
|
||||
newProducts = newProducts
|
||||
.where((product) => product.brand == selectedBrand)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// If we got less than pageSize, there are no more products
|
||||
_hasMore = newProducts.length >= pageSize;
|
||||
|
||||
// Increment page counter
|
||||
_currentPage++;
|
||||
|
||||
// Append new products to existing list
|
||||
final currentProducts = state.value ?? [];
|
||||
state = AsyncValue.data([...currentProducts, ...newProducts]);
|
||||
}
|
||||
|
||||
/// Check if there are more products to load
|
||||
bool get hasMore => _hasMore;
|
||||
|
||||
/// Refresh products data
|
||||
///
|
||||
/// Forces a refresh from the datasource.
|
||||
|
||||
@@ -159,7 +159,7 @@ String _$productsRepositoryHash() =>
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -179,7 +179,7 @@ const productsProvider = ProductsProvider._();
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -197,7 +197,7 @@ final class ProductsProvider
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -228,13 +228,13 @@ final class ProductsProvider
|
||||
Products create() => Products();
|
||||
}
|
||||
|
||||
String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf';
|
||||
String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf';
|
||||
|
||||
/// Products Provider
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
/// Data is fetched from Frappe ERPNext API with pagination support.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/// Provider: Selected Brand Provider
|
||||
///
|
||||
/// Manages the currently selected brand filter state.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'selected_brand_provider.g.dart';
|
||||
|
||||
/// Selected Brand Provider
|
||||
///
|
||||
/// Stores the currently selected brand ID for filtering products.
|
||||
/// Default: 'all' (no brand filter)
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Watch selected brand
|
||||
/// final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
///
|
||||
/// // Update selected brand
|
||||
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
|
||||
/// ```
|
||||
@riverpod
|
||||
class SelectedBrand extends _$SelectedBrand {
|
||||
@override
|
||||
String build() {
|
||||
return 'all'; // Default: show all brands
|
||||
}
|
||||
|
||||
/// Update the selected brand
|
||||
void updateBrand(String brandId) {
|
||||
state = brandId;
|
||||
}
|
||||
|
||||
/// Reset to default (all brands)
|
||||
void reset() {
|
||||
state = 'all';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'selected_brand_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Selected Brand Provider
|
||||
///
|
||||
/// Stores the currently selected brand ID for filtering products.
|
||||
/// Default: 'all' (no brand filter)
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Watch selected brand
|
||||
/// final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
///
|
||||
/// // Update selected brand
|
||||
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
|
||||
/// ```
|
||||
|
||||
@ProviderFor(SelectedBrand)
|
||||
const selectedBrandProvider = SelectedBrandProvider._();
|
||||
|
||||
/// Selected Brand Provider
|
||||
///
|
||||
/// Stores the currently selected brand ID for filtering products.
|
||||
/// Default: 'all' (no brand filter)
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Watch selected brand
|
||||
/// final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
///
|
||||
/// // Update selected brand
|
||||
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
|
||||
/// ```
|
||||
final class SelectedBrandProvider
|
||||
extends $NotifierProvider<SelectedBrand, String> {
|
||||
/// Selected Brand Provider
|
||||
///
|
||||
/// Stores the currently selected brand ID for filtering products.
|
||||
/// Default: 'all' (no brand filter)
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Watch selected brand
|
||||
/// final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
///
|
||||
/// // Update selected brand
|
||||
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
|
||||
/// ```
|
||||
const SelectedBrandProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedBrandProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedBrandHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedBrand create() => SelectedBrand();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedBrandHash() => r'1295bffdcea67b78f7d55ce42f476603e042b19b';
|
||||
|
||||
/// Selected Brand Provider
|
||||
///
|
||||
/// Stores the currently selected brand ID for filtering products.
|
||||
/// Default: 'all' (no brand filter)
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Watch selected brand
|
||||
/// final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
///
|
||||
/// // Update selected brand
|
||||
/// ref.read(selectedBrandProvider.notifier).updateBrand('VASTA');
|
||||
/// ```
|
||||
|
||||
abstract class _$SelectedBrand extends $Notifier<String> {
|
||||
String build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<String, String>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<String, String>,
|
||||
String,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/// Widget: Brand Filter Chips
|
||||
///
|
||||
/// Horizontal scrolling filter chips for product brands.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/selected_brand_provider.dart';
|
||||
|
||||
/// Brand Filter Chips Widget
|
||||
///
|
||||
/// Displays brands as horizontally scrolling chips.
|
||||
/// Updates selected brand when tapped.
|
||||
class BrandFilterChips extends ConsumerWidget {
|
||||
const BrandFilterChips({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedBrand = ref.watch(selectedBrandProvider);
|
||||
final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
|
||||
|
||||
return filterOptionsAsync.when(
|
||||
data: (options) {
|
||||
// Add "All" option at the beginning
|
||||
final allBrands = [
|
||||
const FilterOption(value: 'all', label: 'Tất cả'),
|
||||
...options.brands,
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
height: 48.0,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
itemCount: allBrands.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final brand = allBrands[index];
|
||||
final isSelected = selectedBrand == brand.value;
|
||||
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
brand.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? AppColors.white : AppColors.grey900,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
ref
|
||||
.read(selectedBrandProvider.notifier)
|
||||
.updateBrand(brand.value);
|
||||
}
|
||||
},
|
||||
backgroundColor: AppColors.white,
|
||||
selectedColor: AppColors.primaryBlue,
|
||||
checkmarkColor: AppColors.white,
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppColors.primaryBlue : AppColors.grey100,
|
||||
width: isSelected ? 2.0 : 1.0,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
elevation: isSelected ? AppElevation.low : 0,
|
||||
showCheckmark: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 48.0,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/// Widget: Product Grid
|
||||
///
|
||||
/// Grid view displaying product cards.
|
||||
/// Grid view displaying product cards with pagination support.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -10,22 +10,59 @@ import 'package:worker/features/products/presentation/widgets/product_card.dart'
|
||||
|
||||
/// Product Grid Widget
|
||||
///
|
||||
/// Displays products in a 2-column grid layout.
|
||||
class ProductGrid extends StatelessWidget {
|
||||
/// Displays products in a 2-column grid layout with scroll-to-load-more.
|
||||
class ProductGrid extends StatefulWidget {
|
||||
final List<Product> products;
|
||||
final void Function(Product)? onProductTap;
|
||||
final void Function(Product)? onAddToCart;
|
||||
final VoidCallback? onLoadMore;
|
||||
final bool hasMore;
|
||||
final bool isLoadingMore;
|
||||
|
||||
const ProductGrid({
|
||||
super.key,
|
||||
required this.products,
|
||||
this.onProductTap,
|
||||
this.onAddToCart,
|
||||
this.onLoadMore,
|
||||
this.hasMore = false,
|
||||
this.isLoadingMore = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProductGrid> createState() => _ProductGridState();
|
||||
}
|
||||
|
||||
class _ProductGridState extends State<ProductGrid> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
// Load more when 200px from bottom
|
||||
if (widget.hasMore && !widget.isLoadingMore && widget.onLoadMore != null) {
|
||||
widget.onLoadMore!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: GridSpecs.productGridColumns,
|
||||
@@ -33,14 +70,24 @@ class ProductGrid extends StatelessWidget {
|
||||
mainAxisSpacing: AppSpacing.xs,
|
||||
childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
|
||||
),
|
||||
itemCount: products.length,
|
||||
itemCount: widget.products.length + (widget.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
// Show loading indicator at the end
|
||||
if (index == widget.products.length) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.md),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final product = widget.products[index];
|
||||
|
||||
return ProductCard(
|
||||
product: product,
|
||||
onTap: onProductTap != null ? () => onProductTap!(product) : null,
|
||||
onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null,
|
||||
onTap: widget.onProductTap != null ? () => widget.onProductTap!(product) : null,
|
||||
onAddToCart: widget.onAddToCart != null ? () => widget.onAddToCart!(product) : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user