From 0828ff1355a430411624197f3370687531fb5643 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Mon, 17 Nov 2025 11:03:51 +0700 Subject: [PATCH] fix product page --- .../presentation/pages/addresses_page.dart | 1 + .../pages/change_password_page.dart | 1 + .../presentation/pages/profile_edit_page.dart | 1 + .../providers/cart_provider.g.dart | 10 +- .../pages/notifications_page.dart | 1 + .../products_remote_datasource.dart | 17 ++- .../products_repository_impl.dart | 22 +++- .../repositories/products_repository.dart | 17 ++- .../domain/usecases/get_products.dart | 19 ++- .../presentation/pages/products_page.dart | 45 +++---- .../providers/products_provider.dart | 90 ++++++++++++-- .../providers/products_provider.g.dart | 10 +- .../providers/selected_brand_provider.dart | 39 ++++++ .../providers/selected_brand_provider.g.dart | 116 ++++++++++++++++++ .../widgets/brand_filter_chips.dart | 92 ++++++++++++++ .../presentation/widgets/product_grid.dart | 61 +++++++-- pubspec.lock | 16 +-- 17 files changed, 482 insertions(+), 76 deletions(-) create mode 100644 lib/features/products/presentation/providers/selected_brand_provider.dart create mode 100644 lib/features/products/presentation/providers/selected_brand_provider.g.dart create mode 100644 lib/features/products/presentation/widgets/brand_filter_chips.dart diff --git a/lib/features/account/presentation/pages/addresses_page.dart b/lib/features/account/presentation/pages/addresses_page.dart index 8136aaf..ee649ac 100644 --- a/lib/features/account/presentation/pages/addresses_page.dart +++ b/lib/features/account/presentation/pages/addresses_page.dart @@ -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'; diff --git a/lib/features/account/presentation/pages/change_password_page.dart b/lib/features/account/presentation/pages/change_password_page.dart index a14e801..1081699 100644 --- a/lib/features/account/presentation/pages/change_password_page.dart +++ b/lib/features/account/presentation/pages/change_password_page.dart @@ -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'; diff --git a/lib/features/account/presentation/pages/profile_edit_page.dart b/lib/features/account/presentation/pages/profile_edit_page.dart index b029478..239f421 100644 --- a/lib/features/account/presentation/pages/profile_edit_page.dart +++ b/lib/features/account/presentation/pages/profile_edit_page.dart @@ -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'; diff --git a/lib/features/cart/presentation/providers/cart_provider.g.dart b/lib/features/cart/presentation/providers/cart_provider.g.dart index 03295b3..8fc4a39 100644 --- a/lib/features/cart/presentation/providers/cart_provider.g.dart +++ b/lib/features/cart/presentation/providers/cart_provider.g.dart @@ -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 { /// /// 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 { } } -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 diff --git a/lib/features/notifications/presentation/pages/notifications_page.dart b/lib/features/notifications/presentation/pages/notifications_page.dart index b7bd674..9d90f24 100644 --- a/lib/features/notifications/presentation/pages/notifications_page.dart +++ b/lib/features/notifications/presentation/pages/notifications_page.dart @@ -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'; diff --git a/lib/features/products/data/datasources/products_remote_datasource.dart b/lib/features/products/data/datasources/products_remote_datasource.dart index f5caa94..c433956 100644 --- a/lib/features/products/data/datasources/products_remote_datasource.dart +++ b/lib/features/products/data/datasources/products_remote_datasource.dart @@ -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> getProductsByCategory(String categoryId) async { - // For now, fetch all products and filter locally + Future> 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; diff --git a/lib/features/products/data/repositories/products_repository_impl.dart b/lib/features/products/data/repositories/products_repository_impl.dart index 6766581..fe237c7 100644 --- a/lib/features/products/data/repositories/products_repository_impl.dart +++ b/lib/features/products/data/repositories/products_repository_impl.dart @@ -25,10 +25,16 @@ class ProductsRepositoryImpl implements ProductsRepository { }); @override - Future> getAllProducts() async { + Future> 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> getProductsByCategory(String categoryId) async { + Future> 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) { diff --git a/lib/features/products/domain/repositories/products_repository.dart b/lib/features/products/domain/repositories/products_repository.dart index 2b02076..c6ea1dc 100644 --- a/lib/features/products/domain/repositories/products_repository.dart +++ b/lib/features/products/domain/repositories/products_repository.dart @@ -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> getAllProducts(); + Future> 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> getProductsByCategory(String categoryId); + Future> getProductsByCategory( + String categoryId, { + int limitStart = 0, + int limitPageLength = 12, + }); /// Get product by ID /// diff --git a/lib/features/products/domain/usecases/get_products.dart b/lib/features/products/domain/usecases/get_products.dart index 5cee06b..d3bae71 100644 --- a/lib/features/products/domain/usecases/get_products.dart +++ b/lib/features/products/domain/usecases/get_products.dart @@ -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> call({String? categoryId}) async { + Future> 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, + ); } } } diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 2baf7ed..3f8aac6 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -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}'); diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index a2b1652..04bdf9f 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -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(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(Ref ref) async { /// ``` @riverpod class Products extends _$Products { + static const int pageSize = 12; + int _currentPage = 0; + bool _hasMore = true; + @override Future> 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 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 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. diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart index 3557ee1..d3229be 100644 --- a/lib/features/products/presentation/providers/products_provider.g.dart +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -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 diff --git a/lib/features/products/presentation/providers/selected_brand_provider.dart b/lib/features/products/presentation/providers/selected_brand_provider.dart new file mode 100644 index 0000000..0d6fce1 --- /dev/null +++ b/lib/features/products/presentation/providers/selected_brand_provider.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'; + } +} diff --git a/lib/features/products/presentation/providers/selected_brand_provider.g.dart b/lib/features/products/presentation/providers/selected_brand_provider.g.dart new file mode 100644 index 0000000..301e414 --- /dev/null +++ b/lib/features/products/presentation/providers/selected_brand_provider.g.dart @@ -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 { + /// 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(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 build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/products/presentation/widgets/brand_filter_chips.dart b/lib/features/products/presentation/widgets/brand_filter_chips.dart new file mode 100644 index 0000000..9d748a0 --- /dev/null +++ b/lib/features/products/presentation/widgets/brand_filter_chips.dart @@ -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(), + ); + } +} diff --git a/lib/features/products/presentation/widgets/product_grid.dart b/lib/features/products/presentation/widgets/product_grid.dart index 9d47412..aac9f56 100644 --- a/lib/features/products/presentation/widgets/product_grid.dart +++ b/lib/features/products/presentation/widgets/product_grid.dart @@ -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 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 createState() => _ProductGridState(); +} + +class _ProductGridState extends State { + 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, ); }, ); diff --git a/pubspec.lock b/pubspec.lock index e7a50da..de384dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -912,10 +912,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1437,26 +1437,26 @@ packages: dependency: transitive description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" timing: dependency: transitive description: