650 lines
16 KiB
Markdown
650 lines
16 KiB
Markdown
# 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
|