asdasdasd
This commit is contained in:
@@ -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<String?>((ref) {
|
||||
|
||||
// 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
|
||||
/// 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<ProductsRepository>((ref) {
|
||||
final remoteDataSource = ref.watch(productsRemoteDataSourceProvider);
|
||||
return ProductsRepositoryImpl(remoteDataSource);
|
||||
final localDataSource = ref.watch(productsLocalDataSourceProvider);
|
||||
return ProductsRepositoryImpl(
|
||||
remoteDataSource: remoteDataSource,
|
||||
localDataSource: localDataSource,
|
||||
);
|
||||
});
|
||||
|
||||
// Domain Layer
|
||||
|
||||
86
lib/core/utils/text_utils.dart
Normal file
86
lib/core/utils/text_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Either<Failure, List<ProductEntity>>> 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) {
|
||||
|
||||
@@ -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<Failure, List<ProductEntity>>
|
||||
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
||||
int warehouseId,
|
||||
String type,
|
||||
);
|
||||
String type, {
|
||||
bool forceRefresh = false,
|
||||
});
|
||||
|
||||
/// Get product stages for a product in a warehouse
|
||||
///
|
||||
|
||||
@@ -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<Failure, List<ProductEntity>>
|
||||
Future<Either<Failure, List<ProductEntity>>> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProductDetailPage> {
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
@@ -821,10 +823,91 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<UserEntity>(
|
||||
value: value,
|
||||
DropdownSearch<UserEntity>(
|
||||
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(
|
||||
labelText: label,
|
||||
hintText: 'Chọn $label',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -848,18 +931,8 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
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,
|
||||
isExpanded: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -56,21 +56,23 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
||||
_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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,11 +52,13 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
||||
/// [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<void> 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<ProductsState> {
|
||||
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<ProductsState> {
|
||||
state = const ProductsState();
|
||||
}
|
||||
|
||||
/// Refresh products
|
||||
/// Refresh products - forces fetch from API
|
||||
Future<void> refreshProducts() async {
|
||||
if (state.warehouseId != null) {
|
||||
await loadProducts(
|
||||
state.warehouseId!,
|
||||
state.warehouseName ?? '',
|
||||
state.operationType,
|
||||
forceRefresh: true, // Always force refresh when explicitly requested
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user