This commit is contained in:
2025-10-28 00:43:32 +07:00
parent de49f564b1
commit df99d0c9e3
19 changed files with 1000 additions and 1453 deletions

View File

@@ -144,7 +144,7 @@ class OperationSelectionPage extends ConsumerWidget {
WarehouseEntity warehouse,
String operationType,
) {
context.goNamed(
context.pushNamed(
'products',
extra: {
'warehouse': warehouse,

View File

@@ -1,7 +1,10 @@
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/product_detail_request_model.dart';
import '../models/product_model.dart';
import '../models/product_stage_model.dart';
/// Abstract interface for products remote data source
abstract class ProductsRemoteDataSource {
@@ -13,6 +16,14 @@ abstract class ProductsRemoteDataSource {
/// Returns List<ProductModel>
/// Throws [ServerException] if the API call fails
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
@@ -59,4 +70,55 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
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()}');
}
}
}

View File

@@ -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)';
}
}

View 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)';
}
}

View File

@@ -2,8 +2,10 @@ import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/product_entity.dart';
import '../../domain/entities/product_stage_entity.dart';
import '../../domain/repositories/products_repository.dart';
import '../datasources/products_remote_datasource.dart';
import '../models/product_detail_request_model.dart';
/// Implementation of ProductsRepository
/// Handles data operations and error conversion
@@ -34,4 +36,33 @@ class ProductsRepositoryImpl implements ProductsRepository {
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()}'));
}
}
}

View File

@@ -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)';
}
}

View File

@@ -1,6 +1,7 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_entity.dart';
import '../entities/product_stage_entity.dart';
/// Abstract repository interface for products
/// Defines the contract for product data operations
@@ -15,4 +16,15 @@ abstract class ProductsRepository {
int warehouseId,
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,
);
}

View File

@@ -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);
}
}

View File

@@ -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,
),
],
),
),
),
),
],
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../../../../core/router/app_router.dart';
import '../widgets/product_list_item.dart';
/// Products list page
@@ -261,13 +262,11 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
return ProductListItem(
product: product,
onTap: () {
// Handle product tap if needed
// For now, just show a snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Selected: ${product.fullName}'),
duration: const Duration(seconds: 1),
),
// Navigate to product detail page
context.goToProductDetail(
warehouseId: widget.warehouseId,
productId: product.id,
warehouseName: widget.warehouseName,
);
},
);

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -174,7 +174,7 @@ class _WarehouseSelectionPageState
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Navigate to operations page
context.go('/operations', extra: warehouse);
context.push('/operations', extra: warehouse);
},
);
},

View File

@@ -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'),
),
],
),
),
);
}
}