diff --git a/lib/core/di/providers.dart b/lib/core/di/providers.dart index 398c45a..03798ab 100644 --- a/lib/core/di/providers.dart +++ b/lib/core/di/providers.dart @@ -5,6 +5,7 @@ import '../../features/auth/data/repositories/auth_repository_impl.dart'; import '../../features/auth/domain/repositories/auth_repository.dart'; import '../../features/auth/domain/usecases/login_usecase.dart'; import '../../features/auth/presentation/providers/auth_provider.dart'; +import '../../features/products/data/datasources/products_local_datasource.dart'; import '../../features/products/data/datasources/products_remote_datasource.dart'; import '../../features/products/data/repositories/products_repository_impl.dart'; import '../../features/products/domain/entities/product_stage_entity.dart'; @@ -256,6 +257,12 @@ final warehouseErrorProvider = Provider((ref) { // Data Layer +/// Products local data source provider +/// Handles local storage operations for products using Hive +final productsLocalDataSourceProvider = Provider((ref) { + return ProductsLocalDataSourceImpl(); +}); + /// Products remote data source provider /// Handles API calls for products final productsRemoteDataSourceProvider = @@ -266,9 +273,14 @@ final productsRemoteDataSourceProvider = /// Products repository provider /// Implements domain repository interface +/// Coordinates between local and remote data sources final productsRepositoryProvider = Provider((ref) { final remoteDataSource = ref.watch(productsRemoteDataSourceProvider); - return ProductsRepositoryImpl(remoteDataSource); + final localDataSource = ref.watch(productsLocalDataSourceProvider); + return ProductsRepositoryImpl( + remoteDataSource: remoteDataSource, + localDataSource: localDataSource, + ); }); // Domain Layer diff --git a/lib/core/utils/text_utils.dart b/lib/core/utils/text_utils.dart new file mode 100644 index 0000000..c6fb0e6 --- /dev/null +++ b/lib/core/utils/text_utils.dart @@ -0,0 +1,86 @@ +/// Utility functions for text processing +class TextUtils { + /// Convert Vietnamese characters to English (non-accented) characters + /// Example: "Tuấn" -> "tuan", "Hồ Chí Minh" -> "ho chi minh" + static String removeVietnameseAccents(String text) { + if (text.isEmpty) return text; + + // Convert to lowercase for consistent comparison + String result = text.toLowerCase(); + + // Map of Vietnamese characters to their non-accented equivalents + const vietnameseMap = { + // a with accents + 'á': 'a', 'à': 'a', 'ả': 'a', 'ã': 'a', 'ạ': 'a', + 'ă': 'a', 'ắ': 'a', 'ằ': 'a', 'ẳ': 'a', 'ẵ': 'a', 'ặ': 'a', + 'â': 'a', 'ấ': 'a', 'ầ': 'a', 'ẩ': 'a', 'ẫ': 'a', 'ậ': 'a', + + // e with accents + 'é': 'e', 'è': 'e', 'ẻ': 'e', 'ẽ': 'e', 'ẹ': 'e', + 'ê': 'e', 'ế': 'e', 'ề': 'e', 'ể': 'e', 'ễ': 'e', 'ệ': 'e', + + // i with accents + 'í': 'i', 'ì': 'i', 'ỉ': 'i', 'ĩ': 'i', 'ị': 'i', + + // o with accents + 'ó': 'o', 'ò': 'o', 'ỏ': 'o', 'õ': 'o', 'ọ': 'o', + 'ô': 'o', 'ố': 'o', 'ồ': 'o', 'ổ': 'o', 'ỗ': 'o', 'ộ': 'o', + 'ơ': 'o', 'ớ': 'o', 'ờ': 'o', 'ở': 'o', 'ỡ': 'o', 'ợ': 'o', + + // u with accents + 'ú': 'u', 'ù': 'u', 'ủ': 'u', 'ũ': 'u', 'ụ': 'u', + 'ư': 'u', 'ứ': 'u', 'ừ': 'u', 'ử': 'u', 'ữ': 'u', 'ự': 'u', + + // y with accents + 'ý': 'y', 'ỳ': 'y', 'ỷ': 'y', 'ỹ': 'y', 'ỵ': 'y', + + // d with stroke + 'đ': 'd', + }; + + // Replace each Vietnamese character with its non-accented equivalent + vietnameseMap.forEach((vietnamese, english) { + result = result.replaceAll(vietnamese, english); + }); + + return result; + } + + /// Normalize text for search (lowercase + remove accents) + static String normalizeForSearch(String text) { + return removeVietnameseAccents(text.toLowerCase().trim()); + } + + /// Check if a text contains a search term (Vietnamese-aware, case-insensitive) + /// + /// Example: + /// ```dart + /// containsVietnameseSearch("Nguyễn Văn Tuấn", "tuan") // returns true + /// containsVietnameseSearch("tuan@example.com", "TUAN") // returns true + /// ``` + static bool containsVietnameseSearch(String text, String searchTerm) { + if (searchTerm.isEmpty) return true; + if (text.isEmpty) return false; + + final normalizedText = normalizeForSearch(text); + final normalizedSearch = normalizeForSearch(searchTerm); + + return normalizedText.contains(normalizedSearch); + } + + /// Check if any of the provided texts contains the search term + static bool containsVietnameseSearchInAny( + List texts, + String searchTerm, + ) { + if (searchTerm.isEmpty) return true; + + for (final text in texts) { + if (containsVietnameseSearch(text, searchTerm)) { + return true; + } + } + + return false; + } +} diff --git a/lib/features/products/data/datasources/products_local_datasource.dart b/lib/features/products/data/datasources/products_local_datasource.dart new file mode 100644 index 0000000..0cf6d43 --- /dev/null +++ b/lib/features/products/data/datasources/products_local_datasource.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'package:hive_ce/hive.dart'; +import '../models/product_model.dart'; + +/// Abstract interface for products local data source +abstract class ProductsLocalDataSource { + /// Get cached products for a specific warehouse and operation type + /// + /// [warehouseId] - The ID of the warehouse + /// [type] - The operation type ('import' or 'export') + /// + /// Returns List from cache or empty list if not found + Future> getCachedProducts(int warehouseId, String type); + + /// Cache products for a specific warehouse and operation type + /// + /// [warehouseId] - The ID of the warehouse + /// [type] - The operation type ('import' or 'export') + /// [products] - List of products to cache + Future cacheProducts( + int warehouseId, + String type, + List products, + ); + + /// Clear all cached products + Future clearCache(); + + /// Clear cached products for a specific warehouse and operation type + Future clearCachedProducts(int warehouseId, String type); +} + +/// Implementation of ProductsLocalDataSource using Hive +class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { + static const String _boxName = 'products_cache'; + Box? _box; + + /// Initialize the Hive box + Future init() async { + if (_box == null || !_box!.isOpen) { + _box = await Hive.openBox(_boxName); + } + } + + /// Generate cache key for warehouse and operation type + String _getCacheKey(int warehouseId, String type) { + return 'products_${warehouseId}_$type'; + } + + @override + Future> getCachedProducts( + int warehouseId, + String type, + ) async { + await init(); + + final key = _getCacheKey(warehouseId, type); + final cachedData = _box?.get(key); + + if (cachedData == null) { + return []; + } + + try { + // Decode JSON string to list + final jsonList = jsonDecode(cachedData) as List; + + // Convert JSON list to ProductModel list + return jsonList + .map((json) => ProductModel.fromJson(json as Map)) + .toList(); + } catch (e) { + // If parsing fails, return empty list + return []; + } + } + + @override + Future cacheProducts( + int warehouseId, + String type, + List products, + ) async { + await init(); + + final key = _getCacheKey(warehouseId, type); + + // Convert products to JSON list + final jsonList = products.map((product) => product.toJson()).toList(); + + // Encode to JSON string and save + final jsonString = jsonEncode(jsonList); + await _box?.put(key, jsonString); + } + + @override + Future clearCache() async { + await init(); + await _box?.clear(); + } + + @override + Future clearCachedProducts(int warehouseId, String type) async { + await init(); + + final key = _getCacheKey(warehouseId, type); + await _box?.delete(key); + } +} diff --git a/lib/features/products/data/repositories/products_repository_impl.dart b/lib/features/products/data/repositories/products_repository_impl.dart index 1a41868..eaaee46 100644 --- a/lib/features/products/data/repositories/products_repository_impl.dart +++ b/lib/features/products/data/repositories/products_repository_impl.dart @@ -4,32 +4,69 @@ import '../../../../core/errors/failures.dart'; import '../../domain/entities/product_entity.dart'; import '../../domain/entities/product_stage_entity.dart'; import '../../domain/repositories/products_repository.dart'; +import '../datasources/products_local_datasource.dart'; import '../datasources/products_remote_datasource.dart'; import '../models/create_product_warehouse_request.dart'; import '../models/product_detail_request_model.dart'; /// Implementation of ProductsRepository /// Handles data operations and error conversion +/// Uses local-first approach: loads from cache first, only fetches from API on explicit refresh class ProductsRepositoryImpl implements ProductsRepository { final ProductsRemoteDataSource remoteDataSource; + final ProductsLocalDataSource localDataSource; - ProductsRepositoryImpl(this.remoteDataSource); + ProductsRepositoryImpl({ + required this.remoteDataSource, + required this.localDataSource, + }); @override Future>> getProducts( int warehouseId, - String type, - ) async { + String type, { + bool forceRefresh = false, + }) async { try { - // Fetch products from remote data source + // If not forcing refresh, try to get from cache first + if (!forceRefresh) { + final cachedProducts = + await localDataSource.getCachedProducts(warehouseId, type); + + // If we have cached data, return it immediately + if (cachedProducts.isNotEmpty) { + return Right(cachedProducts.map((model) => model.toEntity()).toList()); + } + } + + // If forcing refresh or no cached data, fetch from remote final products = await remoteDataSource.getProducts(warehouseId, type); + // Cache the fetched products for future use + await localDataSource.cacheProducts(warehouseId, type, products); + // Convert models to entities and return success return Right(products.map((model) => model.toEntity()).toList()); } on ServerException catch (e) { + // If remote fetch fails, try to return cached data as fallback + if (forceRefresh) { + final cachedProducts = + await localDataSource.getCachedProducts(warehouseId, type); + if (cachedProducts.isNotEmpty) { + // Return cached data with a note that it might be outdated + return Right(cachedProducts.map((model) => model.toEntity()).toList()); + } + } // Convert ServerException to ServerFailure return Left(ServerFailure(e.message)); } on NetworkException catch (e) { + // If network fails, try to return cached data as fallback + final cachedProducts = + await localDataSource.getCachedProducts(warehouseId, type); + if (cachedProducts.isNotEmpty) { + // Return cached data when network is unavailable + return Right(cachedProducts.map((model) => model.toEntity()).toList()); + } // Convert NetworkException to NetworkFailure return Left(NetworkFailure(e.message)); } catch (e) { diff --git a/lib/features/products/domain/repositories/products_repository.dart b/lib/features/products/domain/repositories/products_repository.dart index 95990d8..024041a 100644 --- a/lib/features/products/domain/repositories/products_repository.dart +++ b/lib/features/products/domain/repositories/products_repository.dart @@ -11,12 +11,14 @@ abstract class ProductsRepository { /// /// [warehouseId] - The ID of the warehouse /// [type] - The operation type ('import' or 'export') + /// [forceRefresh] - If true, fetch from API even if cache exists /// /// Returns Either> Future>> getProducts( int warehouseId, - String type, - ); + String type, { + bool forceRefresh = false, + }); /// Get product stages for a product in a warehouse /// diff --git a/lib/features/products/domain/usecases/get_products_usecase.dart b/lib/features/products/domain/usecases/get_products_usecase.dart index 400a8c1..405edda 100644 --- a/lib/features/products/domain/usecases/get_products_usecase.dart +++ b/lib/features/products/domain/usecases/get_products_usecase.dart @@ -14,12 +14,18 @@ class GetProductsUseCase { /// /// [warehouseId] - The ID of the warehouse to get products from /// [type] - The operation type ('import' or 'export') + /// [forceRefresh] - If true, bypass cache and fetch from API /// /// Returns Either> Future>> call( int warehouseId, - String type, - ) async { - return await repository.getProducts(warehouseId, type); + String type, { + bool forceRefresh = false, + }) async { + return await repository.getProducts( + warehouseId, + type, + forceRefresh: forceRefresh, + ); } } diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index d0989e4..e2ce5df 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -1,8 +1,10 @@ +import 'package:dropdown_search/dropdown_search.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/di/providers.dart'; import '../../../../core/services/print_service.dart'; +import '../../../../core/utils/text_utils.dart'; import '../../../users/domain/entities/user_entity.dart'; import '../../data/models/create_product_warehouse_request.dart'; import '../../domain/entities/product_stage_entity.dart'; @@ -332,7 +334,7 @@ class _ProductDetailPageState extends ConsumerState { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, @@ -821,45 +823,116 @@ class _ProductDetailPageState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DropdownButtonFormField( - value: value, - decoration: InputDecoration( - labelText: label, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: theme.colorScheme.outline, + DropdownSearch( + items: (filter, infiniteScrollProps) => users, + selectedItem: value, + itemAsString: (UserEntity user) { + return user.name.isNotEmpty + ? '${user.name} ${user.firstName}' + : user.email; + }, + compareFn: (item1, item2) => item1.id == item2.id, + // Custom filter function for Vietnamese-aware search + filterFn: (user, filter) { + if (filter.isEmpty) return true; + + // Search in name, firstName, and email + final searchTexts = [ + user.name, + user.firstName, + user.email, + '${user.name} ${user.firstName}', // Full name + ]; + + // Use Vietnamese-aware search + return TextUtils.containsVietnameseSearchInAny(searchTexts, filter); + }, + popupProps: PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: InputDecoration( + labelText: 'Tìm kiếm', + hintText: 'Nhập tên hoặc email...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: theme.colorScheme.primary, - width: 2, - ), + menuProps: const MenuProps( + borderRadius: BorderRadius.all(Radius.circular(8)), + elevation: 8, ), - filled: true, - fillColor: theme.colorScheme.surface, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + itemBuilder: (context, item, isDisabled, isSelected) { + return ListTile( + selected: isSelected, + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + title: Text( + item.name.isNotEmpty + ? '${item.name} ${item.firstName}' + : item.email, + overflow: TextOverflow.ellipsis, + ), + subtitle: item.email.isNotEmpty && item.name.isNotEmpty + ? Text( + item.email, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ) + : null, + ); + }, + emptyBuilder: (context, searchEntry) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Không tìm thấy kết quả', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }, + ), + decoratorProps: DropDownDecoratorProps( + decoration: InputDecoration( + labelText: label, + hintText: 'Chọn $label', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.outline, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), ), - hint: Text('Chọn $label'), - items: users.map((user) { - return DropdownMenuItem( - value: user, - child: Text( - user.name.isNotEmpty ? '${user.name} ${user.firstName}' : user.email, - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), onChanged: onChanged, - isExpanded: true, ), ], ); diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index f5afc7f..196aa48 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -56,21 +56,23 @@ class _ProductsPageState extends ConsumerState _isTabSwitching = true; // Mark that tab is switching }); - // Load products for new operation type + // Load products for new operation type from cache (forceRefresh: false) ref.read(productsProvider.notifier).loadProducts( widget.warehouseId, widget.warehouseName, _currentOperationType, + forceRefresh: false, // Load from cache when switching tabs ); } }); - // Load products when page is initialized + // Load products from cache when page is initialized (forceRefresh: false) Future.microtask(() { ref.read(productsProvider.notifier).loadProducts( widget.warehouseId, widget.warehouseName, _currentOperationType, + forceRefresh: false, // Load from cache on initial load ); }); } diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index d44bc6c..84d1394 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -52,11 +52,13 @@ class ProductsNotifier extends StateNotifier { /// [warehouseId] - The ID of the warehouse /// [warehouseName] - The name of the warehouse (for display) /// [type] - The operation type ('import' or 'export') + /// [forceRefresh] - If true, bypass cache and fetch from API Future loadProducts( int warehouseId, String warehouseName, - String type, - ) async { + String type, { + bool forceRefresh = false, + }) async { // Set loading state state = state.copyWith( isLoading: true, @@ -66,8 +68,12 @@ class ProductsNotifier extends StateNotifier { operationType: type, ); - // Call the use case - final result = await getProductsUseCase(warehouseId, type); + // Call the use case with forceRefresh flag + final result = await getProductsUseCase( + warehouseId, + type, + forceRefresh: forceRefresh, + ); // Handle the result result.fold( @@ -95,13 +101,14 @@ class ProductsNotifier extends StateNotifier { state = const ProductsState(); } - /// Refresh products + /// Refresh products - forces fetch from API Future refreshProducts() async { if (state.warehouseId != null) { await loadProducts( state.warehouseId!, state.warehouseName ?? '', state.operationType, + forceRefresh: true, // Always force refresh when explicitly requested ); } } diff --git a/pubspec.lock b/pubspec.lock index 1ee0371..692e771 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + dropdown_search: + dependency: "direct main" + description: + name: dropdown_search + sha256: c29b3e5147a82a06a4a08b3b574c51cb48cc17ad89893d53ee72a6f86643622e + url: "https://pub.dev" + source: hosted + version: "6.0.2" equatable: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3657555..f2b1f5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: shimmer: ^3.0.0 cached_network_image: ^3.3.1 cupertino_icons: ^1.0.6 + dropdown_search: ^6.0.1 # Printing & PDF printing: ^5.13.4