Compare commits
3 Commits
03a7b7940a
...
54cb7d0fdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54cb7d0fdd | ||
|
|
73ad2fc80c | ||
|
|
841d77d886 |
@@ -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' \
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ final class LoggingInterceptorProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$loggingInterceptorHash() =>
|
String _$loggingInterceptorHash() =>
|
||||||
r'f3dedaeb3152d5188544232f6f270bb6908c2827';
|
r'6afa480caa6fcd723dab769bb01601b8a37e20fd';
|
||||||
|
|
||||||
/// Provider for ErrorTransformerInterceptor
|
/// Provider for ErrorTransformerInterceptor
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ final class DioProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$dioHash() => r'40bb4b1008c8259c9db4b19bcee674aa6732810c';
|
String _$dioHash() => r'd15bfe824d6501e5cbd56ff152de978030d97be4';
|
||||||
|
|
||||||
/// Provider for DioClient
|
/// Provider for DioClient
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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? ?? 'm²',
|
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? ?? 'm²',
|
unit: json['currency'] as String? ?? 'm²',
|
||||||
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user