prodycrts

This commit is contained in:
Phuoc Nguyen
2025-10-20 15:56:34 +07:00
parent e321e9a419
commit f95fa9d0a6
40 changed files with 3123 additions and 447 deletions

View File

@@ -0,0 +1,233 @@
/// Page: Products Page
///
/// Main products browsing page with search, category filters, and product grid.
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/categories_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/product_grid.dart';
import 'package:worker/features/products/presentation/widgets/product_search_bar.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Products Page
///
/// Displays the products catalog with:
/// - Search bar
/// - Category filter chips
/// - Product grid
/// - Pull-to-refresh
/// - Loading and error states
class ProductsPage extends ConsumerWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final categoriesAsync = ref.watch(categoriesProvider);
final productsAsync = ref.watch(productsProvider);
return Scaffold(
backgroundColor: AppColors.white,
appBar: AppBar(
title: const Text('Sản phẩm', style: TextStyle(color: Colors.black)),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false,
actions: [
// Cart Icon with Badge
IconButton(
icon: const Badge(
label: Text('3'),
backgroundColor: AppColors.danger,
textColor: AppColors.white,
child: Icon(Icons.shopping_cart_outlined, color: Colors.black,),
),
onPressed: () {
// TODO: Navigate to cart page
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cart),
duration: const Duration(seconds: 1),
),
);
},
),
const SizedBox(width: AppSpacing.sm),
],
),
body: Column(
children: [
// Search Bar
const ProductSearchBar(),
const SizedBox(height: AppSpacing.sm),
// 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),
// Products Grid
Expanded(
child: productsAsync.when(
data: (products) {
if (products.isEmpty) {
return _buildEmptyState(context, l10n);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(productsProvider.notifier).refresh();
},
child: ProductGrid(
products: products,
onProductTap: (product) {
// TODO: Navigate to product detail page
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(product.name),
duration: const Duration(seconds: 1),
),
);
},
onAddToCart: (product) {
// TODO: Add to cart logic
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${product.name} ${l10n.addedToCart}'),
duration: const Duration(seconds: 2),
action: SnackBarAction(
label: l10n.viewDetails,
onPressed: () {
// Navigate to cart
},
),
),
);
},
),
);
},
loading: () => _buildLoadingState(),
error: (error, stack) => _buildErrorState(context, l10n, error, ref),
),
),
],
),
);
}
/// Build empty state
Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 80.0,
color: AppColors.grey500.withAlpha(128),
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.noProductsFound,
style: const TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.noResults,
style: const TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
),
),
],
),
);
}
/// Build loading state
Widget _buildLoadingState() {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryBlue,
),
);
}
/// Build error state
Widget _buildErrorState(
BuildContext context,
AppLocalizations l10n,
Object error,
WidgetRef ref,
) {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80.0,
color: AppColors.danger.withAlpha(128),
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.error,
style: const TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
error.toString(),
style: const TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.lg),
ElevatedButton.icon(
onPressed: () async {
await ref.read(productsProvider.notifier).refresh();
},
icon: const Icon(Icons.refresh),
label: Text(l10n.tryAgain),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,36 @@
/// Provider: Categories Provider
///
/// Manages the state of product categories using Riverpod.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/products/data/datasources/products_local_datasource.dart';
import 'package:worker/features/products/data/repositories/products_repository_impl.dart';
import 'package:worker/features/products/domain/entities/category.dart';
import 'package:worker/features/products/domain/usecases/get_categories.dart';
part 'categories_provider.g.dart';
/// Categories Provider
///
/// Fetches and caches product categories.
/// Automatically handles loading, error, and data states.
///
/// Usage:
/// ```dart
/// final categoriesAsync = ref.watch(categoriesProvider);
///
/// categoriesAsync.when(
/// data: (categories) => CategoryFilterChips(categories: categories),
/// loading: () => ShimmerLoader(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@riverpod
Future<List<Category>> categories(Ref ref) async {
final localDataSource = const ProductsLocalDataSourceImpl();
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
final useCase = GetCategories(repository);
return await useCase();
}

View File

@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'categories_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Categories Provider
///
/// Fetches and caches product categories.
/// Automatically handles loading, error, and data states.
///
/// Usage:
/// ```dart
/// final categoriesAsync = ref.watch(categoriesProvider);
///
/// categoriesAsync.when(
/// data: (categories) => CategoryFilterChips(categories: categories),
/// loading: () => ShimmerLoader(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@ProviderFor(categories)
const categoriesProvider = CategoriesProvider._();
/// Categories Provider
///
/// Fetches and caches product categories.
/// Automatically handles loading, error, and data states.
///
/// Usage:
/// ```dart
/// final categoriesAsync = ref.watch(categoriesProvider);
///
/// categoriesAsync.when(
/// data: (categories) => CategoryFilterChips(categories: categories),
/// loading: () => ShimmerLoader(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
final class CategoriesProvider
extends
$FunctionalProvider<
AsyncValue<List<Category>>,
List<Category>,
FutureOr<List<Category>>
>
with $FutureModifier<List<Category>>, $FutureProvider<List<Category>> {
/// Categories Provider
///
/// Fetches and caches product categories.
/// Automatically handles loading, error, and data states.
///
/// Usage:
/// ```dart
/// final categoriesAsync = ref.watch(categoriesProvider);
///
/// categoriesAsync.when(
/// data: (categories) => CategoryFilterChips(categories: categories),
/// loading: () => ShimmerLoader(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
const CategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'categoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$categoriesHash();
@$internal
@override
$FutureProviderElement<List<Category>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<Category>> create(Ref ref) {
return categories(ref);
}
}
String _$categoriesHash() => r'6de35d3271d6d6572d9cdf5ed68edd26036115fc';

View File

@@ -0,0 +1,88 @@
/// Provider: Products Provider
///
/// Manages the state of products data using Riverpod.
/// Provides filtered products based on category and search query.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/products/data/datasources/products_local_datasource.dart';
import 'package:worker/features/products/data/repositories/products_repository_impl.dart';
import 'package:worker/features/products/domain/entities/product.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/presentation/providers/selected_category_provider.dart';
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
part 'products_provider.g.dart';
/// Products Provider
///
/// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes.
///
/// Usage:
/// ```dart
/// final productsAsync = ref.watch(productsProvider);
///
/// productsAsync.when(
/// data: (products) => ProductGrid(products: products),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@riverpod
class Products extends _$Products {
@override
Future<List<Product>> build() async {
// Watch dependencies
final selectedCategory = ref.watch(selectedCategoryProvider);
final searchQuery = ref.watch(searchQueryProvider);
// Initialize dependencies
final localDataSource = const ProductsLocalDataSourceImpl();
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
// Apply filters
List<Product> products;
if (searchQuery.isNotEmpty) {
// Search takes precedence over category filter
final searchUseCase = SearchProducts(repository);
products = await searchUseCase(searchQuery);
// If a category is selected, filter search results by category
if (selectedCategory != 'all') {
products = products
.where((product) => product.categoryId == selectedCategory)
.toList();
}
} else {
// No search query, use category filter
final getProductsUseCase = GetProducts(repository);
products = await getProductsUseCase(categoryId: selectedCategory);
}
return products;
}
/// Refresh products data
///
/// Forces a refresh from the datasource.
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => build());
}
}
/// All Products Provider (no filters)
///
/// Provides all products without any filtering.
/// Useful for product selection dialogs, etc.
@riverpod
Future<List<Product>> allProducts(Ref ref) async {
final localDataSource = const ProductsLocalDataSourceImpl();
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
final getProductsUseCase = GetProducts(repository);
return await getProductsUseCase();
}

View File

@@ -0,0 +1,169 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'products_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Products Provider
///
/// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes.
///
/// Usage:
/// ```dart
/// final productsAsync = ref.watch(productsProvider);
///
/// productsAsync.when(
/// data: (products) => ProductGrid(products: products),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@ProviderFor(Products)
const productsProvider = ProductsProvider._();
/// Products Provider
///
/// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes.
///
/// Usage:
/// ```dart
/// final productsAsync = ref.watch(productsProvider);
///
/// productsAsync.when(
/// data: (products) => ProductGrid(products: products),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
final class ProductsProvider
extends $AsyncNotifierProvider<Products, List<Product>> {
/// Products Provider
///
/// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes.
///
/// Usage:
/// ```dart
/// final productsAsync = ref.watch(productsProvider);
///
/// productsAsync.when(
/// data: (products) => ProductGrid(products: products),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
const ProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'productsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$productsHash();
@$internal
@override
Products create() => Products();
}
String _$productsHash() => r'0f1b32d2c14b9d8d600ffb0270f54d32af753e1f';
/// Products Provider
///
/// Fetches and filters products based on selected category and search query.
/// Automatically updates when category or search query changes.
///
/// Usage:
/// ```dart
/// final productsAsync = ref.watch(productsProvider);
///
/// productsAsync.when(
/// data: (products) => ProductGrid(products: products),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
abstract class _$Products extends $AsyncNotifier<List<Product>> {
FutureOr<List<Product>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Product>>, List<Product>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
AsyncValue<List<Product>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// All Products Provider (no filters)
///
/// Provides all products without any filtering.
/// Useful for product selection dialogs, etc.
@ProviderFor(allProducts)
const allProductsProvider = AllProductsProvider._();
/// All Products Provider (no filters)
///
/// Provides all products without any filtering.
/// Useful for product selection dialogs, etc.
final class AllProductsProvider
extends
$FunctionalProvider<
AsyncValue<List<Product>>,
List<Product>,
FutureOr<List<Product>>
>
with $FutureModifier<List<Product>>, $FutureProvider<List<Product>> {
/// All Products Provider (no filters)
///
/// Provides all products without any filtering.
/// Useful for product selection dialogs, etc.
const AllProductsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'allProductsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$allProductsHash();
@$internal
@override
$FutureProviderElement<List<Product>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<Product>> create(Ref ref) {
return allProducts(ref);
}
}
String _$allProductsHash() => r'a02e989ad36e644d9b62e681b3ced88e10e4d4c3';

View File

@@ -0,0 +1,39 @@
/// Provider: Search Query Provider
///
/// Manages the current search query state for product filtering.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_query_provider.g.dart';
/// Search Query Provider
///
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// ```
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String build() {
return ''; // Default: no search filter
}
/// Update search query
void updateQuery(String query) {
state = query;
}
/// Clear search query
void clear() {
state = '';
}
}

View File

@@ -0,0 +1,115 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_query_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Search Query Provider
///
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// ```
@ProviderFor(SearchQuery)
const searchQueryProvider = SearchQueryProvider._();
/// Search Query Provider
///
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// ```
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Search Query Provider
///
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// ```
const SearchQueryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchQueryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchQueryHash();
@$internal
@override
SearchQuery create() => SearchQuery();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$searchQueryHash() => r'41ea2fa57593abc0cafe16598d8817584ba99ddc';
/// Search Query Provider
///
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// ```
abstract class _$SearchQuery 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,39 @@
/// Provider: Selected Category Provider
///
/// Manages the currently selected category filter state.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'selected_category_provider.g.dart';
/// Selected Category Provider
///
/// Holds the currently selected category ID for filtering products.
/// Default is 'all' which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final selectedCategory = ref.watch(selectedCategoryProvider);
///
/// // Update the value
/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles';
/// ```
@riverpod
class SelectedCategory extends _$SelectedCategory {
@override
String build() {
return 'all'; // Default: show all products
}
/// Update selected category
void updateCategory(String categoryId) {
state = categoryId;
}
/// Reset to show all products
void reset() {
state = 'all';
}
}

View File

@@ -0,0 +1,116 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'selected_category_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Selected Category Provider
///
/// Holds the currently selected category ID for filtering products.
/// Default is 'all' which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final selectedCategory = ref.watch(selectedCategoryProvider);
///
/// // Update the value
/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles';
/// ```
@ProviderFor(SelectedCategory)
const selectedCategoryProvider = SelectedCategoryProvider._();
/// Selected Category Provider
///
/// Holds the currently selected category ID for filtering products.
/// Default is 'all' which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final selectedCategory = ref.watch(selectedCategoryProvider);
///
/// // Update the value
/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles';
/// ```
final class SelectedCategoryProvider
extends $NotifierProvider<SelectedCategory, String> {
/// Selected Category Provider
///
/// Holds the currently selected category ID for filtering products.
/// Default is 'all' which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final selectedCategory = ref.watch(selectedCategoryProvider);
///
/// // Update the value
/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles';
/// ```
const SelectedCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryHash();
@$internal
@override
SelectedCategory create() => SelectedCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$selectedCategoryHash() => r'269171acff2e04353101596c8d65f46fa54dc839';
/// Selected Category Provider
///
/// Holds the currently selected category ID for filtering products.
/// Default is 'all' which shows all products.
///
/// Usage:
/// ```dart
/// // Read the current value
/// final selectedCategory = ref.watch(selectedCategoryProvider);
///
/// // Update the value
/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles';
/// ```
abstract class _$SelectedCategory 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,76 @@
/// Widget: Category Filter Chips
///
/// Horizontal scrolling filter chips for product categories.
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/domain/entities/category.dart';
import 'package:worker/features/products/presentation/providers/selected_category_provider.dart';
/// Category Filter Chips Widget
///
/// Displays categories as horizontally scrolling chips.
/// Updates selected category when tapped.
class CategoryFilterChips extends ConsumerWidget {
final List<Category> categories;
const CategoryFilterChips({
super.key,
required this.categories,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedCategory = ref.watch(selectedCategoryProvider);
return SizedBox(
height: 48.0,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
itemCount: categories.length,
separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm),
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = selectedCategory == category.id;
return FilterChip(
label: Text(
category.name,
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(selectedCategoryProvider.notifier).updateCategory(category.id);
}
},
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,
);
},
),
);
}
}

View File

@@ -0,0 +1,208 @@
/// Widget: Product Card
///
/// Displays a product in a card format with image, name, price, and add to cart button.
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shimmer/shimmer.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Product Card Widget
///
/// Displays product information in a card format.
/// Includes image, name, price, stock status, and add to cart button.
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback? onTap;
final VoidCallback? onAddToCart;
const ProductCard({
super.key,
required this.product,
this.onTap,
this.onAddToCart,
});
String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN');
return '${formatter.format(price)}đ';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
elevation: ProductCardSpecs.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Image
Expanded(
child: Stack(
children: [
// Image
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(ProductCardSpecs.borderRadius),
),
child: CachedNetworkImage(
imageUrl: product.imageUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
memCacheWidth: ImageSpecs.productImageCacheWidth,
memCacheHeight: ImageSpecs.productImageCacheHeight,
placeholder: (context, url) => Shimmer.fromColors(
baseColor: AppColors.grey100,
highlightColor: AppColors.grey50,
child: Container(
color: AppColors.grey100,
),
),
errorWidget: (context, url, error) => Container(
color: AppColors.grey100,
child: const Icon(
Icons.image_not_supported,
size: 48.0,
color: AppColors.grey500,
),
),
),
),
// Sale Badge
if (product.isOnSale)
Positioned(
top: AppSpacing.sm,
right: AppSpacing.sm,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 4.0,
),
decoration: BoxDecoration(
color: AppColors.danger,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
'-${product.discountPercentage}%',
style: const TextStyle(
color: AppColors.white,
fontSize: 11.0,
fontWeight: FontWeight.bold,
),
),
),
),
// Low Stock Badge
if (product.isLowStock)
Positioned(
top: AppSpacing.sm,
left: AppSpacing.sm,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 4.0,
),
decoration: BoxDecoration(
color: AppColors.warning,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
l10n.lowStock,
style: const TextStyle(
color: AppColors.white,
fontSize: 11.0,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
// Product Info
Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Product Name
Text(
product.name,
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpacing.xs),
// Price
Text(
'${_formatPrice(product.effectivePrice)}/${product.unit}',
style: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
color: AppColors.primaryBlue,
),
),
const SizedBox(height: AppSpacing.sm),
// Add to Cart Button - Full Width
SizedBox(
width: double.infinity,
height: 36.0,
child: ElevatedButton.icon(
onPressed: product.inStock ? onAddToCart : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
),
),
icon: const Icon(Icons.shopping_cart, size: 18.0),
label: Text(
product.inStock ? l10n.addToCart : l10n.outOfStock,
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,48 @@
/// Widget: Product Grid
///
/// Grid view displaying product cards.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/features/products/domain/entities/product.dart';
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 {
final List<Product> products;
final void Function(Product)? onProductTap;
final void Function(Product)? onAddToCart;
const ProductGrid({
super.key,
required this.products,
this.onProductTap,
this.onAddToCart,
});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(AppSpacing.xs),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: GridSpecs.productGridColumns,
crossAxisSpacing: AppSpacing.xs,
mainAxisSpacing: AppSpacing.xs,
childAspectRatio: 0.7, // Width / Height ratio
),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ProductCard(
product: product,
onTap: onProductTap != null ? () => onProductTap!(product) : null,
onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null,
);
},
);
}
}

View File

@@ -0,0 +1,113 @@
/// Widget: Product Search Bar
///
/// Custom search bar for filtering products.
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/search_query_provider.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Product Search Bar Widget
///
/// A search input field that updates the search query provider.
/// Includes search icon and clear button.
class ProductSearchBar extends ConsumerStatefulWidget {
const ProductSearchBar({super.key});
@override
ConsumerState<ProductSearchBar> createState() => _ProductSearchBarState();
}
class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_focusNode = FocusNode();
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
// Update search query provider
ref.read(searchQueryProvider.notifier).updateQuery(value);
}
void _onClearSearch() {
_controller.clear();
ref.read(searchQueryProvider.notifier).clear();
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
height: InputFieldSpecs.height,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: l10n.searchProducts,
hintStyle: const TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
),
prefixIcon: const Icon(
Icons.search,
color: AppColors.grey500,
size: AppIconSize.md,
),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(
Icons.clear,
color: AppColors.grey500,
size: AppIconSize.md,
),
onPressed: _onClearSearch,
)
: null,
filled: true,
fillColor: const Color(0xFFF5F5F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
),
style: const TextStyle(
fontSize: InputFieldSpecs.fontSize,
),
),
);
}
}