Compare commits

...

3 Commits

Author SHA1 Message Date
Phuoc Nguyen
4b35d236df save 2025-10-28 16:48:31 +07:00
Phuoc Nguyen
5cfc56f40d add dropdown 2025-10-28 16:24:17 +07:00
Phuoc Nguyen
0010446298 fix 2025-10-28 15:51:48 +07:00
28 changed files with 1617 additions and 116 deletions

View File

@@ -34,6 +34,13 @@ class ApiEndpoints {
/// GET: (requires auth token)
static const String profile = '$apiVersion/auth/profile';
// ==================== User Endpoints ====================
/// Get all users (short info)
/// GET: /PortalUser/GetAllMemberUserShortInfo (requires auth token)
/// Response: List of users
static const String users = '/PortalUser/GetAllMemberUserShortInfo';
// ==================== Warehouse Endpoints ====================
/// Get all warehouses
@@ -53,17 +60,30 @@ class ApiEndpoints {
// ==================== Product Endpoints ====================
/// Get products for a warehouse
/// Get products for import (all products)
/// GET: /portalProduct/getAllProduct (requires auth token)
/// Response: List of products
static const String products = '/portalProduct/getAllProduct';
/// Get products for export (products in specific warehouse)
/// GET: /portalWareHouse/GetAllProductsInWareHouse?warehouseId={id} (requires auth token)
/// Query param: warehouseId (int)
/// Response: List of products in warehouse
static String productsForExport(int warehouseId) =>
'/portalWareHouse/GetAllProductsInWareHouse?warehouseId=$warehouseId';
/// Get product stage in warehouse
/// POST: /portalWareHouse/GetProductStageInWareHouse
/// Body: { "WareHouseId": int, "ProductId": int }
/// Response: Product details with stage information
static const String productDetail = '/portalWareHouse/GetProductStageInWareHouse';
/// Create product warehouse (import/export)
/// POST: /portalWareHouse/createProductWareHouse
/// Body: Array of product warehouse creation objects
/// Response: Created product warehouse record
static const String createProductWarehouse = '/portalWareHouse/createProductWareHouse';
/// Get product by ID
/// GET: (requires auth token)
/// Parameter: productId

View File

@@ -18,6 +18,14 @@ import '../../features/warehouse/data/repositories/warehouse_repository_impl.dar
import '../../features/warehouse/domain/repositories/warehouse_repository.dart';
import '../../features/warehouse/domain/usecases/get_warehouses_usecase.dart';
import '../../features/warehouse/presentation/providers/warehouse_provider.dart';
import '../../features/users/data/datasources/users_local_datasource.dart';
import '../../features/users/data/datasources/users_remote_datasource.dart';
import '../../features/users/data/repositories/users_repository_impl.dart';
import '../../features/users/domain/repositories/users_repository.dart';
import '../../features/users/domain/usecases/get_users_usecase.dart';
import '../../features/users/domain/usecases/sync_users_usecase.dart';
import '../../features/users/presentation/providers/users_provider.dart';
import '../../features/users/presentation/providers/users_state.dart';
import '../network/api_client.dart';
import '../storage/secure_storage.dart';
@@ -392,6 +400,105 @@ final productDetailErrorProvider = Provider.family<String?, String>((ref, key) {
return state.error;
});
/// ========================================================================
/// USERS FEATURE PROVIDERS
/// ========================================================================
/// Providers for users feature following clean architecture
// Data Layer
/// Users local data source provider
/// Handles local storage operations for users using Hive
final usersLocalDataSourceProvider = Provider<UsersLocalDataSource>((ref) {
return UsersLocalDataSourceImpl();
});
/// Users remote data source provider
/// Handles API calls for users
final usersRemoteDataSourceProvider = Provider<UsersRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return UsersRemoteDataSourceImpl(apiClient);
});
/// Users repository provider
/// Implements domain repository interface
/// Coordinates between local and remote data sources
final usersRepositoryProvider = Provider<UsersRepository>((ref) {
final remoteDataSource = ref.watch(usersRemoteDataSourceProvider);
final localDataSource = ref.watch(usersLocalDataSourceProvider);
return UsersRepositoryImpl(
remoteDataSource: remoteDataSource,
localDataSource: localDataSource,
);
});
// Domain Layer
/// Get users use case provider
/// Encapsulates user fetching business logic (local-first strategy)
final getUsersUseCaseProvider = Provider<GetUsersUseCase>((ref) {
final repository = ref.watch(usersRepositoryProvider);
return GetUsersUseCase(repository);
});
/// Sync users use case provider
/// Encapsulates user syncing business logic (force refresh from API)
final syncUsersUseCaseProvider = Provider<SyncUsersUseCase>((ref) {
final repository = ref.watch(usersRepositoryProvider);
return SyncUsersUseCase(repository);
});
// Presentation Layer
/// Users state notifier provider
/// Manages users state including list, loading, and errors
final usersProvider = StateNotifierProvider<UsersNotifier, UsersState>((ref) {
final getUsersUseCase = ref.watch(getUsersUseCaseProvider);
final syncUsersUseCase = ref.watch(syncUsersUseCaseProvider);
return UsersNotifier(
getUsersUseCase: getUsersUseCase,
syncUsersUseCase: syncUsersUseCase,
);
});
/// Convenient providers for users state
/// Provider to get list of users
/// Usage: ref.watch(usersListProvider)
final usersListProvider = Provider((ref) {
final usersState = ref.watch(usersProvider);
return usersState.users;
});
/// Provider to check if users are loading
/// Usage: ref.watch(isUsersLoadingProvider)
final isUsersLoadingProvider = Provider<bool>((ref) {
final usersState = ref.watch(usersProvider);
return usersState.isLoading;
});
/// Provider to check if users list has items
/// Usage: ref.watch(hasUsersProvider)
final hasUsersProvider = Provider<bool>((ref) {
final usersState = ref.watch(usersProvider);
return usersState.users.isNotEmpty;
});
/// Provider to get users count
/// Usage: ref.watch(usersCountProvider)
final usersCountProvider = Provider<int>((ref) {
final usersState = ref.watch(usersProvider);
return usersState.users.length;
});
/// Provider to get users error
/// Returns null if no error
/// Usage: ref.watch(usersErrorProvider)
final usersErrorProvider = Provider<String?>((ref) {
final usersState = ref.watch(usersProvider);
return usersState.error;
});
/// ========================================================================
/// USAGE EXAMPLES
/// ========================================================================

View File

@@ -81,32 +81,24 @@ class AppRouter {
),
/// Products List Route
/// Path: /products
/// Takes warehouse, warehouseName, and operationType as extra parameter
/// Path: /products/:warehouseId/:operationType
/// Query params: name (warehouse name)
/// Shows products for selected warehouse and operation
GoRoute(
path: '/products',
path: '/products/:warehouseId/:operationType',
name: 'products',
builder: (context, state) {
final params = state.extra as Map<String, dynamic>?;
// Extract path parameters
final warehouseIdStr = state.pathParameters['warehouseId'];
final operationType = state.pathParameters['operationType'];
if (params == null) {
// If no params, redirect to warehouses
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Product parameters are required',
);
}
// Extract query parameter
final warehouseName = state.uri.queryParameters['name'];
// Extract required parameters
final warehouse = params['warehouse'] as WarehouseEntity?;
final warehouseName = params['warehouseName'] as String?;
final operationType = params['operationType'] as String?;
// Parse and validate parameters
final warehouseId = int.tryParse(warehouseIdStr ?? '');
// Validate parameters
if (warehouse == null || warehouseName == null || operationType == null) {
if (warehouseId == null || warehouseName == null || operationType == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
@@ -116,7 +108,7 @@ class AppRouter {
}
return ProductsPage(
warehouseId: warehouse.id,
warehouseId: warehouseId,
warehouseName: warehouseName,
operationType: operationType,
);
@@ -124,34 +116,28 @@ class AppRouter {
),
/// Product Detail Route
/// Path: /product-detail
/// Takes warehouseId, productId, warehouseName, and optional stageId as extra parameter
/// Path: /product-detail/:warehouseId/:productId/:operationType
/// Query params: name (warehouse name), stageId (optional)
/// Shows detailed information for a specific product
/// If stageId is provided, only that stage is shown, otherwise all stages are shown
GoRoute(
path: '/product-detail',
path: '/product-detail/:warehouseId/:productId/:operationType',
name: 'product-detail',
builder: (context, state) {
final params = state.extra as Map<String, dynamic>?;
// Extract path parameters
final warehouseIdStr = state.pathParameters['warehouseId'];
final productIdStr = state.pathParameters['productId'];
final operationType = state.pathParameters['operationType'] ?? 'import';
if (params == null) {
// If no params, redirect to warehouses
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Product detail parameters are required',
);
}
// Extract query parameters
final warehouseName = state.uri.queryParameters['name'];
final stageIdStr = state.uri.queryParameters['stageId'];
// Extract required parameters
final warehouseId = params['warehouseId'] as int?;
final productId = params['productId'] as int?;
final warehouseName = params['warehouseName'] as String?;
// Extract optional stageId
final stageId = params['stageId'] as int?;
// Parse and validate parameters
final warehouseId = int.tryParse(warehouseIdStr ?? '');
final productId = int.tryParse(productIdStr ?? '');
final stageId = stageIdStr != null ? int.tryParse(stageIdStr) : null;
// Validate parameters
if (warehouseId == null || productId == null || warehouseName == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
@@ -166,6 +152,7 @@ class AppRouter {
productId: productId,
warehouseName: warehouseName,
stageId: stageId,
operationType: operationType,
);
},
),
@@ -360,18 +347,11 @@ extension AppRouterExtension on BuildContext {
///
/// [warehouse] - Selected warehouse entity
/// [operationType] - Either 'import' or 'export'
void goToProducts({
void pushToProducts({
required WarehouseEntity warehouse,
required String operationType,
}) {
go(
'/products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': operationType,
},
);
push('/products/${warehouse.id}/$operationType?name=${Uri.encodeQueryComponent(warehouse.name)}');
}
/// Navigate to product detail page
@@ -379,22 +359,23 @@ extension AppRouterExtension on BuildContext {
/// [warehouseId] - ID of the warehouse
/// [productId] - ID of the product to view
/// [warehouseName] - Name of the warehouse (for display)
/// [operationType] - Either 'import' or 'export'
/// [stageId] - Optional ID of specific stage to show (if null, show all stages)
void goToProductDetail({
required int warehouseId,
required int productId,
required String warehouseName,
required String operationType,
int? stageId,
}) {
push(
'/product-detail',
extra: {
'warehouseId': warehouseId,
'productId': productId,
'warehouseName': warehouseName,
if (stageId != null) 'stageId': stageId,
},
);
final queryParams = <String, String>{
'name': warehouseName,
if (stageId != null) 'stageId': stageId.toString(),
};
final queryString = queryParams.entries
.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}')
.join('&');
push('/product-detail/$warehouseId/$productId/$operationType?$queryString');
}
/// Pop current route
@@ -421,11 +402,13 @@ extension AppRouterNamedExtension on BuildContext {
}) {
goNamed(
'products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
pathParameters: {
'warehouseId': warehouse.id.toString(),
'operationType': operationType,
},
queryParameters: {
'name': warehouse.name,
},
);
}
}

View File

@@ -37,7 +37,7 @@ curl --request POST \
}'
#Get products
#Get products for import
curl --request GET \
--url https://dotnet.elidev.info:8157/ws/portalProduct/getAllProduct \
--compressed \
@@ -54,6 +54,22 @@ curl --request GET \
--header 'Sec-Fetch-Site: same-site' \
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0'
#Get product for export
curl 'https://dotnet.elidev.info:8157/ws/portalWareHouse/GetAllProductsInWareHouse?warehouseId=1' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0' \
-H 'Accept: application/json, text/plain, */*' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H 'AccessToken: 1k5fXyQVXtGkfjwS4g2USldGyLSA7Zwa2jdj5tLe+3YfSDlk02aYgqsFh5xArkdL4N529x7IYJOGrLJJgLBPNVgD51zFfEYBzfJmMH2RUm7iegvDJaMCLISySw0zd6kcsqeJi7vtuybgY2NDPxDgiSOj4wX417PzB8AVg5bl1ZAAJ3LcVAqqtA1PDTU5ZU1QQYapNeBNxAHjnd2ojTZK1GJBIyY5Gd8P9gB880ppAKq8manNMZYsa4d8tkYf0SJUul2aqLIWJAwDGORpPmfjqkN4hMh85xAfPTZi6m4DdI0u2rHDMLaZ8eIsV16qA8wimSDnWi0VeG0SZ4ugbCdJAi3t1/uICTftiy06PJEkulBLV+h2xS/7SlmEY2xoN5ISi++3FNqsFPGa9QH6akGu2C7IXEUBCg3iGJx0uL+vULmVqk5OJIXdqiKVQ366hvhPlK2AM1zbh49x/ngibe08483WTL5uAY/fsKuBxQCpTc2368Gqhpd7QRtZFKpzikhyTWsR3nQIi6ExSstCeFbe8ehgo0PuTPZNHH5IHTc49snH6IZrSbR+F62Wu/D+4DlvMTK/ktG6LVQ3r3jSJC5MAQDV5Q9WK3RvsWMPvZrsaVW/Exz0GBgWP4W0adADg7MFSlnGDOJm6I4fCLHZIJCUww50L6iNmzvrdibrQT5jKACVgNquMZCfeZlf3m2BwUx9T6J45lAePpJ+QaMh+2voFqRiOLi98MLqOG6TW7z96sadzFVR9YU1xwM51jQDjnUlrXt0+msq29Jqt8LoCyQsG4r3RgS/tUJhximq11MDXsSXanpYM7jesjr8mAG4qjYN6z6c1Gl5N0dhcDF4HeEaIlNIgZ75FqtXZnLqvhHPyk6L2iR2ZT15nobZxLzOUad4a0OymUDUv7xuEBdEk5kmzZLDpbOxrKiyMpGSlbBhEoBMoA0u6ZKtBGQfCJ02s6Ri0WhLLM4XJCjGrpoEkTUuZ7YG39Zva19HGV0kkxeFYkG0lnZBO6jCggem5f+S2NQvXP/kUrWX1GeQFCq5PScvwJexLsbh0LKC2MGovkecoBKtNIK21V6ztvWL8lThJAl9' \
-H 'AppID: Minhthu2016' \
-H 'Origin: https://dotnet.elidev.info:8158' \
-H 'Connection: keep-alive' \
-H 'Referer: https://dotnet.elidev.info:8158/' \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: same-site'
#Get product by id
curl --request POST \
--url https://dotnet.elidev.info:8157/ws/portalWareHouse/GetProductStageInWareHouse \
@@ -76,3 +92,39 @@ curl --request POST \
"WareHouseId": 7,
"ProductId": 11
}'
#Create import product
curl 'https://dotnet.elidev.info:8157/ws/portalWareHouse/createProductWareHouse' \
-X POST \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0' \
-H 'Accept: application/json, text/plain, */*' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H 'AccessToken: 1k5fXyQVXtGkfjwS4g2USldGyLSA7Zwa2jdj5tLe+3YfSDlk02aYgqsFh5xArkdL4N529x7IYJOGrLJJgLBPNVgD51zFfEYBzfJmMH2RUm7iegvDJaMCLISySw0zd6kcsqeJi7vtuybgY2NDPxDgiSOj4wX417PzB8AVg5bl1ZAAJ3LcVAqqtA1PDTU5ZU1QQYapNeBNxAHjnd2ojTZK1GJBIyY5Gd8P9gB880ppAKq8manNMZYsa4d8tkYf0SJUul2aqLIWJAwDGORpPmfjqkN4hMh85xAfPTZi6m4DdI0u2rHDMLaZ8eIsV16qA8wimSDnWi0VeG0SZ4ugbCdJAi3t1/uICTftiy06PJEkulBLV+h2xS/7SlmEY2xoN5ISi++3FNqsFPGa9QH6akGu2C7IXEUBCg3iGJx0uL+vULmVqk5OJIXdqiKVQ366hvhPlK2AM1zbh49x/ngibe08483WTL5uAY/fsKuBxQCpTc2368Gqhpd7QRtZFKpzikhyTWsR3nQIi6ExSstCeFbe8ehgo0PuTPZNHH5IHTc49snH6IZrSbR+F62Wu/D+4DlvMTK/ktG6LVQ3r3jSJC5MAQDV5Q9WK3RvsWMPvZrsaVW/Exz0GBgWP4W0adADg7MFSlnGDOJm6I4fCLHZIJCUww50L6iNmzvrdibrQT5jKACVgNquMZCfeZlf3m2BwUx9T6J45lAePpJ+QaMh+2voFqRiOLi98MLqOG6TW7z96sadzFVR9YU1xwM51jQDjnUlrXt0+msq29Jqt8LoCyQsG4r3RgS/tUJhximq11MDXsSXanpYM7jesjr8mAG4qjYN6z6c1Gl5N0dhcDF4HeEaIlNIgZ75FqtXZnLqvhHPyk6L2iR2ZT15nobZxLzOUad4a0OymUDUv7xuEBdEk5kmzZLDpbOxrKiyMpGSlbBhEoBMoA0u6ZKtBGQfCJ02s6Ri0WhLLM4XJCjGrpoEkTUuZ7YG39Zva19HGV0kkxeFYkG0lnZBO6jCggem5f+S2NQvXP/kUrWX1GeQFCq5PScvwJexLsbh0LKC2MGovkecoBKtNIK21V6ztvWL8lThJAl9' \
-H 'AppID: Minhthu2016' \
-H 'Content-Type: application/json' \
-H 'Origin: https://dotnet.elidev.info:8158' \
-H 'Connection: keep-alive' \
-H 'Referer: https://dotnet.elidev.info:8158/' \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: same-site' \
-H 'Priority: u=0' \
--data-raw $'[{"TypeId":4,"ProductId":11,"StageId":3,"OrderId":null,"RecordDate":"2025-10-28T08:19:20.418Z","PassedQuantityWeight":0.5,"PassedQuantity":5,"IssuedQuantityWeight":0.1,"IssuedQuantity":1,"ResponsibleUserId":12043,"Description":"","ProductName":"Th\xe9p 435","ProductCode":"SCM435","StockPassedQuantityWeight":0,"StockPassedQuantity":0,"StockIssuedQuantity":0,"StockIssuedQuantityWeight":0,"ReceiverUserId":12120,"ActionTypeId":1,"WareHouseId":1,"ProductStageId":3,"IsConfirm":true}]'
#Get users
curl --request GET \
--url https://dotnet.elidev.info:8157/ws/PortalUser/GetAllMemberUserShortInfo \
--compressed \
--header 'Accept: application/json, text/plain, */*' \
--header 'Accept-Encoding: gzip, deflate, br, zstd' \
--header 'Accept-Language: en-US,en;q=0.5' \
--header 'AccessToken: 1k5fXyQVXtGkfjwS4g2USldGyLSA7Zwa2jdj5tLe+3YfSDlk02aYgqsFh5xArkdL4N529x7IYJOGrLJJgLBPNVgD51zFfEYBzfJmMH2RUm7iegvDJaMCLISySw0zd6kcsqeJi7vtuybgY2NDPxDgiSOj4wX417PzB8AVg5bl1ZAAJ3LcVAqqtA1PDTU5ZU1QQYapNeBNxAHjnd2ojTZK1GJBIyY5Gd8P9gB880ppAKq8manNMZYsa4d8tkYf0SJUul2aqLIWJAwDGORpPmfjqkN4hMh85xAfPTZi6m4DdI0u2rHDMLaZ8eIsV16qA8wimSDnWi0VeG0SZ4ugbCdJAi3t1/uICTftiy06PJEkulBLV+h2xS/7SlmEY2xoN5ISi++3FNqsFPGa9QH6akGu2C7IXEUBCg3iGJx0uL+vULmVqk5OJIXdqiKVQ366hvhPlK2AM1zbh49x/ngibe08483WTL5uAY/fsKuBxQCpTc2368Gqhpd7QRtZFKpzikhyTWsR3nQIi6ExSstCeFbe8ehgo0PuTPZNHH5IHTc49snH6IZrSbR+F62Wu/D+4DlvMTK/ktG6LVQ3r3jSJC5MAQDV5Q9WK3RvsWMPvZrsaVW/Exz0GBgWP4W0adADg7MFSlnGDOJm6I4fCLHZIJCUww50L6iNmzvrdibrQT5jKACVgNquMZCfeZlf3m2BwUx9T6J45lAePpJ+QaMh+2voFqRiOLi98MLqOG6TW7z96sadzFVR9YU1xwM51jQDjnUlrXt0+msq29Jqt8LoCyQsG4r3RgS/tUJhximq11MDXsSrJm6RubpKWl/MnF3QxcLTSwFE/SrGoGhnJnH5ILxZdyMN4PJjELeq3g8V5nEEm6lE/WNyvRMskel+Ods3XQIvE6o8KblUmFeM1rOIBkJbUVX7Ghaj0RNpvar86fF85BEozLBcED2XGkDANhGivuhqyrDpEOYjCwuC0eOjdj92fxlTTyo33ioR3xKcYFVlgMTlRX26sQDayf8hsPIBoDQtMHGKFfC2BJx4ujKTxtead8uz7c+CrlJuTbUkk+bp+wEUvKW5TvlX8HXKJWVLm05qZ7KmSLpsp35Iih2tZbBU+g==' \
--header 'AppID: Minhthu2016' \
--header 'Connection: keep-alive' \
--header 'Origin: https://dotnet.elidev.info:8158' \
--header 'Referer: https://dotnet.elidev.info:8158/' \
--header 'Sec-Fetch-Dest: empty' \
--header 'Sec-Fetch-Mode: cors' \
--header 'Sec-Fetch-Site: same-site' \
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0'

View File

@@ -2,6 +2,7 @@ import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/create_product_warehouse_request.dart';
import '../models/product_detail_request_model.dart';
import '../models/product_model.dart';
import '../models/product_stage_model.dart';
@@ -24,6 +25,14 @@ abstract class ProductsRemoteDataSource {
/// Returns List<ProductStageModel> with all stages for the product
/// Throws [ServerException] if the API call fails
Future<List<ProductStageModel>> getProductDetail(ProductDetailRequestModel request);
/// Create product warehouse entry (import/export operation)
///
/// [request] - Request containing all product warehouse details
///
/// Returns void on success
/// Throws [ServerException] if the API call fails
Future<void> createProductWarehouse(CreateProductWarehouseRequest request);
}
/// Implementation of ProductsRemoteDataSource using ApiClient
@@ -35,8 +44,13 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
@override
Future<List<ProductModel>> getProducts(int warehouseId, String type) async {
try {
// Make API call to get all products
final response = await apiClient.get('/portalProduct/getAllProduct');
// Choose endpoint based on operation type
final endpoint = type == 'export'
? ApiEndpoints.productsForExport(warehouseId)
: ApiEndpoints.products;
// Make API call to get products
final response = await apiClient.get(endpoint);
// Parse the API response using ApiResponse wrapper
final apiResponse = ApiResponse.fromJson(
@@ -121,4 +135,47 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
throw ServerException('Failed to get product stages: ${e.toString()}');
}
}
@override
Future<void> createProductWarehouse(
CreateProductWarehouseRequest request) async {
try {
// The API expects an array of requests
final requestData = [request.toJson()];
// Make API call to create product warehouse
final response = await apiClient.post(
ApiEndpoints.createProductWarehouse,
data: requestData,
);
// Parse the API response
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => json, // We don't need to parse the response value
);
// Check if the API call was successful
if (!apiResponse.isSuccess) {
// Throw exception with error message from API
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to create product warehouse entry',
);
}
} catch (e) {
// Re-throw ServerException as-is
if (e is ServerException) {
rethrow;
}
// Re-throw NetworkException as-is
if (e is NetworkException) {
rethrow;
}
// Wrap other exceptions in ServerException
throw ServerException(
'Failed to create product warehouse entry: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,77 @@
/// Request model for creating product warehouse (import/export)
class CreateProductWarehouseRequest {
final int typeId;
final int productId;
final int stageId;
final int? orderId;
final String recordDate;
final double passedQuantityWeight;
final int passedQuantity;
final double issuedQuantityWeight;
final int issuedQuantity;
final int responsibleUserId;
final String description;
final String productName;
final String productCode;
final double stockPassedQuantityWeight;
final int stockPassedQuantity;
final int stockIssuedQuantity;
final double stockIssuedQuantityWeight;
final int receiverUserId;
final int actionTypeId;
final int wareHouseId;
final int productStageId;
final bool isConfirm;
CreateProductWarehouseRequest({
required this.typeId,
required this.productId,
required this.stageId,
this.orderId,
required this.recordDate,
required this.passedQuantityWeight,
required this.passedQuantity,
required this.issuedQuantityWeight,
required this.issuedQuantity,
required this.responsibleUserId,
this.description = '',
required this.productName,
required this.productCode,
this.stockPassedQuantityWeight = 0.0,
this.stockPassedQuantity = 0,
this.stockIssuedQuantity = 0,
this.stockIssuedQuantityWeight = 0.0,
required this.receiverUserId,
required this.actionTypeId,
required this.wareHouseId,
required this.productStageId,
this.isConfirm = true,
});
Map<String, dynamic> toJson() {
return {
'TypeId': typeId,
'ProductId': productId,
'StageId': stageId,
'OrderId': orderId,
'RecordDate': recordDate,
'PassedQuantityWeight': passedQuantityWeight,
'PassedQuantity': passedQuantity,
'IssuedQuantityWeight': issuedQuantityWeight,
'IssuedQuantity': issuedQuantity,
'ResponsibleUserId': responsibleUserId,
'Description': description,
'ProductName': productName,
'ProductCode': productCode,
'StockPassedQuantityWeight': stockPassedQuantityWeight,
'StockPassedQuantity': stockPassedQuantity,
'StockIssuedQuantity': stockIssuedQuantity,
'StockIssuedQuantityWeight': stockIssuedQuantityWeight,
'ReceiverUserId': receiverUserId,
'ActionTypeId': actionTypeId,
'WareHouseId': wareHouseId,
'ProductStageId': productStageId,
'IsConfirm': isConfirm,
};
}
}

View File

@@ -13,6 +13,9 @@ class ProductStageModel extends ProductStageEntity {
required super.passedQuantityWeight,
required super.stageName,
required super.createdDate,
super.productName,
super.productCode,
super.stageId,
});
/// Create ProductStageModel from JSON
@@ -27,6 +30,9 @@ class ProductStageModel extends ProductStageEntity {
passedQuantityWeight: (json['PassedQuantityWeight'] as num).toDouble(),
stageName: json['StageName'] as String?,
createdDate: json['CreatedDate'] as String,
productName: json['ProductName'] as String? ?? '',
productCode: json['ProductCode'] as String? ?? '',
stageId: json['StageId'] as int?,
);
}
@@ -42,6 +48,9 @@ class ProductStageModel extends ProductStageEntity {
'PassedQuantityWeight': passedQuantityWeight,
'StageName': stageName,
'CreatedDate': createdDate,
'ProductName': productName,
'ProductCode': productCode,
'StageId': stageId,
};
}
@@ -57,6 +66,9 @@ class ProductStageModel extends ProductStageEntity {
passedQuantityWeight: passedQuantityWeight,
stageName: stageName,
createdDate: createdDate,
productName: productName,
productCode: productCode,
stageId: stageId,
);
}

View File

@@ -5,6 +5,7 @@ import '../../domain/entities/product_entity.dart';
import '../../domain/entities/product_stage_entity.dart';
import '../../domain/repositories/products_repository.dart';
import '../datasources/products_remote_datasource.dart';
import '../models/create_product_warehouse_request.dart';
import '../models/product_detail_request_model.dart';
/// Implementation of ProductsRepository
@@ -65,4 +66,26 @@ class ProductsRepositoryImpl implements ProductsRepository {
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> createProductWarehouse(
CreateProductWarehouseRequest request,
) async {
try {
// Call remote data source to create product warehouse
await remoteDataSource.createProductWarehouse(request);
// Return success
return const Right(null);
} on ServerException catch (e) {
// Convert ServerException to ServerFailure
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
// Convert NetworkException to NetworkFailure
return Left(NetworkFailure(e.message));
} catch (e) {
// Handle any other exceptions
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
}

View File

@@ -12,6 +12,9 @@ class ProductStageEntity extends Equatable {
final double passedQuantityWeight;
final String? stageName;
final String createdDate;
final String productName;
final String productCode;
final int? stageId;
const ProductStageEntity({
required this.productId,
@@ -23,6 +26,9 @@ class ProductStageEntity extends Equatable {
required this.passedQuantityWeight,
required this.stageName,
required this.createdDate,
this.productName = '',
this.productCode = '',
this.stageId,
});
/// Get display name for the stage
@@ -49,6 +55,9 @@ class ProductStageEntity extends Equatable {
passedQuantityWeight,
stageName,
createdDate,
productName,
productCode,
stageId,
];
@override

View File

@@ -1,5 +1,6 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../data/models/create_product_warehouse_request.dart';
import '../entities/product_entity.dart';
import '../entities/product_stage_entity.dart';
@@ -27,4 +28,13 @@ abstract class ProductsRepository {
int warehouseId,
int productId,
);
/// Create product warehouse entry (import/export operation)
///
/// [request] - Request containing all product warehouse details
///
/// Returns Either<Failure, void>
Future<Either<Failure, void>> createProductWarehouse(
CreateProductWarehouseRequest request,
);
}

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../../../users/domain/entities/user_entity.dart';
import '../../data/models/create_product_warehouse_request.dart';
import '../../domain/entities/product_stage_entity.dart';
/// Product detail page
@@ -12,6 +14,7 @@ class ProductDetailPage extends ConsumerStatefulWidget {
final int productId;
final String warehouseName;
final int? stageId;
final String operationType;
const ProductDetailPage({
super.key,
@@ -19,6 +22,7 @@ class ProductDetailPage extends ConsumerStatefulWidget {
required this.productId,
required this.warehouseName,
this.stageId,
this.operationType = 'import',
});
@override
@@ -28,13 +32,26 @@ class ProductDetailPage extends ConsumerStatefulWidget {
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
late String _providerKey;
// Text editing controllers for quantity fields
final TextEditingController _passedQuantityController = TextEditingController();
final TextEditingController _passedWeightController = TextEditingController();
final TextEditingController _issuedQuantityController = TextEditingController();
final TextEditingController _issuedWeightController = TextEditingController();
// Selected users for dropdowns
UserEntity? _selectedWarehouseUser;
UserEntity? _selectedEmployee;
@override
void initState() {
super.initState();
_providerKey = '${widget.warehouseId}_${widget.productId}';
// Load product stages when page is initialized
// Load product stages and users when page is initialized
Future.microtask(() async {
// Load users from Hive (no API call)
await ref.read(usersProvider.notifier).getUsers();
await ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail(
widget.warehouseId,
widget.productId,
@@ -53,6 +70,15 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
});
}
@override
void dispose() {
_passedQuantityController.dispose();
_passedWeightController.dispose();
_issuedQuantityController.dispose();
_issuedWeightController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
await ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
widget.warehouseId,
@@ -60,6 +86,17 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
);
}
void _clearControllers() {
_passedQuantityController.clear();
_passedWeightController.clear();
_issuedQuantityController.clear();
_issuedWeightController.clear();
setState(() {
_selectedWarehouseUser = null;
_selectedEmployee = null;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -73,17 +110,23 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
final error = productDetailState.error;
final selectedIndex = productDetailState.selectedStageIndex;
// Get product name from stages if available
final productName = stages.isNotEmpty ? stages.first.productName : 'Product';
// Capitalize first letter of operation type
final operationTitle = widget.operationType == 'import' ? 'Import' : 'Export';
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Product Stages',
operationTitle,
style: textTheme.titleMedium,
),
Text(
widget.warehouseName,
productName,
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -333,33 +376,11 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
// Stage header
_buildStageHeader(stageToShow, theme),
const SizedBox(height: 16),
// Quantity information
_buildSectionCard(
theme: theme,
title: 'Quantities',
icon: Icons.inventory_outlined,
children: [
_buildInfoRow('Passed Quantity', '${stageToShow.passedQuantity}'),
_buildInfoRow(
'Passed Weight',
'${stageToShow.passedQuantityWeight.toStringAsFixed(2)} kg',
),
const Divider(height: 24),
_buildInfoRow('Issued Quantity', '${stageToShow.issuedQuantity}'),
_buildInfoRow(
'Issued Weight',
'${stageToShow.issuedQuantityWeight.toStringAsFixed(2)} kg',
),
],
),
const SizedBox(height: 16),
// Stage information
_buildSectionCard(
theme: theme,
title: 'Stage Information',
@@ -373,10 +394,115 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
_buildInfoRow('Stage Name', stageToShow.displayName),
],
),
const SizedBox(height: 16),
// Status indicators
_buildStatusCards(stageToShow, theme),
// Current Quantity information
_buildSectionCard(
theme: theme,
title: 'Current Quantities',
icon: Icons.info_outlined,
children: [
_buildInfoRow('Passed Quantity', '${stageToShow.passedQuantity}'),
_buildInfoRow(
'Passed Weight',
'${stageToShow.passedQuantityWeight.toStringAsFixed(2)} kg',
),
_buildInfoRow('Issued Quantity', '${stageToShow.issuedQuantity}'),
_buildInfoRow(
'Issued Weight',
'${stageToShow.issuedQuantityWeight.toStringAsFixed(2)} kg',
),
],
),
// Add New Quantities section
_buildSectionCard(
theme: theme,
title: 'Add New Quantities',
icon: Icons.add_circle_outline,
children: [
_buildTextField(
label: 'Passed Quantity',
controller: _passedQuantityController,
keyboardType: TextInputType.number,
theme: theme,
),
_buildTextField(
label: 'Passed Weight (kg)',
controller: _passedWeightController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
theme: theme,
),
_buildTextField(
label: 'Issued Quantity',
controller: _issuedQuantityController,
keyboardType: TextInputType.number,
theme: theme,
),
_buildTextField(
label: 'Issued Weight (kg)',
controller: _issuedWeightController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
theme: theme,
),
],
),
_buildSectionCard(theme: theme, title: "Nhân viên", icon: Icons.people, children: [
// Warehouse User Dropdown
_buildUserDropdown(
label: 'Warehouse User',
value: _selectedWarehouseUser,
users: ref.watch(usersListProvider)
.where((user) => user.isWareHouseUser)
.toList(),
onChanged: (user) {
setState(() {
_selectedWarehouseUser = user;
});
},
theme: theme,
),
// All Employees Dropdown
_buildUserDropdown(
label: 'Employee',
value: _selectedEmployee,
users: ref.watch(usersListProvider),
onChanged: (user) {
setState(() {
_selectedEmployee = user;
});
},
theme: theme,
),
]),
// Action buttons
Row(
spacing: 12,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _printQuantities(stageToShow),
icon: const Icon(Icons.print),
label: const Text('Print'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
Expanded(
child: FilledButton.icon(
onPressed: () => _addNewQuantities(stageToShow),
icon: const Icon(Icons.save),
label: const Text('Save'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
],
),
);
@@ -387,6 +513,146 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
);
}
void _printQuantities(ProductStageEntity stage) {
// TODO: Implement print functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Print functionality coming soon'),
duration: Duration(seconds: 2),
),
);
}
Future<void> _addNewQuantities(ProductStageEntity stage) async {
// Parse the values from text fields
final passedQuantity = int.tryParse(_passedQuantityController.text) ?? 0;
final passedWeight = double.tryParse(_passedWeightController.text) ?? 0.0;
final issuedQuantity = int.tryParse(_issuedQuantityController.text) ?? 0;
final issuedWeight = double.tryParse(_issuedWeightController.text) ?? 0.0;
// Validate that at least one field has a value
if (passedQuantity == 0 && passedWeight == 0.0 &&
issuedQuantity == 0 && issuedWeight == 0.0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter at least one quantity or weight value'),
backgroundColor: Colors.orange,
),
);
return;
}
// Validate that both users are selected
if (_selectedEmployee == null || _selectedWarehouseUser == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select both Employee and Warehouse User'),
backgroundColor: Colors.orange,
),
);
return;
}
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
try {
// Determine actionTypeId based on operation type
// 4 = Import, 5 = Export
final typeId = widget.operationType == 'import' ? 4 : 5;
final actionTypeId = widget.operationType == 'import' ? 1 : 2;
// Create request with all required fields
final request = CreateProductWarehouseRequest(
typeId: typeId, // Import type
productId: stage.productId,
stageId: stage.stageId ?? 0,
orderId: null,
recordDate: DateTime.now().toIso8601String(),
passedQuantityWeight: passedWeight,
passedQuantity: passedQuantity,
issuedQuantityWeight: issuedWeight,
issuedQuantity: issuedQuantity,
responsibleUserId: _selectedEmployee!.id,
description: '',
productName: stage.productName,
productCode: stage.productCode,
stockPassedQuantityWeight: stage.passedQuantityWeight,
stockPassedQuantity: stage.passedQuantity,
stockIssuedQuantity: stage.issuedQuantity,
stockIssuedQuantityWeight: stage.issuedQuantityWeight,
receiverUserId: _selectedWarehouseUser!.id,
actionTypeId: actionTypeId,
wareHouseId: widget.warehouseId,
productStageId: stage.productStageId ?? 0,
isConfirm: true,
);
// Call the repository to create product warehouse entry
final repository = ref.read(productsRepositoryProvider);
final result = await repository.createProductWarehouse(request);
// Dismiss loading dialog
if (mounted) Navigator.of(context).pop();
result.fold(
(failure) {
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to add quantities: ${failure.message}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
},
(_) {
// Success - show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Quantities added successfully!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
// Clear the text fields after successful add
_clearControllers();
// Refresh the product detail to show updated quantities
ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
widget.warehouseId,
widget.productId,
);
}
},
);
} catch (e) {
// Dismiss loading dialog
if (mounted) Navigator.of(context).pop();
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Widget _buildStageHeader(ProductStageEntity stage, ThemeData theme) {
return Card(
elevation: 2,
@@ -445,6 +711,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
Row(
children: [
@@ -472,7 +739,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -501,6 +768,159 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
);
}
Widget _buildTextField({
required String label,
required TextEditingController controller,
required TextInputType keyboardType,
required ThemeData theme,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: 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,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _incrementValue(controller, 0.1),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: const Text('+0.1'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () => _incrementValue(controller, 0.5),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: const Text('+0.5'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () => _incrementValue(controller, 1),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: const Text('+1'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () => controller.clear(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
foregroundColor: theme.colorScheme.error,
),
child: const Text('C'),
),
),
],
),
],
);
}
Widget _buildUserDropdown({
required String label,
required UserEntity? value,
required List<UserEntity> users,
required Function(UserEntity?) onChanged,
required ThemeData theme,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<UserEntity>(
value: value,
decoration: InputDecoration(
labelText: 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('Select $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,
),
],
);
}
void _incrementValue(TextEditingController controller, double increment) {
final currentValue = double.tryParse(controller.text) ?? 0.0;
final newValue = currentValue + increment;
// Format the value based on whether it's a whole number or has decimals
if (newValue == newValue.toInt()) {
controller.text = newValue.toInt().toString();
} else {
controller.text = newValue.toStringAsFixed(1);
}
}
Widget _buildStatusCards(ProductStageEntity stage, ThemeData theme) {
return Row(
children: [

View File

@@ -7,7 +7,7 @@ import '../../../../core/router/app_router.dart';
import '../widgets/product_list_item.dart';
/// Products list page
/// Displays products for a specific warehouse and operation type
/// Displays products for a specific warehouse with import/export tabs
class ProductsPage extends ConsumerStatefulWidget {
final int warehouseId;
final String warehouseName;
@@ -24,20 +24,61 @@ class ProductsPage extends ConsumerStatefulWidget {
ConsumerState<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends ConsumerState<ProductsPage> {
class _ProductsPageState extends ConsumerState<ProductsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String _currentOperationType = 'import';
@override
void initState() {
super.initState();
// Initialize tab controller
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: widget.operationType == 'export' ? 1 : 0,
);
_currentOperationType = widget.operationType;
// Listen to tab changes
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
return;
}
final newOperationType = _tabController.index == 0 ? 'import' : 'export';
if (_currentOperationType != newOperationType) {
setState(() {
_currentOperationType = newOperationType;
});
// Load products for new operation type
ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId,
widget.warehouseName,
_currentOperationType,
);
}
});
// Load products when page is initialized
Future.microtask(() {
ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId,
widget.warehouseName,
widget.operationType,
_currentOperationType,
);
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
await ref.read(productsProvider.notifier).refreshProducts();
}
@@ -173,6 +214,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
warehouseId: widget.warehouseId,
productId: productId,
warehouseName: widget.warehouseName,
operationType: widget.operationType,
stageId: stageId,
);
}
@@ -194,7 +236,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Products (${_getOperationTypeDisplay()})',
'Products',
style: textTheme.titleMedium,
),
Text(
@@ -212,6 +254,19 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
tooltip: 'Refresh',
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(
icon: Icon(Icons.arrow_downward),
text: 'Import',
),
Tab(
icon: Icon(Icons.arrow_upward),
text: 'Export',
),
],
),
),
body: _buildBody(
isLoading: isLoading,
@@ -253,10 +308,10 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
child: Row(
children: [
Icon(
widget.operationType == 'import'
_currentOperationType == 'import'
? Icons.arrow_downward
: Icons.arrow_upward,
color: widget.operationType == 'import'
color: _currentOperationType == 'import'
? Colors.green
: Colors.orange,
),
@@ -266,7 +321,9 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getOperationTypeDisplay(),
_currentOperationType == 'import'
? 'Import Products'
: 'Export Products',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -410,6 +467,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
warehouseId: widget.warehouseId,
productId: product.id,
warehouseName: widget.warehouseName,
operationType: widget.operationType,
);
},
);
@@ -417,11 +475,4 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
),
);
}
/// Get display text for operation type
String _getOperationTypeDisplay() {
return widget.operationType == 'import'
? 'Import Products'
: 'Export Products';
}
}

View File

@@ -0,0 +1,62 @@
import 'package:hive_ce/hive.dart';
import '../models/user_model.dart';
/// Abstract interface for users local data source
abstract class UsersLocalDataSource {
/// Get all users from local storage
Future<List<UserModel>> getUsers();
/// Save users to local storage
Future<void> saveUsers(List<UserModel> users);
/// Clear all users from local storage
Future<void> clearUsers();
}
/// Implementation of UsersLocalDataSource using Hive
class UsersLocalDataSourceImpl implements UsersLocalDataSource {
static const String _boxName = 'users';
Future<Box<UserModel>> get _box async {
if (!Hive.isBoxOpen(_boxName)) {
return await Hive.openBox<UserModel>(_boxName);
}
return Hive.box<UserModel>(_boxName);
}
@override
Future<List<UserModel>> getUsers() async {
try {
final box = await _box;
return box.values.toList();
} catch (e) {
throw Exception('Failed to get users from local storage: $e');
}
}
@override
Future<void> saveUsers(List<UserModel> users) async {
try {
final box = await _box;
await box.clear();
// Save users with their ID as key
for (final user in users) {
await box.put(user.id, user);
}
} catch (e) {
throw Exception('Failed to save users to local storage: $e');
}
}
@override
Future<void> clearUsers() async {
try {
final box = await _box;
await box.clear();
} catch (e) {
throw Exception('Failed to clear users from local storage: $e');
}
}
}

View File

@@ -0,0 +1,57 @@
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/user_model.dart';
/// Abstract interface for users remote data source
abstract class UsersRemoteDataSource {
/// Fetch all users from the API
Future<List<UserModel>> getUsers();
}
/// Implementation of UsersRemoteDataSource using ApiClient
class UsersRemoteDataSourceImpl implements UsersRemoteDataSource {
final ApiClient apiClient;
UsersRemoteDataSourceImpl(this.apiClient);
@override
Future<List<UserModel>> getUsers() async {
try {
// Make API call to get all users
final response = await apiClient.get(ApiEndpoints.users);
// Parse the API response using ApiResponse wrapper
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => (json as List)
.map((e) => UserModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
// Check if the API call was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Throw exception with error message from API
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get users',
);
}
} catch (e) {
// Re-throw ServerException as-is
if (e is ServerException) {
rethrow;
}
// Re-throw NetworkException as-is
if (e is NetworkException) {
rethrow;
}
// Wrap other exceptions in ServerException
throw ServerException('Failed to get users: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,163 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/user_entity.dart';
part 'user_model.g.dart';
@HiveType(typeId: 1)
class UserModel extends UserEntity {
@HiveField(0)
@override
final int id;
@HiveField(1)
@override
final String firstName;
@HiveField(2)
@override
final String name;
@HiveField(3)
@override
final String? plateNumber;
@HiveField(4)
@override
final String email;
@HiveField(5)
@override
final String phone;
@HiveField(6)
@override
final bool isParent;
@HiveField(7)
@override
final String fullName;
@HiveField(8)
@override
final String fullNameEmail;
@HiveField(9)
@override
final String? referralCode;
@HiveField(10)
@override
final String? avatar;
@HiveField(11)
@override
final int departmentId;
@HiveField(12)
@override
final bool isWareHouseUser;
@HiveField(13)
@override
final int? wareHouseId;
@HiveField(14)
@override
final int roleId;
const UserModel({
required this.id,
required this.firstName,
required this.name,
this.plateNumber,
required this.email,
required this.phone,
this.isParent = false,
required this.fullName,
required this.fullNameEmail,
this.referralCode,
this.avatar,
required this.departmentId,
this.isWareHouseUser = false,
this.wareHouseId,
required this.roleId,
}) : super(
id: id,
firstName: firstName,
name: name,
plateNumber: plateNumber,
email: email,
phone: phone,
isParent: isParent,
fullName: fullName,
fullNameEmail: fullNameEmail,
referralCode: referralCode,
avatar: avatar,
departmentId: departmentId,
isWareHouseUser: isWareHouseUser,
wareHouseId: wareHouseId,
roleId: roleId,
);
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['Id'] ?? 0,
firstName: json['FirstName'] ?? '',
name: json['Name'] ?? '',
plateNumber: json['PlateNumber'],
email: json['Email'] ?? '',
phone: json['Phone'] ?? '',
isParent: json['IsParent'] ?? false,
fullName: json['FullName'] ?? '',
fullNameEmail: json['FullNameEmail'] ?? '',
referralCode: json['ReferralCode'],
avatar: json['Avatar'],
departmentId: json['DepartmentId'] ?? 0,
isWareHouseUser: json['IsWareHouseUser'] ?? false,
wareHouseId: json['WareHouseId'],
roleId: json['RoleId'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'Id': id,
'FirstName': firstName,
'Name': name,
'PlateNumber': plateNumber,
'Email': email,
'Phone': phone,
'IsParent': isParent,
'FullName': fullName,
'FullNameEmail': fullNameEmail,
'ReferralCode': referralCode,
'Avatar': avatar,
'DepartmentId': departmentId,
'IsWareHouseUser': isWareHouseUser,
'WareHouseId': wareHouseId,
'RoleId': roleId,
};
}
UserEntity toEntity() {
return UserEntity(
id: id,
firstName: firstName,
name: name,
plateNumber: plateNumber,
email: email,
phone: phone,
isParent: isParent,
fullName: fullName,
fullNameEmail: fullNameEmail,
referralCode: referralCode,
avatar: avatar,
departmentId: departmentId,
isWareHouseUser: isWareHouseUser,
wareHouseId: wareHouseId,
roleId: roleId,
);
}
}

View File

@@ -0,0 +1,83 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class UserModelAdapter extends TypeAdapter<UserModel> {
@override
final typeId = 1;
@override
UserModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return UserModel(
id: (fields[0] as num).toInt(),
firstName: fields[1] as String,
name: fields[2] as String,
plateNumber: fields[3] as String?,
email: fields[4] as String,
phone: fields[5] as String,
isParent: fields[6] == null ? false : fields[6] as bool,
fullName: fields[7] as String,
fullNameEmail: fields[8] as String,
referralCode: fields[9] as String?,
avatar: fields[10] as String?,
departmentId: (fields[11] as num).toInt(),
isWareHouseUser: fields[12] == null ? false : fields[12] as bool,
wareHouseId: (fields[13] as num?)?.toInt(),
roleId: (fields[14] as num).toInt(),
);
}
@override
void write(BinaryWriter writer, UserModel obj) {
writer
..writeByte(15)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.firstName)
..writeByte(2)
..write(obj.name)
..writeByte(3)
..write(obj.plateNumber)
..writeByte(4)
..write(obj.email)
..writeByte(5)
..write(obj.phone)
..writeByte(6)
..write(obj.isParent)
..writeByte(7)
..write(obj.fullName)
..writeByte(8)
..write(obj.fullNameEmail)
..writeByte(9)
..write(obj.referralCode)
..writeByte(10)
..write(obj.avatar)
..writeByte(11)
..write(obj.departmentId)
..writeByte(12)
..write(obj.isWareHouseUser)
..writeByte(13)
..write(obj.wareHouseId)
..writeByte(14)
..write(obj.roleId);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,67 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/user_entity.dart';
import '../../domain/repositories/users_repository.dart';
import '../datasources/users_local_datasource.dart';
import '../datasources/users_remote_datasource.dart';
/// Implementation of UsersRepository
class UsersRepositoryImpl implements UsersRepository {
final UsersRemoteDataSource remoteDataSource;
final UsersLocalDataSource localDataSource;
UsersRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, List<UserEntity>>> getUsers() async {
try {
// Try to get users from local storage first
final localUsers = await localDataSource.getUsers();
if (localUsers.isNotEmpty) {
// Return local users if available
return Right(localUsers.map((model) => model.toEntity()).toList());
}
// If no local users, fetch from API
return await syncUsers();
} catch (e) {
return Left(CacheFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<UserEntity>>> syncUsers() async {
try {
// Fetch users from API
final users = await remoteDataSource.getUsers();
// Save to local storage
await localDataSource.saveUsers(users);
// Return as entities
return Right(users.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
@override
Future<Either<Failure, void>> clearUsers() async {
try {
await localDataSource.clearUsers();
return const Right(null);
} catch (e) {
return Left(CacheFailure(e.toString()));
}
}
}

View File

@@ -0,0 +1,78 @@
import 'package:equatable/equatable.dart';
/// User entity representing a user in the system
class UserEntity extends Equatable {
final int id;
final String firstName;
final String name;
final String? plateNumber;
final String email;
final String phone;
final bool isParent;
final String fullName;
final String fullNameEmail;
final String? referralCode;
final String? avatar;
final int departmentId;
final bool isWareHouseUser;
final int? wareHouseId;
final int roleId;
const UserEntity({
required this.id,
required this.firstName,
required this.name,
this.plateNumber,
required this.email,
required this.phone,
this.isParent = false,
required this.fullName,
required this.fullNameEmail,
this.referralCode,
this.avatar,
required this.departmentId,
this.isWareHouseUser = false,
this.wareHouseId,
required this.roleId,
});
@override
List<Object?> get props => [
id,
firstName,
name,
plateNumber,
email,
phone,
isParent,
fullName,
fullNameEmail,
referralCode,
avatar,
departmentId,
isWareHouseUser,
wareHouseId,
roleId,
];
/// Get display name
String get displayName {
if (fullName.isNotEmpty) return fullName;
if (name.isNotEmpty) return name;
return email;
}
/// Get initials from name (for avatar display)
String get initials {
if (firstName.isNotEmpty && name.isNotEmpty) {
return (firstName.substring(0, 1) + name.substring(0, 1)).toUpperCase();
}
final parts = fullName.trim().split(' ');
if (parts.isEmpty) return '?';
if (parts.length == 1) {
return parts[0].substring(0, 1).toUpperCase();
}
return (parts[0].substring(0, 1) + parts[parts.length - 1].substring(0, 1))
.toUpperCase();
}
}

View File

@@ -0,0 +1,16 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/user_entity.dart';
/// Abstract repository interface for users
abstract class UsersRepository {
/// Get all users (from local storage if available, otherwise from API)
Future<Either<Failure, List<UserEntity>>> getUsers();
/// Sync users from API and save to local storage
Future<Either<Failure, List<UserEntity>>> syncUsers();
/// Clear all users from local storage
Future<Either<Failure, void>> clearUsers();
}

View File

@@ -0,0 +1,18 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/user_entity.dart';
import '../repositories/users_repository.dart';
/// Use case for getting users
/// Follows local-first strategy: returns local users if available,
/// otherwise syncs from API
class GetUsersUseCase {
final UsersRepository repository;
GetUsersUseCase(this.repository);
Future<Either<Failure, List<UserEntity>>> call() async {
return await repository.getUsers();
}
}

View File

@@ -0,0 +1,17 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/user_entity.dart';
import '../repositories/users_repository.dart';
/// Use case for syncing users from API
/// Forces refresh from API and saves to local storage
class SyncUsersUseCase {
final UsersRepository repository;
SyncUsersUseCase(this.repository);
Future<Either<Failure, List<UserEntity>>> call() async {
return await repository.syncUsers();
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/usecases/get_users_usecase.dart';
import '../../domain/usecases/sync_users_usecase.dart';
import 'users_state.dart';
/// State notifier for users
class UsersNotifier extends StateNotifier<UsersState> {
final GetUsersUseCase getUsersUseCase;
final SyncUsersUseCase syncUsersUseCase;
UsersNotifier({
required this.getUsersUseCase,
required this.syncUsersUseCase,
}) : super(const UsersState());
/// Get users from local storage (or API if not cached)
Future<void> getUsers() async {
state = state.copyWith(isLoading: true, error: null);
final result = await getUsersUseCase();
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
),
(users) => state = state.copyWith(
users: users,
isLoading: false,
error: null,
),
);
}
/// Sync users from API (force refresh)
Future<void> syncUsers() async {
state = state.copyWith(isLoading: true, error: null);
final result = await syncUsersUseCase();
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
),
(users) => state = state.copyWith(
users: users,
isLoading: false,
error: null,
),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/user_entity.dart';
/// State for users feature
class UsersState extends Equatable {
final List<UserEntity> users;
final bool isLoading;
final String? error;
const UsersState({
this.users = const [],
this.isLoading = false,
this.error,
});
UsersState copyWith({
List<UserEntity>? users,
bool? isLoading,
String? error,
}) {
return UsersState(
users: users ?? this.users,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
@override
List<Object?> get props => [users, isLoading, error];
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/providers.dart';
import '../../../../core/router/app_router.dart';
import '../widgets/warehouse_card.dart';
/// Warehouse selection page
@@ -26,9 +26,11 @@ class _WarehouseSelectionPageState
@override
void initState() {
super.initState();
// Load warehouses when page is first created
// Load warehouses and sync users when page is first created
Future.microtask(() {
ref.read(warehouseProvider.notifier).loadWarehouses();
// Sync users from API and save to local storage
ref.read(usersProvider.notifier).syncUsers();
});
}
@@ -170,11 +172,14 @@ class _WarehouseSelectionPageState
return WarehouseCard(
warehouse: warehouse,
onTap: () {
// Select warehouse and navigate to operations
// Select warehouse and navigate directly to products page
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Navigate to operations page
context.push('/operations', extra: warehouse);
// Navigate to products page with warehouse data
context.pushToProducts(
warehouse: warehouse,
operationType: 'import', // Default to import
);
},
);
},

18
lib/hive_registrar.g.dart Normal file
View File

@@ -0,0 +1,18 @@
// Generated by Hive CE
// Do not modify
// Check in to version control
import 'package:hive_ce/hive.dart';
import 'package:minhthu/features/users/data/models/user_model.dart';
extension HiveRegistrar on HiveInterface {
void registerAdapters() {
registerAdapter(UserModelAdapter());
}
}
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(UserModelAdapter());
}
}

View File

@@ -1,12 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'core/theme/app_theme.dart';
import 'core/router/app_router.dart';
import 'features/users/data/models/user_model.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive
final appDocumentDir = await getApplicationDocumentsDirectory();
await Hive.initFlutter(appDocumentDir.path);
// Register Hive adapters
Hive.registerAdapter(UserModelAdapter());
runApp(
const ProviderScope(
child: MyApp(),

View File

@@ -633,7 +633,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"

View File

@@ -21,6 +21,7 @@ dependencies:
dartz: ^0.10.1
get_it: ^7.6.4
flutter_secure_storage: ^9.0.0
path_provider: ^2.1.0
# Data Classes & Serialization
freezed_annotation: ^2.4.1