From 4e40a52b84e1bd1da60f42b97b002ffde28099d0 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Tue, 11 Nov 2025 09:17:53 +0700 Subject: [PATCH] fix filter --- docs/products.sh | 17 +- .../products_remote_datasource.dart | 161 ++++++ .../product_filter_options_provider.dart | 165 ++++++ .../product_filter_options_provider.g.dart | 104 ++++ .../widgets/product_filter_drawer.dart | 539 ++++++++++-------- 5 files changed, 736 insertions(+), 250 deletions(-) create mode 100644 lib/features/products/presentation/providers/product_filter_options_provider.dart create mode 100644 lib/features/products/presentation/providers/product_filter_options_provider.g.dart diff --git a/docs/products.sh b/docs/products.sh index 4f05678..01d9a42 100644 --- a/docs/products.sh +++ b/docs/products.sh @@ -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' \ diff --git a/lib/features/products/data/datasources/products_remote_datasource.dart b/lib/features/products/data/datasources/products_remote_datasource.dart index 7e2ebd6..f5caa94 100644 --- a/lib/features/products/data/datasources/products_remote_datasource.dart +++ b/lib/features/products/data/datasources/products_remote_datasource.dart @@ -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>> getProductGroups() async { + try { + final headers = await _frappeAuthService.getHeaders(); + final url = + '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}'; + + final response = await _dioClient.post>( + 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>(); + } 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> getProductBrands() async { + try { + final headers = await _frappeAuthService.getHeaders(); + final url = + '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}'; + + final response = await _dioClient.post>( + 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>> getProductAttributes() async { + try { + final headers = await _frappeAuthService.getHeaders(); + final url = + '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItemAttributes}'; + + final response = await _dioClient.post>( + 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>(); + } 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'); + } + } } diff --git a/lib/features/products/presentation/providers/product_filter_options_provider.dart b/lib/features/products/presentation/providers/product_filter_options_provider.dart new file mode 100644 index 0000000..6f45166 --- /dev/null +++ b/lib/features/products/presentation/providers/product_filter_options_provider.dart @@ -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 groups; + + /// Product brands + final List brands; + + /// Product attribute groups (Colour, Size, Surface, etc.) + /// Each group contains a list of attribute values + final List 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 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(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>; + final brandsData = results[1] as List; + final attributesData = results[2] as List>; + + // 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; + } +} diff --git a/lib/features/products/presentation/providers/product_filter_options_provider.g.dart b/lib/features/products/presentation/providers/product_filter_options_provider.g.dart new file mode 100644 index 0000000..53495a3 --- /dev/null +++ b/lib/features/products/presentation/providers/product_filter_options_provider.g.dart @@ -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, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// 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 $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return productFilterOptions(ref); + } +} + +String _$productFilterOptionsHash() => + r'394f47113bc2afeea8a0a4548df826900884644b'; diff --git a/lib/features/products/presentation/widgets/product_filter_drawer.dart b/lib/features/products/presentation/widgets/product_filter_drawer.dart index 4bb194a..c18fa3e 100644 --- a/lib/features/products/presentation/widgets/product_filter_drawer.dart +++ b/lib/features/products/presentation/widgets/product_filter_drawer.dart @@ -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 _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 _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 _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 _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 _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(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 options, required Set 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 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, - }); -}