Compare commits

..

3 Commits

Author SHA1 Message Date
Phuoc Nguyen
54cb7d0fdd detail 2025-11-19 16:16:30 +07:00
Phuoc Nguyen
73ad2fc80c update product detail 2025-11-19 16:07:54 +07:00
Phuoc Nguyen
841d77d886 fix product search/filter 2025-11-19 15:48:51 +07:00
24 changed files with 866 additions and 322 deletions

View File

@@ -8,6 +8,25 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
"limit_page_length": 0 "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 get product attribute list
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item_attribute.get_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 'X-Frappe-Csrf-Token: 13c271e0e58dcad9bcc0053cad0057540eb0675bb7052c2cc1a815b2' \

View File

@@ -572,7 +572,7 @@ LoggingInterceptor loggingInterceptor(Ref ref) {
const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter
return LoggingInterceptor( return LoggingInterceptor(
enableRequestLogging: isDebug, enableRequestLogging: false,
enableResponseLogging: isDebug, enableResponseLogging: isDebug,
enableErrorLogging: isDebug, enableErrorLogging: isDebug,
); );

View File

@@ -189,7 +189,7 @@ final class LoggingInterceptorProvider
} }
String _$loggingInterceptorHash() => String _$loggingInterceptorHash() =>
r'f3dedaeb3152d5188544232f6f270bb6908c2827'; r'6afa480caa6fcd723dab769bb01601b8a37e20fd';
/// Provider for ErrorTransformerInterceptor /// Provider for ErrorTransformerInterceptor

View File

@@ -8,7 +8,8 @@
/// - Retry logic /// - Retry logic
library; 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/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.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 // Retry Interceptor
// ============================================================================ // ============================================================================
@@ -383,8 +465,9 @@ Future<Dio> dio(Ref ref) async {
}, },
) )
// Add interceptors in order // Add interceptors in order
// 1. Curl interceptor (first to log cURL commands) // 1. Custom Curl interceptor (first to log cURL commands)
..interceptors.add(CurlLoggerDioInterceptor()) // Uses debugPrint and developer.log for better visibility
..interceptors.add(CustomCurlLoggerInterceptor())
// 2. Logging interceptor // 2. Logging interceptor
..interceptors.add(ref.watch(loggingInterceptorProvider)) ..interceptors.add(ref.watch(loggingInterceptorProvider))
// 3. Auth interceptor (add tokens to requests) // 3. Auth interceptor (add tokens to requests)

View File

@@ -131,7 +131,7 @@ final class DioProvider
} }
} }
String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c'; String _$dioHash() => r'd15bfe824d6501e5cbd56ff152de978030d97be4';
/// Provider for DioClient /// Provider for DioClient

View File

@@ -191,7 +191,7 @@ final class CitiesProvider extends $AsyncNotifierProvider<Cities, List<City>> {
Cities create() => Cities(); Cities create() => Cities();
} }
String _$citiesHash() => r'92405067c99ad5e33bd1b4fecd33576baa0c4e2f'; String _$citiesHash() => r'54a7db2bdf4874286493e8631d38cfac7707627e';
/// Manages list of cities with offline-first approach /// 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 /// 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>>( final response = await _dioClient.post<Map<String, dynamic>>(
url, url,
data: { data: {'limit_start': limitStart, 'limit_page_length': limitPageLength},
'limit_start': limitStart,
'limit_page_length': limitPageLength,
},
options: Options(headers: headers), options: Options(headers: headers),
); );
@@ -83,7 +80,9 @@ class ProductsRemoteDataSource {
final productsList = message as List; final productsList = message as List;
return productsList return productsList
.map((item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>)) .map(
(item) => ProductModel.fromJson(item as Map<String, dynamic>),
)
.toList(); .toList();
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 404) { if (e.response?.statusCode == 404) {
@@ -125,9 +124,7 @@ class ProductsRemoteDataSource {
final response = await _dioClient.post<Map<String, dynamic>>( final response = await _dioClient.post<Map<String, dynamic>>(
url, url,
data: { data: {'name': itemCode},
'name': itemCode,
},
options: Options(headers: headers), options: Options(headers: headers),
); );
@@ -141,7 +138,7 @@ class ProductsRemoteDataSource {
throw Exception('Product not found: $itemCode'); throw Exception('Product not found: $itemCode');
} }
return ProductModel.fromFrappeJson(message as Map<String, dynamic>); return ProductModel.fromJson(message as Map<String, dynamic>);
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 404) { if (e.response?.statusCode == 404) {
throw Exception('Product not found: $itemCode'); throw Exception('Product not found: $itemCode');
@@ -161,25 +158,72 @@ class ProductsRemoteDataSource {
/// Search products /// Search products
/// ///
/// Searches products by name or description. /// Searches products by keyword using Frappe API with pagination support.
/// For now, we fetch all products and filter locally. /// Uses the search_keyword parameter from the API.
/// In the future, the API might support server-side search. ///
Future<List<ProductModel>> searchProducts(String query) async { /// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_list
// For now, fetch all products and filter locally /// Request body:
// TODO: Implement server-side search if API supports it /// ```json
final allProducts = await getAllProducts(); /// {
/// "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 response = await _dioClient.post<Map<String, dynamic>>(
final name = product.name.toLowerCase(); url,
final description = (product.description ?? '').toLowerCase(); data: {
final productId = product.productId.toLowerCase(); 'limit_start': limitStart,
'limit_page_length': limitPageLength,
'search_keyword': query,
},
options: Options(headers: headers),
);
return name.contains(lowercaseQuery) || if (response.data == null) {
description.contains(lowercaseQuery) || throw Exception('Empty response from server');
productId.contains(lowercaseQuery); }
}).toList();
// 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.fromJson(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 /// Get products by category
@@ -204,7 +248,7 @@ class ProductsRemoteDataSource {
} }
return allProducts return allProducts
.where((product) => product.category == categoryId) .where((product) => product.itemGroupName == categoryId)
.toList(); .toList();
} }
@@ -302,9 +346,7 @@ class ProductsRemoteDataSource {
throw Exception('No message field in response'); throw Exception('No message field in response');
} }
return (message as List) return (message as List).map((item) => item['name'] as String).toList();
.map((item) => item['name'] as String)
.toList();
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 404) { if (e.response?.statusCode == 404) {
throw Exception('Product brands endpoint not found'); throw Exception('Product brands endpoint not found');
@@ -368,4 +410,108 @@ class ProductsRemoteDataSource {
throw Exception('Unexpected error fetching product attributes: $e'); 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.fromJson(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

@@ -26,7 +26,7 @@ class ProductModel extends HiveObject {
this.imageCaptions, this.imageCaptions,
this.customLink360, this.customLink360,
this.specifications, this.specifications,
this.category, this.itemGroupName,
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
@@ -75,9 +75,9 @@ class ProductModel extends HiveObject {
@HiveField(8) @HiveField(8)
final String? specifications; final String? specifications;
/// Product category /// Item group name (from ERPNext)
@HiveField(9) @HiveField(9)
final String? category; final String? itemGroupName;
/// Product brand /// Product brand
@HiveField(10) @HiveField(10)
@@ -122,72 +122,24 @@ class ProductModel extends HiveObject {
// JSON SERIALIZATION // JSON SERIALIZATION
// ========================================================================= // =========================================================================
/// Create ProductModel from JSON /// Create ProductModel from API JSON
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
productId: json['product_id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
basePrice: (json['base_price'] as num).toDouble(),
images: json['images'] != null ? jsonEncode(json['images']) : null,
thumbnail: json['thumbnail'] as String,
imageCaptions: json['image_captions'] != null
? jsonEncode(json['image_captions'])
: null,
customLink360: json['custom_link_360'] as String?,
specifications: json['specifications'] != null
? jsonEncode(json['specifications'])
: null,
category: json['category'] as String?,
brand: json['brand'] as String?,
unit: json['unit'] as String?,
conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble()
: null,
introAttributes: json['intro_attributes'] != null
? jsonEncode(json['intro_attributes'])
: null,
isActive: json['is_active'] as bool? ?? true,
isFeatured: json['is_featured'] as bool? ?? false,
erpnextItemCode: json['erpnext_item_code'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String)
: null,
);
}
/// Create ProductModel from Frappe ERPNext API JSON
/// ///
/// Maps Frappe Item doctype fields to our ProductModel structure. /// Handles the new Frappe ERPNext API structure with full image URLs.
/// Frappe fields: /// API response structure:
/// - name: Item code (e.g., "CHG S01P") /// - name: Item code (e.g., "CHG S01P")
/// - item_name: Display name /// - item_name: Display name
/// - description: Product description /// - item_group_name: Category
/// - price: Price (from product detail API)
/// - standard_rate: Price (from product list API)
/// - stock_uom: Unit of measurement
/// - thumbnail: Thumbnail image URL
/// - image_list: Array of images with image_url
/// - brand: Brand name /// - brand: Brand name
/// - item_group_name: Category/group name /// - thumbnail: Full image URL
/// - custom_link_360: Custom 360 view link /// - image_list: Array of {image_name, image_url, description}
/// - attributes: Array of product attributes (Size, Color, Surface, etc.) /// - price: Product price
factory ProductModel.fromFrappeJson(Map<String, dynamic> json) { /// - conversion_of_sm: Conversion factor
// Handle thumbnail - prepend base URL if needed /// - intro_attributes: Array of {code, value}
String? thumbnailUrl; /// - attributes: Array of {attribute, attribute_name, attribute_value}
if (json['thumbnail'] != null && (json['thumbnail'] as String).isNotEmpty) { factory ProductModel.fromJson(Map<String, dynamic> json) {
final thumbnailPath = json['thumbnail'] as String; final now = DateTime.now();
if (thumbnailPath.startsWith('/')) {
thumbnailUrl = '${ApiConstants.baseUrl}$thumbnailPath';
} else if (thumbnailPath.startsWith('http')) {
thumbnailUrl = thumbnailPath;
} else {
thumbnailUrl = '${ApiConstants.baseUrl}/$thumbnailPath';
}
}
// Handle image_list array (from product detail API) // Parse image_list array
final List<String> imagesList = []; final List<String> imagesList = [];
final Map<String, String> imageCaptionsMap = {}; final Map<String, String> imageCaptionsMap = {};
@@ -198,26 +150,13 @@ class ProductModel extends HiveObject {
final imageUrl = imgData['image_url'] as String; final imageUrl = imgData['image_url'] as String;
imagesList.add(imageUrl); imagesList.add(imageUrl);
// Store image caption (image_name: A, B, C, etc.) // Store image caption (image_name: Face A, Face B, etc.)
if (imgData['image_name'] != null) { if (imgData['image_name'] != null) {
imageCaptionsMap[imageUrl] = imgData['image_name'] as String; imageCaptionsMap[imageUrl] = imgData['image_name'] as String;
} }
} }
} }
} }
// Fallback to single image field (from product list API)
else if (json['image'] != null && (json['image'] as String).isNotEmpty) {
final imagePath = json['image'] as String;
String imageUrl;
if (imagePath.startsWith('/')) {
imageUrl = '${ApiConstants.baseUrl}$imagePath';
} else if (imagePath.startsWith('http')) {
imageUrl = imagePath;
} else {
imageUrl = '${ApiConstants.baseUrl}/$imagePath';
}
imagesList.add(imageUrl);
}
// Parse attributes array into specifications map // Parse attributes array into specifications map
final Map<String, dynamic> specificationsMap = {}; final Map<String, dynamic> specificationsMap = {};
@@ -233,7 +172,7 @@ class ProductModel extends HiveObject {
} }
} }
// Parse intro_attributes array for quick reference // Parse intro_attributes array
final List<Map<String, String>> introAttributesList = []; final List<Map<String, String>> introAttributesList = [];
if (json['intro_attributes'] != null && json['intro_attributes'] is List) { if (json['intro_attributes'] != null && json['intro_attributes'] is List) {
final introAttrsData = json['intro_attributes'] as List; final introAttrsData = json['intro_attributes'] as List;
@@ -249,20 +188,13 @@ class ProductModel extends HiveObject {
} }
} }
final now = DateTime.now();
// Handle price from both product detail (price) and product list (standard_rate)
final price = (json['price'] as num?)?.toDouble() ??
(json['standard_rate'] as num?)?.toDouble() ??
0.0;
return ProductModel( return ProductModel(
productId: json['name'] as String, // Item code productId: json['name'] as String, // Item code
name: json['item_name'] as String? ?? json['name'] as String, name: json['item_name'] as String? ?? json['name'] as String,
description: json['description'] as String?, description: json['description'] as String?, // May be null in API
basePrice: price, basePrice: (json['price'] as num?)?.toDouble() ?? 0.0,
images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null, images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null,
thumbnail: thumbnailUrl ?? '', thumbnail: json['thumbnail'] as String? ?? '',
imageCaptions: imageCaptionsMap.isNotEmpty imageCaptions: imageCaptionsMap.isNotEmpty
? jsonEncode(imageCaptionsMap) ? jsonEncode(imageCaptionsMap)
: null, : null,
@@ -270,28 +202,24 @@ class ProductModel extends HiveObject {
specifications: specificationsMap.isNotEmpty specifications: specificationsMap.isNotEmpty
? jsonEncode(specificationsMap) ? jsonEncode(specificationsMap)
: null, : null,
category: json['item_group_name'] as String? ?? itemGroupName: json['item_group_name'] as String?,
json['item_group'] as String?, // Try item_group_name first, fallback to item_group
brand: json['brand'] as String?, brand: json['brand'] as String?,
unit: json['stock_uom'] as String? ?? '', unit: json['currency'] as String?, // Use currency as unit for now
conversionOfSm: json['conversion_of_sm'] != null conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble() ? (json['conversion_of_sm'] as num).toDouble()
: null, : null,
introAttributes: introAttributesList.isNotEmpty introAttributes: introAttributesList.isNotEmpty
? jsonEncode(introAttributesList) ? jsonEncode(introAttributesList)
: null, : null,
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field isActive: true, // Assume active if returned by API
isFeatured: false, // Not provided by API, default to false isFeatured: false, // Not provided by API
erpnextItemCode: json['name'] as String, // Store item code for reference erpnextItemCode: json['item_code'] as String? ?? json['name'] as String,
createdAt: json['creation'] != null createdAt: now, // Not provided by this API endpoint
? DateTime.tryParse(json['creation'] as String) ?? now updatedAt: now,
: now,
updatedAt: json['modified'] != null
? DateTime.tryParse(json['modified'] as String)
: null,
); );
} }
/// Create ProductModel from Wishlist API JSON /// Create ProductModel from Wishlist API JSON
/// ///
/// The wishlist API returns a simplified product structure: /// The wishlist API returns a simplified product structure:
@@ -330,7 +258,7 @@ class ProductModel extends HiveObject {
imageCaptions: null, imageCaptions: null,
customLink360: json['custom_link_360'] as String?, customLink360: json['custom_link_360'] as String?,
specifications: null, specifications: null,
category: json['item_group_name'] as String?, itemGroupName: json['item_group_name'] as String?,
brand: null, // Not provided by wishlist API brand: null, // Not provided by wishlist API
unit: json['currency'] as String? ?? '', unit: json['currency'] as String? ?? '',
conversionOfSm: json['conversion_of_sm'] != null conversionOfSm: json['conversion_of_sm'] != null
@@ -361,7 +289,7 @@ class ProductModel extends HiveObject {
'specifications': specifications != null 'specifications': specifications != null
? jsonDecode(specifications!) ? jsonDecode(specifications!)
: null, : null,
'category': category, 'item_group_name': itemGroupName,
'brand': brand, 'brand': brand,
'unit': unit, 'unit': unit,
'conversion_of_sm': conversionOfSm, 'conversion_of_sm': conversionOfSm,
@@ -476,7 +404,7 @@ class ProductModel extends HiveObject {
String? imageCaptions, String? imageCaptions,
String? customLink360, String? customLink360,
String? specifications, String? specifications,
String? category, String? itemGroupName,
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
@@ -497,7 +425,7 @@ class ProductModel extends HiveObject {
imageCaptions: imageCaptions ?? this.imageCaptions, imageCaptions: imageCaptions ?? this.imageCaptions,
customLink360: customLink360 ?? this.customLink360, customLink360: customLink360 ?? this.customLink360,
specifications: specifications ?? this.specifications, specifications: specifications ?? this.specifications,
category: category ?? this.category, itemGroupName: itemGroupName ?? this.itemGroupName,
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
@@ -541,7 +469,7 @@ class ProductModel extends HiveObject {
imageCaptions: imageCaptionsMap ?? {}, imageCaptions: imageCaptionsMap ?? {},
customLink360: customLink360, customLink360: customLink360,
specifications: specificationsMap ?? {}, specifications: specificationsMap ?? {},
category: category, itemGroupName: itemGroupName,
brand: brand, brand: brand,
unit: unit, unit: unit,
conversionOfSm: conversionOfSm, conversionOfSm: conversionOfSm,

View File

@@ -26,7 +26,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
imageCaptions: fields[6] as String?, imageCaptions: fields[6] as String?,
customLink360: fields[7] as String?, customLink360: fields[7] as String?,
specifications: fields[8] as String?, specifications: fields[8] as String?,
category: fields[9] as String?, itemGroupName: fields[9] as String?,
brand: fields[10] as String?, brand: fields[10] as String?,
unit: fields[11] as String?, unit: fields[11] as String?,
conversionOfSm: (fields[17] as num?)?.toDouble(), conversionOfSm: (fields[17] as num?)?.toDouble(),
@@ -62,7 +62,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
..writeByte(8) ..writeByte(8)
..write(obj.specifications) ..write(obj.specifications)
..writeByte(9) ..writeByte(9)
..write(obj.category) ..write(obj.itemGroupName)
..writeByte(10) ..writeByte(10)
..write(obj.brand) ..write(obj.brand)
..writeByte(11) ..writeByte(11)

View File

@@ -43,10 +43,18 @@ class ProductsRepositoryImpl implements ProductsRepository {
} }
@override @override
Future<List<Product>> searchProducts(String query) async { Future<List<Product>> searchProducts(
String query, {
int limitStart = 0,
int limitPageLength = 12,
}) async {
try { try {
// Search via remote API // Search via remote API with pagination
final productModels = await remoteDataSource.searchProducts(query); final productModels = await remoteDataSource.searchProducts(
query,
limitStart: limitStart,
limitPageLength: limitPageLength,
);
return productModels.map((model) => model.toEntity()).toList(); return productModels.map((model) => model.toEntity()).toList();
} catch (e) { } catch (e) {
print('[ProductsRepository] Error searching products: $e'); print('[ProductsRepository] Error searching products: $e');
@@ -98,4 +106,30 @@ class ProductsRepositoryImpl implements ProductsRepository {
rethrow; 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

@@ -20,7 +20,7 @@ class Product {
required this.imageCaptions, required this.imageCaptions,
this.customLink360, this.customLink360,
required this.specifications, required this.specifications,
this.category, this.itemGroupName,
this.brand, this.brand,
this.unit, this.unit,
this.conversionOfSm, this.conversionOfSm,
@@ -58,8 +58,8 @@ class Product {
/// Product specifications /// Product specifications
final Map<String, dynamic> specifications; final Map<String, dynamic> specifications;
/// Category name /// Item group name (from ERPNext)
final String? category; final String? itemGroupName;
/// Brand name /// Brand name
final String? brand; final String? brand;
@@ -97,8 +97,8 @@ class Product {
/// Alias for primaryImage (used by UI widgets) /// Alias for primaryImage (used by UI widgets)
String get imageUrl => primaryImage ?? ''; String get imageUrl => primaryImage ?? '';
/// Category ID (alias for category field) /// Category ID (alias for itemGroupName field)
String? get categoryId => category; String? get categoryId => itemGroupName;
/// Check if product has 360 view /// Check if product has 360 view
bool get has360View => customLink360 != null && customLink360!.isNotEmpty; bool get has360View => customLink360 != null && customLink360!.isNotEmpty;
@@ -152,7 +152,7 @@ class Product {
Map<String, String>? imageCaptions, Map<String, String>? imageCaptions,
String? customLink360, String? customLink360,
Map<String, dynamic>? specifications, Map<String, dynamic>? specifications,
String? category, String? itemGroupName,
String? brand, String? brand,
String? unit, String? unit,
double? conversionOfSm, double? conversionOfSm,
@@ -173,7 +173,7 @@ class Product {
imageCaptions: imageCaptions ?? this.imageCaptions, imageCaptions: imageCaptions ?? this.imageCaptions,
customLink360: customLink360 ?? this.customLink360, customLink360: customLink360 ?? this.customLink360,
specifications: specifications ?? this.specifications, specifications: specifications ?? this.specifications,
category: category ?? this.category, itemGroupName: itemGroupName ?? this.itemGroupName,
brand: brand ?? this.brand, brand: brand ?? this.brand,
unit: unit ?? this.unit, unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm, conversionOfSm: conversionOfSm ?? this.conversionOfSm,
@@ -189,7 +189,7 @@ class Product {
@override @override
String toString() { String toString() {
return 'Product(productId: $productId, name: $name, basePrice: $basePrice, ' return 'Product(productId: $productId, name: $name, basePrice: $basePrice, '
'category: $category, isActive: $isActive, isFeatured: $isFeatured)'; 'itemGroupName: $itemGroupName, isActive: $isActive, isFeatured: $isFeatured)';
} }
@override @override
@@ -201,7 +201,7 @@ class Product {
other.name == name && other.name == name &&
other.description == description && other.description == description &&
other.basePrice == basePrice && other.basePrice == basePrice &&
other.category == category && other.itemGroupName == itemGroupName &&
other.brand == brand && other.brand == brand &&
other.unit == unit && other.unit == unit &&
other.isActive == isActive && other.isActive == isActive &&
@@ -216,7 +216,7 @@ class Product {
name, name,
description, description,
basePrice, basePrice,
category, itemGroupName,
brand, brand,
unit, unit,
isActive, isActive,

View File

@@ -26,8 +26,14 @@ abstract class ProductsRepository {
/// Search products by query /// Search products by query
/// ///
/// [query] - Search term to filter products /// [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. /// 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 /// Get products by category
/// ///
@@ -52,4 +58,23 @@ abstract class ProductsRepository {
/// ///
/// Returns a list of all product categories. /// Returns a list of all product categories.
Future<List<Category>> getCategories(); 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 /// Use Case: Search Products
/// ///
/// Business logic for searching products by query string. /// Business logic for searching products by query string with pagination support.
library; library;
import 'package:worker/features/products/domain/entities/product.dart'; 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 /// Search Products Use Case
/// ///
/// Searches for products matching the given query string. /// Searches for products matching the given query string with pagination.
class SearchProducts { class SearchProducts {
final ProductsRepository repository; final ProductsRepository repository;
@@ -17,13 +17,26 @@ class SearchProducts {
/// Execute the use case /// Execute the use case
/// ///
/// [query] - Search query string /// [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 /// 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 // Return all products if query is empty
if (query.trim().isEmpty) { 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

@@ -213,28 +213,27 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
), ),
body: productAsync.when( body: productAsync.when(
data: (product) { data: (product) {
return Stack( return Column(
children: [ children: [
// Scrollable content // Scrollable content
SingleChildScrollView( Expanded(
child: Column( child: SingleChildScrollView(
children: [ child: Column(
// Image Gallery Section children: [
ImageGallerySection(product: product), // Image Gallery Section
ImageGallerySection(product: product),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Product Information Section // Product Information Section
ProductInfoSection(product: product), ProductInfoSection(product: product),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Product Tabs Section // Product Tabs Section
ProductTabsSection(product: product), ProductTabsSection(product: product),
],
// Bottom padding for sticky action bar ),
const SizedBox(height: 88.0),
],
), ),
), ),

View File

@@ -8,31 +8,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Product Filters State /// Product Filters State
class ProductFiltersState { class ProductFiltersState {
final Set<String> productLines; final Set<String> productLines;
final Set<String> spaces;
final Set<String> sizes; final Set<String> sizes;
final Set<String> surfaces; final Set<String> surfaces;
final Set<String> colors;
final Set<String> brands; final Set<String> brands;
const ProductFiltersState({ const ProductFiltersState({
this.productLines = const {}, this.productLines = const {},
this.spaces = const {},
this.sizes = const {}, this.sizes = const {},
this.surfaces = const {}, this.surfaces = const {},
this.colors = const {},
this.brands = const {}, this.brands = const {},
}); });
ProductFiltersState copyWith({ ProductFiltersState copyWith({
Set<String>? productLines, Set<String>? productLines,
Set<String>? spaces,
Set<String>? sizes, Set<String>? sizes,
Set<String>? surfaces, Set<String>? surfaces,
Set<String>? colors,
Set<String>? brands, Set<String>? brands,
}) { }) {
return ProductFiltersState( return ProductFiltersState(
productLines: productLines ?? this.productLines, productLines: productLines ?? this.productLines,
spaces: spaces ?? this.spaces,
sizes: sizes ?? this.sizes, sizes: sizes ?? this.sizes,
surfaces: surfaces ?? this.surfaces, surfaces: surfaces ?? this.surfaces,
colors: colors ?? this.colors,
brands: brands ?? this.brands, brands: brands ?? this.brands,
); );
} }
@@ -40,9 +40,9 @@ class ProductFiltersState {
/// Get total filter count /// Get total filter count
int get totalCount => int get totalCount =>
productLines.length + productLines.length +
spaces.length +
sizes.length + sizes.length +
surfaces.length + surfaces.length +
colors.length +
brands.length; brands.length;
/// Check if any filters are active /// Check if any filters are active
@@ -70,17 +70,6 @@ class ProductFiltersNotifier extends Notifier<ProductFiltersState> {
state = state.copyWith(productLines: newSet); 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 /// Toggle size filter
void toggleSize(String value) { void toggleSize(String value) {
final newSet = Set<String>.from(state.sizes); final newSet = Set<String>.from(state.sizes);
@@ -103,6 +92,17 @@ class ProductFiltersNotifier extends Notifier<ProductFiltersState> {
state = state.copyWith(surfaces: newSet); 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 /// Toggle brand filter
void toggleBrand(String value) { void toggleBrand(String value) {
final newSet = Set<String>.from(state.brands); 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/entities/product.dart';
import 'package:worker/features/products/domain/repositories/products_repository.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/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/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'; import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
part 'products_provider.g.dart'; part 'products_provider.g.dart';
@@ -39,7 +38,9 @@ Future<ProductsRemoteDataSource> productsRemoteDataSource(Ref ref) async {
@riverpod @riverpod
Future<ProductsRepository> productsRepository(Ref ref) async { Future<ProductsRepository> productsRepository(Ref ref) async {
final localDataSource = ref.watch(productsLocalDataSourceProvider); final localDataSource = ref.watch(productsLocalDataSourceProvider);
final remoteDataSource = await ref.watch(productsRemoteDataSourceProvider.future); final remoteDataSource = await ref.watch(
productsRemoteDataSourceProvider.future,
);
return ProductsRepositoryImpl( return ProductsRepositoryImpl(
localDataSource: localDataSource, localDataSource: localDataSource,
remoteDataSource: remoteDataSource, remoteDataSource: remoteDataSource,
@@ -70,53 +71,66 @@ class Products extends _$Products {
@override @override
Future<List<Product>> build() async { 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; _currentPage = 0;
_hasMore = true; _hasMore = true;
// Watch dependencies // Watch dependencies (triggers rebuild when they change)
final selectedBrand = ref.watch(selectedBrandProvider);
final searchQuery = ref.watch(searchQueryProvider); final searchQuery = ref.watch(searchQueryProvider);
final filters = ref.watch(productFiltersProvider);
// Get repository with injected data sources // Get repository with injected data sources
final repository = await ref.watch(productsRepositoryProvider.future); final repository = await ref.watch(productsRepositoryProvider.future);
// Fetch first page of products // Fetch first page of products using unified API
List<Product> products; List<Product> products;
if (searchQuery.isNotEmpty) { // Build filter parameters from filter drawer
// Search takes precedence over brand filter final List<String>? itemGroups = filters.productLines.isNotEmpty
final searchUseCase = SearchProducts(repository); ? filters.productLines.toList()
products = await searchUseCase(searchQuery); : null;
// If a brand is selected, filter search results by brand // Use brands from productFiltersProvider (shared by chips and drawer)
if (selectedBrand != 'all') { final List<String>? brands = filters.brands.isNotEmpty
products = products ? filters.brands.toList()
.where((product) => product.brand == selectedBrand) : null;
.toList();
}
// For search, we fetch all results at once, so no more pages // Build item attributes from filter drawer (sizes, surfaces, colors)
_hasMore = false; final List<Map<String, String>> itemAttributes = [];
} else {
// No search query, fetch all products with pagination
final getProductsUseCase = GetProducts(repository);
products = await getProductsUseCase(
limitStart: 0,
limitPageLength: pageSize,
);
// Filter by brand if not 'all' // Add size attributes
if (selectedBrand != 'all') { for (final size in filters.sizes) {
products = products itemAttributes.add({'attribute': 'Kích thước', 'attribute_value': size});
.where((product) => product.brand == selectedBrand)
.toList();
}
// If we got less than pageSize, there are no more products
_hasMore = products.length >= pageSize;
} }
// 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; _currentPage = 1;
return products; return products;
} }
@@ -125,12 +139,9 @@ class Products extends _$Products {
Future<void> loadMore() async { Future<void> loadMore() async {
if (!_hasMore) return; if (!_hasMore) return;
// Watch dependencies to get current filters // Read dependencies to get current filters (use read, not watch)
final selectedBrand = ref.read(selectedBrandProvider);
final searchQuery = ref.read(searchQueryProvider); final searchQuery = ref.read(searchQueryProvider);
final filters = ref.read(productFiltersProvider);
// Don't paginate search results (already fetched all)
if (searchQuery.isNotEmpty) return;
// Get repository // Get repository
final repository = await ref.read(productsRepositoryProvider.future); final repository = await ref.read(productsRepositoryProvider.future);
@@ -138,20 +149,46 @@ class Products extends _$Products {
// Calculate pagination parameters // Calculate pagination parameters
final limitStart = _currentPage * pageSize; final limitStart = _currentPage * pageSize;
// Fetch next page from API // Build filter parameters (same logic as build() method)
final getProductsUseCase = GetProducts(repository); final List<String>? itemGroups = filters.productLines.isNotEmpty
var newProducts = await getProductsUseCase( ? 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, limitStart: limitStart,
limitPageLength: pageSize, 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 // If we got less than pageSize, there are no more products
_hasMore = newProducts.length >= pageSize; _hasMore = newProducts.length >= pageSize;

View File

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

View File

@@ -1,8 +1,10 @@
/// Provider: Search Query Provider /// Provider: Search Query Provider
/// ///
/// Manages the current search query state for product filtering. /// Manages the current search query state for product filtering.
/// Includes debounce functionality with 1-second delay and minimum 2 characters.
library; library;
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_query_provider.g.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. /// Holds the current search query string for filtering products.
/// Default is empty string which shows all 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: /// Usage:
/// ```dart /// ```dart
/// // Read the current value /// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider); /// final searchQuery = ref.watch(searchQueryProvider);
/// ///
/// // Update the value /// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; /// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ``` /// ```
@riverpod @riverpod
class SearchQuery extends _$SearchQuery { class SearchQuery extends _$SearchQuery {
Timer? _debounceTimer;
@override @override
String build() { String build() {
// Cancel timer when provider is disposed
ref.onDispose(() {
_debounceTimer?.cancel();
});
return ''; // Default: no search filter 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) { 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() { void clear() {
_debounceTimer?.cancel();
state = ''; state = '';
} }
} }

View File

@@ -13,13 +13,18 @@ part of 'search_query_provider.dart';
/// Holds the current search query string for filtering products. /// Holds the current search query string for filtering products.
/// Default is empty string which shows all 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: /// Usage:
/// ```dart /// ```dart
/// // Read the current value /// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider); /// final searchQuery = ref.watch(searchQueryProvider);
/// ///
/// // Update the value /// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; /// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ``` /// ```
@ProviderFor(SearchQuery) @ProviderFor(SearchQuery)
@@ -30,13 +35,18 @@ const searchQueryProvider = SearchQueryProvider._();
/// Holds the current search query string for filtering products. /// Holds the current search query string for filtering products.
/// Default is empty string which shows all 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: /// Usage:
/// ```dart /// ```dart
/// // Read the current value /// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider); /// final searchQuery = ref.watch(searchQueryProvider);
/// ///
/// // Update the value /// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; /// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ``` /// ```
final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> { final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Search Query Provider /// Search Query Provider
@@ -44,13 +54,18 @@ final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
/// Holds the current search query string for filtering products. /// Holds the current search query string for filtering products.
/// Default is empty string which shows all 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: /// Usage:
/// ```dart /// ```dart
/// // Read the current value /// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider); /// final searchQuery = ref.watch(searchQueryProvider);
/// ///
/// // Update the value /// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; /// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ``` /// ```
const SearchQueryProvider._() const SearchQueryProvider._()
: super( : super(
@@ -79,20 +94,25 @@ final class SearchQueryProvider extends $NotifierProvider<SearchQuery, String> {
} }
} }
String _$searchQueryHash() => r'41ea2fa57593abc0cafe16598d8817584ba99ddc'; String _$searchQueryHash() => r'3a4178c8c220a1016d20887d7bd97cd157f777f8';
/// Search Query Provider /// Search Query Provider
/// ///
/// Holds the current search query string for filtering products. /// Holds the current search query string for filtering products.
/// Default is empty string which shows all 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: /// Usage:
/// ```dart /// ```dart
/// // Read the current value /// // Read the current value
/// final searchQuery = ref.watch(searchQueryProvider); /// final searchQuery = ref.watch(searchQueryProvider);
/// ///
/// // Update the value /// // Update the value (will debounce)
/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; /// ref.read(searchQueryProvider.notifier).updateQuery('gạch men');
/// ``` /// ```
abstract class _$SearchQuery extends $Notifier<String> { 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/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.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/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 /// Brand Filter Chips Widget
/// ///
/// Displays brands as horizontally scrolling chips. /// 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 { class BrandFilterChips extends ConsumerWidget {
const BrandFilterChips({super.key}); const BrandFilterChips({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedBrand = ref.watch(selectedBrandProvider); final filtersState = ref.watch(productFiltersProvider);
final filterOptionsAsync = ref.watch(productFilterOptionsProvider); final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
return filterOptionsAsync.when( return filterOptionsAsync.when(
@@ -40,7 +41,13 @@ class BrandFilterChips extends ConsumerWidget {
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final brand = allBrands[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( return FilterChip(
label: Text( label: Text(
@@ -54,9 +61,27 @@ class BrandFilterChips extends ConsumerWidget {
selected: isSelected, selected: isSelected,
onSelected: (selected) { onSelected: (selected) {
if (selected) { if (selected) {
ref final notifier = ref.read(productFiltersProvider.notifier);
.read(selectedBrandProvider.notifier)
.updateBrand(brand.value); 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, backgroundColor: AppColors.white,

View File

@@ -228,64 +228,184 @@ class _SpecificationsTab extends StatelessWidget {
}; };
return Container( return Container(
margin: const EdgeInsets.all(20), margin: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFe0e0e0)), border: Border.all(color: const Color(0xFFe0e0e0)),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
children: specs.entries.map((entry) { children: [
final isLast = entry == specs.entries.last; Container(
return Container( decoration: const BoxDecoration(
decoration: BoxDecoration( border: Border(
border: isLast bottom: BorderSide(color: Color(0xFFe0e0e0)),
? null ),
: const Border(
bottom: BorderSide(color: Color(0xFFe0e0e0)),
),
), ),
child: Row( child: IntrinsicHeight(
children: [ child: Row(
// Label crossAxisAlignment: CrossAxisAlignment.stretch,
Expanded( children: [
child: Container( // Label
padding: const EdgeInsets.all(12), Expanded(
color: const Color(0xFFF4F6F8), child: Container(
child: Text( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
entry.key, color: const Color(0xFFF4F6F8),
style: const TextStyle( child: Align(
fontSize: 14, alignment: Alignment.centerLeft,
fontWeight: FontWeight.w500, child: Text(
color: AppColors.grey900, "Thương hiệu",
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
height: 1.5,
),
),
), ),
), ),
), ),
),
// Divider // Divider
Container( Container(
width: 1, width: 1,
height: 44, color: const Color(0xFFe0e0e0),
color: const Color(0xFFe0e0e0), ),
),
// Value // Value
Expanded( Expanded(
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Text( child: Text(
entry.value.toString(), '${product.brand}',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.grey900, color: AppColors.grey900,
height: 1.5,
),
softWrap: true,
), ),
), ),
), ),
), ],
], ),
), ),
); ),
}).toList(), Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFe0e0e0)),
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Label
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
color: const Color(0xFFF4F6F8),
child: const Align(
alignment: Alignment.centerLeft,
child: Text(
"Dòng sản phẩm",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
height: 1.5,
),
),
),
),
),
// Divider
Container(
width: 1,
color: const Color(0xFFe0e0e0),
),
// Value
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Text(
'${product.itemGroupName}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
height: 1.5,
),
softWrap: true,
),
),
),
],
),
),
),
...specs.entries.map((entry) {
final isLast = entry == specs.entries.last;
return Container(
decoration: BoxDecoration(
border: isLast
? null
: const Border(
bottom: BorderSide(color: Color(0xFFe0e0e0)),
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Label
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
color: const Color(0xFFF4F6F8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
entry.key,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
height: 1.5,
),
),
),
),
),
// Divider
Container(
width: 1,
color: const Color(0xFFe0e0e0),
),
// Value
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Text(
'${entry.value}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey900,
height: 1.5,
),
softWrap: true,
),
),
),
],
),
),
);
}),
],
), ),
); );
} }
@@ -334,7 +454,8 @@ class _ReviewsTab extends ConsumerWidget {
// Review Items // Review Items
...reviews.map((review) => _ReviewItem(review: review)), ...reviews.map((review) => _ReviewItem(review: review)),
const SizedBox(height: 48), const SizedBox(height: 24),
], ],
); );
}, },

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: /// A drawer that slides from the right with filter options:
/// - Dòng sản phẩm (Product Line) /// - Dòng sản phẩm (Product Line)
/// - Không gian (Space)
/// - Kích thước (Size) /// - Kích thước (Size)
/// - Bề mặt (Surface) /// - Bề mặt (Surface)
/// - Màu sắc (Color)
/// - Thương hiệu (Brand) /// - Thương hiệu (Brand)
class ProductFilterDrawer extends ConsumerWidget { class ProductFilterDrawer extends ConsumerWidget {
const ProductFilterDrawer({super.key}); const ProductFilterDrawer({super.key});
@@ -94,15 +94,23 @@ class ProductFilterDrawer extends ConsumerWidget {
// Attribute Groups (Colour, Size, Surface) - from API // Attribute Groups (Colour, Size, Surface) - from API
...filterOptions.attributeGroups.map((attrGroup) { ...filterOptions.attributeGroups.map((attrGroup) {
// Dynamically map attribute to correct filter state
final selectedValues = _getSelectedValuesForAttribute(
attrGroup.attributeName,
filtersState,
);
return Column( return Column(
children: [ children: [
_buildAttributeGroup( _buildAttributeGroup(
title: attrGroup.attributeName, title: attrGroup.attributeName,
attributeGroup: attrGroup, attributeGroup: attrGroup,
selectedValues: filtersState.sizes, // TODO: Map to correct filter state selectedValues: selectedValues,
onToggle: (value) => ref onToggle: (value) => _toggleAttributeValue(
.read(productFiltersProvider.notifier) ref,
.toggleSize(value), attrGroup.attributeName,
value,
),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
], ],
@@ -341,9 +349,9 @@ class ProductFilterDrawer extends ConsumerWidget {
// ), // ),
// ) // )
// : null, // : null,
value: selectedValues.contains(value.name), value: selectedValues.contains(value.attributeValue),
onChanged: (bool? checked) { onChanged: (bool? checked) {
onToggle(value.name); onToggle(value.attributeValue);
}, },
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero, 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> { class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
late final TextEditingController _controller; late final TextEditingController _controller;
late final FocusNode _focusNode; late final FocusNode _focusNode;
bool _hasText = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(); _controller = TextEditingController();
_focusNode = FocusNode(); _focusNode = FocusNode();
// Listen to text changes to update clear button visibility
_controller.addListener(() {
setState(() {
_hasText = _controller.text.isNotEmpty;
});
});
} }
@override @override
@@ -53,7 +61,7 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
return SizedBox( return SizedBox(
height: InputFieldSpecs.height, height: InputFieldSpecs.height,
@@ -72,7 +80,7 @@ class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
color: AppColors.grey500, color: AppColors.grey500,
size: AppIconSize.md, size: AppIconSize.md,
), ),
suffixIcon: _controller.text.isNotEmpty suffixIcon: _hasText
? IconButton( ? IconButton(
icon: const Icon( icon: const Icon(
FontAwesomeIcons.xmark, FontAwesomeIcons.xmark,

View File

@@ -52,7 +52,7 @@ dependencies:
dio: ^5.4.3+1 dio: ^5.4.3+1
connectivity_plus: ^6.0.3 connectivity_plus: ^6.0.3
pretty_dio_logger: ^1.3.1 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_intercept_to_curl: ^0.2.0
dio_cache_interceptor: ^3.5.0 dio_cache_interceptor: ^3.5.0
dio_cache_interceptor_hive_store: ^3.2.2 dio_cache_interceptor_hive_store: ^3.2.2