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

@@ -8,7 +8,7 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
"limit_page_length": 0
}'
get attribute list
get product attribute list
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item_attribute.get_list' \
--header 'X-Frappe-Csrf-Token: 13c271e0e58dcad9bcc0053cad0057540eb0675bb7052c2cc1a815b2' \
--header 'Cookie: sid=d9ddd3862832f12901ef4c0d77d6891cd08ef851a254b7d56c857724; full_name=Ha%20Duy%20Lam; sid=d9ddd3862832f12901ef4c0d77d6891cd08ef851a254b7d56c857724; system_user=yes; user_id=lamhd%40gmail.com; user_image=' \
@@ -18,8 +18,7 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
"limit_page_length": 0
}'
get brand
get product brand
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--header 'X-Frappe-Csrf-Token: 52e3deff2accdc4d990312508dff6be0ecae61e01da837f00b2bfae9' \
--header 'Cookie: sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
@@ -30,6 +29,18 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
"limit_page_length": 0
}'
get product group
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--header 'X-Frappe-Csrf-Token: 52e3deff2accdc4d990312508dff6be0ecae61e01da837f00b2bfae9' \
--header 'Cookie: sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
--header 'Content-Type: application/json' \
--data '{
"doctype": "Item Group",
"fields": ["item_group_name","name"],
"filters": {"is_group": 0},
"limit_page_length": 0
}'
get product detail
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item.get_detail' \
--header 'X-Frappe-Csrf-Token: 4989ff095956a891bbae0944a1483097b6eb06f1080961f7164a7e17' \

View File

@@ -200,4 +200,165 @@ class ProductsRemoteDataSource {
.where((product) => product.category == categoryId)
.toList();
}
/// Get product groups (Item Groups)
///
/// Fetches product groups from Frappe ERPNext.
/// Returns a list of group objects with name and item_group_name.
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
/// Request body:
/// ```json
/// {
/// "doctype": "Item Group",
/// "fields": ["item_group_name", "name"],
/// "filters": {"is_group": 0},
/// "limit_page_length": 0
/// }
/// ```
Future<List<Map<String, dynamic>>> getProductGroups() async {
try {
final headers = await _frappeAuthService.getHeaders();
final url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Item Group',
'fields': ['item_group_name', 'name'],
'filters': {'is_group': 0},
'limit_page_length': 0,
},
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
final message = response.data!['message'];
if (message == null) {
throw Exception('No message field in response');
}
return (message as List).cast<Map<String, dynamic>>();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Product groups endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching product groups');
} else {
throw Exception('Failed to fetch product groups: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching product groups: $e');
}
}
/// Get product brands
///
/// Fetches brands from Frappe ERPNext.
/// Returns a list of brand names.
///
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
/// Request body:
/// ```json
/// {
/// "doctype": "Brand",
/// "fields": ["name"],
/// "limit_page_length": 0
/// }
/// ```
Future<List<String>> getProductBrands() async {
try {
final headers = await _frappeAuthService.getHeaders();
final url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'doctype': 'Brand',
'fields': ['name'],
'limit_page_length': 0,
},
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
final message = response.data!['message'];
if (message == null) {
throw Exception('No message field in response');
}
return (message as List)
.map((item) => item['name'] as String)
.toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Product brands endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching product brands');
} else {
throw Exception('Failed to fetch product brands: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching product brands: $e');
}
}
/// Get product attributes
///
/// Fetches product attributes from Frappe ERPNext.
/// Returns a list of attribute objects.
///
/// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item_attribute.get_list
/// Request body:
/// ```json
/// {
/// "filters": {"is_group": 0},
/// "limit_page_length": 0
/// }
/// ```
Future<List<Map<String, dynamic>>> getProductAttributes() async {
try {
final headers = await _frappeAuthService.getHeaders();
final url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItemAttributes}';
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'filters': {'is_group': 0},
'limit_page_length': 0,
},
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
final message = response.data!['message'];
if (message == null) {
throw Exception('No message field in response');
}
return (message as List).cast<Map<String, dynamic>>();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Product attributes endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching product attributes');
} else {
throw Exception('Failed to fetch product attributes: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching product attributes: $e');
}
}
}

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,55 +21,15 @@ 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(
child: filterOptionsAsync.when(
data: (filterOptions) => Column(
children: [
// Header
Container(
@@ -105,10 +66,11 @@ class ProductFilterDrawer extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Dòng sản phẩm
// Nhóm sản phẩm (Item Groups) - from API
_buildFilterGroup(
title: 'Dòng sản phẩm',
options: _productLines,
title: 'Nhóm sản phẩm',
options: filterOptions.groups,
initiallyExpanded: true,
selectedValues: filtersState.productLines,
onToggle: (value) => ref
.read(productFiltersProvider.notifier)
@@ -117,52 +79,35 @@ class ProductFilterDrawer extends ConsumerWidget {
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
// Thương hiệu (Brands) - from API
_buildFilterGroup(
title: 'Thương hiệu',
options: _brands,
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
],
),
@@ -251,6 +196,56 @@ class ProductFilterDrawer extends ConsumerWidget {
),
],
),
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(
'Không thể tải bộ lọc',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
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'),
),
],
),
),
),
),
),
);
}
@@ -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,8 +274,7 @@ class ProductFilterDrawer extends ConsumerWidget {
color: AppColors.grey900,
),
),
const SizedBox(height: 12),
Column(
initiallyExpanded: initiallyExpanded,
children: options.map((option) {
return CheckboxListTile(
title: Text(
@@ -294,18 +295,62 @@ class ProductFilterDrawer extends ConsumerWidget {
);
}).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,
});
}