asdasdasd

This commit is contained in:
Phuoc Nguyen
2025-10-29 16:12:37 +07:00
parent cb4df363ab
commit c12869b01f
11 changed files with 394 additions and 51 deletions

View File

@@ -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/repositories/auth_repository.dart';
import '../../features/auth/domain/usecases/login_usecase.dart'; import '../../features/auth/domain/usecases/login_usecase.dart';
import '../../features/auth/presentation/providers/auth_provider.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/datasources/products_remote_datasource.dart';
import '../../features/products/data/repositories/products_repository_impl.dart'; import '../../features/products/data/repositories/products_repository_impl.dart';
import '../../features/products/domain/entities/product_stage_entity.dart'; import '../../features/products/domain/entities/product_stage_entity.dart';
@@ -256,6 +257,12 @@ final warehouseErrorProvider = Provider<String?>((ref) {
// Data Layer // Data Layer
/// Products local data source provider
/// Handles local storage operations for products using Hive
final productsLocalDataSourceProvider = Provider<ProductsLocalDataSource>((ref) {
return ProductsLocalDataSourceImpl();
});
/// Products remote data source provider /// Products remote data source provider
/// Handles API calls for products /// Handles API calls for products
final productsRemoteDataSourceProvider = final productsRemoteDataSourceProvider =
@@ -266,9 +273,14 @@ final productsRemoteDataSourceProvider =
/// Products repository provider /// Products repository provider
/// Implements domain repository interface /// Implements domain repository interface
/// Coordinates between local and remote data sources
final productsRepositoryProvider = Provider<ProductsRepository>((ref) { final productsRepositoryProvider = Provider<ProductsRepository>((ref) {
final remoteDataSource = ref.watch(productsRemoteDataSourceProvider); final remoteDataSource = ref.watch(productsRemoteDataSourceProvider);
return ProductsRepositoryImpl(remoteDataSource); final localDataSource = ref.watch(productsLocalDataSourceProvider);
return ProductsRepositoryImpl(
remoteDataSource: remoteDataSource,
localDataSource: localDataSource,
);
}); });
// Domain Layer // Domain Layer

View File

@@ -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<String> texts,
String searchTerm,
) {
if (searchTerm.isEmpty) return true;
for (final text in texts) {
if (containsVietnameseSearch(text, searchTerm)) {
return true;
}
}
return false;
}
}

View File

@@ -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<ProductModel> from cache or empty list if not found
Future<List<ProductModel>> 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<void> cacheProducts(
int warehouseId,
String type,
List<ProductModel> products,
);
/// Clear all cached products
Future<void> clearCache();
/// Clear cached products for a specific warehouse and operation type
Future<void> clearCachedProducts(int warehouseId, String type);
}
/// Implementation of ProductsLocalDataSource using Hive
class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
static const String _boxName = 'products_cache';
Box<String>? _box;
/// Initialize the Hive box
Future<void> init() async {
if (_box == null || !_box!.isOpen) {
_box = await Hive.openBox<String>(_boxName);
}
}
/// Generate cache key for warehouse and operation type
String _getCacheKey(int warehouseId, String type) {
return 'products_${warehouseId}_$type';
}
@override
Future<List<ProductModel>> 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<String, dynamic>))
.toList();
} catch (e) {
// If parsing fails, return empty list
return [];
}
}
@override
Future<void> cacheProducts(
int warehouseId,
String type,
List<ProductModel> 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<void> clearCache() async {
await init();
await _box?.clear();
}
@override
Future<void> clearCachedProducts(int warehouseId, String type) async {
await init();
final key = _getCacheKey(warehouseId, type);
await _box?.delete(key);
}
}

View File

@@ -4,32 +4,69 @@ import '../../../../core/errors/failures.dart';
import '../../domain/entities/product_entity.dart'; import '../../domain/entities/product_entity.dart';
import '../../domain/entities/product_stage_entity.dart'; import '../../domain/entities/product_stage_entity.dart';
import '../../domain/repositories/products_repository.dart'; import '../../domain/repositories/products_repository.dart';
import '../datasources/products_local_datasource.dart';
import '../datasources/products_remote_datasource.dart'; import '../datasources/products_remote_datasource.dart';
import '../models/create_product_warehouse_request.dart'; import '../models/create_product_warehouse_request.dart';
import '../models/product_detail_request_model.dart'; import '../models/product_detail_request_model.dart';
/// Implementation of ProductsRepository /// Implementation of ProductsRepository
/// Handles data operations and error conversion /// 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 { class ProductsRepositoryImpl implements ProductsRepository {
final ProductsRemoteDataSource remoteDataSource; final ProductsRemoteDataSource remoteDataSource;
final ProductsLocalDataSource localDataSource;
ProductsRepositoryImpl(this.remoteDataSource); ProductsRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override @override
Future<Either<Failure, List<ProductEntity>>> getProducts( Future<Either<Failure, List<ProductEntity>>> getProducts(
int warehouseId, int warehouseId,
String type, String type, {
) async { bool forceRefresh = false,
}) async {
try { 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); 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 // Convert models to entities and return success
return Right(products.map((model) => model.toEntity()).toList()); return Right(products.map((model) => model.toEntity()).toList());
} on ServerException catch (e) { } 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 // Convert ServerException to ServerFailure
return Left(ServerFailure(e.message)); return Left(ServerFailure(e.message));
} on NetworkException catch (e) { } 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 // Convert NetworkException to NetworkFailure
return Left(NetworkFailure(e.message)); return Left(NetworkFailure(e.message));
} catch (e) { } catch (e) {

View File

@@ -11,12 +11,14 @@ abstract class ProductsRepository {
/// ///
/// [warehouseId] - The ID of the warehouse /// [warehouseId] - The ID of the warehouse
/// [type] - The operation type ('import' or 'export') /// [type] - The operation type ('import' or 'export')
/// [forceRefresh] - If true, fetch from API even if cache exists
/// ///
/// Returns Either<Failure, List<ProductEntity>> /// Returns Either<Failure, List<ProductEntity>>
Future<Either<Failure, List<ProductEntity>>> getProducts( Future<Either<Failure, List<ProductEntity>>> getProducts(
int warehouseId, int warehouseId,
String type, String type, {
); bool forceRefresh = false,
});
/// Get product stages for a product in a warehouse /// Get product stages for a product in a warehouse
/// ///

View File

@@ -14,12 +14,18 @@ class GetProductsUseCase {
/// ///
/// [warehouseId] - The ID of the warehouse to get products from /// [warehouseId] - The ID of the warehouse to get products from
/// [type] - The operation type ('import' or 'export') /// [type] - The operation type ('import' or 'export')
/// [forceRefresh] - If true, bypass cache and fetch from API
/// ///
/// Returns Either<Failure, List<ProductEntity>> /// Returns Either<Failure, List<ProductEntity>>
Future<Either<Failure, List<ProductEntity>>> call( Future<Either<Failure, List<ProductEntity>>> call(
int warehouseId, int warehouseId,
String type, String type, {
) async { bool forceRefresh = false,
return await repository.getProducts(warehouseId, type); }) async {
return await repository.getProducts(
warehouseId,
type,
forceRefresh: forceRefresh,
);
} }
} }

View File

@@ -1,8 +1,10 @@
import 'package:dropdown_search/dropdown_search.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart'; import '../../../../core/di/providers.dart';
import '../../../../core/services/print_service.dart'; import '../../../../core/services/print_service.dart';
import '../../../../core/utils/text_utils.dart';
import '../../../users/domain/entities/user_entity.dart'; import '../../../users/domain/entities/user_entity.dart';
import '../../data/models/create_product_warehouse_request.dart'; import '../../data/models/create_product_warehouse_request.dart';
import '../../domain/entities/product_stage_entity.dart'; import '../../domain/entities/product_stage_entity.dart';
@@ -332,7 +334,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
return SingleChildScrollView( return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
@@ -821,10 +823,91 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
DropdownButtonFormField<UserEntity>( DropdownSearch<UserEntity>(
value: value, 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,
),
),
),
menuProps: const MenuProps(
borderRadius: BorderRadius.all(Radius.circular(8)),
elevation: 8,
),
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( decoration: InputDecoration(
labelText: label, labelText: label,
hintText: 'Chọn $label',
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
@@ -848,18 +931,8 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
vertical: 12, vertical: 12,
), ),
), ),
hint: Text('Chọn $label'),
items: users.map((user) {
return DropdownMenuItem<UserEntity>(
value: user,
child: Text(
user.name.isNotEmpty ? '${user.name} ${user.firstName}' : user.email,
overflow: TextOverflow.ellipsis,
), ),
);
}).toList(),
onChanged: onChanged, onChanged: onChanged,
isExpanded: true,
), ),
], ],
); );

View File

@@ -56,21 +56,23 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
_isTabSwitching = true; // Mark that tab is switching _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( ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId, widget.warehouseId,
widget.warehouseName, widget.warehouseName,
_currentOperationType, _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(() { Future.microtask(() {
ref.read(productsProvider.notifier).loadProducts( ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId, widget.warehouseId,
widget.warehouseName, widget.warehouseName,
_currentOperationType, _currentOperationType,
forceRefresh: false, // Load from cache on initial load
); );
}); });
} }

View File

@@ -52,11 +52,13 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
/// [warehouseId] - The ID of the warehouse /// [warehouseId] - The ID of the warehouse
/// [warehouseName] - The name of the warehouse (for display) /// [warehouseName] - The name of the warehouse (for display)
/// [type] - The operation type ('import' or 'export') /// [type] - The operation type ('import' or 'export')
/// [forceRefresh] - If true, bypass cache and fetch from API
Future<void> loadProducts( Future<void> loadProducts(
int warehouseId, int warehouseId,
String warehouseName, String warehouseName,
String type, String type, {
) async { bool forceRefresh = false,
}) async {
// Set loading state // Set loading state
state = state.copyWith( state = state.copyWith(
isLoading: true, isLoading: true,
@@ -66,8 +68,12 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
operationType: type, operationType: type,
); );
// Call the use case // Call the use case with forceRefresh flag
final result = await getProductsUseCase(warehouseId, type); final result = await getProductsUseCase(
warehouseId,
type,
forceRefresh: forceRefresh,
);
// Handle the result // Handle the result
result.fold( result.fold(
@@ -95,13 +101,14 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
state = const ProductsState(); state = const ProductsState();
} }
/// Refresh products /// Refresh products - forces fetch from API
Future<void> refreshProducts() async { Future<void> refreshProducts() async {
if (state.warehouseId != null) { if (state.warehouseId != null) {
await loadProducts( await loadProducts(
state.warehouseId!, state.warehouseId!,
state.warehouseName ?? '', state.warehouseName ?? '',
state.operationType, state.operationType,
forceRefresh: true, // Always force refresh when explicitly requested
); );
} }
} }

View File

@@ -289,6 +289,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" 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: equatable:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -35,6 +35,7 @@ dependencies:
shimmer: ^3.0.0 shimmer: ^3.0.0
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
cupertino_icons: ^1.0.6 cupertino_icons: ^1.0.6
dropdown_search: ^6.0.1
# Printing & PDF # Printing & PDF
printing: ^5.13.4 printing: ^5.13.4