aaa
This commit is contained in:
@@ -58,6 +58,12 @@ class ApiEndpoints {
|
|||||||
/// Response: List of products
|
/// Response: List of products
|
||||||
static const String products = '/portalProduct/getAllProduct';
|
static const String products = '/portalProduct/getAllProduct';
|
||||||
|
|
||||||
|
/// 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';
|
||||||
|
|
||||||
/// Get product by ID
|
/// Get product by ID
|
||||||
/// GET: (requires auth token)
|
/// GET: (requires auth token)
|
||||||
/// Parameter: productId
|
/// Parameter: productId
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ 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_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/repositories/products_repository.dart';
|
import '../../features/products/domain/repositories/products_repository.dart';
|
||||||
|
import '../../features/products/domain/usecases/get_product_detail_usecase.dart';
|
||||||
import '../../features/products/domain/usecases/get_products_usecase.dart';
|
import '../../features/products/domain/usecases/get_products_usecase.dart';
|
||||||
|
import '../../features/products/presentation/providers/product_detail_provider.dart';
|
||||||
import '../../features/products/presentation/providers/products_provider.dart';
|
import '../../features/products/presentation/providers/products_provider.dart';
|
||||||
import '../../features/warehouse/data/datasources/warehouse_remote_datasource.dart';
|
import '../../features/warehouse/data/datasources/warehouse_remote_datasource.dart';
|
||||||
import '../../features/warehouse/data/repositories/warehouse_repository_impl.dart';
|
import '../../features/warehouse/data/repositories/warehouse_repository_impl.dart';
|
||||||
@@ -269,6 +272,13 @@ final getProductsUseCaseProvider = Provider<GetProductsUseCase>((ref) {
|
|||||||
return GetProductsUseCase(repository);
|
return GetProductsUseCase(repository);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Get product detail use case provider
|
||||||
|
/// Encapsulates product detail fetching business logic
|
||||||
|
final getProductDetailUseCaseProvider = Provider<GetProductDetailUseCase>((ref) {
|
||||||
|
final repository = ref.watch(productsRepositoryProvider);
|
||||||
|
return GetProductDetailUseCase(repository);
|
||||||
|
});
|
||||||
|
|
||||||
// Presentation Layer
|
// Presentation Layer
|
||||||
|
|
||||||
/// Products state notifier provider
|
/// Products state notifier provider
|
||||||
@@ -340,6 +350,48 @@ final productsErrorProvider = Provider<String?>((ref) {
|
|||||||
return productsState.error;
|
return productsState.error;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Product detail state notifier provider
|
||||||
|
/// Manages product detail state for a specific product in a warehouse
|
||||||
|
/// This needs to be a family provider to support multiple product details
|
||||||
|
final productDetailProvider =
|
||||||
|
StateNotifierProvider.family<ProductDetailNotifier, ProductDetailState, String>(
|
||||||
|
(ref, _) {
|
||||||
|
final getProductDetailUseCase = ref.watch(getProductDetailUseCaseProvider);
|
||||||
|
return ProductDetailNotifier(getProductDetailUseCase);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Convenient providers for product detail state
|
||||||
|
|
||||||
|
/// Provider to get product stages list
|
||||||
|
/// Usage: ref.watch(productStagesProvider(key))
|
||||||
|
final productStagesProvider = Provider.family<List<ProductStageEntity>, String>((ref, key) {
|
||||||
|
final state = ref.watch(productDetailProvider(key));
|
||||||
|
return state.stages;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to get selected product stage
|
||||||
|
/// Usage: ref.watch(selectedProductStageProvider(key))
|
||||||
|
final selectedProductStageProvider = Provider.family<ProductStageEntity?, String>((ref, key) {
|
||||||
|
final state = ref.watch(productDetailProvider(key));
|
||||||
|
return state.selectedStage;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to check if product detail is loading
|
||||||
|
/// Usage: ref.watch(isProductDetailLoadingProvider(key))
|
||||||
|
final isProductDetailLoadingProvider = Provider.family<bool, String>((ref, key) {
|
||||||
|
final state = ref.watch(productDetailProvider(key));
|
||||||
|
return state.isLoading;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to get product detail error
|
||||||
|
/// Returns null if no error
|
||||||
|
/// Usage: ref.watch(productDetailErrorProvider(key))
|
||||||
|
final productDetailErrorProvider = Provider.family<String?, String>((ref, key) {
|
||||||
|
final state = ref.watch(productDetailProvider(key));
|
||||||
|
return state.error;
|
||||||
|
});
|
||||||
|
|
||||||
/// ========================================================================
|
/// ========================================================================
|
||||||
/// USAGE EXAMPLES
|
/// USAGE EXAMPLES
|
||||||
/// ========================================================================
|
/// ========================================================================
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../features/auth/di/auth_dependency_injection.dart';
|
|||||||
import '../../features/warehouse/presentation/pages/warehouse_selection_page.dart';
|
import '../../features/warehouse/presentation/pages/warehouse_selection_page.dart';
|
||||||
import '../../features/operation/presentation/pages/operation_selection_page.dart';
|
import '../../features/operation/presentation/pages/operation_selection_page.dart';
|
||||||
import '../../features/products/presentation/pages/products_page.dart';
|
import '../../features/products/presentation/pages/products_page.dart';
|
||||||
|
import '../../features/products/presentation/pages/product_detail_page.dart';
|
||||||
import '../../features/warehouse/domain/entities/warehouse_entity.dart';
|
import '../../features/warehouse/domain/entities/warehouse_entity.dart';
|
||||||
import '../storage/secure_storage.dart';
|
import '../storage/secure_storage.dart';
|
||||||
|
|
||||||
@@ -121,6 +122,49 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/// Product Detail Route
|
||||||
|
/// Path: /product-detail
|
||||||
|
/// Takes warehouseId, productId, and warehouseName as extra parameter
|
||||||
|
/// Shows detailed information for a specific product
|
||||||
|
GoRoute(
|
||||||
|
path: '/product-detail',
|
||||||
|
name: 'product-detail',
|
||||||
|
builder: (context, state) {
|
||||||
|
final params = state.extra as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
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 required parameters
|
||||||
|
final warehouseId = params['warehouseId'] as int?;
|
||||||
|
final productId = params['productId'] as int?;
|
||||||
|
final warehouseName = params['warehouseName'] as String?;
|
||||||
|
|
||||||
|
// Validate parameters
|
||||||
|
if (warehouseId == null || productId == null || warehouseName == null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.go('/warehouses');
|
||||||
|
});
|
||||||
|
return const _ErrorScreen(
|
||||||
|
message: 'Invalid product detail parameters',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProductDetailPage(
|
||||||
|
warehouseId: warehouseId,
|
||||||
|
productId: productId,
|
||||||
|
warehouseName: warehouseName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ==================== Error Handling ====================
|
// ==================== Error Handling ====================
|
||||||
@@ -326,6 +370,26 @@ extension AppRouterExtension on BuildContext {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigate to product detail page
|
||||||
|
///
|
||||||
|
/// [warehouseId] - ID of the warehouse
|
||||||
|
/// [productId] - ID of the product to view
|
||||||
|
/// [warehouseName] - Name of the warehouse (for display)
|
||||||
|
void goToProductDetail({
|
||||||
|
required int warehouseId,
|
||||||
|
required int productId,
|
||||||
|
required String warehouseName,
|
||||||
|
}) {
|
||||||
|
push(
|
||||||
|
'/product-detail',
|
||||||
|
extra: {
|
||||||
|
'warehouseId': warehouseId,
|
||||||
|
'productId': productId,
|
||||||
|
'warehouseName': warehouseName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pop current route
|
/// Pop current route
|
||||||
void goBack() => pop();
|
void goBack() => pop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,26 @@ curl --request GET \
|
|||||||
--header 'Sec-Fetch-Mode: cors' \
|
--header 'Sec-Fetch-Mode: cors' \
|
||||||
--header 'Sec-Fetch-Site: same-site' \
|
--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'
|
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0'
|
||||||
|
|
||||||
|
#Get product by id
|
||||||
|
curl --request POST \
|
||||||
|
--url https://dotnet.elidev.info:8157/ws/portalWareHouse/GetProductStageInWareHouse \
|
||||||
|
--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/tUJhximq11MDXsQV51BDt6Umpp4VKXfWllZcI1W9et5G18msjj8GtRXqaApWsfVcrnXk3s8rJVjeZocqi7vKw361ZLjd8onMzte884jxAx7qq/7Tdt6eQwSdzTHLwzxB3x+hvpbSPQQTkMrV4TLy7VuKLt7+duGDNPYGypFW1kamS3jZYmv26Pkr4xW257BEXduflDRKOOMjsr4K0d2KyYn0fJA6RzZoKWrUqBQyukkX6I8tzjopaTn0bKGCN32/lGVZ4bB3BMJMEphdFqaTyjS2p9k5/GcOt0qmrwztEinb+epzYJjsLXZheg==' \
|
||||||
|
--header 'AppID: Minhthu2016' \
|
||||||
|
--header 'Connection: keep-alive' \
|
||||||
|
--header 'Origin: https://dotnet.elidev.info:8158' \
|
||||||
|
--header 'Priority: u=0' \
|
||||||
|
--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:144.0) Gecko/20100101 Firefox/144.0' \
|
||||||
|
--header 'content-type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"WareHouseId": 7,
|
||||||
|
"ProductId": 11
|
||||||
|
}'
|
||||||
@@ -144,7 +144,7 @@ class OperationSelectionPage extends ConsumerWidget {
|
|||||||
WarehouseEntity warehouse,
|
WarehouseEntity warehouse,
|
||||||
String operationType,
|
String operationType,
|
||||||
) {
|
) {
|
||||||
context.goNamed(
|
context.pushNamed(
|
||||||
'products',
|
'products',
|
||||||
extra: {
|
extra: {
|
||||||
'warehouse': warehouse,
|
'warehouse': warehouse,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
import '../../../../core/errors/exceptions.dart';
|
import '../../../../core/errors/exceptions.dart';
|
||||||
import '../../../../core/network/api_client.dart';
|
import '../../../../core/network/api_client.dart';
|
||||||
import '../../../../core/network/api_response.dart';
|
import '../../../../core/network/api_response.dart';
|
||||||
|
import '../models/product_detail_request_model.dart';
|
||||||
import '../models/product_model.dart';
|
import '../models/product_model.dart';
|
||||||
|
import '../models/product_stage_model.dart';
|
||||||
|
|
||||||
/// Abstract interface for products remote data source
|
/// Abstract interface for products remote data source
|
||||||
abstract class ProductsRemoteDataSource {
|
abstract class ProductsRemoteDataSource {
|
||||||
@@ -13,6 +16,14 @@ abstract class ProductsRemoteDataSource {
|
|||||||
/// Returns List<ProductModel>
|
/// Returns List<ProductModel>
|
||||||
/// Throws [ServerException] if the API call fails
|
/// Throws [ServerException] if the API call fails
|
||||||
Future<List<ProductModel>> getProducts(int warehouseId, String type);
|
Future<List<ProductModel>> getProducts(int warehouseId, String type);
|
||||||
|
|
||||||
|
/// Get product stages for a product in a warehouse
|
||||||
|
///
|
||||||
|
/// [request] - Request containing warehouseId and productId
|
||||||
|
///
|
||||||
|
/// Returns List<ProductStageModel> with all stages for the product
|
||||||
|
/// Throws [ServerException] if the API call fails
|
||||||
|
Future<List<ProductStageModel>> getProductDetail(ProductDetailRequestModel request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of ProductsRemoteDataSource using ApiClient
|
/// Implementation of ProductsRemoteDataSource using ApiClient
|
||||||
@@ -59,4 +70,55 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
|
|||||||
throw ServerException('Failed to get products: ${e.toString()}');
|
throw ServerException('Failed to get products: ${e.toString()}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductStageModel>> getProductDetail(
|
||||||
|
ProductDetailRequestModel request) async {
|
||||||
|
try {
|
||||||
|
// Make API call to get product stages
|
||||||
|
final response = await apiClient.post(
|
||||||
|
ApiEndpoints.productDetail,
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse the API response - the Value field contains a list of stage objects
|
||||||
|
final apiResponse = ApiResponse.fromJson(
|
||||||
|
response.data as Map<String, dynamic>,
|
||||||
|
(json) {
|
||||||
|
// The API returns a list of stages for the product
|
||||||
|
final list = json as List;
|
||||||
|
if (list.isEmpty) {
|
||||||
|
throw const ServerException('Product stages not found');
|
||||||
|
}
|
||||||
|
// Parse all stages from the list
|
||||||
|
return list
|
||||||
|
.map((e) => ProductStageModel.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 product stages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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 product stages: ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/// Request model for getting product details in a warehouse
|
||||||
|
///
|
||||||
|
/// Used to fetch product stage information for a specific warehouse and product
|
||||||
|
class ProductDetailRequestModel {
|
||||||
|
final int warehouseId;
|
||||||
|
final int productId;
|
||||||
|
|
||||||
|
const ProductDetailRequestModel({
|
||||||
|
required this.warehouseId,
|
||||||
|
required this.productId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convert to JSON for API request
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'WareHouseId': warehouseId,
|
||||||
|
'ProductId': productId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ProductDetailRequestModel(warehouseId: $warehouseId, productId: $productId)';
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/features/products/data/models/product_stage_model.dart
Normal file
67
lib/features/products/data/models/product_stage_model.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import '../../domain/entities/product_stage_entity.dart';
|
||||||
|
|
||||||
|
/// Product stage model for data layer
|
||||||
|
/// Represents a product at a specific production stage
|
||||||
|
class ProductStageModel extends ProductStageEntity {
|
||||||
|
const ProductStageModel({
|
||||||
|
required super.productId,
|
||||||
|
required super.productStageId,
|
||||||
|
required super.actionTypeId,
|
||||||
|
required super.passedQuantity,
|
||||||
|
required super.issuedQuantity,
|
||||||
|
required super.issuedQuantityWeight,
|
||||||
|
required super.passedQuantityWeight,
|
||||||
|
required super.stageName,
|
||||||
|
required super.createdDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create ProductStageModel from JSON
|
||||||
|
factory ProductStageModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProductStageModel(
|
||||||
|
productId: json['ProductId'] as int,
|
||||||
|
productStageId: json['ProductStageId'] as int?,
|
||||||
|
actionTypeId: json['ActionTypeId'] as int?,
|
||||||
|
passedQuantity: json['PassedQuantity'] as int,
|
||||||
|
issuedQuantity: json['IssuedQuantity'] as int,
|
||||||
|
issuedQuantityWeight: (json['IssuedQuantityWeight'] as num).toDouble(),
|
||||||
|
passedQuantityWeight: (json['PassedQuantityWeight'] as num).toDouble(),
|
||||||
|
stageName: json['StageName'] as String?,
|
||||||
|
createdDate: json['CreatedDate'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'ProductId': productId,
|
||||||
|
'ProductStageId': productStageId,
|
||||||
|
'ActionTypeId': actionTypeId,
|
||||||
|
'PassedQuantity': passedQuantity,
|
||||||
|
'IssuedQuantity': issuedQuantity,
|
||||||
|
'IssuedQuantityWeight': issuedQuantityWeight,
|
||||||
|
'PassedQuantityWeight': passedQuantityWeight,
|
||||||
|
'StageName': stageName,
|
||||||
|
'CreatedDate': createdDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to entity
|
||||||
|
ProductStageEntity toEntity() {
|
||||||
|
return ProductStageEntity(
|
||||||
|
productId: productId,
|
||||||
|
productStageId: productStageId,
|
||||||
|
actionTypeId: actionTypeId,
|
||||||
|
passedQuantity: passedQuantity,
|
||||||
|
issuedQuantity: issuedQuantity,
|
||||||
|
issuedQuantityWeight: issuedQuantityWeight,
|
||||||
|
passedQuantityWeight: passedQuantityWeight,
|
||||||
|
stageName: stageName,
|
||||||
|
createdDate: createdDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ProductStageModel(productId: $productId, stageName: $stageName, passedQuantity: $passedQuantity)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import 'package:dartz/dartz.dart';
|
|||||||
import '../../../../core/errors/exceptions.dart';
|
import '../../../../core/errors/exceptions.dart';
|
||||||
import '../../../../core/errors/failures.dart';
|
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/repositories/products_repository.dart';
|
import '../../domain/repositories/products_repository.dart';
|
||||||
import '../datasources/products_remote_datasource.dart';
|
import '../datasources/products_remote_datasource.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
|
||||||
@@ -34,4 +36,33 @@ class ProductsRepositoryImpl implements ProductsRepository {
|
|||||||
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
|
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, List<ProductStageEntity>>> getProductDetail(
|
||||||
|
int warehouseId,
|
||||||
|
int productId,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
// Create request model
|
||||||
|
final request = ProductDetailRequestModel(
|
||||||
|
warehouseId: warehouseId,
|
||||||
|
productId: productId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch product stages from remote data source
|
||||||
|
final stages = await remoteDataSource.getProductDetail(request);
|
||||||
|
|
||||||
|
// Convert models to entities and return success
|
||||||
|
return Right(stages.map((stage) => stage.toEntity()).toList());
|
||||||
|
} 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()}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Product stage entity - pure domain model
|
||||||
|
/// Represents a product at a specific production stage with quantities
|
||||||
|
class ProductStageEntity extends Equatable {
|
||||||
|
final int productId;
|
||||||
|
final int? productStageId;
|
||||||
|
final int? actionTypeId;
|
||||||
|
final int passedQuantity;
|
||||||
|
final int issuedQuantity;
|
||||||
|
final double issuedQuantityWeight;
|
||||||
|
final double passedQuantityWeight;
|
||||||
|
final String? stageName;
|
||||||
|
final String createdDate;
|
||||||
|
|
||||||
|
const ProductStageEntity({
|
||||||
|
required this.productId,
|
||||||
|
required this.productStageId,
|
||||||
|
required this.actionTypeId,
|
||||||
|
required this.passedQuantity,
|
||||||
|
required this.issuedQuantity,
|
||||||
|
required this.issuedQuantityWeight,
|
||||||
|
required this.passedQuantityWeight,
|
||||||
|
required this.stageName,
|
||||||
|
required this.createdDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get display name for the stage
|
||||||
|
/// Returns "No Stage" if stageName is null
|
||||||
|
String get displayName => stageName ?? 'No Stage';
|
||||||
|
|
||||||
|
/// Check if this is a valid stage (has a stage name)
|
||||||
|
bool get hasStage => stageName != null && stageName!.isNotEmpty;
|
||||||
|
|
||||||
|
/// Check if this stage has any passed quantity
|
||||||
|
bool get hasPassedQuantity => passedQuantity > 0;
|
||||||
|
|
||||||
|
/// Check if this stage has any issued quantity
|
||||||
|
bool get hasIssuedQuantity => issuedQuantity > 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
productId,
|
||||||
|
productStageId,
|
||||||
|
actionTypeId,
|
||||||
|
passedQuantity,
|
||||||
|
issuedQuantity,
|
||||||
|
issuedQuantityWeight,
|
||||||
|
passedQuantityWeight,
|
||||||
|
stageName,
|
||||||
|
createdDate,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ProductStageEntity(productId: $productId, stageName: $stageName, passedQuantity: $passedQuantity)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dartz/dartz.dart';
|
import 'package:dartz/dartz.dart';
|
||||||
import '../../../../core/errors/failures.dart';
|
import '../../../../core/errors/failures.dart';
|
||||||
import '../entities/product_entity.dart';
|
import '../entities/product_entity.dart';
|
||||||
|
import '../entities/product_stage_entity.dart';
|
||||||
|
|
||||||
/// Abstract repository interface for products
|
/// Abstract repository interface for products
|
||||||
/// Defines the contract for product data operations
|
/// Defines the contract for product data operations
|
||||||
@@ -15,4 +16,15 @@ abstract class ProductsRepository {
|
|||||||
int warehouseId,
|
int warehouseId,
|
||||||
String type,
|
String type,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Get product stages for a product in a warehouse
|
||||||
|
///
|
||||||
|
/// [warehouseId] - The ID of the warehouse
|
||||||
|
/// [productId] - The ID of the product
|
||||||
|
///
|
||||||
|
/// Returns Either<Failure, List<ProductStageEntity>>
|
||||||
|
Future<Either<Failure, List<ProductStageEntity>>> getProductDetail(
|
||||||
|
int warehouseId,
|
||||||
|
int productId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../entities/product_stage_entity.dart';
|
||||||
|
import '../repositories/products_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for getting product stages
|
||||||
|
/// Encapsulates the business logic for fetching product stage information
|
||||||
|
class GetProductDetailUseCase {
|
||||||
|
final ProductsRepository repository;
|
||||||
|
|
||||||
|
GetProductDetailUseCase(this.repository);
|
||||||
|
|
||||||
|
/// Execute the use case
|
||||||
|
///
|
||||||
|
/// [warehouseId] - The ID of the warehouse
|
||||||
|
/// [productId] - The ID of the product
|
||||||
|
///
|
||||||
|
/// Returns Either<Failure, List<ProductStageEntity>>
|
||||||
|
Future<Either<Failure, List<ProductStageEntity>>> call(
|
||||||
|
int warehouseId,
|
||||||
|
int productId,
|
||||||
|
) async {
|
||||||
|
return await repository.getProductDetail(warehouseId, productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../../core/di/providers.dart';
|
||||||
|
import '../../domain/entities/product_stage_entity.dart';
|
||||||
|
|
||||||
|
/// Product detail page
|
||||||
|
/// Displays product stages as chips and shows selected stage information
|
||||||
|
class ProductDetailPage extends ConsumerStatefulWidget {
|
||||||
|
final int warehouseId;
|
||||||
|
final int productId;
|
||||||
|
final String warehouseName;
|
||||||
|
|
||||||
|
const ProductDetailPage({
|
||||||
|
super.key,
|
||||||
|
required this.warehouseId,
|
||||||
|
required this.productId,
|
||||||
|
required this.warehouseName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProductDetailPage> createState() => _ProductDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||||
|
late String _providerKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_providerKey = '${widget.warehouseId}_${widget.productId}';
|
||||||
|
|
||||||
|
// Load product stages when page is initialized
|
||||||
|
Future.microtask(() {
|
||||||
|
ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail(
|
||||||
|
widget.warehouseId,
|
||||||
|
widget.productId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRefresh() async {
|
||||||
|
await ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
|
||||||
|
widget.warehouseId,
|
||||||
|
widget.productId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final textTheme = theme.textTheme;
|
||||||
|
|
||||||
|
// Watch the product detail state
|
||||||
|
final productDetailState = ref.watch(productDetailProvider(_providerKey));
|
||||||
|
final stages = productDetailState.stages;
|
||||||
|
final selectedStage = productDetailState.selectedStage;
|
||||||
|
final isLoading = productDetailState.isLoading;
|
||||||
|
final error = productDetailState.error;
|
||||||
|
final selectedIndex = productDetailState.selectedStageIndex;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Product Stages',
|
||||||
|
style: textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.warehouseName,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _onRefresh,
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(
|
||||||
|
isLoading: isLoading,
|
||||||
|
error: error,
|
||||||
|
stages: stages,
|
||||||
|
selectedStage: selectedStage,
|
||||||
|
selectedIndex: selectedIndex,
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody({
|
||||||
|
required bool isLoading,
|
||||||
|
required String? error,
|
||||||
|
required List<ProductStageEntity> stages,
|
||||||
|
required ProductStageEntity? selectedStage,
|
||||||
|
required int selectedIndex,
|
||||||
|
required ThemeData theme,
|
||||||
|
}) {
|
||||||
|
if (isLoading && stages.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null && stages.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Error',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
error,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stages.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No stages found',
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Stage chips section
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Production Stages (${stages.length})',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: List.generate(stages.length, (index) {
|
||||||
|
final stage = stages[index];
|
||||||
|
final isSelected = index == selectedIndex;
|
||||||
|
|
||||||
|
return FilterChip(
|
||||||
|
selected: isSelected,
|
||||||
|
label: Text(stage.displayName),
|
||||||
|
onSelected: (_) {
|
||||||
|
ref
|
||||||
|
.read(productDetailProvider(_providerKey).notifier)
|
||||||
|
.selectStage(index);
|
||||||
|
},
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
selectedColor: theme.colorScheme.primaryContainer,
|
||||||
|
checkmarkColor: theme.colorScheme.primary,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Stage details section
|
||||||
|
Expanded(
|
||||||
|
child: selectedStage == null
|
||||||
|
? const Center(child: Text('No stage selected'))
|
||||||
|
: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Stage header
|
||||||
|
_buildStageHeader(selectedStage, theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Quantity information
|
||||||
|
_buildSectionCard(
|
||||||
|
theme: theme,
|
||||||
|
title: 'Quantities',
|
||||||
|
icon: Icons.inventory_outlined,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow('Passed Quantity', '${selectedStage.passedQuantity}'),
|
||||||
|
_buildInfoRow(
|
||||||
|
'Passed Weight',
|
||||||
|
'${selectedStage.passedQuantityWeight.toStringAsFixed(2)} kg',
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
_buildInfoRow('Issued Quantity', '${selectedStage.issuedQuantity}'),
|
||||||
|
_buildInfoRow(
|
||||||
|
'Issued Weight',
|
||||||
|
'${selectedStage.issuedQuantityWeight.toStringAsFixed(2)} kg',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Stage information
|
||||||
|
_buildSectionCard(
|
||||||
|
theme: theme,
|
||||||
|
title: 'Stage Information',
|
||||||
|
icon: Icons.info_outlined,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow('Product ID', '${selectedStage.productId}'),
|
||||||
|
if (selectedStage.productStageId != null)
|
||||||
|
_buildInfoRow('Stage ID', '${selectedStage.productStageId}'),
|
||||||
|
if (selectedStage.actionTypeId != null)
|
||||||
|
_buildInfoRow('Action Type ID', '${selectedStage.actionTypeId}'),
|
||||||
|
_buildInfoRow('Stage Name', selectedStage.displayName),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Status indicators
|
||||||
|
_buildStatusCards(selectedStage, theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStageHeader(ProductStageEntity stage, ThemeData theme) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.build_circle_outlined,
|
||||||
|
size: 32,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
stage.displayName,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Product ID: ${stage.productId}',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionCard({
|
||||||
|
required ThemeData theme,
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusCards(ProductStageEntity stage, ThemeData theme) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Card(
|
||||||
|
color: stage.hasPassedQuantity
|
||||||
|
? Colors.green.withValues(alpha: 0.1)
|
||||||
|
: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
stage.hasPassedQuantity ? Icons.check_circle : Icons.cancel,
|
||||||
|
color: stage.hasPassedQuantity ? Colors.green : Colors.grey,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Has Passed',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Card(
|
||||||
|
color: stage.hasIssuedQuantity
|
||||||
|
? Colors.blue.withValues(alpha: 0.1)
|
||||||
|
: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
stage.hasIssuedQuantity ? Icons.check_circle : Icons.cancel,
|
||||||
|
color: stage.hasIssuedQuantity ? Colors.blue : Colors.grey,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Has Issued',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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/router/app_router.dart';
|
||||||
import '../widgets/product_list_item.dart';
|
import '../widgets/product_list_item.dart';
|
||||||
|
|
||||||
/// Products list page
|
/// Products list page
|
||||||
@@ -261,13 +262,11 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
|||||||
return ProductListItem(
|
return ProductListItem(
|
||||||
product: product,
|
product: product,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Handle product tap if needed
|
// Navigate to product detail page
|
||||||
// For now, just show a snackbar
|
context.goToProductDetail(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
warehouseId: widget.warehouseId,
|
||||||
SnackBar(
|
productId: product.id,
|
||||||
content: Text('Selected: ${product.fullName}'),
|
warehouseName: widget.warehouseName,
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../domain/entities/product_stage_entity.dart';
|
||||||
|
import '../../domain/usecases/get_product_detail_usecase.dart';
|
||||||
|
|
||||||
|
/// Product detail state class
|
||||||
|
/// Holds the current state of product stages and selection
|
||||||
|
class ProductDetailState {
|
||||||
|
final List<ProductStageEntity> stages;
|
||||||
|
final int selectedStageIndex;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const ProductDetailState({
|
||||||
|
this.stages = const [],
|
||||||
|
this.selectedStageIndex = 0,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get the currently selected stage
|
||||||
|
ProductStageEntity? get selectedStage {
|
||||||
|
if (stages.isEmpty || selectedStageIndex >= stages.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stages[selectedStageIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if there are any stages loaded
|
||||||
|
bool get hasStages => stages.isNotEmpty;
|
||||||
|
|
||||||
|
ProductDetailState copyWith({
|
||||||
|
List<ProductStageEntity>? stages,
|
||||||
|
int? selectedStageIndex,
|
||||||
|
bool? isLoading,
|
||||||
|
String? error,
|
||||||
|
}) {
|
||||||
|
return ProductDetailState(
|
||||||
|
stages: stages ?? this.stages,
|
||||||
|
selectedStageIndex: selectedStageIndex ?? this.selectedStageIndex,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Product detail notifier
|
||||||
|
/// Manages the product detail state and business logic
|
||||||
|
class ProductDetailNotifier extends StateNotifier<ProductDetailState> {
|
||||||
|
final GetProductDetailUseCase getProductDetailUseCase;
|
||||||
|
|
||||||
|
ProductDetailNotifier(this.getProductDetailUseCase)
|
||||||
|
: super(const ProductDetailState());
|
||||||
|
|
||||||
|
/// Load product stages for a specific warehouse and product
|
||||||
|
///
|
||||||
|
/// [warehouseId] - The ID of the warehouse
|
||||||
|
/// [productId] - The ID of the product
|
||||||
|
Future<void> loadProductDetail(int warehouseId, int productId) async {
|
||||||
|
// Set loading state
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call the use case
|
||||||
|
final result = await getProductDetailUseCase(warehouseId, productId);
|
||||||
|
|
||||||
|
// Handle the result
|
||||||
|
result.fold(
|
||||||
|
(failure) {
|
||||||
|
// Handle failure
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: failure.message,
|
||||||
|
stages: [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(stages) {
|
||||||
|
// Handle success - load all stages
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
stages: stages,
|
||||||
|
selectedStageIndex: 0, // Select first stage by default
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a stage by index
|
||||||
|
void selectStage(int index) {
|
||||||
|
if (index >= 0 && index < state.stages.length) {
|
||||||
|
state = state.copyWith(selectedStageIndex: index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh product stages
|
||||||
|
Future<void> refreshProductDetail(int warehouseId, int productId) async {
|
||||||
|
await loadProductDetail(warehouseId, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear product detail
|
||||||
|
void clearProductDetail() {
|
||||||
|
state = const ProductDetailState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
# Warehouse Feature - Architecture Diagram
|
|
||||||
|
|
||||||
## Clean Architecture Layers
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ PRESENTATION LAYER │
|
|
||||||
│ (UI, State Management, User Interactions) │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseCard │ │ WarehouseSelectionPage │ │
|
|
||||||
│ │ - Shows warehouse │ │ - Displays warehouse list │ │
|
|
||||||
│ │ information │ │ - Handles user selection │ │
|
|
||||||
│ └─────────────────────┘ │ - Pull to refresh │ │
|
|
||||||
│ │ - Loading/Error/Empty states │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
│ ↓ │
|
|
||||||
│ ┌──────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseNotifier │ │
|
|
||||||
│ │ (StateNotifier) │ │
|
|
||||||
│ │ - loadWarehouses() │ │
|
|
||||||
│ │ - selectWarehouse() │ │
|
|
||||||
│ │ - refresh() │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
│ ↓ │
|
|
||||||
│ ┌──────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseState │ │
|
|
||||||
│ │ - warehouses: List │ │
|
|
||||||
│ │ - selectedWarehouse: Warehouse? │ │
|
|
||||||
│ │ - isLoading: bool │ │
|
|
||||||
│ │ - error: String? │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
↓ uses
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ DOMAIN LAYER │
|
|
||||||
│ (Business Logic, Entities, Use Cases) │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ GetWarehousesUseCase │ │
|
|
||||||
│ │ - Encapsulates business logic for fetching warehouses │ │
|
|
||||||
│ │ - Single responsibility │ │
|
|
||||||
│ │ - Returns Either<Failure, List<WarehouseEntity>> │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ uses │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseRepository (Interface) │ │
|
|
||||||
│ │ + getWarehouses(): Either<Failure, List<Warehouse>> │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseEntity │ │
|
|
||||||
│ │ - id: int │ │
|
|
||||||
│ │ - name: String │ │
|
|
||||||
│ │ - code: String │ │
|
|
||||||
│ │ - description: String? │ │
|
|
||||||
│ │ - isNGWareHouse: bool │ │
|
|
||||||
│ │ - totalCount: int │ │
|
|
||||||
│ │ + hasItems: bool │ │
|
|
||||||
│ │ + isNGType: bool │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
↓ implements
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ DATA LAYER │
|
|
||||||
│ (API Calls, Data Sources, Models) │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseRepositoryImpl │ │
|
|
||||||
│ │ - Implements WarehouseRepository interface │ │
|
|
||||||
│ │ - Coordinates data sources │ │
|
|
||||||
│ │ - Converts exceptions to failures │ │
|
|
||||||
│ │ - Maps models to entities │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ uses │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseRemoteDataSource (Interface) │ │
|
|
||||||
│ │ + getWarehouses(): Future<List<WarehouseModel>> │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseRemoteDataSourceImpl │ │
|
|
||||||
│ │ - Makes API calls using ApiClient │ │
|
|
||||||
│ │ - Parses ApiResponse wrapper │ │
|
|
||||||
│ │ - Throws ServerException or NetworkException │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ uses │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ WarehouseModel │ │
|
|
||||||
│ │ - Extends WarehouseEntity │ │
|
|
||||||
│ │ - Adds JSON serialization (fromJson, toJson) │ │
|
|
||||||
│ │ - Maps API fields to entity fields │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ↓ uses │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ApiClient (Core) │ │
|
|
||||||
│ │ - Dio HTTP client wrapper │ │
|
|
||||||
│ │ - Adds authentication headers │ │
|
|
||||||
│ │ - Handles 401 errors │ │
|
|
||||||
│ │ - Logging and error handling │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### 1. Loading Warehouses Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User Action (Pull to Refresh / Page Load)
|
|
||||||
↓
|
|
||||||
WarehouseSelectionPage
|
|
||||||
↓ calls
|
|
||||||
ref.read(warehouseProvider.notifier).loadWarehouses()
|
|
||||||
↓
|
|
||||||
WarehouseNotifier.loadWarehouses()
|
|
||||||
↓ sets state
|
|
||||||
state = state.setLoading() → UI shows loading indicator
|
|
||||||
↓ calls
|
|
||||||
GetWarehousesUseCase.call()
|
|
||||||
↓ calls
|
|
||||||
WarehouseRepository.getWarehouses()
|
|
||||||
↓ calls
|
|
||||||
WarehouseRemoteDataSource.getWarehouses()
|
|
||||||
↓ makes HTTP request
|
|
||||||
ApiClient.get('/warehouses')
|
|
||||||
↓ API Response
|
|
||||||
{
|
|
||||||
"Value": [...],
|
|
||||||
"IsSuccess": true,
|
|
||||||
"IsFailure": false,
|
|
||||||
"Errors": [],
|
|
||||||
"ErrorCodes": []
|
|
||||||
}
|
|
||||||
↓ parse
|
|
||||||
List<WarehouseModel> from JSON
|
|
||||||
↓ convert
|
|
||||||
List<WarehouseEntity>
|
|
||||||
↓ wrap
|
|
||||||
Right(warehouses) or Left(failure)
|
|
||||||
↓ update state
|
|
||||||
state = state.setSuccess(warehouses)
|
|
||||||
↓
|
|
||||||
UI rebuilds with warehouse list
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Error Handling Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
API Error / Network Error
|
|
||||||
↓
|
|
||||||
ApiClient throws DioException
|
|
||||||
↓
|
|
||||||
_handleDioError() converts to custom exception
|
|
||||||
↓
|
|
||||||
ServerException or NetworkException
|
|
||||||
↓
|
|
||||||
WarehouseRemoteDataSource catches and rethrows
|
|
||||||
↓
|
|
||||||
WarehouseRepositoryImpl catches exception
|
|
||||||
↓
|
|
||||||
Converts to Failure:
|
|
||||||
- ServerException → ServerFailure
|
|
||||||
- NetworkException → NetworkFailure
|
|
||||||
↓
|
|
||||||
Returns Left(failure)
|
|
||||||
↓
|
|
||||||
GetWarehousesUseCase returns Left(failure)
|
|
||||||
↓
|
|
||||||
WarehouseNotifier receives Left(failure)
|
|
||||||
↓
|
|
||||||
state = state.setError(failure.message)
|
|
||||||
↓
|
|
||||||
UI shows error state with retry button
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Warehouse Selection Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User taps on WarehouseCard
|
|
||||||
↓
|
|
||||||
onTap callback triggered
|
|
||||||
↓
|
|
||||||
_onWarehouseSelected(warehouse)
|
|
||||||
↓
|
|
||||||
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse)
|
|
||||||
↓
|
|
||||||
state = state.setSelectedWarehouse(warehouse)
|
|
||||||
↓
|
|
||||||
Navigation: context.push('/operations', extra: warehouse)
|
|
||||||
↓
|
|
||||||
OperationSelectionPage receives warehouse
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependency Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Riverpod Providers │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ secureStorageProvider │
|
|
||||||
│ ↓ │
|
|
||||||
│ apiClientProvider │
|
|
||||||
│ ↓ │
|
|
||||||
│ warehouseRemoteDataSourceProvider │
|
|
||||||
│ ↓ │
|
|
||||||
│ warehouseRepositoryProvider │
|
|
||||||
│ ↓ │
|
|
||||||
│ getWarehousesUseCaseProvider │
|
|
||||||
│ ↓ │
|
|
||||||
│ warehouseProvider (StateNotifierProvider) │
|
|
||||||
│ ↓ │
|
|
||||||
│ WarehouseSelectionPage watches this provider │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Dependencies
|
|
||||||
|
|
||||||
```
|
|
||||||
warehouse_selection_page.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_entity.dart
|
|
||||||
- warehouse_card.dart
|
|
||||||
- warehouse_provider.dart (via DI setup)
|
|
||||||
|
|
||||||
warehouse_card.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_entity.dart
|
|
||||||
|
|
||||||
warehouse_provider.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_entity.dart
|
|
||||||
- get_warehouses_usecase.dart
|
|
||||||
|
|
||||||
get_warehouses_usecase.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_entity.dart
|
|
||||||
- warehouse_repository.dart (interface)
|
|
||||||
|
|
||||||
warehouse_repository_impl.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_entity.dart
|
|
||||||
- warehouse_repository.dart (interface)
|
|
||||||
- warehouse_remote_datasource.dart
|
|
||||||
|
|
||||||
warehouse_remote_datasource.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_model.dart
|
|
||||||
- api_client.dart
|
|
||||||
- api_response.dart
|
|
||||||
|
|
||||||
warehouse_model.dart
|
|
||||||
↓ imports
|
|
||||||
- warehouse_entity.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Transitions
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────┐
|
|
||||||
│ Initial │
|
|
||||||
│ isLoading: F │
|
|
||||||
│ error: null │
|
|
||||||
│ warehouses:[]│
|
|
||||||
└──────────────┘
|
|
||||||
↓
|
|
||||||
loadWarehouses()
|
|
||||||
↓
|
|
||||||
┌──────────────┐
|
|
||||||
│ Loading │
|
|
||||||
│ isLoading: T │────────────────┐
|
|
||||||
│ error: null │ │
|
|
||||||
│ warehouses:[]│ │
|
|
||||||
└──────────────┘ │
|
|
||||||
↓ │
|
|
||||||
Success Failure
|
|
||||||
↓ ↓
|
|
||||||
┌──────────────┐ ┌──────────────┐
|
|
||||||
│ Success │ │ Error │
|
|
||||||
│ isLoading: F │ │ isLoading: F │
|
|
||||||
│ error: null │ │ error: "..." │
|
|
||||||
│ warehouses:[…]│ │ warehouses:[]│
|
|
||||||
└──────────────┘ └──────────────┘
|
|
||||||
↓ ↓
|
|
||||||
Selection Retry
|
|
||||||
↓ ↓
|
|
||||||
┌──────────────┐ (back to Loading)
|
|
||||||
│ Selected │
|
|
||||||
│ selected: W │
|
|
||||||
└──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Response Parsing
|
|
||||||
|
|
||||||
```
|
|
||||||
Raw API Response (JSON)
|
|
||||||
↓
|
|
||||||
{
|
|
||||||
"Value": [
|
|
||||||
{
|
|
||||||
"Id": 1,
|
|
||||||
"Name": "Warehouse A",
|
|
||||||
"Code": "001",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"IsSuccess": true,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
↓
|
|
||||||
ApiResponse.fromJson() parses wrapper
|
|
||||||
↓
|
|
||||||
ApiResponse<List<WarehouseModel>> {
|
|
||||||
value: [WarehouseModel, WarehouseModel, ...],
|
|
||||||
isSuccess: true,
|
|
||||||
isFailure: false,
|
|
||||||
errors: [],
|
|
||||||
errorCodes: []
|
|
||||||
}
|
|
||||||
↓
|
|
||||||
Check isSuccess
|
|
||||||
↓
|
|
||||||
if (isSuccess && value != null)
|
|
||||||
return value!
|
|
||||||
else
|
|
||||||
throw ServerException(errors.first)
|
|
||||||
↓
|
|
||||||
List<WarehouseModel>
|
|
||||||
↓
|
|
||||||
map((model) => model.toEntity())
|
|
||||||
↓
|
|
||||||
List<WarehouseEntity>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Separation of Concerns
|
|
||||||
|
|
||||||
### Domain Layer
|
|
||||||
- **No dependencies** on Flutter, Dio, or other frameworks
|
|
||||||
- Contains **pure business logic**
|
|
||||||
- Defines **contracts** (repository interfaces)
|
|
||||||
- **Independent** and **testable**
|
|
||||||
|
|
||||||
### Data Layer
|
|
||||||
- **Implements** domain contracts
|
|
||||||
- Handles **external dependencies** (API, database)
|
|
||||||
- **Converts** between models and entities
|
|
||||||
- **Transforms** exceptions to failures
|
|
||||||
|
|
||||||
### Presentation Layer
|
|
||||||
- **Depends** only on domain layer
|
|
||||||
- Handles **UI rendering** and **user interactions**
|
|
||||||
- Manages **local state** with Riverpod
|
|
||||||
- **Observes** changes and **reacts** to state updates
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
```
|
|
||||||
Unit Tests
|
|
||||||
├── Domain Layer
|
|
||||||
│ ├── Test entities (equality, methods)
|
|
||||||
│ ├── Test use cases (mock repository)
|
|
||||||
│ └── Verify business logic
|
|
||||||
├── Data Layer
|
|
||||||
│ ├── Test models (JSON serialization)
|
|
||||||
│ ├── Test data sources (mock ApiClient)
|
|
||||||
│ └── Test repository (mock data source)
|
|
||||||
└── Presentation Layer
|
|
||||||
├── Test notifier (mock use case)
|
|
||||||
└── Test state transitions
|
|
||||||
|
|
||||||
Widget Tests
|
|
||||||
├── Test UI rendering
|
|
||||||
├── Test user interactions
|
|
||||||
└── Test state-based UI changes
|
|
||||||
|
|
||||||
Integration Tests
|
|
||||||
├── Test complete flow
|
|
||||||
└── Test with real dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits of This Architecture
|
|
||||||
|
|
||||||
1. **Testability**: Each layer can be tested independently with mocks
|
|
||||||
2. **Maintainability**: Changes in one layer don't affect others
|
|
||||||
3. **Scalability**: Easy to add new features following the same pattern
|
|
||||||
4. **Reusability**: Domain entities and use cases can be reused
|
|
||||||
5. **Separation**: Clear boundaries between UI, business logic, and data
|
|
||||||
6. **Flexibility**: Easy to swap implementations (e.g., change API client)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2025-10-27
|
|
||||||
**Version:** 1.0.0
|
|
||||||
@@ -1,649 +0,0 @@
|
|||||||
# Warehouse Feature
|
|
||||||
|
|
||||||
Complete implementation of the warehouse feature following **Clean Architecture** principles.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
This feature follows a three-layer clean architecture pattern:
|
|
||||||
|
|
||||||
```
|
|
||||||
Presentation Layer (UI)
|
|
||||||
↓ (uses)
|
|
||||||
Domain Layer (Business Logic)
|
|
||||||
↓ (uses)
|
|
||||||
Data Layer (API & Data Sources)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Principles
|
|
||||||
|
|
||||||
- **Separation of Concerns**: Each layer has a single responsibility
|
|
||||||
- **Dependency Inversion**: Outer layers depend on inner layers, not vice versa
|
|
||||||
- **Testability**: Each layer can be tested independently
|
|
||||||
- **Maintainability**: Changes in one layer don't affect others
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/features/warehouse/
|
|
||||||
├── data/
|
|
||||||
│ ├── datasources/
|
|
||||||
│ │ └── warehouse_remote_datasource.dart # API calls using ApiClient
|
|
||||||
│ ├── models/
|
|
||||||
│ │ └── warehouse_model.dart # Data transfer objects with JSON serialization
|
|
||||||
│ └── repositories/
|
|
||||||
│ └── warehouse_repository_impl.dart # Repository implementation
|
|
||||||
├── domain/
|
|
||||||
│ ├── entities/
|
|
||||||
│ │ └── warehouse_entity.dart # Pure business models
|
|
||||||
│ ├── repositories/
|
|
||||||
│ │ └── warehouse_repository.dart # Repository interface/contract
|
|
||||||
│ └── usecases/
|
|
||||||
│ └── get_warehouses_usecase.dart # Business logic use cases
|
|
||||||
├── presentation/
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ └── warehouse_selection_page.dart # Main warehouse selection screen
|
|
||||||
│ ├── providers/
|
|
||||||
│ │ └── warehouse_provider.dart # Riverpod state management
|
|
||||||
│ └── widgets/
|
|
||||||
│ └── warehouse_card.dart # Reusable warehouse card widget
|
|
||||||
├── warehouse_exports.dart # Barrel file for clean imports
|
|
||||||
├── warehouse_provider_setup_example.dart # Provider setup guide
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layer Details
|
|
||||||
|
|
||||||
### 1. Domain Layer (Core Business Logic)
|
|
||||||
|
|
||||||
The innermost layer that contains business entities, repository interfaces, and use cases. **No dependencies on external frameworks or packages** (except dartz for Either).
|
|
||||||
|
|
||||||
#### Entities
|
|
||||||
|
|
||||||
`domain/entities/warehouse_entity.dart`
|
|
||||||
|
|
||||||
- Pure Dart class representing a warehouse
|
|
||||||
- No JSON serialization logic
|
|
||||||
- Contains business rules and validations
|
|
||||||
- Extends Equatable for value comparison
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class WarehouseEntity extends Equatable {
|
|
||||||
final int id;
|
|
||||||
final String name;
|
|
||||||
final String code;
|
|
||||||
final String? description;
|
|
||||||
final bool isNGWareHouse;
|
|
||||||
final int totalCount;
|
|
||||||
|
|
||||||
bool get hasItems => totalCount > 0;
|
|
||||||
bool get isNGType => isNGWareHouse;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repository Interface
|
|
||||||
|
|
||||||
`domain/repositories/warehouse_repository.dart`
|
|
||||||
|
|
||||||
- Abstract interface defining data operations
|
|
||||||
- Returns `Either<Failure, T>` for error handling
|
|
||||||
- Implementation is provided by the data layer
|
|
||||||
|
|
||||||
```dart
|
|
||||||
abstract class WarehouseRepository {
|
|
||||||
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Use Cases
|
|
||||||
|
|
||||||
`domain/usecases/get_warehouses_usecase.dart`
|
|
||||||
|
|
||||||
- Single responsibility: fetch warehouses
|
|
||||||
- Encapsulates business logic
|
|
||||||
- Depends only on repository interface
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class GetWarehousesUseCase {
|
|
||||||
final WarehouseRepository repository;
|
|
||||||
|
|
||||||
Future<Either<Failure, List<WarehouseEntity>>> call() async {
|
|
||||||
return await repository.getWarehouses();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Data Layer (External Data Management)
|
|
||||||
|
|
||||||
Handles all data operations including API calls, JSON serialization, and error handling.
|
|
||||||
|
|
||||||
#### Models
|
|
||||||
|
|
||||||
`data/models/warehouse_model.dart`
|
|
||||||
|
|
||||||
- Extends domain entity
|
|
||||||
- Adds JSON serialization (`fromJson`, `toJson`)
|
|
||||||
- Maps API response format to domain entities
|
|
||||||
- Matches API field naming (PascalCase)
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class WarehouseModel extends WarehouseEntity {
|
|
||||||
factory WarehouseModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return WarehouseModel(
|
|
||||||
id: json['Id'] ?? 0,
|
|
||||||
name: json['Name'] ?? '',
|
|
||||||
code: json['Code'] ?? '',
|
|
||||||
description: json['Description'],
|
|
||||||
isNGWareHouse: json['IsNGWareHouse'] ?? false,
|
|
||||||
totalCount: json['TotalCount'] ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Data Sources
|
|
||||||
|
|
||||||
`data/datasources/warehouse_remote_datasource.dart`
|
|
||||||
|
|
||||||
- Interface + implementation pattern
|
|
||||||
- Makes API calls using `ApiClient`
|
|
||||||
- Parses `ApiResponse` wrapper
|
|
||||||
- Throws typed exceptions (`ServerException`, `NetworkException`)
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
|
|
||||||
Future<List<WarehouseModel>> getWarehouses() async {
|
|
||||||
final response = await apiClient.get('/warehouses');
|
|
||||||
final apiResponse = ApiResponse.fromJson(
|
|
||||||
response.data,
|
|
||||||
(json) => (json as List).map((e) => WarehouseModel.fromJson(e)).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
|
||||||
return apiResponse.value!;
|
|
||||||
} else {
|
|
||||||
throw ServerException(apiResponse.errors.first);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repository Implementation
|
|
||||||
|
|
||||||
`data/repositories/warehouse_repository_impl.dart`
|
|
||||||
|
|
||||||
- Implements domain repository interface
|
|
||||||
- Coordinates data sources
|
|
||||||
- Converts exceptions to failures
|
|
||||||
- Maps models to entities
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class WarehouseRepositoryImpl implements WarehouseRepository {
|
|
||||||
@override
|
|
||||||
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses() async {
|
|
||||||
try {
|
|
||||||
final warehouses = await remoteDataSource.getWarehouses();
|
|
||||||
final entities = warehouses.map((model) => model.toEntity()).toList();
|
|
||||||
return Right(entities);
|
|
||||||
} on ServerException catch (e) {
|
|
||||||
return Left(ServerFailure(e.message));
|
|
||||||
} on NetworkException catch (e) {
|
|
||||||
return Left(NetworkFailure(e.message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Presentation Layer (UI & State Management)
|
|
||||||
|
|
||||||
Handles UI rendering, user interactions, and state management using Riverpod.
|
|
||||||
|
|
||||||
#### State Management
|
|
||||||
|
|
||||||
`presentation/providers/warehouse_provider.dart`
|
|
||||||
|
|
||||||
- `WarehouseState`: Immutable state class
|
|
||||||
- `warehouses`: List of warehouses
|
|
||||||
- `selectedWarehouse`: Currently selected warehouse
|
|
||||||
- `isLoading`: Loading indicator
|
|
||||||
- `error`: Error message
|
|
||||||
|
|
||||||
- `WarehouseNotifier`: StateNotifier managing state
|
|
||||||
- `loadWarehouses()`: Fetch warehouses from API
|
|
||||||
- `selectWarehouse()`: Select a warehouse
|
|
||||||
- `refresh()`: Reload warehouses
|
|
||||||
- `clearError()`: Clear error state
|
|
||||||
|
|
||||||
```dart
|
|
||||||
class WarehouseState {
|
|
||||||
final List<WarehouseEntity> warehouses;
|
|
||||||
final WarehouseEntity? selectedWarehouse;
|
|
||||||
final bool isLoading;
|
|
||||||
final String? error;
|
|
||||||
}
|
|
||||||
|
|
||||||
class WarehouseNotifier extends StateNotifier<WarehouseState> {
|
|
||||||
Future<void> loadWarehouses() async {
|
|
||||||
state = state.setLoading();
|
|
||||||
final result = await getWarehousesUseCase();
|
|
||||||
result.fold(
|
|
||||||
(failure) => state = state.setError(failure.message),
|
|
||||||
(warehouses) => state = state.setSuccess(warehouses),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pages
|
|
||||||
|
|
||||||
`presentation/pages/warehouse_selection_page.dart`
|
|
||||||
|
|
||||||
- ConsumerStatefulWidget using Riverpod
|
|
||||||
- Loads warehouses on initialization
|
|
||||||
- Displays different UI states:
|
|
||||||
- Loading: CircularProgressIndicator
|
|
||||||
- Error: Error message with retry button
|
|
||||||
- Empty: No warehouses message
|
|
||||||
- Success: List of warehouse cards
|
|
||||||
- Pull-to-refresh support
|
|
||||||
- Navigation to operations page on selection
|
|
||||||
|
|
||||||
#### Widgets
|
|
||||||
|
|
||||||
`presentation/widgets/warehouse_card.dart`
|
|
||||||
|
|
||||||
- Reusable warehouse card component
|
|
||||||
- Displays:
|
|
||||||
- Warehouse name (title)
|
|
||||||
- Code (with QR icon)
|
|
||||||
- Total items count (with inventory icon)
|
|
||||||
- Description (if available)
|
|
||||||
- NG warehouse badge (if applicable)
|
|
||||||
- Material Design 3 styling
|
|
||||||
- Tap to select functionality
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
### Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /warehouses
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET https://api.example.com/warehouses \
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Value": [
|
|
||||||
{
|
|
||||||
"Id": 1,
|
|
||||||
"Name": "Kho nguyên vật liệu",
|
|
||||||
"Code": "001",
|
|
||||||
"Description": "Kho chứa nguyên vật liệu",
|
|
||||||
"IsNGWareHouse": false,
|
|
||||||
"TotalCount": 8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Id": 2,
|
|
||||||
"Name": "Kho bán thành phẩm công đoạn",
|
|
||||||
"Code": "002",
|
|
||||||
"Description": null,
|
|
||||||
"IsNGWareHouse": false,
|
|
||||||
"TotalCount": 12
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"IsSuccess": true,
|
|
||||||
"IsFailure": false,
|
|
||||||
"Errors": [],
|
|
||||||
"ErrorCodes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Setup & Integration
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
|
|
||||||
Ensure your `pubspec.yaml` includes:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
dependencies:
|
|
||||||
flutter_riverpod: ^2.4.9
|
|
||||||
dio: ^5.3.2
|
|
||||||
dartz: ^0.10.1
|
|
||||||
equatable: ^2.0.5
|
|
||||||
flutter_secure_storage: ^9.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Set Up Providers
|
|
||||||
|
|
||||||
Create or update your provider configuration file (e.g., `lib/core/di/providers.dart`):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../features/warehouse/warehouse_exports.dart';
|
|
||||||
|
|
||||||
// Core providers (if not already set up)
|
|
||||||
final secureStorageProvider = Provider<SecureStorage>((ref) {
|
|
||||||
return SecureStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
|
||||||
final secureStorage = ref.watch(secureStorageProvider);
|
|
||||||
return ApiClient(secureStorage);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Warehouse data layer providers
|
|
||||||
final warehouseRemoteDataSourceProvider = Provider<WarehouseRemoteDataSource>((ref) {
|
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
|
||||||
return WarehouseRemoteDataSourceImpl(apiClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
final warehouseRepositoryProvider = Provider((ref) {
|
|
||||||
final remoteDataSource = ref.watch(warehouseRemoteDataSourceProvider);
|
|
||||||
return WarehouseRepositoryImpl(remoteDataSource);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Warehouse domain layer providers
|
|
||||||
final getWarehousesUseCaseProvider = Provider((ref) {
|
|
||||||
final repository = ref.watch(warehouseRepositoryProvider);
|
|
||||||
return GetWarehousesUseCase(repository);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Warehouse presentation layer providers
|
|
||||||
final warehouseProvider = StateNotifierProvider<WarehouseNotifier, WarehouseState>((ref) {
|
|
||||||
final getWarehousesUseCase = ref.watch(getWarehousesUseCaseProvider);
|
|
||||||
return WarehouseNotifier(getWarehousesUseCase);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update WarehouseSelectionPage
|
|
||||||
|
|
||||||
Replace the TODO comments in `warehouse_selection_page.dart`:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
ref.read(warehouseProvider.notifier).loadWarehouses();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final state = ref.watch(warehouseProvider);
|
|
||||||
|
|
||||||
// Rest of the implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Add to Router
|
|
||||||
|
|
||||||
Using go_router:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
GoRoute(
|
|
||||||
path: '/warehouses',
|
|
||||||
name: 'warehouses',
|
|
||||||
builder: (context, state) => const WarehouseSelectionPage(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/operations',
|
|
||||||
name: 'operations',
|
|
||||||
builder: (context, state) {
|
|
||||||
final warehouse = state.extra as WarehouseEntity;
|
|
||||||
return OperationSelectionPage(warehouse: warehouse);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Navigate to Warehouse Page
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// From login page after successful authentication
|
|
||||||
context.go('/warehouses');
|
|
||||||
|
|
||||||
// Or using Navigator
|
|
||||||
Navigator.of(context).pushNamed('/warehouses');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Loading Warehouses
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// In a widget
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(warehouseProvider.notifier).loadWarehouses();
|
|
||||||
},
|
|
||||||
child: const Text('Load Warehouses'),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Watching State
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// In a ConsumerWidget
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final state = ref.watch(warehouseProvider);
|
|
||||||
|
|
||||||
if (state.isLoading) {
|
|
||||||
return const CircularProgressIndicator();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.error != null) {
|
|
||||||
return Text('Error: ${state.error}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: state.warehouses.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final warehouse = state.warehouses[index];
|
|
||||||
return ListTile(title: Text(warehouse.name));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Selecting a Warehouse
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// Select warehouse and navigate
|
|
||||||
void onWarehouseTap(WarehouseEntity warehouse) {
|
|
||||||
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
|
||||||
context.push('/operations', extra: warehouse);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pull to Refresh
|
|
||||||
|
|
||||||
```dart
|
|
||||||
RefreshIndicator(
|
|
||||||
onRefresh: () => ref.read(warehouseProvider.notifier).refresh(),
|
|
||||||
child: ListView(...),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessing Selected Warehouse
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// In another page
|
|
||||||
final state = ref.watch(warehouseProvider);
|
|
||||||
final selectedWarehouse = state.selectedWarehouse;
|
|
||||||
|
|
||||||
if (selectedWarehouse != null) {
|
|
||||||
Text('Current: ${selectedWarehouse.name}');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The feature uses dartz's `Either` type for functional error handling:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// In use case or repository
|
|
||||||
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses() async {
|
|
||||||
try {
|
|
||||||
final warehouses = await remoteDataSource.getWarehouses();
|
|
||||||
return Right(warehouses); // Success
|
|
||||||
} on ServerException catch (e) {
|
|
||||||
return Left(ServerFailure(e.message)); // Failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In presentation layer
|
|
||||||
result.fold(
|
|
||||||
(failure) => print('Error: ${failure.message}'),
|
|
||||||
(warehouses) => print('Success: ${warehouses.length} items'),
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Failure Types
|
|
||||||
|
|
||||||
- `ServerFailure`: API errors, HTTP errors
|
|
||||||
- `NetworkFailure`: Connection issues, timeouts
|
|
||||||
- `CacheFailure`: Local storage errors (if implemented)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
**Test Use Case:**
|
|
||||||
```dart
|
|
||||||
test('should get warehouses from repository', () async {
|
|
||||||
// Arrange
|
|
||||||
when(mockRepository.getWarehouses())
|
|
||||||
.thenAnswer((_) async => Right(mockWarehouses));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
final result = await useCase();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result, Right(mockWarehouses));
|
|
||||||
verify(mockRepository.getWarehouses());
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Repository:**
|
|
||||||
```dart
|
|
||||||
test('should return warehouses when remote call is successful', () async {
|
|
||||||
// Arrange
|
|
||||||
when(mockRemoteDataSource.getWarehouses())
|
|
||||||
.thenAnswer((_) async => mockWarehouseModels);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
final result = await repository.getWarehouses();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result.isRight(), true);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Notifier:**
|
|
||||||
```dart
|
|
||||||
test('should emit loading then success when warehouses are loaded', () async {
|
|
||||||
// Arrange
|
|
||||||
when(mockUseCase()).thenAnswer((_) async => Right(mockWarehouses));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await notifier.loadWarehouses();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(notifier.state.isLoading, false);
|
|
||||||
expect(notifier.state.warehouses, mockWarehouses);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Widget Tests
|
|
||||||
|
|
||||||
```dart
|
|
||||||
testWidgets('should display warehouse list when loaded', (tester) async {
|
|
||||||
// Arrange
|
|
||||||
final container = ProviderContainer(
|
|
||||||
overrides: [
|
|
||||||
warehouseProvider.overrideWith((ref) => MockWarehouseNotifier()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await tester.pumpWidget(
|
|
||||||
UncontrolledProviderScope(
|
|
||||||
container: container,
|
|
||||||
child: const WarehouseSelectionPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(find.byType(WarehouseCard), findsWidgets);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Always use Either for error handling** - Don't throw exceptions across layers
|
|
||||||
2. **Keep domain layer pure** - No Flutter/external dependencies
|
|
||||||
3. **Use value objects** - Entities should be immutable
|
|
||||||
4. **Single responsibility** - Each class has one reason to change
|
|
||||||
5. **Dependency inversion** - Depend on abstractions, not concretions
|
|
||||||
6. **Test each layer independently** - Use mocks and test doubles
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
### Provider Not Found
|
|
||||||
|
|
||||||
**Error:** `ProviderNotFoundException`
|
|
||||||
|
|
||||||
**Solution:** Make sure you've set up all providers in your provider configuration file and wrapped your app with `ProviderScope`.
|
|
||||||
|
|
||||||
### Null Safety Issues
|
|
||||||
|
|
||||||
**Error:** `Null check operator used on a null value`
|
|
||||||
|
|
||||||
**Solution:** Always check for null before accessing optional fields:
|
|
||||||
```dart
|
|
||||||
if (warehouse.description != null) {
|
|
||||||
Text(warehouse.description!);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Response Format Mismatch
|
|
||||||
|
|
||||||
**Error:** `ServerException: Invalid response format`
|
|
||||||
|
|
||||||
**Solution:** Verify that the API response matches the expected format in `ApiResponse.fromJson` and `WarehouseModel.fromJson`.
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Add caching with Hive for offline support
|
|
||||||
- [ ] Implement warehouse search functionality
|
|
||||||
- [ ] Add warehouse filtering (by type, name, etc.)
|
|
||||||
- [ ] Add pagination for large warehouse lists
|
|
||||||
- [ ] Implement warehouse CRUD operations
|
|
||||||
- [ ] Add warehouse analytics and statistics
|
|
||||||
|
|
||||||
## Related Features
|
|
||||||
|
|
||||||
- **Authentication**: `/lib/features/auth/` - User login and token management
|
|
||||||
- **Operations**: `/lib/features/operation/` - Import/Export selection
|
|
||||||
- **Products**: `/lib/features/products/` - Product listing per warehouse
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Clean Architecture by Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
|
||||||
- [Flutter Riverpod Documentation](https://riverpod.dev/)
|
|
||||||
- [Dartz Package for Functional Programming](https://pub.dev/packages/dartz)
|
|
||||||
- [Material Design 3](https://m3.material.io/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2025-10-27
|
|
||||||
**Version:** 1.0.0
|
|
||||||
**Author:** Claude Code
|
|
||||||
@@ -174,7 +174,7 @@ class _WarehouseSelectionPageState
|
|||||||
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
||||||
|
|
||||||
// Navigate to operations page
|
// Navigate to operations page
|
||||||
context.go('/operations', extra: warehouse);
|
context.push('/operations', extra: warehouse);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../domain/entities/warehouse_entity.dart';
|
|
||||||
import '../widgets/warehouse_card.dart';
|
|
||||||
import '../../../../core/router/app_router.dart';
|
|
||||||
|
|
||||||
/// EXAMPLE: Warehouse selection page with proper navigation integration
|
|
||||||
///
|
|
||||||
/// This is a complete example showing how to integrate the warehouse selection
|
|
||||||
/// page with the new router. Use this as a reference when implementing the
|
|
||||||
/// actual warehouse provider and state management.
|
|
||||||
///
|
|
||||||
/// Key Features:
|
|
||||||
/// - Uses type-safe navigation with extension methods
|
|
||||||
/// - Proper error handling
|
|
||||||
/// - Loading states
|
|
||||||
/// - Pull to refresh
|
|
||||||
/// - Integration with router
|
|
||||||
class WarehouseSelectionPageExample extends ConsumerStatefulWidget {
|
|
||||||
const WarehouseSelectionPageExample({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<WarehouseSelectionPageExample> createState() =>
|
|
||||||
_WarehouseSelectionPageExampleState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WarehouseSelectionPageExampleState
|
|
||||||
extends ConsumerState<WarehouseSelectionPageExample> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// Load warehouses when page is first created
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
// TODO: Replace with actual provider
|
|
||||||
// ref.read(warehouseProvider.notifier).loadWarehouses();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// TODO: Replace with actual provider
|
|
||||||
// final state = ref.watch(warehouseProvider);
|
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Select Warehouse'),
|
|
||||||
backgroundColor: colorScheme.primaryContainer,
|
|
||||||
foregroundColor: colorScheme.onPrimaryContainer,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
onPressed: () => _handleLogout(context),
|
|
||||||
tooltip: 'Logout',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: _buildBody(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody(BuildContext context) {
|
|
||||||
// For demonstration, showing example warehouse list
|
|
||||||
// In actual implementation, use state from provider:
|
|
||||||
// if (state.isLoading) return _buildLoadingState();
|
|
||||||
// if (state.error != null) return _buildErrorState(context, state.error!);
|
|
||||||
// if (!state.hasWarehouses) return _buildEmptyState(context);
|
|
||||||
// return _buildWarehouseList(state.warehouses);
|
|
||||||
|
|
||||||
// Example warehouses for demonstration
|
|
||||||
final exampleWarehouses = _getExampleWarehouses();
|
|
||||||
return _buildWarehouseList(exampleWarehouses);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build loading state UI
|
|
||||||
Widget _buildLoadingState() {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Loading warehouses...',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build error state UI
|
|
||||||
Widget _buildErrorState(BuildContext context, String error) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Error Loading Warehouses',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
error,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Replace with actual provider
|
|
||||||
// ref.read(warehouseProvider.notifier).loadWarehouses();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build empty state UI
|
|
||||||
Widget _buildEmptyState(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.inventory_2_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No Warehouses Available',
|
|
||||||
style: theme.textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'There are no warehouses to display.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Replace with actual provider
|
|
||||||
// ref.read(warehouseProvider.notifier).loadWarehouses();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build warehouse list UI
|
|
||||||
Widget _buildWarehouseList(List<WarehouseEntity> warehouses) {
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () async {
|
|
||||||
// TODO: Replace with actual provider
|
|
||||||
// await ref.read(warehouseProvider.notifier).refresh();
|
|
||||||
},
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
itemCount: warehouses.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final warehouse = warehouses[index];
|
|
||||||
return WarehouseCard(
|
|
||||||
warehouse: warehouse,
|
|
||||||
onTap: () => _onWarehouseSelected(context, warehouse),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle warehouse selection
|
|
||||||
///
|
|
||||||
/// This is the key integration point with the router!
|
|
||||||
/// Uses the type-safe extension method to navigate to operations page
|
|
||||||
void _onWarehouseSelected(BuildContext context, WarehouseEntity warehouse) {
|
|
||||||
// TODO: Optionally save selected warehouse to provider
|
|
||||||
// ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
|
||||||
|
|
||||||
// Navigate to operations page using type-safe extension method
|
|
||||||
context.goToOperations(warehouse);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle logout
|
|
||||||
void _handleLogout(BuildContext context) async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Logout'),
|
|
||||||
content: const Text('Are you sure you want to logout?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
child: const Text('Logout'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
|
||||||
// TODO: Call logout from auth provider
|
|
||||||
// await ref.read(authProvider.notifier).logout();
|
|
||||||
|
|
||||||
// Router will automatically redirect to login page
|
|
||||||
// due to authentication state change
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get example warehouses for demonstration
|
|
||||||
List<WarehouseEntity> _getExampleWarehouses() {
|
|
||||||
return [
|
|
||||||
WarehouseEntity(
|
|
||||||
id: 1,
|
|
||||||
name: 'Kho nguyên vật liệu',
|
|
||||||
code: '001',
|
|
||||||
description: 'Warehouse for raw materials',
|
|
||||||
isNGWareHouse: false,
|
|
||||||
totalCount: 8,
|
|
||||||
),
|
|
||||||
WarehouseEntity(
|
|
||||||
id: 2,
|
|
||||||
name: 'Kho bán thành phẩm công đoạn',
|
|
||||||
code: '002',
|
|
||||||
description: 'Semi-finished goods warehouse',
|
|
||||||
isNGWareHouse: false,
|
|
||||||
totalCount: 8,
|
|
||||||
),
|
|
||||||
WarehouseEntity(
|
|
||||||
id: 3,
|
|
||||||
name: 'Kho thành phẩm',
|
|
||||||
code: '003',
|
|
||||||
description: 'Finished goods warehouse',
|
|
||||||
isNGWareHouse: false,
|
|
||||||
totalCount: 8,
|
|
||||||
),
|
|
||||||
WarehouseEntity(
|
|
||||||
id: 4,
|
|
||||||
name: 'Kho tiêu hao',
|
|
||||||
code: '004',
|
|
||||||
description: 'Để chứa phụ tùng',
|
|
||||||
isNGWareHouse: false,
|
|
||||||
totalCount: 8,
|
|
||||||
),
|
|
||||||
WarehouseEntity(
|
|
||||||
id: 5,
|
|
||||||
name: 'Kho NG',
|
|
||||||
code: '005',
|
|
||||||
description: 'Non-conforming products warehouse',
|
|
||||||
isNGWareHouse: true,
|
|
||||||
totalCount: 3,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EXAMPLE: Alternative approach using named routes
|
|
||||||
///
|
|
||||||
/// This shows how to use named routes instead of path-based navigation
|
|
||||||
class WarehouseSelectionWithNamedRoutesExample extends ConsumerWidget {
|
|
||||||
const WarehouseSelectionWithNamedRoutesExample({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
// Example warehouse for demonstration
|
|
||||||
final warehouse = WarehouseEntity(
|
|
||||||
id: 1,
|
|
||||||
name: 'Example Warehouse',
|
|
||||||
code: '001',
|
|
||||||
isNGWareHouse: false,
|
|
||||||
totalCount: 10,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Named Routes Example')),
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
// Using named route navigation
|
|
||||||
context.goToOperationsNamed(warehouse);
|
|
||||||
},
|
|
||||||
child: const Text('Go to Operations (Named)'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
// Using path-based navigation
|
|
||||||
context.goToOperations(warehouse);
|
|
||||||
},
|
|
||||||
child: const Text('Go to Operations (Path)'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EXAMPLE: Navigation from operation to products
|
|
||||||
///
|
|
||||||
/// Shows how to navigate from operation selection to products page
|
|
||||||
class OperationNavigationExample extends StatelessWidget {
|
|
||||||
final WarehouseEntity warehouse;
|
|
||||||
|
|
||||||
const OperationNavigationExample({
|
|
||||||
super.key,
|
|
||||||
required this.warehouse,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Operation Navigation Example')),
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Navigate to products with import operation
|
|
||||||
context.goToProducts(
|
|
||||||
warehouse: warehouse,
|
|
||||||
operationType: 'import',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.arrow_downward),
|
|
||||||
label: const Text('Import Products'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Navigate to products with export operation
|
|
||||||
context.goToProducts(
|
|
||||||
warehouse: warehouse,
|
|
||||||
operationType: 'export',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.arrow_upward),
|
|
||||||
label: const Text('Export Products'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: () {
|
|
||||||
// Navigate back
|
|
||||||
context.goBack();
|
|
||||||
},
|
|
||||||
child: const Text('Go Back'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user