fix filter

This commit is contained in:
Phuoc Nguyen
2025-11-11 09:17:53 +07:00
parent b367d405c4
commit 4e40a52b84
5 changed files with 736 additions and 250 deletions

View File

@@ -0,0 +1,165 @@
/// Provider: Product Filter Options Provider
///
/// Merges data from 3 APIs (groups, brands, attributes) for product filtering.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart';
part 'product_filter_options_provider.g.dart';
/// Filter Options Data Model
///
/// Combines all filter options from the 3 APIs
class ProductFilterOptions {
const ProductFilterOptions({
required this.groups,
required this.brands,
required this.attributeGroups,
});
/// Product groups (Item Groups)
final List<FilterOption> groups;
/// Product brands
final List<FilterOption> brands;
/// Product attribute groups (Colour, Size, Surface, etc.)
/// Each group contains a list of attribute values
final List<AttributeGroup> attributeGroups;
}
/// Attribute Group Model
///
/// Represents a group of attributes (e.g., Colour, Size, Surface)
/// with its associated values
class AttributeGroup {
const AttributeGroup({
required this.name,
required this.attributeName,
required this.values,
});
/// Internal name
final String name;
/// Display name
final String attributeName;
/// List of attribute values
final List<AttributeValue> values;
}
/// Attribute Value Model
///
/// Represents a single attribute value within a group
class AttributeValue {
const AttributeValue({
required this.name,
required this.attributeValue,
this.abbr,
});
/// Internal name/ID
final String name;
/// Display value
final String attributeValue;
/// Abbreviation (optional)
final String? abbr;
}
/// Filter Option Model
///
/// Represents a single filter option with value and label
class FilterOption {
const FilterOption({
required this.value,
required this.label,
});
final String value;
final String label;
}
/// Product Filter Options Provider
///
/// Fetches and combines data from 3 Frappe APIs:
/// 1. Product Groups (Item Groups)
/// 2. Product Brands
/// 3. Product Attributes
///
/// Usage:
/// ```dart
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
///
/// filterOptionsAsync.when(
/// data: (options) => ProductFilterDrawer(options: options),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@riverpod
Future<ProductFilterOptions> productFilterOptions(Ref ref) async {
try {
// Get remote datasource
final remoteDataSource = await ref.watch(productsRemoteDataSourceProvider.future);
// Fetch all 3 APIs in parallel
final results = await Future.wait([
remoteDataSource.getProductGroups(),
remoteDataSource.getProductBrands(),
remoteDataSource.getProductAttributes(),
]);
final groupsData = results[0] as List<Map<String, dynamic>>;
final brandsData = results[1] as List<String>;
final attributesData = results[2] as List<Map<String, dynamic>>;
// Convert groups to FilterOption
final groups = groupsData
.map((group) => FilterOption(
value: group['name'] as String,
label: group['item_group_name'] as String? ?? group['name'] as String,
))
.toList();
// Convert brands to FilterOption
final brands = brandsData
.map((brand) => FilterOption(
value: brand,
label: brand,
))
.toList();
// Convert attributes to AttributeGroup
// Each attribute has: name, attribute_name, and values list
final attributeGroups = attributesData.map((attr) {
final valuesList = attr['values'] as List? ?? [];
final values = valuesList.map((val) {
return AttributeValue(
name: val['name'] as String,
attributeValue: val['attribute_value'] as String,
abbr: val['abbr'] as String?,
);
}).toList();
return AttributeGroup(
name: attr['name'] as String,
attributeName: attr['attribute_name'] as String? ?? attr['name'] as String,
values: values,
);
}).toList();
return ProductFilterOptions(
groups: groups,
brands: brands,
attributeGroups: attributeGroups,
);
} catch (e) {
print('[ProductFilterOptionsProvider] Error fetching filter options: $e');
rethrow;
}
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'product_filter_options_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Product Filter Options Provider
///
/// Fetches and combines data from 3 Frappe APIs:
/// 1. Product Groups (Item Groups)
/// 2. Product Brands
/// 3. Product Attributes
///
/// Usage:
/// ```dart
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
///
/// filterOptionsAsync.when(
/// data: (options) => ProductFilterDrawer(options: options),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
@ProviderFor(productFilterOptions)
const productFilterOptionsProvider = ProductFilterOptionsProvider._();
/// Product Filter Options Provider
///
/// Fetches and combines data from 3 Frappe APIs:
/// 1. Product Groups (Item Groups)
/// 2. Product Brands
/// 3. Product Attributes
///
/// Usage:
/// ```dart
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
///
/// filterOptionsAsync.when(
/// data: (options) => ProductFilterDrawer(options: options),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
final class ProductFilterOptionsProvider
extends
$FunctionalProvider<
AsyncValue<ProductFilterOptions>,
ProductFilterOptions,
FutureOr<ProductFilterOptions>
>
with
$FutureModifier<ProductFilterOptions>,
$FutureProvider<ProductFilterOptions> {
/// Product Filter Options Provider
///
/// Fetches and combines data from 3 Frappe APIs:
/// 1. Product Groups (Item Groups)
/// 2. Product Brands
/// 3. Product Attributes
///
/// Usage:
/// ```dart
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
///
/// filterOptionsAsync.when(
/// data: (options) => ProductFilterDrawer(options: options),
/// loading: () => CircularProgressIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```
const ProductFilterOptionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'productFilterOptionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$productFilterOptionsHash();
@$internal
@override
$FutureProviderElement<ProductFilterOptions> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<ProductFilterOptions> create(Ref ref) {
return productFilterOptions(ref);
}
}
String _$productFilterOptionsHash() =>
r'394f47113bc2afeea8a0a4548df826900884644b';

View File

@@ -8,6 +8,7 @@ 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';
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
/// Product Filter Drawer Widget
///
@@ -20,236 +21,230 @@ import 'package:worker/features/products/presentation/providers/product_filters_
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);
final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
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: filterOptionsAsync.when(
data: (filterOptions) => 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,
),
],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// Filter Options (Scrollable)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nhóm sản phẩm (Item Groups) - from API
_buildFilterGroup(
title: 'Nhóm sản phẩm',
options: filterOptions.groups,
initiallyExpanded: true,
selectedValues: filtersState.productLines,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleProductLine(value),
),
const SizedBox(height: AppSpacing.lg),
// Thương hiệu (Brands) - from API
_buildFilterGroup(
title: 'Thương hiệu',
options: filterOptions.brands,
selectedValues: filtersState.brands,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleBrand(value),
),
const SizedBox(height: AppSpacing.lg),
// Attribute Groups (Colour, Size, Surface) - from API
...filterOptions.attributeGroups.map((attrGroup) {
return Column(
children: [
_buildAttributeGroup(
title: attrGroup.attributeName,
attributeGroup: attrGroup,
selectedValues: filtersState.sizes, // TODO: Map to correct filter state
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
.toggleSize(value),
),
const SizedBox(height: AppSpacing.lg),
],
);
}),
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,
),
),
),
),
],
),
),
],
),
loading: () => const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
),
),
error: (error, stack) => Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: AppColors.danger,
),
const SizedBox(height: AppSpacing.md),
const Text(
'Bộ lọc sản phẩm',
'Không thể tải bộ lọc',
style: TextStyle(
fontSize: 18,
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
color: AppColors.grey500,
const SizedBox(height: AppSpacing.sm),
Text(
error.toString(),
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.lg),
ElevatedButton(
onPressed: () {
ref.invalidate(productFilterOptionsProvider);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
),
child: const Text('Thử lại'),
),
],
),
),
// 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,
),
),
),
),
],
),
),
],
),
),
),
);
@@ -260,11 +255,18 @@ class ProductFilterDrawer extends ConsumerWidget {
required List<FilterOption> options,
required Set<String> selectedValues,
required Function(String) onToggle,
bool initiallyExpanded = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
return Theme(
data: ThemeData(
dividerColor: Colors.transparent,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(left: 8),
title: Text(
title,
style: const TextStyle(
fontSize: 14,
@@ -272,40 +274,83 @@ class ProductFilterDrawer extends ConsumerWidget {
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,
),
initiallyExpanded: initiallyExpanded,
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(),
),
value: selectedValues.contains(option.value),
onChanged: (bool? checked) {
onToggle(option.value);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
activeColor: AppColors.primaryBlue,
);
}).toList(),
),
);
}
Widget _buildAttributeGroup({
required String title,
required AttributeGroup attributeGroup,
required Set<String> selectedValues,
required Function(String) onToggle,
}) {
return Theme(
data: ThemeData(
dividerColor: Colors.transparent,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(left: 8),
title: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
],
initiallyExpanded: false,
children: attributeGroup.values.map((value) {
return CheckboxListTile(
title: Text(
value.attributeValue,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
),
),
// subtitle: value.abbr != null
// ? Text(
// 'Mã: ${value.abbr}',
// style: const TextStyle(
// fontSize: 12,
// color: AppColors.grey500,
// ),
// )
// : null,
value: selectedValues.contains(value.name),
onChanged: (bool? checked) {
onToggle(value.name);
},
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,
});
}