update products page + filter

This commit is contained in:
Phuoc Nguyen
2025-11-07 17:43:49 +07:00
parent 9057ebdc6d
commit 2a71c65577
5 changed files with 536 additions and 43 deletions

View File

@@ -194,7 +194,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
TextButton(onPressed: () {
context.pushNamed(RouteNames.otpVerification);
}, child: Text('otp'))
}, child: Text('otp')),
TextButton(onPressed: () {
context.pushReplacementNamed(RouteNames.home);
}, child: Text('home'))
],
),
),

View File

@@ -13,6 +13,7 @@ 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/products_provider.dart';
import 'package:worker/features/products/presentation/widgets/category_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';
import 'package:worker/generated/l10n/app_localizations.dart';
@@ -37,6 +38,7 @@ class ProductsPage extends ConsumerWidget {
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), // Match HTML background
endDrawer: const ProductFilterDrawer(),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
@@ -65,12 +67,55 @@ class ProductsPage extends ConsumerWidget {
const SizedBox(width: AppSpacing.sm),
],
),
body: Column(
children: [
// Search Bar
const SizedBox(height: AppSpacing.sm),
const ProductSearchBar(),
const SizedBox(height: AppSpacing.sm),
body: Builder(
builder: (BuildContext scaffoldContext) {
return Column(
children: [
// Search Bar with Filter Button
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
children: [
// Search Bar (Expanded)
const Expanded(
child: ProductSearchBar(),
),
const SizedBox(width: 8),
// Filter Button
SizedBox(
height: InputFieldSpecs.height,
child: OutlinedButton.icon(
onPressed: () {
// Open filter drawer from right
Scaffold.of(scaffoldContext).openEndDrawer();
},
icon: const Icon(Icons.filter_list, size: 20),
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,
),
),
),
),
),
],
),
),
// Category Filter Chips
categoriesAsync.when(
@@ -82,45 +127,48 @@ class ProductsPage extends ConsumerWidget {
error: (error, stack) => const SizedBox.shrink(),
),
const SizedBox(height: AppSpacing.sm),
const SizedBox(height: AppSpacing.sm),
// Products Grid
Expanded(
child: productsAsync.when(
data: (products) {
if (products.isEmpty) {
return _buildEmptyState(context, l10n);
}
// Products Grid
Expanded(
child: productsAsync.when(
data: (products) {
if (products.isEmpty) {
return _buildEmptyState(context, l10n);
}
return ProductGrid(
products: products,
onProductTap: (product) {
// Navigate to product detail page
context.push('/products/${product.productId}');
},
onAddToCart: (product) {
// Add to cart
ref.read(cartProvider.notifier).addToCart(product);
return ProductGrid(
products: products,
onProductTap: (product) {
// Navigate to product detail page
context.push('/products/${product.productId}');
},
onAddToCart: (product) {
// Add to cart
ref.read(cartProvider.notifier).addToCart(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${product.name} đã thêm vào giỏ hàng'),
duration: const Duration(seconds: 2),
action: SnackBarAction(
label: 'Xem',
onPressed: () => context.go(RouteNames.cart),
),
),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('${product.name} đã thêm vào giỏ hàng'),
duration: const Duration(seconds: 2),
action: SnackBarAction(
label: 'Xem',
onPressed: () => context.go(RouteNames.cart),
),
),
);
},
);
},
);
},
loading: () => _buildLoadingState(),
error: (error, stack) =>
_buildErrorState(context, l10n, error, ref),
),
),
],
loading: () => _buildLoadingState(),
error: (error, stack) =>
_buildErrorState(context, l10n, error, ref),
),
),
],
);
},
),
);
}

View File

@@ -0,0 +1,132 @@
/// Provider: Product Filters State
///
/// Manages product filter selections.
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Product Filters State
class ProductFiltersState {
final Set<String> productLines;
final Set<String> spaces;
final Set<String> sizes;
final Set<String> surfaces;
final Set<String> brands;
const ProductFiltersState({
this.productLines = const {},
this.spaces = const {},
this.sizes = const {},
this.surfaces = const {},
this.brands = const {},
});
ProductFiltersState copyWith({
Set<String>? productLines,
Set<String>? spaces,
Set<String>? sizes,
Set<String>? surfaces,
Set<String>? brands,
}) {
return ProductFiltersState(
productLines: productLines ?? this.productLines,
spaces: spaces ?? this.spaces,
sizes: sizes ?? this.sizes,
surfaces: surfaces ?? this.surfaces,
brands: brands ?? this.brands,
);
}
/// Get total filter count
int get totalCount =>
productLines.length +
spaces.length +
sizes.length +
surfaces.length +
brands.length;
/// Check if any filters are active
bool get hasActiveFilters => totalCount > 0;
/// Reset all filters
ProductFiltersState reset() {
return const ProductFiltersState();
}
}
/// Product Filters Notifier
class ProductFiltersNotifier extends Notifier<ProductFiltersState> {
@override
ProductFiltersState build() => const ProductFiltersState();
/// Toggle product line filter
void toggleProductLine(String value) {
final newSet = Set<String>.from(state.productLines);
if (newSet.contains(value)) {
newSet.remove(value);
} else {
newSet.add(value);
}
state = state.copyWith(productLines: newSet);
}
/// Toggle space filter
void toggleSpace(String value) {
final newSet = Set<String>.from(state.spaces);
if (newSet.contains(value)) {
newSet.remove(value);
} else {
newSet.add(value);
}
state = state.copyWith(spaces: newSet);
}
/// Toggle size filter
void toggleSize(String value) {
final newSet = Set<String>.from(state.sizes);
if (newSet.contains(value)) {
newSet.remove(value);
} else {
newSet.add(value);
}
state = state.copyWith(sizes: newSet);
}
/// Toggle surface filter
void toggleSurface(String value) {
final newSet = Set<String>.from(state.surfaces);
if (newSet.contains(value)) {
newSet.remove(value);
} else {
newSet.add(value);
}
state = state.copyWith(surfaces: newSet);
}
/// Toggle brand filter
void toggleBrand(String value) {
final newSet = Set<String>.from(state.brands);
if (newSet.contains(value)) {
newSet.remove(value);
} else {
newSet.add(value);
}
state = state.copyWith(brands: newSet);
}
/// Reset all filters
void reset() {
state = const ProductFiltersState();
}
/// Apply filters (placeholder for future implementation)
void apply() {
// TODO: Trigger products provider refresh with filters
}
}
/// Product Filters Provider
final productFiltersProvider =
NotifierProvider<ProductFiltersNotifier, ProductFiltersState>(
ProductFiltersNotifier.new,
);

View File

@@ -0,0 +1,311 @@
/// Widget: Product Filter Drawer
///
/// Right side drawer with product filtering options.
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_filters_provider.dart';
/// Product Filter Drawer Widget
///
/// A drawer that slides from the right with filter options:
/// - Dòng sản phẩm (Product Line)
/// - Không gian (Space)
/// - Kích thước (Size)
/// - Bề mặt (Surface)
/// - Thương hiệu (Brand)
class ProductFilterDrawer extends ConsumerWidget {
const ProductFilterDrawer({super.key});
// Filter options (from HTML)
static const List<FilterOption> _productLines = [
FilterOption(value: 'tam-lon', label: 'Tấm lớn'),
FilterOption(value: 'third-firing', label: 'Third-Firing'),
FilterOption(value: 'outdoor', label: 'Outdoor'),
FilterOption(value: 'van-da', label: 'Vân đá'),
FilterOption(value: 'xi-mang', label: 'Xi măng'),
FilterOption(value: 'van-go', label: 'Vân gỗ'),
FilterOption(value: 'xuong-trang', label: 'Xương trắng'),
FilterOption(value: 'cam-thach', label: 'Cẩm thạch'),
];
static const List<FilterOption> _spaces = [
FilterOption(value: 'phong-khach', label: 'Phòng khách'),
FilterOption(value: 'phong-ngu', label: 'Phòng ngủ'),
FilterOption(value: 'phong-tam', label: 'Phòng tắm'),
FilterOption(value: 'nha-bep', label: 'Nhà bếp'),
FilterOption(value: 'khong-gian-khac', label: 'Không gian khác'),
];
static const List<FilterOption> _sizes = [
FilterOption(value: '200x1600', label: '200x1600'),
FilterOption(value: '1200x2400', label: '1200x2400'),
FilterOption(value: '7500x1500', label: '7500x1500'),
FilterOption(value: '1200x1200', label: '1200x1200'),
FilterOption(value: '600x1200', label: '600x1200'),
FilterOption(value: '450x900', label: '450x900'),
];
static const List<FilterOption> _surfaces = [
FilterOption(value: 'satin', label: 'SATIN'),
FilterOption(value: 'honed', label: 'HONED'),
FilterOption(value: 'matt', label: 'MATT'),
FilterOption(value: 'polish', label: 'POLISH'),
FilterOption(value: 'babyskin', label: 'BABYSKIN'),
];
static const List<FilterOption> _brands = [
FilterOption(value: 'eurotile', label: 'Eurotile'),
FilterOption(value: 'vasta-stone', label: 'Vasta Stone'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final filtersState = ref.watch(productFiltersProvider);
return Drawer(
child: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.grey100, width: 1),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Bộ lọc sản phẩm',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
color: AppColors.grey500,
),
],
),
),
// Filter Options (Scrollable)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Dòng sản phẩm
_buildFilterGroup(
title: 'Dòng sản phẩm',
options: _productLines,
selectedValues: filtersState.productLines,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleProductLine(value),
),
const SizedBox(height: AppSpacing.lg),
// Không gian
_buildFilterGroup(
title: 'Không gian',
options: _spaces,
selectedValues: filtersState.spaces,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleSpace(value),
),
const SizedBox(height: AppSpacing.lg),
// Kích thước
_buildFilterGroup(
title: 'Kích thước',
options: _sizes,
selectedValues: filtersState.sizes,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleSize(value),
),
const SizedBox(height: AppSpacing.lg),
// Bề mặt
_buildFilterGroup(
title: 'Bề mặt',
options: _surfaces,
selectedValues: filtersState.surfaces,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleSurface(value),
),
const SizedBox(height: AppSpacing.lg),
// Thương hiệu
_buildFilterGroup(
title: 'Thương hiệu',
options: _brands,
selectedValues: filtersState.brands,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleBrand(value),
),
const SizedBox(height: 100), // Space for footer buttons
],
),
),
),
// Footer Buttons
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.grey100, width: 1),
),
),
child: Row(
children: [
// Reset Button
Expanded(
child: OutlinedButton(
onPressed: () {
ref.read(productFiltersProvider.notifier).reset();
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.grey900,
side: const BorderSide(
color: AppColors.grey100,
width: 1,
),
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Xóa bộ lọc',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(width: AppSpacing.md),
// Apply Button
Expanded(
child: ElevatedButton(
onPressed: () {
ref.read(productFiltersProvider.notifier).apply();
Navigator.of(context).pop();
if (filtersState.hasActiveFilters) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Đã áp dụng ${filtersState.totalCount} bộ lọc',
),
duration: const Duration(seconds: 2),
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.md,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Áp dụng',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildFilterGroup({
required String title,
required List<FilterOption> options,
required Set<String> selectedValues,
required Function(String) onToggle,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 12),
Column(
children: options.map((option) {
return CheckboxListTile(
title: Text(
option.label,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
),
),
value: selectedValues.contains(option.value),
onChanged: (bool? checked) {
onToggle(option.value);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
activeColor: AppColors.primaryBlue,
);
}).toList(),
),
],
);
}
}
/// Filter Option Model
class FilterOption {
final String value;
final String label;
const FilterOption({
required this.value,
required this.label,
});
}

View File

@@ -54,9 +54,8 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
return SizedBox(
height: InputFieldSpecs.height,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: TextField(
controller: _controller,
focusNode: _focusNode,