From 841d77d886c61cf4de8c5b57fad29ff339615bd1 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Wed, 19 Nov 2025 15:48:51 +0700 Subject: [PATCH] fix product search/filter --- docs/products.sh | 19 ++ lib/core/network/api_interceptor.dart | 2 +- lib/core/network/api_interceptor.g.dart | 2 +- lib/core/network/dio_client.dart | 89 +++++++- lib/core/network/dio_client.g.dart | 2 +- .../providers/location_provider.g.dart | 4 +- .../products_remote_datasource.dart | 200 +++++++++++++++--- .../products_repository_impl.dart | 40 +++- .../repositories/products_repository.dart | 27 ++- .../domain/usecases/search_products.dart | 23 +- .../providers/product_filters_provider.dart | 32 +-- .../providers/products_provider.dart | 137 +++++++----- .../providers/products_provider.g.dart | 2 +- .../providers/search_query_provider.dart | 46 +++- .../providers/search_query_provider.g.dart | 38 +++- .../widgets/brand_filter_chips.dart | 39 +++- .../widgets/product_filter_drawer.dart | 64 +++++- .../widgets/product_search_bar.dart | 12 +- pubspec.yaml | 2 +- 19 files changed, 638 insertions(+), 142 deletions(-) diff --git a/docs/products.sh b/docs/products.sh index 01d9a42..c47bd9d 100644 --- a/docs/products.sh +++ b/docs/products.sh @@ -8,6 +8,25 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma "limit_page_length": 0 }' +get product final version +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item.get_list' \ +--header 'X-Frappe-Csrf-Token: a22fa53eeaa923f71f2fd879d2863a0985a6f2107f5f7f66d34cd62d' \ +--header 'Cookie: sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; full_name=phuoc; sid=a0c9a51c8d1fbbec824283115094bdca939bb829345e0005334aa99f; system_user=no; user_id=vodanh.2901%40gmail.com; user_image=https%3A//secure.gravatar.com/avatar/753a0e2601b9bd87aed417e2ad123bf8%3Fd%3D404%26s%3D200' \ +--header 'Content-Type: application/json' \ +--data '{ + "limit_start" : 0, + "limit_page_length": 0, + "item_group" : ["CẨM THẠCH [ Marble ]"], + "brand" : ["TEST 1"], + "item_attribute" : [ + { + "attribute": "Màu sắc", + "attribute_value" : "Nhạt" + } + ], + "search_keyword" : "chề lính" +}' + 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' \ diff --git a/lib/core/network/api_interceptor.dart b/lib/core/network/api_interceptor.dart index ddf9c64..d3f7a9d 100644 --- a/lib/core/network/api_interceptor.dart +++ b/lib/core/network/api_interceptor.dart @@ -572,7 +572,7 @@ LoggingInterceptor loggingInterceptor(Ref ref) { const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter return LoggingInterceptor( - enableRequestLogging: isDebug, + enableRequestLogging: false, enableResponseLogging: isDebug, enableErrorLogging: isDebug, ); diff --git a/lib/core/network/api_interceptor.g.dart b/lib/core/network/api_interceptor.g.dart index ba43112..a21528d 100644 --- a/lib/core/network/api_interceptor.g.dart +++ b/lib/core/network/api_interceptor.g.dart @@ -189,7 +189,7 @@ final class LoggingInterceptorProvider } String _$loggingInterceptorHash() => - r'f3dedaeb3152d5188544232f6f270bb6908c2827'; + r'6afa480caa6fcd723dab769bb01601b8a37e20fd'; /// Provider for ErrorTransformerInterceptor diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 536b31f..626492b 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -8,7 +8,8 @@ /// - Retry logic library; -import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart'; +import 'dart:developer' as developer; + import 'package:dio/dio.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; @@ -238,6 +239,87 @@ class DioClient { } } +// ============================================================================ +// Custom Curl Logger Interceptor +// ============================================================================ + +/// Custom Curl Logger that uses debugPrint instead of print +class CustomCurlLoggerInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final curl = _cURLRepresentation(options); + // debugPrint( + // '╔╣ CURL Request ╠══════════════════════════════════════════════════', + // ); + // debugPrint(curl); + // debugPrint( + // '╚═════════════════════════════════════════════════════════════════', + // ); + // Also log to dart:developer for better filtering in DevTools + developer.log(curl, name: 'DIO_CURL', time: DateTime.now()); + handler.next(options); + } + + String _cURLRepresentation(RequestOptions options) { + final components = ['curl --location']; + + // Add method + if (options.method.toUpperCase() != 'GET') { + components.add('-X ${options.method}'); + } + + // Add headers (INCLUDING Cookie this time!) + options.headers.forEach((key, value) { + // Escape single quotes in header values + final escapedValue = value.toString().replaceAll("'", "'\\''"); + components.add("--header '$key: $escapedValue'"); + }); + + // Add data with proper JSON formatting + if (options.data != null) { + if (options.data is FormData) { + components.add('--data-binary [FormData]'); + } else { + // Convert data to proper JSON string + String jsonData; + if (options.data is Map || options.data is List) { + // Use dart:convert to properly encode JSON + jsonData = _jsonEncode(options.data); + } else { + jsonData = options.data.toString(); + } + // Escape single quotes for shell + final escapedData = jsonData.replaceAll("'", "'\\''"); + components.add("--data '$escapedData'"); + } + } + + // Add URL + final uri = options.uri.toString(); + components.add("'$uri'"); + + return components.join(' \\\n'); + } + + /// Simple JSON encoder (without importing dart:convert in this file) + String _jsonEncode(dynamic data) { + if (data == null) return 'null'; + if (data is String) return '"${data.replaceAll('"', r'\"')}"'; + if (data is num || data is bool) return data.toString(); + if (data is List) { + final items = data.map((e) => _jsonEncode(e)).join(','); + return '[$items]'; + } + if (data is Map) { + final pairs = data.entries + .map((e) => '"${e.key}":${_jsonEncode(e.value)}') + .join(','); + return '{$pairs}'; + } + return data.toString(); + } +} + // ============================================================================ // Retry Interceptor // ============================================================================ @@ -383,8 +465,9 @@ Future dio(Ref ref) async { }, ) // Add interceptors in order - // 1. Curl interceptor (first to log cURL commands) - ..interceptors.add(CurlLoggerDioInterceptor()) + // 1. Custom Curl interceptor (first to log cURL commands) + // Uses debugPrint and developer.log for better visibility + ..interceptors.add(CustomCurlLoggerInterceptor()) // 2. Logging interceptor ..interceptors.add(ref.watch(loggingInterceptorProvider)) // 3. Auth interceptor (add tokens to requests) diff --git a/lib/core/network/dio_client.g.dart b/lib/core/network/dio_client.g.dart index 6c605a3..31d3f64 100644 --- a/lib/core/network/dio_client.g.dart +++ b/lib/core/network/dio_client.g.dart @@ -131,7 +131,7 @@ final class DioProvider } } -String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c'; +String _$dioHash() => r'd15bfe824d6501e5cbd56ff152de978030d97be4'; /// Provider for DioClient diff --git a/lib/features/account/presentation/providers/location_provider.g.dart b/lib/features/account/presentation/providers/location_provider.g.dart index 873e93b..77423c8 100644 --- a/lib/features/account/presentation/providers/location_provider.g.dart +++ b/lib/features/account/presentation/providers/location_provider.g.dart @@ -191,7 +191,7 @@ final class CitiesProvider extends $AsyncNotifierProvider> { 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 /// diff --git a/lib/features/products/data/datasources/products_remote_datasource.dart b/lib/features/products/data/datasources/products_remote_datasource.dart index c433956..bd2f762 100644 --- a/lib/features/products/data/datasources/products_remote_datasource.dart +++ b/lib/features/products/data/datasources/products_remote_datasource.dart @@ -64,10 +64,7 @@ class ProductsRemoteDataSource { final response = await _dioClient.post>( 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)) + .map( + (item) => ProductModel.fromFrappeJson(item as Map), + ) .toList(); } on DioException catch (e) { if (e.response?.statusCode == 404) { @@ -125,9 +124,7 @@ class ProductsRemoteDataSource { final response = await _dioClient.post>( 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> 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> 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>( + 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), + ) + .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> getProductsWithFilters({ + int limitStart = 0, + int limitPageLength = 12, + List? itemGroups, + List? brands, + List>? 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 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>( + 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), + ) + .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'); + } + } } diff --git a/lib/features/products/data/repositories/products_repository_impl.dart b/lib/features/products/data/repositories/products_repository_impl.dart index fe237c7..bda2e37 100644 --- a/lib/features/products/data/repositories/products_repository_impl.dart +++ b/lib/features/products/data/repositories/products_repository_impl.dart @@ -43,10 +43,18 @@ class ProductsRepositoryImpl implements ProductsRepository { } @override - Future> searchProducts(String query) async { + Future> 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> getProductsWithFilters({ + int limitStart = 0, + int limitPageLength = 12, + List? itemGroups, + List? brands, + List>? 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; + } + } } diff --git a/lib/features/products/domain/repositories/products_repository.dart b/lib/features/products/domain/repositories/products_repository.dart index c6ea1dc..c83352e 100644 --- a/lib/features/products/domain/repositories/products_repository.dart +++ b/lib/features/products/domain/repositories/products_repository.dart @@ -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> searchProducts(String query); + Future> 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> 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> getProductsWithFilters({ + int limitStart = 0, + int limitPageLength = 12, + List? itemGroups, + List? brands, + List>? itemAttributes, + String? searchKeyword, + }); } diff --git a/lib/features/products/domain/usecases/search_products.dart b/lib/features/products/domain/usecases/search_products.dart index 007dceb..1719b0e 100644 --- a/lib/features/products/domain/usecases/search_products.dart +++ b/lib/features/products/domain/usecases/search_products.dart @@ -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> call(String query) async { + Future> 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, + ); } } diff --git a/lib/features/products/presentation/providers/product_filters_provider.dart b/lib/features/products/presentation/providers/product_filters_provider.dart index 382d671..43e80dd 100644 --- a/lib/features/products/presentation/providers/product_filters_provider.dart +++ b/lib/features/products/presentation/providers/product_filters_provider.dart @@ -8,31 +8,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Product Filters State class ProductFiltersState { final Set productLines; - final Set spaces; final Set sizes; final Set surfaces; + final Set colors; final Set brands; const ProductFiltersState({ this.productLines = const {}, - this.spaces = const {}, this.sizes = const {}, this.surfaces = const {}, + this.colors = const {}, this.brands = const {}, }); ProductFiltersState copyWith({ Set? productLines, - Set? spaces, Set? sizes, Set? surfaces, + Set? colors, Set? 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 { state = state.copyWith(productLines: newSet); } - /// Toggle space filter - void toggleSpace(String value) { - final newSet = Set.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.from(state.sizes); @@ -103,6 +92,17 @@ class ProductFiltersNotifier extends Notifier { state = state.copyWith(surfaces: newSet); } + /// Toggle color filter + void toggleColor(String value) { + final newSet = Set.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.from(state.brands); diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index 04bdf9f..2b761fa 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -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(Ref ref) async { @riverpod Future 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> 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 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? 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? 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> 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 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? itemGroups = filters.productLines.isNotEmpty + ? filters.productLines.toList() + : null; + + // Use brands from productFiltersProvider (shared by chips and drawer) + final List? brands = filters.brands.isNotEmpty + ? filters.brands.toList() + : null; + + // Build item attributes from filter drawer (sizes, surfaces, colors) + final List> 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; diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart index d3229be..f6b7ed9 100644 --- a/lib/features/products/presentation/providers/products_provider.g.dart +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -228,7 +228,7 @@ final class ProductsProvider Products create() => Products(); } -String _$productsHash() => r'5fe0fdb46c3a6845327221ff26ba5f3624fcf3bf'; +String _$productsHash() => r'6c55b22e75b912281feff3a68f84e488ccb7ab79'; /// Products Provider /// diff --git a/lib/features/products/presentation/providers/search_query_provider.dart b/lib/features/products/presentation/providers/search_query_provider.dart index 867b782..1d5f4e5 100644 --- a/lib/features/products/presentation/providers/search_query_provider.dart +++ b/lib/features/products/presentation/providers/search_query_provider.dart @@ -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 = ''; } } diff --git a/lib/features/products/presentation/providers/search_query_provider.g.dart b/lib/features/products/presentation/providers/search_query_provider.g.dart index 239ad65..0ae229f 100644 --- a/lib/features/products/presentation/providers/search_query_provider.g.dart +++ b/lib/features/products/presentation/providers/search_query_provider.g.dart @@ -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 { /// Search Query Provider @@ -44,13 +54,18 @@ final class SearchQueryProvider extends $NotifierProvider { /// 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 { } } -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 { diff --git a/lib/features/products/presentation/widgets/brand_filter_chips.dart b/lib/features/products/presentation/widgets/brand_filter_chips.dart index 9d748a0..769e5a0 100644 --- a/lib/features/products/presentation/widgets/brand_filter_chips.dart +++ b/lib/features/products/presentation/widgets/brand_filter_chips.dart @@ -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.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.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, diff --git a/lib/features/products/presentation/widgets/product_filter_drawer.dart b/lib/features/products/presentation/widgets/product_filter_drawer.dart index 26789fe..900ab23 100644 --- a/lib/features/products/presentation/widgets/product_filter_drawer.dart +++ b/lib/features/products/presentation/widgets/product_filter_drawer.dart @@ -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 _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; + } + } } diff --git a/lib/features/products/presentation/widgets/product_search_bar.dart b/lib/features/products/presentation/widgets/product_search_bar.dart index f65d477..c424e68 100644 --- a/lib/features/products/presentation/widgets/product_search_bar.dart +++ b/lib/features/products/presentation/widgets/product_search_bar.dart @@ -25,12 +25,20 @@ class ProductSearchBar extends ConsumerStatefulWidget { class _ProductSearchBarState extends ConsumerState { 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 { @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 { color: AppColors.grey500, size: AppIconSize.md, ), - suffixIcon: _controller.text.isNotEmpty + suffixIcon: _hasText ? IconButton( icon: const Icon( FontAwesomeIcons.xmark, diff --git a/pubspec.yaml b/pubspec.yaml index 1537c65..456df80 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: dio: ^5.4.3+1 connectivity_plus: ^6.0.3 pretty_dio_logger: ^1.3.1 - curl_logger_dio_interceptor: ^1.0.0 + curl_logger_dio_interceptor: ^1.0.1 dio_intercept_to_curl: ^0.2.0 dio_cache_interceptor: ^3.5.0 dio_cache_interceptor_hive_store: ^3.2.2