fix product page

This commit is contained in:
Phuoc Nguyen
2025-11-17 11:03:51 +07:00
parent 49082026f5
commit 0828ff1355
17 changed files with 482 additions and 76 deletions

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';

View File

@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';

View File

@@ -12,7 +12,7 @@ part of 'cart_provider.dart';
///
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
@@ -24,7 +24,7 @@ const cartProvider = CartProvider._();
///
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
@@ -33,7 +33,7 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
///
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
@@ -64,13 +64,13 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
}
}
String _$cartHash() => r'3bb1372a0e87268e35c7c8d424d2d8315b4d09b2';
String _$cartHash() => r'706de28734e7059b2e9484f3b1d94226a0e90bb9';
/// Cart Notifier
///
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation

View File

@@ -10,6 +10,7 @@ library;
import 'package:flutter/material.dart' hide Notification;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';

View File

@@ -184,13 +184,20 @@ class ProductsRemoteDataSource {
/// Get products by category
///
/// Filters products by category.
/// For now, we fetch all products and filter locally.
/// Filters products by category with pagination support.
/// For now, we fetch products with pagination and filter locally.
/// In the future, the API might support category filtering.
Future<List<ProductModel>> getProductsByCategory(String categoryId) async {
// For now, fetch all products and filter locally
Future<List<ProductModel>> getProductsByCategory(
String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
// Fetch products with pagination and filter locally
// TODO: Implement server-side category filtering if API supports it
final allProducts = await getAllProducts();
final allProducts = await getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
if (categoryId == 'all') {
return allProducts;

View File

@@ -25,10 +25,16 @@ class ProductsRepositoryImpl implements ProductsRepository {
});
@override
Future<List<Product>> getAllProducts() async {
Future<List<Product>> getAllProducts({
int limitStart = 0,
int limitPageLength = 12,
}) async {
try {
// Fetch from Frappe API
final productModels = await remoteDataSource.getAllProducts();
// Fetch from Frappe API with pagination
final productModels = await remoteDataSource.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
return productModels.map((model) => model.toEntity()).toList();
} catch (e) {
print('[ProductsRepository] Error getting products: $e');
@@ -49,11 +55,17 @@ class ProductsRepositoryImpl implements ProductsRepository {
}
@override
Future<List<Product>> getProductsByCategory(String categoryId) async {
Future<List<Product>> getProductsByCategory(
String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
try {
// Filter by category via remote API
// Filter by category via remote API with pagination
final productModels = await remoteDataSource.getProductsByCategory(
categoryId,
limitStart: limitStart,
limitPageLength: limitPageLength,
);
return productModels.map((model) => model.toEntity()).toList();
} catch (e) {

View File

@@ -14,9 +14,14 @@ import 'package:worker/features/products/domain/entities/product.dart';
abstract class ProductsRepository {
/// Get all products
///
/// Returns a list of all available products.
/// Returns a list of all available products with pagination support.
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Throws an exception if the operation fails.
Future<List<Product>> getAllProducts();
Future<List<Product>> getAllProducts({
int limitStart = 0,
int limitPageLength = 12,
});
/// Search products by query
///
@@ -27,8 +32,14 @@ abstract class ProductsRepository {
/// Get products by category
///
/// [categoryId] - Category ID to filter by
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Returns list of products in the specified category.
Future<List<Product>> getProductsByCategory(String categoryId);
Future<List<Product>> getProductsByCategory(
String categoryId, {
int limitStart = 0,
int limitPageLength = 12,
});
/// Get product by ID
///

View File

@@ -17,12 +17,25 @@ class GetProducts {
/// Execute the use case
///
/// [categoryId] - Optional category ID to filter products
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Returns list of products (all or filtered by category)
Future<List<Product>> call({String? categoryId}) async {
Future<List<Product>> call({
String? categoryId,
int limitStart = 0,
int limitPageLength = 12,
}) async {
if (categoryId == null || categoryId == 'all') {
return await repository.getAllProducts();
return await repository.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
} else {
return await repository.getProductsByCategory(categoryId);
return await repository.getProductsByCategory(
categoryId,
limitStart: limitStart,
limitPageLength: limitPageLength,
);
}
}
}

View File

@@ -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}');

View File

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

View File

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

View File

@@ -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';
}
}

View File

@@ -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);
}
}

View File

@@ -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(),
);
}
}

View File

@@ -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,
);
},
);