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/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';

View File

@@ -11,6 +11,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.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/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.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: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - 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() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - keepAlive: true to maintain cart state across navigation
@@ -24,7 +24,7 @@ const cartProvider = CartProvider._();
/// ///
/// Manages cart state with API integration: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - 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() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - 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: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - 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() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - 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 /// Cart Notifier
/// ///
/// Manages cart state with API integration: /// Manages cart state with API integration:
/// - Adding/removing items (syncs with API) /// - 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() /// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations /// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation /// - 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/material.dart' hide Notification;
import 'package:flutter_hooks/flutter_hooks.dart'; 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';

View File

@@ -184,13 +184,20 @@ class ProductsRemoteDataSource {
/// Get products by category /// Get products by category
/// ///
/// Filters products by category. /// Filters products by category with pagination support.
/// For now, we fetch all products and filter locally. /// For now, we fetch products with pagination and filter locally.
/// In the future, the API might support category filtering. /// In the future, the API might support category filtering.
Future<List<ProductModel>> getProductsByCategory(String categoryId) async { Future<List<ProductModel>> getProductsByCategory(
// For now, fetch all products and filter locally 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 // 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') { if (categoryId == 'all') {
return allProducts; return allProducts;

View File

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

View File

@@ -14,9 +14,14 @@ import 'package:worker/features/products/domain/entities/product.dart';
abstract class ProductsRepository { abstract class ProductsRepository {
/// Get all products /// 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. /// 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 /// Search products by query
/// ///
@@ -27,8 +32,14 @@ abstract class ProductsRepository {
/// Get products by category /// Get products by category
/// ///
/// [categoryId] - Category ID to filter by /// [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. /// 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 /// Get product by ID
/// ///

View File

@@ -17,12 +17,25 @@ class GetProducts {
/// Execute the use case /// Execute the use case
/// ///
/// [categoryId] - Optional category ID to filter products /// [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) /// 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') { if (categoryId == null || categoryId == 'all') {
return await repository.getAllProducts(); return await repository.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
} else { } 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/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/cart/presentation/providers/cart_provider.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/product_filter_options_provider.dart';
import 'package:worker/features/products/presentation/providers/products_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_filter_drawer.dart';
import 'package:worker/features/products/presentation/widgets/product_grid.dart'; import 'package:worker/features/products/presentation/widgets/product_grid.dart';
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart'; import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
@@ -34,7 +33,6 @@ class ProductsPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final categoriesAsync = ref.watch(categoriesProvider);
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
final cartItemCount = ref.watch(cartItemCountProvider); final cartItemCount = ref.watch(cartItemCountProvider);
@@ -75,46 +73,35 @@ class ProductsPage extends ConsumerWidget {
children: [ children: [
// Search Bar with Filter Button // Search Bar with Filter Button
Padding( Padding(
padding: const EdgeInsets.all(AppSpacing.md), padding: const EdgeInsets.all(AppSpacing.sm),
child: Row( child: Row(
children: [ children: [
// Search Bar (Expanded) // Search Bar (Expanded)
const Expanded(child: ProductSearchBar()), const Expanded(child: ProductSearchBar()),
const SizedBox(width: 8), const SizedBox(width: 8),
// Filter Button // Filter Button
SizedBox( Container(
height: InputFieldSpecs.height, height: InputFieldSpecs.height,
child: OutlinedButton.icon( width: InputFieldSpecs.height,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
child: IconButton(
onPressed: () { onPressed: () {
// Open filter drawer from right // Open filter drawer from right
Scaffold.of(scaffoldContext).openEndDrawer(); Scaffold.of(scaffoldContext).openEndDrawer();
}, },
icon: const FaIcon(FontAwesomeIcons.sliders, size: 18), icon: const FaIcon(FontAwesomeIcons.sliders, size: 18),
label: const Text('Lọc', style: TextStyle(fontSize: 12)), color: AppColors.grey900,
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),
),
),
), ),
), ),
], ],
), ),
), ),
// Category Filter Chips // Brand Filter Chips
categoriesAsync.when( const BrandFilterChips(),
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),
// Products Grid // Products Grid
Expanded( Expanded(
@@ -124,8 +111,16 @@ class ProductsPage extends ConsumerWidget {
return _buildEmptyState(context, l10n); return _buildEmptyState(context, l10n);
} }
final productsNotifier = ref.read(productsProvider.notifier);
final hasMore = productsNotifier.hasMore;
return ProductGrid( return ProductGrid(
products: products, products: products,
hasMore: hasMore,
isLoadingMore: false,
onLoadMore: () async {
await productsNotifier.loadMore();
},
onProductTap: (product) { onProductTap: (product) {
// Navigate to product detail page // Navigate to product detail page
context.push('/products/${product.productId}'); 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/get_products.dart';
import 'package:worker/features/products/domain/usecases/search_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/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'; import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
part 'products_provider.g.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. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// 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: /// Usage:
/// ```dart /// ```dart
@@ -64,38 +64,108 @@ Future<ProductsRepository> productsRepository(Ref ref) async {
/// ``` /// ```
@riverpod @riverpod
class Products extends _$Products { class Products extends _$Products {
static const int pageSize = 12;
int _currentPage = 0;
bool _hasMore = true;
@override @override
Future<List<Product>> build() async { Future<List<Product>> build() async {
// Reset pagination when dependencies change
_currentPage = 0;
_hasMore = true;
// Watch dependencies // Watch dependencies
final selectedCategory = ref.watch(selectedCategoryProvider); final selectedBrand = ref.watch(selectedBrandProvider);
final searchQuery = ref.watch(searchQueryProvider); final searchQuery = ref.watch(searchQueryProvider);
// Get repository with injected data sources // Get repository with injected data sources
final repository = await ref.watch(productsRepositoryProvider.future); final repository = await ref.watch(productsRepositoryProvider.future);
// Apply filters // Fetch first page of products
List<Product> products; List<Product> products;
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
// Search takes precedence over category filter // Search takes precedence over brand filter
final searchUseCase = SearchProducts(repository); final searchUseCase = SearchProducts(repository);
products = await searchUseCase(searchQuery); products = await searchUseCase(searchQuery);
// If a category is selected, filter search results by category // If a brand is selected, filter search results by brand
if (selectedCategory != 'all') { if (selectedBrand != 'all') {
products = products products = products
.where((product) => product.categoryId == selectedCategory) .where((product) => product.brand == selectedBrand)
.toList(); .toList();
} }
// For search, we fetch all results at once, so no more pages
_hasMore = false;
} else { } else {
// No search query, use category filter // No search query, fetch all products with pagination
final getProductsUseCase = GetProducts(repository); 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; 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 /// Refresh products data
/// ///
/// Forces a refresh from the datasource. /// 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. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// 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: /// Usage:
/// ```dart /// ```dart
@@ -179,7 +179,7 @@ const productsProvider = ProductsProvider._();
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// 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: /// Usage:
/// ```dart /// ```dart
@@ -197,7 +197,7 @@ final class ProductsProvider
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// 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: /// Usage:
/// ```dart /// ```dart
@@ -228,13 +228,13 @@ final class ProductsProvider
Products create() => Products(); Products create() => Products();
} }
String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf'; String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf';
/// Products Provider /// Products Provider
/// ///
/// Fetches and filters products based on selected category and search query. /// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes. /// 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: /// Usage:
/// ```dart /// ```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 /// Widget: Product Grid
/// ///
/// Grid view displaying product cards. /// Grid view displaying product cards with pagination support.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,22 +10,59 @@ import 'package:worker/features/products/presentation/widgets/product_card.dart'
/// Product Grid Widget /// Product Grid Widget
/// ///
/// Displays products in a 2-column grid layout. /// Displays products in a 2-column grid layout with scroll-to-load-more.
class ProductGrid extends StatelessWidget { class ProductGrid extends StatefulWidget {
final List<Product> products; final List<Product> products;
final void Function(Product)? onProductTap; final void Function(Product)? onProductTap;
final void Function(Product)? onAddToCart; final void Function(Product)? onAddToCart;
final VoidCallback? onLoadMore;
final bool hasMore;
final bool isLoadingMore;
const ProductGrid({ const ProductGrid({
super.key, super.key,
required this.products, required this.products,
this.onProductTap, this.onProductTap,
this.onAddToCart, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GridView.builder( return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xs), padding: const EdgeInsets.all(AppSpacing.xs),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: GridSpecs.productGridColumns, crossAxisCount: GridSpecs.productGridColumns,
@@ -33,14 +70,24 @@ class ProductGrid extends StatelessWidget {
mainAxisSpacing: AppSpacing.xs, mainAxisSpacing: AppSpacing.xs,
childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons) childAspectRatio: 0.62, // Width / Height ratio (adjusted for 2 buttons)
), ),
itemCount: products.length, itemCount: widget.products.length + (widget.hasMore ? 1 : 0),
itemBuilder: (context, index) { 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( return ProductCard(
product: product, product: product,
onTap: onProductTap != null ? () => onProductTap!(product) : null, onTap: widget.onProductTap != null ? () => widget.onProductTap!(product) : null,
onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null, onAddToCart: widget.onAddToCart != null ? () => widget.onAddToCart!(product) : null,
); );
}, },
); );

View File

@@ -912,10 +912,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1437,26 +1437,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.26.2" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.11" version: "0.6.12"
timing: timing:
dependency: transitive dependency: transitive
description: description: