update products page + filter
This commit is contained in:
@@ -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'))
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user