fix product search/filter

This commit is contained in:
Phuoc Nguyen
2025-11-19 15:48:51 +07:00
parent 03a7b7940a
commit 841d77d886
19 changed files with 638 additions and 142 deletions

View File

@@ -191,7 +191,7 @@ final class CitiesProvider extends $AsyncNotifierProvider<Cities, List<City>> {
Cities create() => Cities();
}
String _$citiesHash() => r'92405067c99ad5e33bd1b4fecd33576baa0c4e2f';
String _$citiesHash() => r'54a7db2bdf4874286493e8631d38cfac7707627e';
/// Manages list of cities with offline-first approach
///
@@ -285,7 +285,7 @@ final class WardsProvider
}
}
String _$wardsHash() => r'7e970ebd13149d6c1d4e76d0ba9f2a9a43cd62fc';
String _$wardsHash() => r'a680d66a629d6c1beadb3128c29fefca5607f39a';
/// Manages list of wards for a specific city with offline-first approach
///

View File

@@ -64,10 +64,7 @@ class ProductsRemoteDataSource {
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
},
data: {'limit_start': limitStart, 'limit_page_length': limitPageLength},
options: Options(headers: headers),
);
@@ -83,7 +80,9 @@ class ProductsRemoteDataSource {
final productsList = message as List;
return productsList
.map((item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>))
.map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>),
)
.toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
@@ -125,9 +124,7 @@ class ProductsRemoteDataSource {
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'name': itemCode,
},
data: {'name': itemCode},
options: Options(headers: headers),
);
@@ -161,25 +158,72 @@ class ProductsRemoteDataSource {
/// Search products
///
/// Searches products by name or description.
/// For now, we fetch all products and filter locally.
/// In the future, the API might support server-side search.
Future<List<ProductModel>> searchProducts(String query) async {
// For now, fetch all products and filter locally
// TODO: Implement server-side search if API supports it
final allProducts = await getAllProducts();
/// Searches products by keyword using Frappe API with pagination support.
/// Uses the search_keyword parameter from the API.
///
/// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_list
/// Request body:
/// ```json
/// {
/// "limit_start": 0,
/// "limit_page_length": 12,
/// "search_keyword": "gạch men"
/// }
/// ```
Future<List<ProductModel>> searchProducts(
String query, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
final lowercaseQuery = query.toLowerCase();
// Build full API URL
const url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItems}';
return allProducts.where((product) {
final name = product.name.toLowerCase();
final description = (product.description ?? '').toLowerCase();
final productId = product.productId.toLowerCase();
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
'search_keyword': query,
},
options: Options(headers: headers),
);
return name.contains(lowercaseQuery) ||
description.contains(lowercaseQuery) ||
productId.contains(lowercaseQuery);
}).toList();
if (response.data == null) {
throw Exception('Empty response from server');
}
// Parse the response
final message = response.data!['message'];
if (message == null) {
throw Exception('No message field in response');
}
final productsList = message as List;
return productsList
.map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>),
)
.toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Search endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while searching products');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while searching products');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while searching products');
} else {
throw Exception('Failed to search products: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error searching products: $e');
}
}
/// Get products by category
@@ -302,9 +346,7 @@ class ProductsRemoteDataSource {
throw Exception('No message field in response');
}
return (message as List)
.map((item) => item['name'] as String)
.toList();
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');
@@ -368,4 +410,108 @@ class ProductsRemoteDataSource {
throw Exception('Unexpected error fetching product attributes: $e');
}
}
/// Get products with filters (Complete API - Final Version)
///
/// Fetches products with support for all filter combinations:
/// - item_group: List of product groups/categories
/// - brand: List of brands
/// - item_attribute: List of attribute filters (color, size, surface, etc.)
/// - search_keyword: Search query
/// - Pagination support
///
/// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_list
/// Request body:
/// ```json
/// {
/// "limit_start": 0,
/// "limit_page_length": 12,
/// "item_group": ["CẨM THẠCH [ Marble ]"],
/// "brand": ["TEST 1"],
/// "item_attribute": [
/// {
/// "attribute": "Màu sắc",
/// "attribute_value": "Nhạt"
/// }
/// ],
/// "search_keyword": "gạch"
/// }
/// ```
Future<List<ProductModel>> getProductsWithFilters({
int limitStart = 0,
int limitPageLength = 12,
List<String>? itemGroups,
List<String>? brands,
List<Map<String, String>>? itemAttributes,
String? searchKeyword,
}) async {
try {
// Get Frappe session headers
final headers = await _frappeAuthService.getHeaders();
// Build full API URL
const url =
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItems}';
// Build request data
final Map<String, dynamic> requestData = {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
};
// Add filters only if they have values
if (itemGroups != null && itemGroups.isNotEmpty) {
requestData['item_group'] = itemGroups;
}
if (brands != null && brands.isNotEmpty) {
requestData['brand'] = brands;
}
if (itemAttributes != null && itemAttributes.isNotEmpty) {
requestData['item_attribute'] = itemAttributes;
}
if (searchKeyword != null && searchKeyword.isNotEmpty) {
requestData['search_keyword'] = searchKeyword;
}
final response = await _dioClient.post<Map<String, dynamic>>(
url,
data: requestData,
options: Options(headers: headers),
);
if (response.data == null) {
throw Exception('Empty response from server');
}
// Parse the response
final message = response.data!['message'];
if (message == null) {
throw Exception('No message field in response');
}
final productsList = message as List;
return productsList
.map(
(item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>),
)
.toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception('Products endpoint not found');
} else if (e.response?.statusCode == 500) {
throw Exception('Server error while fetching filtered products');
} else if (e.type == DioExceptionType.connectionTimeout) {
throw Exception('Connection timeout while fetching filtered products');
} else if (e.type == DioExceptionType.receiveTimeout) {
throw Exception('Response timeout while fetching filtered products');
} else {
throw Exception('Failed to fetch filtered products: ${e.message}');
}
} catch (e) {
throw Exception('Unexpected error fetching filtered products: $e');
}
}
}

View File

@@ -43,10 +43,18 @@ class ProductsRepositoryImpl implements ProductsRepository {
}
@override
Future<List<Product>> searchProducts(String query) async {
Future<List<Product>> searchProducts(
String query, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
try {
// Search via remote API
final productModels = await remoteDataSource.searchProducts(query);
// Search via remote API with pagination
final productModels = await remoteDataSource.searchProducts(
query,
limitStart: limitStart,
limitPageLength: limitPageLength,
);
return productModels.map((model) => model.toEntity()).toList();
} catch (e) {
print('[ProductsRepository] Error searching products: $e');
@@ -98,4 +106,30 @@ class ProductsRepositoryImpl implements ProductsRepository {
rethrow;
}
}
@override
Future<List<Product>> getProductsWithFilters({
int limitStart = 0,
int limitPageLength = 12,
List<String>? itemGroups,
List<String>? brands,
List<Map<String, String>>? itemAttributes,
String? searchKeyword,
}) async {
try {
// Fetch from Frappe API with all filters and pagination
final productModels = await remoteDataSource.getProductsWithFilters(
limitStart: limitStart,
limitPageLength: limitPageLength,
itemGroups: itemGroups,
brands: brands,
itemAttributes: itemAttributes,
searchKeyword: searchKeyword,
);
return productModels.map((model) => model.toEntity()).toList();
} catch (e) {
print('[ProductsRepository] Error getting filtered products: $e');
rethrow;
}
}
}

View File

@@ -26,8 +26,14 @@ abstract class ProductsRepository {
/// Search products by query
///
/// [query] - Search term to filter products
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Returns filtered list of products matching the query.
Future<List<Product>> searchProducts(String query);
Future<List<Product>> searchProducts(
String query, {
int limitStart = 0,
int limitPageLength = 12,
});
/// Get products by category
///
@@ -52,4 +58,23 @@ abstract class ProductsRepository {
///
/// Returns a list of all product categories.
Future<List<Category>> getCategories();
/// Get products with filters
///
/// Fetches products with comprehensive filtering support:
/// - [itemGroups] - List of product group names to filter by
/// - [brands] - List of brand names to filter by
/// - [itemAttributes] - List of attribute filters (attribute + value pairs)
/// - [searchKeyword] - Search query string
/// - [limitStart] - Starting index for pagination (default: 0)
/// - [limitPageLength] - Number of items per page (default: 12)
/// Returns filtered list of products matching all criteria.
Future<List<Product>> getProductsWithFilters({
int limitStart = 0,
int limitPageLength = 12,
List<String>? itemGroups,
List<String>? brands,
List<Map<String, String>>? itemAttributes,
String? searchKeyword,
});
}

View File

@@ -1,6 +1,6 @@
/// Use Case: Search Products
///
/// Business logic for searching products by query string.
/// Business logic for searching products by query string with pagination support.
library;
import 'package:worker/features/products/domain/entities/product.dart';
@@ -8,7 +8,7 @@ import 'package:worker/features/products/domain/repositories/products_repository
/// Search Products Use Case
///
/// Searches for products matching the given query string.
/// Searches for products matching the given query string with pagination.
class SearchProducts {
final ProductsRepository repository;
@@ -17,13 +17,26 @@ class SearchProducts {
/// Execute the use case
///
/// [query] - Search query string
/// [limitStart] - Starting index for pagination (default: 0)
/// [limitPageLength] - Number of items per page (default: 12)
/// Returns list of products matching the query
Future<List<Product>> call(String query) async {
Future<List<Product>> call(
String query, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
// Return all products if query is empty
if (query.trim().isEmpty) {
return await repository.getAllProducts();
return await repository.getAllProducts(
limitStart: limitStart,
limitPageLength: limitPageLength,
);
}
return await repository.searchProducts(query);
return await repository.searchProducts(
query,
limitStart: limitStart,
limitPageLength: limitPageLength,
);
}
}

View File

@@ -8,31 +8,31 @@ 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> colors;
final Set<String> brands;
const ProductFiltersState({
this.productLines = const {},
this.spaces = const {},
this.sizes = const {},
this.surfaces = const {},
this.colors = const {},
this.brands = const {},
});
ProductFiltersState copyWith({
Set<String>? productLines,
Set<String>? spaces,
Set<String>? sizes,
Set<String>? surfaces,
Set<String>? colors,
Set<String>? brands,
}) {
return ProductFiltersState(
productLines: productLines ?? this.productLines,
spaces: spaces ?? this.spaces,
sizes: sizes ?? this.sizes,
surfaces: surfaces ?? this.surfaces,
colors: colors ?? this.colors,
brands: brands ?? this.brands,
);
}
@@ -40,9 +40,9 @@ class ProductFiltersState {
/// Get total filter count
int get totalCount =>
productLines.length +
spaces.length +
sizes.length +
surfaces.length +
colors.length +
brands.length;
/// Check if any filters are active
@@ -70,17 +70,6 @@ class ProductFiltersNotifier extends Notifier<ProductFiltersState> {
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);
@@ -103,6 +92,17 @@ class ProductFiltersNotifier extends Notifier<ProductFiltersState> {
state = state.copyWith(surfaces: newSet);
}
/// Toggle color filter
void toggleColor(String value) {
final newSet = Set<String>.from(state.colors);
if (newSet.contains(value)) {
newSet.remove(value);
} else {
newSet.add(value);
}
state = state.copyWith(colors: newSet);
}
/// Toggle brand filter
void toggleBrand(String value) {
final newSet = Set<String>.from(state.brands);

View File

@@ -14,9 +14,8 @@ import 'package:worker/features/products/data/repositories/products_repository_i
import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/domain/repositories/products_repository.dart';
import 'package:worker/features/products/domain/usecases/get_products.dart';
import 'package:worker/features/products/domain/usecases/search_products.dart';
import 'package:worker/features/products/domain/usecases/get_product_detail.dart';
import 'package:worker/features/products/presentation/providers/selected_brand_provider.dart';
import 'package:worker/features/products/presentation/providers/product_filters_provider.dart';
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
part 'products_provider.g.dart';
@@ -39,7 +38,9 @@ Future<ProductsRemoteDataSource> productsRemoteDataSource(Ref ref) async {
@riverpod
Future<ProductsRepository> productsRepository(Ref ref) async {
final localDataSource = ref.watch(productsLocalDataSourceProvider);
final remoteDataSource = await ref.watch(productsRemoteDataSourceProvider.future);
final remoteDataSource = await ref.watch(
productsRemoteDataSourceProvider.future,
);
return ProductsRepositoryImpl(
localDataSource: localDataSource,
remoteDataSource: remoteDataSource,
@@ -70,53 +71,66 @@ class Products extends _$Products {
@override
Future<List<Product>> build() async {
// Reset pagination when dependencies change
// IMPORTANT: This method is called automatically whenever any watched
// provider changes (searchQueryProvider, productFiltersProvider, etc.)
// This ensures pagination is ALWAYS reset when filters/search change.
// Reset pagination state
_currentPage = 0;
_hasMore = true;
// Watch dependencies
final selectedBrand = ref.watch(selectedBrandProvider);
// Watch dependencies (triggers rebuild when they change)
final searchQuery = ref.watch(searchQueryProvider);
final filters = ref.watch(productFiltersProvider);
// Get repository with injected data sources
final repository = await ref.watch(productsRepositoryProvider.future);
// Fetch first page of products
// Fetch first page of products using unified API
List<Product> products;
if (searchQuery.isNotEmpty) {
// Search takes precedence over brand filter
final searchUseCase = SearchProducts(repository);
products = await searchUseCase(searchQuery);
// Build filter parameters from filter drawer
final List<String>? itemGroups = filters.productLines.isNotEmpty
? filters.productLines.toList()
: null;
// If a brand is selected, filter search results by brand
if (selectedBrand != 'all') {
products = products
.where((product) => product.brand == selectedBrand)
.toList();
}
// Use brands from productFiltersProvider (shared by chips and drawer)
final List<String>? brands = filters.brands.isNotEmpty
? filters.brands.toList()
: null;
// For search, we fetch all results at once, so no more pages
_hasMore = false;
} else {
// No search query, fetch all products with pagination
final getProductsUseCase = GetProducts(repository);
products = await getProductsUseCase(
limitStart: 0,
limitPageLength: pageSize,
);
// Build item attributes from filter drawer (sizes, surfaces, colors)
final List<Map<String, String>> itemAttributes = [];
// Filter by brand if not 'all'
if (selectedBrand != 'all') {
products = products
.where((product) => product.brand == selectedBrand)
.toList();
}
// If we got less than pageSize, there are no more products
_hasMore = products.length >= pageSize;
// Add size attributes
for (final size in filters.sizes) {
itemAttributes.add({'attribute': 'Kích thước', 'attribute_value': size});
}
// Add surface attributes
for (final surface in filters.surfaces) {
itemAttributes.add({'attribute': 'Bề mặt', 'attribute_value': surface});
}
// Add color attributes
for (final color in filters.colors) {
itemAttributes.add({'attribute': 'Màu sắc', 'attribute_value': color});
}
final String? keyword = searchQuery.isNotEmpty ? searchQuery : null;
// Use the comprehensive getProductsWithFilters method
products = await repository.getProductsWithFilters(
limitStart: 0,
limitPageLength: pageSize,
itemGroups: itemGroups,
brands: brands,
itemAttributes: itemAttributes.isNotEmpty ? itemAttributes : null,
searchKeyword: keyword,
);
// If we got less than pageSize, there are no more products
_hasMore = products.length >= pageSize;
_currentPage = 1;
return products;
}
@@ -125,12 +139,9 @@ class Products extends _$Products {
Future<void> loadMore() async {
if (!_hasMore) return;
// Watch dependencies to get current filters
final selectedBrand = ref.read(selectedBrandProvider);
// Read dependencies to get current filters (use read, not watch)
final searchQuery = ref.read(searchQueryProvider);
// Don't paginate search results (already fetched all)
if (searchQuery.isNotEmpty) return;
final filters = ref.read(productFiltersProvider);
// Get repository
final repository = await ref.read(productsRepositoryProvider.future);
@@ -138,20 +149,46 @@ class Products extends _$Products {
// Calculate pagination parameters
final limitStart = _currentPage * pageSize;
// Fetch next page from API
final getProductsUseCase = GetProducts(repository);
var newProducts = await getProductsUseCase(
// Build filter parameters (same logic as build() method)
final List<String>? itemGroups = filters.productLines.isNotEmpty
? filters.productLines.toList()
: null;
// Use brands from productFiltersProvider (shared by chips and drawer)
final List<String>? brands = filters.brands.isNotEmpty
? filters.brands.toList()
: null;
// Build item attributes from filter drawer (sizes, surfaces, colors)
final List<Map<String, String>> itemAttributes = [];
// Add size attributes
for (final size in filters.sizes) {
itemAttributes.add({'attribute': 'Kích thước', 'attribute_value': size});
}
// Add surface attributes
for (final surface in filters.surfaces) {
itemAttributes.add({'attribute': 'Bề mặt', 'attribute_value': surface});
}
// Add color attributes
for (final color in filters.colors) {
itemAttributes.add({'attribute': 'Màu sắc', 'attribute_value': color});
}
final String? keyword = searchQuery.isNotEmpty ? searchQuery : null;
// Fetch next page using unified API
final newProducts = await repository.getProductsWithFilters(
limitStart: limitStart,
limitPageLength: pageSize,
itemGroups: itemGroups,
brands: brands,
itemAttributes: itemAttributes.isNotEmpty ? itemAttributes : null,
searchKeyword: keyword,
);
// Filter by brand if not 'all'
if (selectedBrand != 'all') {
newProducts = newProducts
.where((product) => product.brand == selectedBrand)
.toList();
}
// If we got less than pageSize, there are no more products
_hasMore = newProducts.length >= pageSize;

View File

@@ -228,7 +228,7 @@ final class ProductsProvider
Products create() => Products();
}
String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf';
String _$productsHash() => r'6c55b22e75b912281feff3a68f84e488ccb7ab79';
/// Products Provider
///

View File

@@ -1,8 +1,10 @@
/// Provider: Search Query Provider
///
/// Manages the current search query state for product filtering.
/// Includes debounce functionality with 1-second delay and minimum 2 characters.
library;
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_query_provider.g.dart';
@@ -12,28 +14,62 @@ part 'search_query_provider.g.dart';
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Features:
/// - 1-second debounce delay
/// - Only triggers search with 2+ non-whitespace characters
/// - Auto-clears search when query is empty or too short
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ```
@riverpod
class SearchQuery extends _$SearchQuery {
Timer? _debounceTimer;
@override
String build() {
// Cancel timer when provider is disposed
ref.onDispose(() {
_debounceTimer?.cancel();
});
return ''; // Default: no search filter
}
/// Update search query
/// Update search query with debounce
///
/// Only updates state after 1 second of no typing.
/// Only triggers API call if query has 2+ non-whitespace characters.
void updateQuery(String query) {
state = query;
// Cancel previous timer
_debounceTimer?.cancel();
// Trim whitespace from query
final trimmedQuery = query.trim();
// If query is empty or too short, clear search immediately
if (trimmedQuery.isEmpty || trimmedQuery.length < 2) {
state = '';
return;
}
// Set up debounce timer (1 second)
_debounceTimer = Timer(const Duration(seconds: 1), () {
// Only update if query still meets requirements after delay
if (trimmedQuery.length >= 2) {
state = trimmedQuery;
}
});
}
/// Clear search query
/// Clear search query immediately
void clear() {
_debounceTimer?.cancel();
state = '';
}
}

View File

@@ -13,13 +13,18 @@ part of 'search_query_provider.dart';
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Features:
/// - 1-second debounce delay
/// - Only triggers search with 2+ non-whitespace characters
/// - Auto-clears search when query is empty or too short
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ```
@ProviderFor(SearchQuery)
@@ -30,13 +35,18 @@ const searchQueryProvider = SearchQueryProvider._();
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Features:
/// - 1-second debounce delay
/// - Only triggers search with 2+ non-whitespace characters
/// - Auto-clears search when query is empty or too short
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ```
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Search Query Provider
@@ -44,13 +54,18 @@ final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Features:
/// - 1-second debounce delay
/// - Only triggers search with 2+ non-whitespace characters
/// - Auto-clears search when query is empty or too short
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ```
const SearchQueryProvider._()
: super(
@@ -79,20 +94,25 @@ final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
}
}
String _$searchQueryHash() => r'41ea2fa57593abc0cafe16598d8817584ba99ddc';
String _$searchQueryHash() => r'3a4178c8c220a1016d20887d7bd97cd157f777f8';
/// Search Query Provider
///
/// Holds the current search query string for filtering products.
/// Default is empty string which shows all products.
///
/// Features:
/// - 1-second debounce delay
/// - Only triggers search with 2+ non-whitespace characters
/// - Auto-clears search when query is empty or too short
///
/// Usage:
/// ```dart
/// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider);
///
/// // Update the value
/// ref.read(searchQueryProvider.notifier).state = 'gạch men';
/// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ```
abstract class _$SearchQuery extends $Notifier<String> {

View File

@@ -8,18 +8,19 @@ 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_filter_options_provider.dart';
import 'package:worker/features/products/presentation/providers/selected_brand_provider.dart';
import 'package:worker/features/products/presentation/providers/product_filters_provider.dart';
/// Brand Filter Chips Widget
///
/// Displays brands as horizontally scrolling chips.
/// Updates selected brand when tapped.
/// Synced with filter drawer - both use productFiltersProvider.brands.
/// Chips are single-select (tapping a brand clears others and sets just that one).
class BrandFilterChips extends ConsumerWidget {
const BrandFilterChips({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedBrand = ref.watch(selectedBrandProvider);
final filtersState = ref.watch(productFiltersProvider);
final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
return filterOptionsAsync.when(
@@ -40,7 +41,13 @@ class BrandFilterChips extends ConsumerWidget {
const SizedBox(width: AppSpacing.sm),
itemBuilder: (context, index) {
final brand = allBrands[index];
final isSelected = selectedBrand == brand.value;
// "Tất cả" is selected if no brands are selected
// A brand chip is selected if it's the ONLY brand selected
final isSelected = brand.value == 'all'
? filtersState.brands.isEmpty
: (filtersState.brands.length == 1 &&
filtersState.brands.contains(brand.value));
return FilterChip(
label: Text(
@@ -54,9 +61,27 @@ class BrandFilterChips extends ConsumerWidget {
selected: isSelected,
onSelected: (selected) {
if (selected) {
ref
.read(selectedBrandProvider.notifier)
.updateBrand(brand.value);
final notifier = ref.read(productFiltersProvider.notifier);
if (brand.value == 'all') {
// Clear all brand filters
// Reset all brands by setting to empty set
final currentBrands = List<String>.from(filtersState.brands);
for (final b in currentBrands) {
notifier.toggleBrand(b); // Toggle off each brand
}
} else {
// Single-select: clear all other brands and set only this one
final currentBrands = List<String>.from(filtersState.brands);
// First, clear all existing brands
for (final b in currentBrands) {
notifier.toggleBrand(b);
}
// Then add the selected brand
notifier.toggleBrand(brand.value);
}
}
},
backgroundColor: AppColors.white,

View File

@@ -15,9 +15,9 @@ import 'package:worker/features/products/presentation/providers/product_filter_o
///
/// 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)
/// - Màu sắc (Color)
/// - Thương hiệu (Brand)
class ProductFilterDrawer extends ConsumerWidget {
const ProductFilterDrawer({super.key});
@@ -94,15 +94,23 @@ class ProductFilterDrawer extends ConsumerWidget {
// Attribute Groups (Colour, Size, Surface) - from API
...filterOptions.attributeGroups.map((attrGroup) {
// Dynamically map attribute to correct filter state
final selectedValues = _getSelectedValuesForAttribute(
attrGroup.attributeName,
filtersState,
);
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),
selectedValues: selectedValues,
onToggle: (value) => _toggleAttributeValue(
ref,
attrGroup.attributeName,
value,
),
),
const SizedBox(height: AppSpacing.lg),
],
@@ -341,9 +349,9 @@ class ProductFilterDrawer extends ConsumerWidget {
// ),
// )
// : null,
value: selectedValues.contains(value.name),
value: selectedValues.contains(value.attributeValue),
onChanged: (bool? checked) {
onToggle(value.name);
onToggle(value.attributeValue);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
@@ -354,4 +362,46 @@ class ProductFilterDrawer extends ConsumerWidget {
),
);
}
/// Get selected values for a specific attribute based on its name
Set<String> _getSelectedValuesForAttribute(
String attributeName,
ProductFiltersState filtersState,
) {
switch (attributeName) {
case 'Kích thước':
return filtersState.sizes;
case 'Bề mặt':
return filtersState.surfaces;
case 'Màu sắc':
return filtersState.colors;
default:
// For unknown attributes, return empty set
return {};
}
}
/// Toggle attribute value based on attribute name
void _toggleAttributeValue(
WidgetRef ref,
String attributeName,
String value,
) {
final notifier = ref.read(productFiltersProvider.notifier);
switch (attributeName) {
case 'Kích thước':
notifier.toggleSize(value);
break;
case 'Bề mặt':
notifier.toggleSurface(value);
break;
case 'Màu sắc':
notifier.toggleColor(value);
break;
default:
// For unknown attributes, do nothing
break;
}
}
}

View File

@@ -25,12 +25,20 @@ class ProductSearchBar extends ConsumerStatefulWidget {
class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
bool _hasText = false;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_focusNode = FocusNode();
// Listen to text changes to update clear button visibility
_controller.addListener(() {
setState(() {
_hasText = _controller.text.isNotEmpty;
});
});
}
@override
@@ -53,7 +61,7 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final l10n = AppLocalizations.of(context);
return SizedBox(
height: InputFieldSpecs.height,
@@ -72,7 +80,7 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
color: AppColors.grey500,
size: AppIconSize.md,
),
suffixIcon: _controller.text.isNotEmpty
suffixIcon: _hasText
? IconButton(
icon: const Icon(
FontAwesomeIcons.xmark,