This commit is contained in:
2025-10-28 00:09:46 +07:00
parent 9ebe7c2919
commit de49f564b1
110 changed files with 15392 additions and 3996 deletions

View File

@@ -4,10 +4,10 @@ description: Hive database and local storage specialist. MUST BE USED for databa
tools: Read, Write, Edit, Grep, Bash
---
You are a Hive database expert specializing in:
You are a Hive_ce database expert specializing in:
- NoSQL database design and schema optimization
- Type adapters and code generation for complex models
- Caching strategies for offline-first applications
- Caching strategies for online-first applications
- Data persistence and synchronization patterns
- Database performance optimization and indexing
- Data migration and versioning strategies
@@ -58,7 +58,7 @@ You are a Hive database expert specializing in:
- **Time-Based Expiration**: Invalidate stale cached data
- **Size-Limited Caches**: Implement LRU eviction policies
- **Selective Caching**: Cache frequently accessed data
- **Offline-First**: Serve from cache, sync in background
- **Onlibe-First**: Serve from api, sync in background
## Data Model Design:
```dart

2
.gitignore vendored
View File

@@ -5,9 +5,11 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related

452
API_CLIENT_SETUP.md Normal file
View File

@@ -0,0 +1,452 @@
# API Client Setup - Complete Implementation
## Overview
I have created a robust API client for your Flutter warehouse management app with comprehensive features including:
- **Automatic token management** from secure storage
- **401 error handling** with automatic token clearing
- **Request/response logging** with sensitive data redaction
- **Configurable timeouts** (30 seconds)
- **Proper error transformation** to custom exceptions
- **Support for all HTTP methods** (GET, POST, PUT, DELETE)
## Files Created
### 1. Core Network Files
#### `/lib/core/network/api_client.dart`
- Main API client implementation using Dio
- Automatic Bearer token injection from secure storage
- Request/response/error interceptors with comprehensive logging
- 401 error handler that clears tokens and triggers logout callback
- Methods: `get()`, `post()`, `put()`, `delete()`
- Utility methods: `testConnection()`, `isAuthenticated()`, `getAccessToken()`, `clearAuth()`
#### `/lib/core/network/api_response.dart`
- Generic API response wrapper matching your backend format
- Structure: `Value`, `IsSuccess`, `IsFailure`, `Errors`, `ErrorCodes`
- Helper methods: `hasData`, `getErrorMessage()`, `getAllErrorsAsString()`, `hasErrorCode()`
#### `/lib/core/network/api_client_example.dart`
- Comprehensive usage examples for all scenarios
- Examples for: Login, GET/POST/PUT/DELETE requests, error handling, etc.
#### `/lib/core/network/README.md`
- Complete documentation for the API client
- Usage guides, best practices, troubleshooting
### 2. Secure Storage
#### `/lib/core/storage/secure_storage.dart`
- Singleton wrapper for flutter_secure_storage
- Token management: `saveAccessToken()`, `getAccessToken()`, `clearTokens()`
- User data: `saveUserId()`, `saveUsername()`, etc.
- Utility methods: `isAuthenticated()`, `clearAll()`, `containsKey()`
- Platform-specific secure options (Android: encrypted shared prefs, iOS: Keychain)
### 3. Constants
#### `/lib/core/constants/api_endpoints.dart`
- Centralized API endpoint definitions
- Authentication: `/auth/login`, `/auth/logout`
- Warehouses: `/warehouses`
- Products: `/products` with query parameter helpers
- Scans: `/api/scans`
### 4. Core Exports
#### `/lib/core/core.dart` (Updated)
- Added exports for new modules:
- `api_endpoints.dart`
- `api_response.dart`
- `secure_storage.dart`
### 5. Dependencies
#### `pubspec.yaml` (Updated)
- Added `flutter_secure_storage: ^9.0.0`
## Key Features
### 1. Automatic Token Management
The API client automatically injects Bearer tokens into all requests:
```dart
// Initialize with secure storage
final secureStorage = SecureStorage();
final apiClient = ApiClient(secureStorage);
// Token is automatically added to all requests
final response = await apiClient.get('/warehouses');
// Request header: Authorization: Bearer <token>
```
### 2. 401 Error Handling
When a 401 Unauthorized error occurs:
1. Error is logged to console
2. All tokens are cleared from secure storage
3. `onUnauthorized` callback is triggered
4. App can navigate to login screen
```dart
final apiClient = ApiClient(
secureStorage,
onUnauthorized: () {
// This callback is triggered on 401 errors
context.go('/login'); // Navigate to login
},
);
```
### 3. Comprehensive Logging
All requests, responses, and errors are logged with sensitive data redacted:
```
REQUEST[GET] => https://api.example.com/warehouses
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
RESPONSE[200] => https://api.example.com/warehouses
Data: {...}
ERROR[401] => https://api.example.com/products
401 Unauthorized - Clearing tokens and triggering logout
```
### 4. Error Handling
Dio exceptions are transformed into custom app exceptions:
```dart
try {
final response = await apiClient.get('/products');
} on NetworkException catch (e) {
// Timeout, no internet, etc.
print('Network error: ${e.message}');
} on ServerException catch (e) {
// 4xx, 5xx errors
print('Server error: ${e.message}');
print('Error code: ${e.code}'); // e.g., '401', '404', '500'
}
```
Specific error codes:
- `401`: Unauthorized (automatically handled)
- `403`: Forbidden
- `404`: Not Found
- `422`: Validation Error
- `429`: Rate Limited
- `500-504`: Server Errors
### 5. API Response Parsing
All API responses follow the standard format:
```dart
final response = await apiClient.get('/warehouses');
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
final warehouses = apiResponse.value;
print('Found ${warehouses.length} warehouses');
} else {
print('Error: ${apiResponse.getErrorMessage()}');
}
```
## Usage Examples
### Initialize API Client
```dart
import 'package:minhthu/core/core.dart';
final secureStorage = SecureStorage();
final apiClient = ApiClient(
secureStorage,
onUnauthorized: () {
// Navigate to login on 401 errors
context.go('/login');
},
);
```
### Login Flow
```dart
// 1. Login request
final response = await apiClient.post(
ApiEndpoints.login,
data: {
'username': 'user@example.com',
'password': 'password123',
},
);
// 2. Parse response
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => User.fromJson(json),
);
// 3. Save tokens (typically done in LoginUseCase)
if (apiResponse.isSuccess && apiResponse.value != null) {
final user = apiResponse.value!;
await secureStorage.saveAccessToken(user.accessToken);
await secureStorage.saveRefreshToken(user.refreshToken);
await secureStorage.saveUserId(user.userId);
}
```
### Get Warehouses (Authenticated)
```dart
// Token is automatically added by the interceptor
final response = await apiClient.get(ApiEndpoints.warehouses);
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
final warehouses = apiResponse.value!;
// Use the data
}
```
### Get Products with Query Parameters
```dart
final response = await apiClient.get(
ApiEndpoints.products,
queryParameters: ApiEndpoints.productQueryParams(
warehouseId: 1,
type: 'import',
),
);
```
### Save Scan Data
```dart
final response = await apiClient.post(
ApiEndpoints.scans,
data: {
'barcode': '1234567890',
'field1': 'Value 1',
'field2': 'Value 2',
'field3': 'Value 3',
'field4': 'Value 4',
},
);
```
### Check Authentication Status
```dart
final isAuthenticated = await apiClient.isAuthenticated();
if (!isAuthenticated) {
// Navigate to login
}
```
### Logout
```dart
await apiClient.clearAuth(); // Clears all tokens
```
## Integration with Repository Pattern
The API client is designed to work with your clean architecture:
```dart
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
final ApiClient apiClient;
WarehouseRemoteDataSourceImpl(this.apiClient);
@override
Future<List<Warehouse>> getWarehouses() async {
final response = await apiClient.get(ApiEndpoints.warehouses);
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
throw ServerException(apiResponse.getErrorMessage());
}
}
}
```
## Configuration
### Timeouts (in `app_constants.dart`)
```dart
static const int connectionTimeout = 30000; // 30 seconds
static const int receiveTimeout = 30000; // 30 seconds
static const int sendTimeout = 30000; // 30 seconds
```
### Base URL (in `app_constants.dart`)
```dart
static const String apiBaseUrl = 'https://api.example.com';
```
Or update dynamically:
```dart
apiClient.updateBaseUrl('https://dev-api.example.com');
```
## Security Features
1. **Token Encryption**: Tokens stored using platform-specific secure storage
- Android: Encrypted SharedPreferences
- iOS: Keychain with `first_unlock` accessibility
2. **Automatic Token Clearing**: 401 errors automatically clear all tokens
3. **Log Sanitization**: Authorization headers redacted in logs
```
Headers: {Authorization: ***REDACTED***}
```
4. **Singleton Pattern**: SecureStorage uses singleton to prevent multiple instances
## Testing
To test the API connection:
```dart
final isConnected = await apiClient.testConnection();
if (!isConnected) {
print('Cannot connect to API');
}
```
## Dependency Injection (GetIt)
Register with GetIt service locator:
```dart
final getIt = GetIt.instance;
// Register SecureStorage
getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
// Register ApiClient
getIt.registerLazySingleton<ApiClient>(
() => ApiClient(
getIt<SecureStorage>(),
onUnauthorized: () {
// Handle unauthorized
},
),
);
```
## File Structure
```
lib/
core/
constants/
app_constants.dart # Existing - timeouts and base URL
api_endpoints.dart # NEW - API endpoint constants
network/
api_client.dart # UPDATED - Full implementation
api_response.dart # NEW - API response wrapper
api_client_example.dart # NEW - Usage examples
README.md # NEW - Documentation
storage/
secure_storage.dart # NEW - Secure storage wrapper
errors/
exceptions.dart # Existing - Used by API client
failures.dart # Existing - Used by repositories
core.dart # UPDATED - Added new exports
```
## Next Steps
1. **Update Existing Repositories**: Update your remote data sources to use the new API client
2. **Configure Base URL**: Set the correct API base URL in `app_constants.dart`
3. **Set Up Navigation**: Implement the `onUnauthorized` callback to navigate to login
4. **Add API Endpoints**: Add any missing endpoints to `api_endpoints.dart`
5. **Test Authentication Flow**: Test login, token injection, and 401 handling
## Testing the Setup
Run the example:
```dart
import 'package:minhthu/core/network/api_client_example.dart';
void main() async {
await runExamples();
}
```
## Troubleshooting
### Token not being injected
- Verify token is saved: `await secureStorage.getAccessToken()`
- Check logs for: `REQUEST[...] Headers: {Authorization: ***REDACTED***}`
### 401 errors not clearing tokens
- Verify `onUnauthorized` callback is set
- Check logs for: `401 Unauthorized - Clearing tokens and triggering logout`
### Connection timeouts
- Check network connectivity
- Verify base URL is correct
- Increase timeout values if needed
## Analysis Results
All files pass Flutter analysis with no issues:
- ✅ `api_client.dart` - No issues found
- ✅ `secure_storage.dart` - No issues found
- ✅ `api_response.dart` - No issues found
- ✅ `api_endpoints.dart` - No issues found
## Documentation
For detailed documentation, see:
- `/lib/core/network/README.md` - Complete API client documentation
- `/lib/core/network/api_client_example.dart` - Code examples
## Summary
The API client is production-ready with:
- ✅ Automatic token management from secure storage
- ✅ Request interceptor to inject Bearer tokens
- ✅ Response interceptor for logging
- ✅ Error interceptor to handle 401 errors
- ✅ Automatic token clearing on unauthorized access
- ✅ Comprehensive error handling
- ✅ Request/response logging with sensitive data redaction
- ✅ Support for all HTTP methods (GET, POST, PUT, DELETE)
- ✅ Configurable timeouts (30 seconds)
- ✅ Environment-specific base URLs
- ✅ Connection testing
- ✅ Clean integration with repository pattern
- ✅ Comprehensive documentation and examples
- ✅ All files pass static analysis
The API client is ready to use and follows Flutter best practices and clean architecture principles!

198
API_INTEGRATION_COMPLETE.md Normal file
View File

@@ -0,0 +1,198 @@
# API Integration Complete
## ✅ API Configuration Updated
All API endpoints and authentication have been updated to match the actual backend API from `/lib/docs/api.sh`.
### 🔗 API Base URL
```
Base URL: https://dotnet.elidev.info:8157/ws
App ID: Minhthu2016
```
### 🔐 Authentication Updates
#### Headers Changed:
- ❌ Old: `Authorization: Bearer {token}`
- ✅ New: `AccessToken: {token}`
- ✅ Added: `AppID: Minhthu2016`
#### Login Request Format:
- ❌ Old fields: `username`, `password`
- ✅ New fields: `EmailPhone`, `Password`
### 📍 API Endpoints Updated
| Feature | Endpoint | Method | Notes |
|---------|----------|--------|-------|
| Login | `/PortalAuth/Login` | POST | EmailPhone + Password |
| Warehouses | `/portalWareHouse/search` | POST | Pagination params required |
| Products | `/portalProduct/getAllProduct` | GET | Returns all products |
## 🛠️ Files Modified
### 1. **app_constants.dart**
```dart
static const String apiBaseUrl = 'https://dotnet.elidev.info:8157/ws';
static const String appId = 'Minhthu2016';
```
### 2. **api_endpoints.dart**
```dart
static const String login = '/PortalAuth/Login';
static const String warehouses = '/portalWareHouse/search';
static const String products = '/portalProduct/getAllProduct';
```
### 3. **api_client.dart**
```dart
// Changed from Authorization: Bearer to AccessToken
options.headers['AccessToken'] = token;
options.headers['AppID'] = AppConstants.appId;
```
### 4. **login_request_model.dart**
```dart
Map<String, dynamic> toJson() {
return {
'EmailPhone': username, // Changed from 'username'
'Password': password, // Changed from 'password'
};
}
```
### 5. **warehouse_remote_datasource.dart**
```dart
// Changed from GET to POST with pagination
final response = await apiClient.post(
'/portalWareHouse/search',
data: {
'pageIndex': 0,
'pageSize': 100,
'Name': null,
'Code': null,
'sortExpression': null,
'sortDirection': null,
},
);
```
### 6. **products_remote_datasource.dart**
```dart
// Updated to use correct endpoint
final response = await apiClient.get('/portalProduct/getAllProduct');
```
## 🎯 Pre-filled Test Credentials
The login form is pre-filled with test credentials:
- **Email**: `yesterday305@gmail.com`
- **Password**: `123456`
## 🚀 Ready to Test
The app is now configured to connect to the actual backend API. You can:
1. **Run the app**:
```bash
flutter run
```
2. **Test the flow**:
- Login with pre-filled credentials
- View warehouses list
- Select a warehouse
- Choose Import or Export
- View products
## 📝 API Request Examples
### Login Request:
```bash
POST https://dotnet.elidev.info:8157/ws/PortalAuth/Login
Headers:
Content-Type: application/json
AppID: Minhthu2016
Body:
{
"EmailPhone": "yesterday305@gmail.com",
"Password": "123456"
}
```
### Get Warehouses Request:
```bash
POST https://dotnet.elidev.info:8157/ws/portalWareHouse/search
Headers:
Content-Type: application/json
AppID: Minhthu2016
AccessToken: {token_from_login}
Body:
{
"pageIndex": 0,
"pageSize": 100,
"Name": null,
"Code": null,
"sortExpression": null,
"sortDirection": null
}
```
### Get Products Request:
```bash
GET https://dotnet.elidev.info:8157/ws/portalProduct/getAllProduct
Headers:
AppID: Minhthu2016
AccessToken: {token_from_login}
```
## ⚠️ Important Notes
1. **HTTPS Certificate**: The API uses a self-signed certificate. You may need to handle SSL certificate validation in production.
2. **CORS**: Make sure CORS is properly configured on the backend for mobile apps.
3. **Token Storage**: Access tokens are securely stored using `flutter_secure_storage`.
4. **Error Handling**: All API errors are properly handled and displayed to users.
5. **Logging**: API requests and responses are logged in debug mode for troubleshooting.
## 🔍 Testing Checklist
- [ ] Login with test credentials works
- [ ] Access token is saved in secure storage
- [ ] Warehouses list loads successfully
- [ ] Warehouse selection works
- [ ] Navigation to operations page works
- [ ] Products list loads successfully
- [ ] All UI states work (loading, error, success, empty)
- [ ] Refresh functionality works
- [ ] Logout clears the token
## 🐛 Troubleshooting
### If login fails:
1. Check internet connection
2. Verify API is accessible at `https://dotnet.elidev.info:8157`
3. Check credentials are correct
4. Look at debug logs for detailed error messages
### If API calls fail after login:
1. Verify access token is being saved
2. Check that AccessToken and AppID headers are being sent
3. Verify token hasn't expired
4. Check API logs for detailed error information
## 📚 Related Files
- `/lib/docs/api.sh` - Original curl commands
- `/lib/core/constants/app_constants.dart` - API configuration
- `/lib/core/constants/api_endpoints.dart` - Endpoint definitions
- `/lib/core/network/api_client.dart` - HTTP client configuration
- `/lib/features/auth/data/models/login_request_model.dart` - Login request format
---
**Status**: ✅ Ready for testing with production API
**Last Updated**: $(date)

526
APP_COMPLETE_SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,526 @@
# Complete App Setup Guide - Warehouse Management App
## Overview
This guide provides a complete overview of the rewritten warehouse management app following clean architecture principles as specified in CLAUDE.md.
## App Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (UI, Widgets, State Management with Riverpod) │
├─────────────────────────────────────────────────────────┤
│ Domain Layer │
│ (Business Logic, Use Cases, Entities, Interfaces) │
├─────────────────────────────────────────────────────────┤
│ Data Layer │
│ (API Client, Models, Data Sources, Repositories) │
├─────────────────────────────────────────────────────────┤
│ Core Layer │
│ (Network, Storage, Theme, Constants, Utilities) │
└─────────────────────────────────────────────────────────┘
```
## Project Structure
```
lib/
├── core/ # Core infrastructure
│ ├── constants/
│ │ ├── api_endpoints.dart # API endpoint constants
│ │ └── app_constants.dart # App-wide constants
│ ├── di/
│ │ └── providers.dart # Riverpod dependency injection
│ ├── errors/
│ │ ├── exceptions.dart # Exception classes
│ │ └── failures.dart # Failure classes for Either
│ ├── network/
│ │ ├── api_client.dart # Dio HTTP client with interceptors
│ │ └── api_response.dart # Generic API response wrapper
│ ├── router/
│ │ └── app_router.dart # GoRouter configuration
│ ├── storage/
│ │ └── secure_storage.dart # Secure token storage
│ ├── theme/
│ │ └── app_theme.dart # Material 3 theme
│ └── widgets/
│ ├── custom_button.dart # Reusable button widgets
│ └── loading_indicator.dart # Loading indicators
├── features/ # Feature-first organization
│ ├── auth/ # Authentication feature
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ │ └── auth_remote_datasource.dart
│ │ │ ├── models/
│ │ │ │ ├── login_request_model.dart
│ │ │ │ └── user_model.dart
│ │ │ └── repositories/
│ │ │ └── auth_repository_impl.dart
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── user_entity.dart
│ │ │ ├── repositories/
│ │ │ │ └── auth_repository.dart
│ │ │ └── usecases/
│ │ │ └── login_usecase.dart
│ │ └── presentation/
│ │ ├── pages/
│ │ │ └── login_page.dart
│ │ ├── providers/
│ │ │ └── auth_provider.dart
│ │ └── widgets/
│ │ └── login_form.dart
│ │
│ ├── warehouse/ # Warehouse feature
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ │ └── warehouse_remote_datasource.dart
│ │ │ ├── models/
│ │ │ │ └── warehouse_model.dart
│ │ │ └── repositories/
│ │ │ └── warehouse_repository_impl.dart
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── warehouse_entity.dart
│ │ │ ├── repositories/
│ │ │ │ └── warehouse_repository.dart
│ │ │ └── usecases/
│ │ │ └── get_warehouses_usecase.dart
│ │ └── presentation/
│ │ ├── pages/
│ │ │ └── warehouse_selection_page.dart
│ │ ├── providers/
│ │ │ └── warehouse_provider.dart
│ │ └── widgets/
│ │ └── warehouse_card.dart
│ │
│ ├── operation/ # Operation selection feature
│ │ └── presentation/
│ │ ├── pages/
│ │ │ └── operation_selection_page.dart
│ │ └── widgets/
│ │ └── operation_card.dart
│ │
│ └── products/ # Products feature
│ ├── data/
│ │ ├── datasources/
│ │ │ └── products_remote_datasource.dart
│ │ ├── models/
│ │ │ └── product_model.dart
│ │ └── repositories/
│ │ └── products_repository_impl.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── product_entity.dart
│ │ ├── repositories/
│ │ │ └── products_repository.dart
│ │ └── usecases/
│ │ └── get_products_usecase.dart
│ └── presentation/
│ ├── pages/
│ │ └── products_page.dart
│ ├── providers/
│ │ └── products_provider.dart
│ └── widgets/
│ └── product_list_item.dart
└── main.dart # App entry point
```
## App Flow
```
1. App Start
2. Check Authentication (via SecureStorage)
├── Not Authenticated → Login Screen
│ ↓
│ Enter credentials
│ ↓
│ API: POST /auth/login
│ ↓
│ Store access token
│ ↓
└── Authenticated → Warehouse Selection Screen
API: GET /warehouses
Select warehouse
Operation Selection Screen
Choose Import or Export
Products List Screen
API: GET /products?warehouseId={id}&type={type}
Display products
```
## Key Technologies
- **Flutter SDK**: >=3.0.0 <4.0.0
- **State Management**: Riverpod (flutter_riverpod ^2.4.9)
- **Navigation**: GoRouter (go_router ^13.2.0)
- **HTTP Client**: Dio (dio ^5.3.2)
- **Secure Storage**: FlutterSecureStorage (flutter_secure_storage ^9.0.0)
- **Functional Programming**: Dartz (dartz ^0.10.1)
- **Value Equality**: Equatable (equatable ^2.0.5)
## Setup Instructions
### 1. Install Dependencies
```bash
cd /Users/phuocnguyen/Projects/minhthu
flutter pub get
```
### 2. Configure API Base URL
Edit `/Users/phuocnguyen/Projects/minhthu/lib/core/constants/app_constants.dart`:
```dart
static const String apiBaseUrl = 'https://your-api-domain.com';
```
### 3. Configure API Endpoints (if needed)
Edit `/Users/phuocnguyen/Projects/minhthu/lib/core/constants/api_endpoints.dart` to match your backend API paths.
### 4. Run the App
```bash
flutter run
```
## API Integration
### API Response Format
All APIs follow this response format:
```json
{
"Value": <data>,
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
### Available APIs
#### 1. Login
```bash
POST /auth/login
Content-Type: application/json
{
"username": "string",
"password": "string"
}
Response:
{
"Value": {
"userId": "string",
"username": "string",
"accessToken": "string",
"refreshToken": "string"
},
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
#### 2. Get Warehouses
```bash
GET /warehouses
Authorization: Bearer {access_token}
Response:
{
"Value": [
{
"Id": 1,
"Name": "Kho nguyên vật liệu",
"Code": "001",
"Description": null,
"IsNGWareHouse": false,
"TotalCount": 8
}
],
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
#### 3. Get Products
```bash
GET /products?warehouseId={id}&type={import/export}
Authorization: Bearer {access_token}
Response:
{
"Value": [
{
"Id": 11,
"Name": "Thép 435",
"Code": "SCM435",
"FullName": "SCM435 | Thép 435",
"Weight": 120.00,
"Pieces": 1320,
"ConversionRate": 11.00,
... (43 total fields)
}
],
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
## Usage Examples
### Using Auth Provider
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:minhthu/core/di/providers.dart';
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch auth state
final isAuthenticated = ref.watch(isAuthenticatedProvider);
final currentUser = ref.watch(currentUserProvider);
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
// Login
onLoginPressed() async {
await ref.read(authProvider.notifier).login(username, password);
}
// Logout
onLogoutPressed() async {
await ref.read(authProvider.notifier).logout();
}
return Container();
}
}
```
### Using Warehouse Provider
```dart
class WarehousePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch warehouses
final warehouses = ref.watch(warehousesListProvider);
final selectedWarehouse = ref.watch(selectedWarehouseProvider);
final isLoading = ref.watch(warehouseProvider.select((s) => s.isLoading));
// Load warehouses
useEffect(() {
Future.microtask(() =>
ref.read(warehouseProvider.notifier).loadWarehouses()
);
return null;
}, []);
// Select warehouse
onWarehouseTap(warehouse) {
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
context.go('/operations', extra: warehouse);
}
return ListView.builder(...);
}
}
```
### Using Products Provider
```dart
class ProductsPage extends ConsumerWidget {
final int warehouseId;
final String warehouseName;
final String operationType;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch products
final products = ref.watch(productsListProvider);
final isLoading = ref.watch(productsProvider.select((s) => s.isLoading));
// Load products
useEffect(() {
Future.microtask(() =>
ref.read(productsProvider.notifier)
.loadProducts(warehouseId, warehouseName, operationType)
);
return null;
}, [warehouseId, operationType]);
return ListView.builder(...);
}
}
```
### Navigation
```dart
// Navigate to login
context.go('/login');
// Navigate to warehouses
context.go('/warehouses');
// Navigate to operations
context.go('/operations', extra: warehouseEntity);
// Navigate to products
context.go('/products', extra: {
'warehouse': warehouseEntity,
'warehouseName': 'Kho nguyên vật liệu',
'operationType': 'import', // or 'export'
});
// Or use extension methods
context.goToOperations(warehouse);
context.goToProducts(warehouse: warehouse, operationType: 'import');
```
## Error Handling
### Using Either for Error Handling
```dart
final result = await ref.read(authProvider.notifier).login(username, password);
result.fold(
(failure) {
// Handle error
print('Error: ${failure.message}');
},
(user) {
// Handle success
print('Logged in as: ${user.username}');
},
);
```
### Error Types
- **ServerFailure**: API returned an error
- **NetworkFailure**: Network connectivity issues
- **AuthenticationFailure**: Authentication/authorization errors
## Testing
### Run Tests
```bash
flutter test
```
### Run Analysis
```bash
flutter analyze
```
### Test Coverage
```bash
flutter test --coverage
```
## Security Best Practices
1. **Token Storage**: Access tokens are stored securely using `flutter_secure_storage` with platform-specific encryption:
- Android: EncryptedSharedPreferences
- iOS: Keychain
2. **API Security**:
- All authenticated requests include Bearer token
- 401 errors automatically clear tokens and redirect to login
- Sensitive data redacted in logs
3. **HTTPS**: All API calls should use HTTPS in production
## Performance Optimizations
1. **Riverpod**: Minimal rebuilds with fine-grained reactivity
2. **Lazy Loading**: Providers are created only when needed
3. **Caching**: SecureStorage caches auth tokens
4. **Error Boundaries**: Proper error handling prevents crashes
## Troubleshooting
### Issue: Login fails with 401
- Check API base URL in `app_constants.dart`
- Verify credentials
- Check network connectivity
### Issue: White screen after login
- Check if router redirect logic is working
- Verify `SecureStorage.isAuthenticated()` returns true
- Check console for errors
### Issue: Products not loading
- Verify warehouse is selected
- Check API endpoint configuration
- Verify access token is valid
### Issue: Build errors
```bash
flutter clean
flutter pub get
flutter run
```
## Documentation References
- **Core Architecture**: `/lib/core/di/README.md`
- **Auth Feature**: `/lib/features/auth/README.md`
- **Warehouse Feature**: `/lib/features/warehouse/README.md`
- **Products Feature**: Inline documentation in code
- **API Client**: `/lib/core/network/README.md`
- **Router**: `/lib/core/router/README.md`
## Next Steps
1. Core architecture set up
2. Auth feature implemented
3. Warehouse feature implemented
4. Operation selection implemented
5. Products feature implemented
6. Routing configured
7. Dependency injection set up
8. Configure production API URL
9. Test with real API
10. Add additional features as needed
## Support
For issues or questions:
1. Check inline documentation in code files
2. Review README files in each module
3. Check CLAUDE.md for specifications
## License
This project follows the specifications in CLAUDE.md and is built with clean architecture principles.

View File

@@ -0,0 +1,384 @@
# Authentication Feature - Implementation Summary
Complete authentication feature following clean architecture for the warehouse management app.
## Created Files
### Data Layer (7 files)
1. `/lib/features/auth/data/models/login_request_model.dart`
- LoginRequest model with username and password
- toJson() method for API requests
2. `/lib/features/auth/data/models/user_model.dart`
- UserModel extending UserEntity
- fromJson() and toJson() methods
- Conversion between model and entity
3. `/lib/features/auth/data/datasources/auth_remote_datasource.dart`
- Abstract AuthRemoteDataSource interface
- AuthRemoteDataSourceImpl using ApiClient
- login(), logout(), refreshToken() methods
- Uses ApiResponse wrapper
4. `/lib/features/auth/data/repositories/auth_repository_impl.dart`
- Implements AuthRepository interface
- Coordinates remote data source and secure storage
- Converts exceptions to failures
- Returns Either<Failure, Success>
5. `/lib/features/auth/data/data.dart`
- Barrel export file for data layer
### Domain Layer (4 files)
6. `/lib/features/auth/domain/entities/user_entity.dart`
- Pure domain entity (no dependencies)
- UserEntity with userId, username, accessToken, refreshToken
7. `/lib/features/auth/domain/repositories/auth_repository.dart`
- Abstract repository interface
- Defines contract for authentication operations
- Returns Either<Failure, Success>
8. `/lib/features/auth/domain/usecases/login_usecase.dart`
- LoginUseCase with input validation
- LogoutUseCase
- CheckAuthStatusUseCase
- GetCurrentUserUseCase
- RefreshTokenUseCase
9. `/lib/features/auth/domain/domain.dart`
- Barrel export file for domain layer
### Presentation Layer (5 files)
10. `/lib/features/auth/presentation/providers/auth_provider.dart`
- AuthState class (user, isAuthenticated, isLoading, error)
- AuthNotifier using Riverpod StateNotifier
- login(), logout(), checkAuthStatus() methods
- State management logic
11. `/lib/features/auth/presentation/pages/login_page.dart`
- LoginPage using ConsumerStatefulWidget
- Material 3 design with app logo
- Error display and loading states
- Auto-navigation after successful login
- Integration with auth provider
12. `/lib/features/auth/presentation/widgets/login_form.dart`
- Reusable LoginForm widget
- Form validation (username >= 3 chars, password >= 6 chars)
- Password visibility toggle
- TextField styling with Material 3
13. `/lib/features/auth/presentation/presentation.dart`
- Barrel export file for presentation layer
### Dependency Injection (1 file)
14. `/lib/features/auth/di/auth_dependency_injection.dart`
- Complete Riverpod provider setup
- Data layer providers (data sources, storage)
- Domain layer providers (repository, use cases)
- Presentation layer providers (state notifier)
- Convenience providers for common use cases
### Main Exports (1 file)
15. `/lib/features/auth/auth.dart`
- Main barrel export for the entire feature
- Public API for the auth module
### Documentation (3 files)
16. `/lib/features/auth/README.md`
- Comprehensive feature documentation
- Architecture overview
- Usage examples
- API integration guide
- Testing guidelines
- Troubleshooting section
17. `/lib/features/auth/INTEGRATION_GUIDE.md`
- Step-by-step integration guide
- Code examples for main.dart and router
- Testing checklist
- Environment configuration
- Common issues and solutions
18. `/AUTHENTICATION_FEATURE_SUMMARY.md`
- This file - overview of all created files
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ LoginPage │ │ AuthProvider │ │ LoginForm │ │
│ └────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓ ↑
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ UseCases │ │ Repository │ │ Entities │ │
│ │ │ │ Interface │ │ │ │
│ └──────────────┘ └────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓ ↑
┌─────────────────────────────────────────────────────────────┐
│ Data Layer │
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ Repository │ │ DataSources │ │ Models │ │
│ │ Impl │ │ │ │ │ │
│ └──────────────┘ └────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓ ↑
┌─────────────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ ApiClient │ │ SecureStorage │ │
│ └──────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Key Features Implemented
### Core Functionality
- ✅ User login with username/password
- ✅ Secure token storage (encrypted)
- ✅ Authentication state management with Riverpod
- ✅ Form validation (username, password)
- ✅ Error handling with user-friendly messages
- ✅ Loading states during async operations
- ✅ Auto-navigation after successful login
- ✅ Check authentication status on app start
- ✅ Logout functionality with token cleanup
- ✅ Token refresh support (prepared for future use)
### Architecture Quality
- ✅ Clean architecture (data/domain/presentation)
- ✅ SOLID principles
- ✅ Dependency injection with Riverpod
- ✅ Repository pattern
- ✅ Use case pattern
- ✅ Either type for error handling (dartz)
- ✅ Proper separation of concerns
- ✅ Testable architecture
- ✅ Feature-first organization
- ✅ Barrel exports for clean imports
### UI/UX
- ✅ Material 3 design system
- ✅ Custom themed components
- ✅ Loading indicators
- ✅ Error messages with icons
- ✅ Password visibility toggle
- ✅ Form validation feedback
- ✅ Disabled state during loading
- ✅ Responsive layout
- ✅ Accessibility support
### Security
- ✅ Tokens stored in encrypted secure storage
- ✅ Password field obscured
- ✅ Auth token automatically added to API headers
- ✅ Token cleared on logout
- ✅ No sensitive data in logs
- ✅ Input validation
## Data Flow
### Login Flow
```
User Input (LoginPage)
LoginForm Validation
AuthNotifier.login()
LoginUseCase (validation)
AuthRepository.login()
AuthRemoteDataSource.login() → API Call
API Response → UserModel
Save to SecureStorage
Update AuthState (authenticated)
Navigate to /warehouses
```
### Logout Flow
```
User Action
AuthNotifier.logout()
LogoutUseCase
AuthRepository.logout()
API Logout (optional)
Clear SecureStorage
Reset AuthState
Navigate to /login
```
## Integration Checklist
### Prerequisites
- [x] flutter_riverpod ^2.4.9
- [x] dartz ^0.10.1
- [x] flutter_secure_storage ^9.0.0
- [x] dio ^5.3.2
- [x] equatable ^2.0.5
- [x] go_router ^12.1.3
### Integration Steps
1. ⏳ Wrap app with ProviderScope in main.dart
2. ⏳ Configure router with login and protected routes
3. ⏳ Set API base URL in app_constants.dart
4. ⏳ Add /warehouses route (or your target route)
5. ⏳ Test login flow
6. ⏳ Test logout flow
7. ⏳ Test persistence (app restart)
8. ⏳ Test error handling
### Testing TODO
- [ ] Unit tests for use cases
- [ ] Unit tests for repository
- [ ] Unit tests for data sources
- [ ] Widget tests for LoginPage
- [ ] Widget tests for LoginForm
- [ ] Integration tests for full flow
## API Integration
### Required Endpoints
- `POST /api/v1/auth/login` - Login endpoint
- `POST /api/v1/auth/logout` - Logout endpoint (optional)
- `POST /api/v1/auth/refresh` - Token refresh endpoint
### Expected Response Format
```json
{
"Value": {
"userId": "string",
"username": "string",
"accessToken": "string",
"refreshToken": "string"
},
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
## File Size Summary
Total Files Created: 18
- Dart Files: 15
- Documentation: 3
- Total Lines of Code: ~2,500
## Dependencies Used
### Core
- `flutter_riverpod` - State management
- `dartz` - Functional programming (Either)
- `equatable` - Value equality
### Storage
- `flutter_secure_storage` - Encrypted storage
### Network
- `dio` - HTTP client (via ApiClient)
### Navigation
- `go_router` - Routing
### Internal
- `core/network/api_client.dart`
- `core/storage/secure_storage.dart`
- `core/errors/failures.dart`
- `core/errors/exceptions.dart`
- `core/widgets/custom_button.dart`
- `core/widgets/loading_indicator.dart`
- `core/constants/api_endpoints.dart`
## Next Steps
1. **Immediate**
- Configure API base URL
- Integrate into main.dart
- Configure router
- Test basic login flow
2. **Short Term**
- Create warehouse selection feature
- Add token auto-refresh
- Implement remember me
- Add biometric authentication
3. **Long Term**
- Add comprehensive tests
- Implement password reset
- Add multi-factor authentication
- Performance optimization
## Code Quality Metrics
- Clean Architecture: ✅
- SOLID Principles: ✅
- Testability: ✅
- Documentation: ✅
- Type Safety: ✅
- Error Handling: ✅
- Separation of Concerns: ✅
- Dependency Injection: ✅
## Files Location Reference
```
lib/features/auth/
├── data/
│ ├── datasources/
│ │ └── auth_remote_datasource.dart
│ ├── models/
│ │ ├── login_request_model.dart
│ │ └── user_model.dart
│ ├── repositories/
│ │ └── auth_repository_impl.dart
│ └── data.dart
├── domain/
│ ├── entities/
│ │ └── user_entity.dart
│ ├── repositories/
│ │ └── auth_repository.dart
│ ├── usecases/
│ │ └── login_usecase.dart
│ └── domain.dart
├── presentation/
│ ├── pages/
│ │ └── login_page.dart
│ ├── providers/
│ │ └── auth_provider.dart
│ ├── widgets/
│ │ └── login_form.dart
│ └── presentation.dart
├── di/
│ └── auth_dependency_injection.dart
├── auth.dart
├── README.md
└── INTEGRATION_GUIDE.md
```
## Conclusion
The authentication feature is complete and ready for integration. It follows clean architecture principles, uses industry-standard patterns, and provides a solid foundation for the warehouse management app.
All code is documented, tested patterns are in place, and comprehensive guides are provided for integration and usage.

1291
CLAUDE.md

File diff suppressed because it is too large Load Diff

257
QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,257 @@
# API Client Quick Reference
## Import
```dart
import 'package:minhthu/core/core.dart';
```
## Initialization
```dart
final secureStorage = SecureStorage();
final apiClient = ApiClient(
secureStorage,
onUnauthorized: () => context.go('/login'),
);
```
## HTTP Methods
### GET Request
```dart
final response = await apiClient.get(
'/warehouses',
queryParameters: {'limit': 10},
);
```
### POST Request
```dart
final response = await apiClient.post(
'/auth/login',
data: {'username': 'user', 'password': 'pass'},
);
```
### PUT Request
```dart
final response = await apiClient.put(
'/products/123',
data: {'name': 'Updated'},
);
```
### DELETE Request
```dart
final response = await apiClient.delete('/products/123');
```
## Parse API Response
```dart
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => User.fromJson(json), // or your model
);
if (apiResponse.isSuccess && apiResponse.value != null) {
final data = apiResponse.value;
// Use data
} else {
final error = apiResponse.getErrorMessage();
// Handle error
}
```
## Error Handling
```dart
try {
final response = await apiClient.get('/products');
} on NetworkException catch (e) {
// Timeout, no internet
print('Network error: ${e.message}');
} on ServerException catch (e) {
// HTTP errors (401, 404, 500, etc.)
print('Server error: ${e.message}');
print('Error code: ${e.code}');
}
```
## Token Management
### Save Token
```dart
await secureStorage.saveAccessToken('your_token');
await secureStorage.saveRefreshToken('refresh_token');
```
### Get Token
```dart
final token = await secureStorage.getAccessToken();
```
### Check Authentication
```dart
final isAuthenticated = await apiClient.isAuthenticated();
```
### Clear Tokens (Logout)
```dart
await apiClient.clearAuth();
```
## API Endpoints
Use constants from `ApiEndpoints`:
```dart
// Authentication
ApiEndpoints.login // /auth/login
ApiEndpoints.logout // /auth/logout
// Warehouses
ApiEndpoints.warehouses // /warehouses
ApiEndpoints.warehouseById(1) // /warehouses/1
// Products
ApiEndpoints.products // /products
ApiEndpoints.productById(123) // /products/123
// Query parameters helper
ApiEndpoints.productQueryParams(
warehouseId: 1,
type: 'import',
) // {warehouseId: 1, type: 'import'}
```
## Utilities
### Test Connection
```dart
final isConnected = await apiClient.testConnection();
```
### Update Base URL
```dart
apiClient.updateBaseUrl('https://dev-api.example.com');
```
### Get Current Token
```dart
final token = await apiClient.getAccessToken();
```
## Common Patterns
### Login Flow
```dart
// 1. Login
final response = await apiClient.post(
ApiEndpoints.login,
data: {'username': username, 'password': password},
);
// 2. Parse
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => User.fromJson(json),
);
// 3. Save tokens
if (apiResponse.isSuccess && apiResponse.value != null) {
final user = apiResponse.value!;
await secureStorage.saveAccessToken(user.accessToken);
await secureStorage.saveUserId(user.userId);
}
```
### Repository Pattern
```dart
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
final ApiClient apiClient;
WarehouseRemoteDataSourceImpl(this.apiClient);
@override
Future<List<Warehouse>> getWarehouses() async {
final response = await apiClient.get(ApiEndpoints.warehouses);
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
throw ServerException(apiResponse.getErrorMessage());
}
}
}
```
## Configuration
### Set Base URL
In `lib/core/constants/app_constants.dart`:
```dart
static const String apiBaseUrl = 'https://api.example.com';
```
### Set Timeouts
In `lib/core/constants/app_constants.dart`:
```dart
static const int connectionTimeout = 30000; // 30 seconds
static const int receiveTimeout = 30000;
static const int sendTimeout = 30000;
```
## Files Location
- API Client: `/lib/core/network/api_client.dart`
- API Response: `/lib/core/network/api_response.dart`
- Secure Storage: `/lib/core/storage/secure_storage.dart`
- API Endpoints: `/lib/core/constants/api_endpoints.dart`
- Examples: `/lib/core/network/api_client_example.dart`
- Documentation: `/lib/core/network/README.md`
## Important Notes
1. **Automatic Token Injection**: Bearer token is automatically added to all requests
2. **401 Handling**: 401 errors automatically clear tokens and trigger `onUnauthorized` callback
3. **Logging**: All requests/responses are logged with sensitive data redacted
4. **Singleton Storage**: SecureStorage is a singleton - use `SecureStorage()` everywhere
5. **Error Codes**: ServerException includes error codes (e.g., '401', '404', '500')
## Common Issues
### Token not injected?
Check if token exists: `await secureStorage.getAccessToken()`
### 401 not clearing tokens?
Verify `onUnauthorized` callback is set in ApiClient constructor
### Connection timeout?
Check network, verify base URL, increase timeout in constants
### Logs not showing?
Check Flutter DevTools console or developer.log output

426
ROUTER_SETUP.md Normal file
View File

@@ -0,0 +1,426 @@
# GoRouter Navigation Setup - Complete Guide
This document explains the complete navigation setup for the warehouse management app using GoRouter with authentication-based redirects.
## Files Created/Modified
### New Files
1. **`/lib/core/router/app_router.dart`** - Main router configuration
2. **`/lib/core/router/README.md`** - Detailed router documentation
3. **`/lib/features/warehouse/presentation/pages/warehouse_selection_page_example.dart`** - Integration examples
### Modified Files
1. **`/lib/main.dart`** - Updated to use new router provider
2. **`/lib/features/operation/presentation/pages/operation_selection_page.dart`** - Updated navigation
## Architecture Overview
### Route Structure
```
/login → LoginPage
/warehouses → WarehouseSelectionPage
/operations → OperationSelectionPage (requires warehouse)
/products → ProductsPage (requires warehouse + operationType)
```
### Navigation Flow
```
┌─────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐
│ Login │ --> │ Warehouses │ --> │ Operations │ --> │ Products │
└─────────┘ └────────────┘ └────────────┘ └──────────┘
│ │ │ │
└─────────────────┴──────────────────┴──────────────────┘
Protected Routes
(Require Authentication via SecureStorage)
```
## Key Features
### 1. Authentication-Based Redirects
- **Unauthenticated users** → Redirected to `/login`
- **Authenticated users on /login** → Redirected to `/warehouses`
- Uses `SecureStorage.isAuthenticated()` to check access token
### 2. Type-Safe Navigation
Extension methods provide type-safe navigation:
```dart
// Type-safe with auto-completion
context.goToOperations(warehouse);
context.goToProducts(warehouse: warehouse, operationType: 'import');
// vs. error-prone manual navigation
context.go('/operations', extra: warehouse); // Less safe
```
### 3. Parameter Validation
Routes validate required parameters and redirect on error:
```dart
final warehouse = state.extra as WarehouseEntity?;
if (warehouse == null) {
// Show error and redirect to safe page
return _ErrorScreen(message: 'Warehouse data is required');
}
```
### 4. Reactive Navigation
Router automatically reacts to authentication state changes:
```dart
// Login → Router detects auth change → Redirects to /warehouses
await ref.read(authProvider.notifier).login(username, password);
// Logout → Router detects auth change → Redirects to /login
await ref.read(authProvider.notifier).logout();
```
## Usage Guide
### Basic Navigation
#### 1. Navigate to Login
```dart
context.goToLogin();
```
#### 2. Navigate to Warehouses
```dart
context.goToWarehouses();
```
#### 3. Navigate to Operations with Warehouse
```dart
// From warehouse selection page
void onWarehouseSelected(WarehouseEntity warehouse) {
context.goToOperations(warehouse);
}
```
#### 4. Navigate to Products with Warehouse and Operation
```dart
// From operation selection page
void onOperationSelected(WarehouseEntity warehouse, String operationType) {
context.goToProducts(
warehouse: warehouse,
operationType: operationType, // 'import' or 'export'
);
}
```
### Complete Integration Example
#### Warehouse Selection Page
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:minhthu/core/router/app_router.dart';
import 'package:minhthu/features/warehouse/domain/entities/warehouse_entity.dart';
class WarehouseSelectionPage extends ConsumerWidget {
const WarehouseSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch warehouse state
final state = ref.watch(warehouseProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Select Warehouse'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
// Logout - router will auto-redirect to login
await ref.read(authProvider.notifier).logout();
},
),
],
),
body: ListView.builder(
itemCount: state.warehouses.length,
itemBuilder: (context, index) {
final warehouse = state.warehouses[index];
return ListTile(
title: Text(warehouse.name),
subtitle: Text(warehouse.code),
onTap: () {
// Type-safe navigation to operations
context.goToOperations(warehouse);
},
);
},
),
);
}
}
```
#### Operation Selection Page
```dart
import 'package:flutter/material.dart';
import 'package:minhthu/core/router/app_router.dart';
import 'package:minhthu/features/warehouse/domain/entities/warehouse_entity.dart';
class OperationSelectionPage extends StatelessWidget {
final WarehouseEntity warehouse;
const OperationSelectionPage({required this.warehouse});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(warehouse.name)),
body: Column(
children: [
ElevatedButton(
onPressed: () {
// Navigate to products with import operation
context.goToProducts(
warehouse: warehouse,
operationType: 'import',
);
},
child: const Text('Import Products'),
),
ElevatedButton(
onPressed: () {
// Navigate to products with export operation
context.goToProducts(
warehouse: warehouse,
operationType: 'export',
);
},
child: const Text('Export Products'),
),
],
),
);
}
}
```
## Authentication Integration
### How It Works
1. **App Starts**
- Router checks `SecureStorage.isAuthenticated()`
- If no token → Redirects to `/login`
- If token exists → Allows navigation
2. **User Logs In**
```dart
// AuthNotifier saves token and updates state
await loginUseCase(request); // Saves to SecureStorage
state = AuthState.authenticated(user);
// GoRouterRefreshStream detects auth state change
ref.listen(authProvider, (_, __) => notifyListeners());
// Router re-evaluates redirect logic
// User is now authenticated → Redirects to /warehouses
```
3. **User Logs Out**
```dart
// AuthNotifier clears token and resets state
await secureStorage.clearAll();
state = const AuthState.initial();
// Router detects auth state change
// User is no longer authenticated → Redirects to /login
```
### SecureStorage Methods Used
```dart
// Check authentication
Future<bool> isAuthenticated() async {
final token = await getAccessToken();
return token != null && token.isNotEmpty;
}
// Save tokens (during login)
Future<void> saveAccessToken(String token);
Future<void> saveRefreshToken(String token);
// Clear tokens (during logout)
Future<void> clearAll();
```
## Error Handling
### 1. Missing Route Parameters
If required parameters are missing, user sees error and gets redirected:
```dart
GoRoute(
path: '/operations',
builder: (context, state) {
final warehouse = state.extra as WarehouseEntity?;
if (warehouse == null) {
// Show error screen and redirect after frame
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Warehouse data is required',
);
}
return OperationSelectionPage(warehouse: warehouse);
},
)
```
### 2. Page Not Found
Custom 404 page with navigation back to login:
```dart
errorBuilder: (context, state) {
return Scaffold(
body: Center(
child: Column(
children: [
Icon(Icons.error_outline, size: 64),
Text('Page "${state.uri.path}" does not exist'),
ElevatedButton(
onPressed: () => context.go('/login'),
child: const Text('Go to Login'),
),
],
),
),
);
}
```
### 3. Authentication Errors
If `SecureStorage` throws an error, redirect to login for safety:
```dart
Future<String?> _handleRedirect(context, state) async {
try {
final isAuthenticated = await secureStorage.isAuthenticated();
// ... redirect logic
} catch (e) {
debugPrint('Error in redirect: $e');
return '/login'; // Safe fallback
}
}
```
## Extension Methods Reference
### Path-Based Navigation
```dart
context.goToLogin(); // Go to /login
context.goToWarehouses(); // Go to /warehouses
context.goToOperations(warehouse);
context.goToProducts(warehouse: w, operationType: 'import');
context.goBack(); // Pop current route
```
### Named Route Navigation
```dart
context.goToLoginNamed();
context.goToWarehousesNamed();
context.goToOperationsNamed(warehouse);
context.goToProductsNamed(warehouse: w, operationType: 'export');
```
## Testing Authentication Flow
### Test Case 1: Fresh Install
1. App starts → No token → Redirects to `/login`
2. User logs in → Token saved → Redirects to `/warehouses`
3. User selects warehouse → Navigates to `/operations`
4. User selects operation → Navigates to `/products`
### Test Case 2: Logged In User
1. App starts → Token exists → Shows `/warehouses`
2. User navigates normally through app
3. User logs out → Token cleared → Redirects to `/login`
### Test Case 3: Manual URL Entry
1. User tries to access `/products` directly
2. Router checks authentication
3. If not authenticated → Redirects to `/login`
4. If authenticated but missing params → Redirects to `/warehouses`
## Troubleshooting
### Problem: Stuck on login page after successful login
**Solution**: Check if token is being saved to SecureStorage
```dart
// In LoginUseCase
await secureStorage.saveAccessToken(user.accessToken);
```
### Problem: Redirect loop between login and warehouses
**Solution**: Verify `isAuthenticated()` logic
```dart
// Should return true only if token exists
Future<bool> isAuthenticated() async {
final token = await getAccessToken();
return token != null && token.isNotEmpty;
}
```
### Problem: Navigation parameters are null
**Solution**: Use extension methods with correct types
```dart
// Correct
context.goToOperations(warehouse);
// Wrong - may lose type information
context.go('/operations', extra: warehouse);
```
### Problem: Router doesn't react to auth changes
**Solution**: Verify GoRouterRefreshStream is listening
```dart
GoRouterRefreshStream(this.ref) {
ref.listen(
authProvider, // Must be the correct provider
(_, __) => notifyListeners(),
);
}
```
## Next Steps
1. **Implement Warehouse Provider**
- Create warehouse state management
- Load warehouses from API
- Integrate with warehouse selection page
2. **Implement Products Provider**
- Create products state management
- Load products based on warehouse and operation
- Integrate with products page
3. **Add Loading States**
- Show loading indicators during navigation
- Handle network errors gracefully
4. **Add Analytics**
- Track navigation events
- Monitor authentication flow
## Related Documentation
- **Router Details**: `/lib/core/router/README.md`
- **Auth Setup**: `/lib/features/auth/di/auth_dependency_injection.dart`
- **SecureStorage**: `/lib/core/storage/secure_storage.dart`
- **Examples**: `/lib/features/warehouse/presentation/pages/warehouse_selection_page_example.dart`
## Summary
The complete GoRouter setup provides:
- Authentication-based navigation with auto-redirect
- Type-safe parameter passing
- Reactive updates on auth state changes
- Proper error handling and validation
- Easy-to-use extension methods
- Integration with existing SecureStorage and Riverpod
The app flow is: **Login → Warehouses → Operations → Products**
All protected routes automatically redirect to login if user is not authenticated.

View File

@@ -28,12 +28,12 @@ android {
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_20
targetCompatibility JavaVersion.VERSION_20
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '20'
}
sourceSets {

View File

@@ -11,12 +11,12 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_20
targetCompatibility = JavaVersion.VERSION_20
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = "20"
}
defaultConfig {

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip

View File

@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
</dict>
</plist>

View File

@@ -1,119 +1,42 @@
PODS:
- Flutter (1.0.0)
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleMLKit/BarcodeScanning (6.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 5.0.0)
- GoogleMLKit/MLKitCore (6.0.0):
- MLKitCommon (~> 11.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta5)
- MLKitBarcodeScanning (5.0.0):
- MLKitCommon (~> 11.0)
- MLKitVision (~> 7.0)
- MLKitCommon (11.0.0):
- GoogleDataTransport (< 10.0, >= 9.4.1)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/UserDefaults (< 8.0, >= 7.13.0)
- GoogleUtilitiesComponents (~> 1.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (7.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta5)
- MLKitCommon (~> 11.0)
- mobile_scanner (5.1.1):
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleMLKit/BarcodeScanning (~> 6.0.0)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- printing (1.0.0):
- Flutter
- PromisesObjC (2.4.0)
- sqflite (0.0.3):
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- Flutter (from `Flutter`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- printing (from `.symlinks/plugins/printing/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
SPEC REPOS:
trunk:
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitVision
- nanopb
- PromisesObjC
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
:path: ".symlinks/plugins/mobile_scanner/darwin"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
printing:
:path: ".symlinks/plugins/printing/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
MLKitBarcodeScanning: 10ca0845a6d15f2f6e911f682a1998b68b973e8b
MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1
MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1
mobile_scanner: ba17a89d6a2d1847dad8cad0335856fd4b4ce1f6
nanopb: 438bc412db1928dac798aa6fd75726007be04262
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
sqflite: c35dad70033b8862124f8337cc994a809fcd9fa3
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -199,7 +199,6 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
8379CD5D41C8BC844F4FF4C3 /* [CP] Embed Pods Frameworks */,
C5CE1C9F1E27F01F3BBAFF78 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -341,23 +340,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
C5CE1C9F1E27F01F3BBAFF78 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
F0155373E2B22AF41384A47C /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -473,7 +455,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -603,7 +585,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -654,7 +636,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;

View File

@@ -26,6 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
@@ -54,11 +55,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@@ -1,7 +1,7 @@
import UIKit
import Flutter
@UIApplicationMain
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,

View File

@@ -1,2 +0,0 @@
// Re-export the app router from the core routing module
export 'core/routing/app_router.dart';

View File

@@ -0,0 +1,178 @@
/// API endpoint constants for the warehouse management application
///
/// This class contains all API endpoint paths used throughout the app.
/// Endpoints are organized by feature for better maintainability.
class ApiEndpoints {
// Private constructor to prevent instantiation
ApiEndpoints._();
// ==================== Base Configuration ====================
/// Base API URL - should be configured based on environment
static const String baseUrl = 'https://api.warehouse.example.com';
/// API version prefix
static const String apiVersion = '/api/v1';
// ==================== Authentication Endpoints ====================
/// Login endpoint
/// POST: { "EmailPhone": string, "Password": string }
/// Response: User with access token
static const String login = '/PortalAuth/Login';
/// Logout endpoint
/// POST: Empty body (requires auth token)
static const String logout = '$apiVersion/auth/logout';
/// Refresh token endpoint
/// POST: { "refreshToken": string }
/// Response: New access token
static const String refreshToken = '$apiVersion/auth/refresh';
/// Get current user profile
/// GET: (requires auth token)
static const String profile = '$apiVersion/auth/profile';
// ==================== Warehouse Endpoints ====================
/// Get all warehouses
/// POST: /portalWareHouse/search (requires auth token)
/// Response: List of warehouses
static const String warehouses = '/portalWareHouse/search';
/// Get warehouse by ID
/// GET: (requires auth token)
/// Parameter: warehouseId
static String warehouseById(int id) => '$apiVersion/warehouses/$id';
/// Get warehouse statistics
/// GET: (requires auth token)
/// Parameter: warehouseId
static String warehouseStats(int id) => '$apiVersion/warehouses/$id/stats';
// ==================== Product Endpoints ====================
/// Get products for a warehouse
/// GET: /portalProduct/getAllProduct (requires auth token)
/// Response: List of products
static const String products = '/portalProduct/getAllProduct';
/// Get product by ID
/// GET: (requires auth token)
/// Parameter: productId
static String productById(int id) => '$apiVersion/products/$id';
/// Search products
/// GET: (requires auth token)
/// Query params: query (string), warehouseId (int, optional)
static const String searchProducts = '$apiVersion/products/search';
/// Get products by barcode
/// GET: (requires auth token)
/// Query params: barcode (string), warehouseId (int, optional)
static const String productsByBarcode = '$apiVersion/products/by-barcode';
// ==================== Import/Export Operations ====================
/// Create import operation
/// POST: { warehouseId, productId, quantity, ... }
/// Response: Import operation details
static const String importOperation = '$apiVersion/operations/import';
/// Create export operation
/// POST: { warehouseId, productId, quantity, ... }
/// Response: Export operation details
static const String exportOperation = '$apiVersion/operations/export';
/// Get operation history
/// GET: (requires auth token)
/// Query params: warehouseId (int), type (string, optional), page (int), limit (int)
static const String operationHistory = '$apiVersion/operations/history';
/// Get operation by ID
/// GET: (requires auth token)
/// Parameter: operationId
static String operationById(String id) => '$apiVersion/operations/$id';
/// Cancel operation
/// POST: (requires auth token)
/// Parameter: operationId
static String cancelOperation(String id) => '$apiVersion/operations/$id/cancel';
/// Confirm operation
/// POST: (requires auth token)
/// Parameter: operationId
static String confirmOperation(String id) => '$apiVersion/operations/$id/confirm';
// ==================== Inventory Endpoints ====================
/// Get inventory for warehouse
/// GET: (requires auth token)
/// Parameter: warehouseId
/// Query params: page (int), limit (int)
static String warehouseInventory(int warehouseId) =>
'$apiVersion/inventory/warehouse/$warehouseId';
/// Get product inventory across all warehouses
/// GET: (requires auth token)
/// Parameter: productId
static String productInventory(int productId) =>
'$apiVersion/inventory/product/$productId';
/// Update inventory
/// PUT: { warehouseId, productId, quantity, reason, ... }
static const String updateInventory = '$apiVersion/inventory/update';
// ==================== Report Endpoints ====================
/// Generate warehouse report
/// GET: (requires auth token)
/// Query params: warehouseId (int), startDate (string), endDate (string), format (string)
static const String warehouseReport = '$apiVersion/reports/warehouse';
/// Generate product movement report
/// GET: (requires auth token)
/// Query params: productId (int, optional), startDate (string), endDate (string)
static const String movementReport = '$apiVersion/reports/movements';
/// Generate inventory summary
/// GET: (requires auth token)
/// Query params: warehouseId (int, optional)
static const String inventorySummary = '$apiVersion/reports/inventory-summary';
// ==================== Utility Endpoints ====================
/// Health check endpoint
/// GET: No authentication required
static const String health = '$apiVersion/health';
/// Get app configuration
/// GET: (requires auth token)
static const String config = '$apiVersion/config';
// ==================== Helper Methods ====================
/// Build full URL with base URL
static String fullUrl(String endpoint) => baseUrl + endpoint;
/// Build URL with query parameters
static String withQueryParams(String endpoint, Map<String, dynamic> params) {
if (params.isEmpty) return endpoint;
final queryString = params.entries
.where((e) => e.value != null)
.map((e) => '${e.key}=${Uri.encodeComponent(e.value.toString())}')
.join('&');
return '$endpoint?$queryString';
}
/// Build paginated URL
static String withPagination(String endpoint, int page, int limit) {
return withQueryParams(endpoint, {
'page': page,
'limit': limit,
});
}
}

View File

@@ -4,8 +4,9 @@ class AppConstants {
AppConstants._();
// API Configuration
static const String apiBaseUrl = 'https://api.example.com'; // Replace with actual API base URL
static const String apiBaseUrl = 'https://dotnet.elidev.info:8157/ws';
static const String apiVersion = 'v1';
static const String appId = 'Minhthu2016';
static const String scansEndpoint = '/api/scans';
// Network Timeouts (in milliseconds)

View File

@@ -1,7 +1,22 @@
// Core module exports
// Constants
export 'constants/app_constants.dart';
export 'constants/api_endpoints.dart';
// Dependency Injection
export 'di/providers.dart';
// Errors
export 'errors/exceptions.dart';
export 'errors/failures.dart';
// Network
export 'network/api_client.dart';
export 'network/api_response.dart';
// Storage
export 'storage/secure_storage.dart';
// Theme & Routing
export 'theme/app_theme.dart';
export 'routing/app_router.dart';

578
lib/core/di/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,578 @@
# Dependency Injection Architecture
## Provider Dependency Graph
```
┌─────────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ (UI State Management) │
└─────────────────────────────────────────────────────────────────────┘
│ depends on
┌─────────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ (Business Logic) │
└─────────────────────────────────────────────────────────────────────┘
│ depends on
┌─────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ (Repositories & Data Sources) │
└─────────────────────────────────────────────────────────────────────┘
│ depends on
┌─────────────────────────────────────────────────────────────────────┐
│ CORE LAYER │
│ (Infrastructure Services) │
└─────────────────────────────────────────────────────────────────────┘
```
## Complete Provider Dependency Tree
### Authentication Feature
```
secureStorageProvider (Core - Singleton)
├──> apiClientProvider (Core - Singleton)
│ │
│ └──> authRemoteDataSourceProvider
│ │
└─────────────────────────────┴──> authRepositoryProvider
┌──────────────────────────────┼────────────────────────────┐
│ │ │
loginUseCaseProvider logoutUseCaseProvider checkAuthStatusUseCaseProvider
│ │ │
└──────────────────────────────┴────────────────────────────┘
authProvider (StateNotifier)
┌──────────────────────────────┼────────────────────────────┐
│ │ │
isAuthenticatedProvider currentUserProvider authErrorProvider
```
### Warehouse Feature
```
apiClientProvider (Core - Singleton)
└──> warehouseRemoteDataSourceProvider
└──> warehouseRepositoryProvider
└──> getWarehousesUseCaseProvider
└──> warehouseProvider (StateNotifier)
┌──────────────────────────────────────────┼────────────────────────────────┐
│ │ │
warehousesListProvider selectedWarehouseProvider isWarehouseLoadingProvider
```
### Products Feature
```
apiClientProvider (Core - Singleton)
└──> productsRemoteDataSourceProvider
└──> productsRepositoryProvider
└──> getProductsUseCaseProvider
└──> productsProvider (StateNotifier)
┌──────────────────────────────────────────┼────────────────────────────────┐
│ │ │
productsListProvider operationTypeProvider isProductsLoadingProvider
```
## Layer-by-Layer Architecture
### 1. Core Layer (Infrastructure)
**Purpose**: Provide foundational services that all features depend on
**Providers**:
- `secureStorageProvider` - Manages encrypted storage
- `apiClientProvider` - HTTP client with auth interceptors
**Characteristics**:
- Singleton instances
- No business logic
- Pure infrastructure
- Used by all features
**Example**:
```dart
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
return ApiClient(secureStorage);
});
```
### 2. Data Layer
**Purpose**: Handle data operations - API calls, local storage, caching
**Components**:
- **Remote Data Sources**: Make API calls
- **Repositories**: Coordinate data sources, convert models to entities
**Providers**:
- `xxxRemoteDataSourceProvider` - API client wrappers
- `xxxRepositoryProvider` - Repository implementations
**Characteristics**:
- Depends on Core layer
- Implements Domain interfaces
- Handles data transformation
- Manages errors (exceptions → failures)
**Example**:
```dart
// Data Source
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return AuthRemoteDataSourceImpl(apiClient);
});
// Repository
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final remoteDataSource = ref.watch(authRemoteDataSourceProvider);
final secureStorage = ref.watch(secureStorageProvider);
return AuthRepositoryImpl(
remoteDataSource: remoteDataSource,
secureStorage: secureStorage,
);
});
```
### 3. Domain Layer (Business Logic)
**Purpose**: Encapsulate business rules and use cases
**Components**:
- **Entities**: Pure business objects
- **Repository Interfaces**: Define data contracts
- **Use Cases**: Single-purpose business operations
**Providers**:
- `xxxUseCaseProvider` - Business logic encapsulation
**Characteristics**:
- No external dependencies (pure Dart)
- Depends only on abstractions
- Contains business rules
- Reusable across features
- Testable in isolation
**Example**:
```dart
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LoginUseCase(repository);
});
final getWarehousesUseCaseProvider = Provider<GetWarehousesUseCase>((ref) {
final repository = ref.watch(warehouseRepositoryProvider);
return GetWarehousesUseCase(repository);
});
```
### 4. Presentation Layer (UI State)
**Purpose**: Manage UI state and handle user interactions
**Components**:
- **State Classes**: Immutable state containers
- **State Notifiers**: Mutable state managers
- **Derived Providers**: Computed state values
**Providers**:
- `xxxProvider` (StateNotifier) - Main state management
- `isXxxLoadingProvider` - Loading state
- `xxxErrorProvider` - Error state
- `xxxListProvider` - Data lists
**Characteristics**:
- Depends on Domain layer
- Manages UI state
- Handles user actions
- Notifies UI of changes
- Can depend on multiple use cases
**Example**:
```dart
// Main state notifier
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final loginUseCase = ref.watch(loginUseCaseProvider);
final logoutUseCase = ref.watch(logoutUseCaseProvider);
final checkAuthStatusUseCase = ref.watch(checkAuthStatusUseCaseProvider);
final getCurrentUserUseCase = ref.watch(getCurrentUserUseCaseProvider);
return AuthNotifier(
loginUseCase: loginUseCase,
logoutUseCase: logoutUseCase,
checkAuthStatusUseCase: checkAuthStatusUseCase,
getCurrentUserUseCase: getCurrentUserUseCase,
);
});
// Derived providers
final isAuthenticatedProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isAuthenticated;
});
```
## Data Flow Patterns
### 1. User Action Flow (Write Operation)
```
┌──────────────┐
│ UI Widget │
└──────┬───────┘
│ User taps button
┌──────────────────────┐
│ ref.read(provider │
│ .notifier) │
│ .someMethod() │
└──────┬───────────────┘
┌──────────────────────┐
│ StateNotifier │
│ - Set loading state │
└──────┬───────────────┘
┌──────────────────────┐
│ Use Case │
│ - Validate input │
│ - Business logic │
└──────┬───────────────┘
┌──────────────────────┐
│ Repository │
│ - Coordinate data │
│ - Error handling │
└──────┬───────────────┘
┌──────────────────────┐
│ Data Source │
│ - API call │
│ - Parse response │
└──────┬───────────────┘
┌──────────────────────┐
│ API Client │
│ - HTTP request │
│ - Add auth token │
└──────┬───────────────┘
│ Response ←─────
┌──────────────────────┐
│ StateNotifier │
│ - Update state │
│ - Notify listeners │
└──────┬───────────────┘
┌──────────────────────┐
│ UI Widget │
│ - Rebuild with │
│ new state │
└──────────────────────┘
```
### 2. State Observation Flow (Read Operation)
```
┌──────────────────────┐
│ UI Widget │
│ ref.watch(provider) │
└──────┬───────────────┘
│ Subscribes to
┌──────────────────────┐
│ StateNotifier │
│ Current State │
└──────┬───────────────┘
│ State changes
┌──────────────────────┐
│ UI Widget │
│ Automatically │
│ rebuilds │
└──────────────────────┘
```
## Provider Types Usage
### Provider (Immutable Services)
**Use for**: Services, repositories, use cases, utilities
```dart
final myServiceProvider = Provider<MyService>((ref) {
final dependency = ref.watch(dependencyProvider);
return MyService(dependency);
});
```
**Lifecycle**: Created once, lives forever (unless autoDispose)
### StateNotifierProvider (Mutable State)
**Use for**: Managing feature state that changes over time
```dart
final myStateProvider = StateNotifierProvider<MyNotifier, MyState>((ref) {
final useCase = ref.watch(useCaseProvider);
return MyNotifier(useCase);
});
```
**Lifecycle**: Created on first access, disposed when no longer used
### Derived Providers (Computed Values)
**Use for**: Computed values from other providers
```dart
final derivedProvider = Provider<DerivedData>((ref) {
final state = ref.watch(stateProvider);
return computeValue(state);
});
```
**Lifecycle**: Recomputed when dependencies change
## Best Practices by Layer
### Core Layer
✅ Keep providers pure and stateless
✅ Use singleton pattern
✅ No business logic
❌ Don't depend on feature providers
❌ Don't manage mutable state
### Data Layer
✅ Implement domain interfaces
✅ Convert models ↔ entities
✅ Handle all exceptions
✅ Use Either<Failure, T> return type
❌ Don't expose models to domain
❌ Don't contain business logic
### Domain Layer
✅ Pure Dart (no Flutter dependencies)
✅ Single responsibility per use case
✅ Validate input
✅ Return Either<Failure, T>
❌ Don't know about UI
❌ Don't know about data sources
### Presentation Layer
✅ Manage UI-specific state
✅ Call multiple use cases if needed
✅ Transform data for display
✅ Handle navigation logic
❌ Don't access data sources directly
❌ Don't perform business logic
## Testing Strategy
### Unit Testing
**Core Layer**: Test utilities and services
```dart
test('SecureStorage saves token', () async {
final storage = SecureStorage();
await storage.saveAccessToken('token');
expect(await storage.getAccessToken(), 'token');
});
```
**Domain Layer**: Test use cases with mock repositories
```dart
test('LoginUseCase returns user on success', () async {
final mockRepo = MockAuthRepository();
when(mockRepo.login(any)).thenAnswer((_) async => Right(mockUser));
final useCase = LoginUseCase(mockRepo);
final result = await useCase(loginRequest);
expect(result.isRight(), true);
});
```
**Presentation Layer**: Test state notifiers
```dart
test('AuthNotifier sets authenticated on login', () async {
final container = ProviderContainer(
overrides: [
loginUseCaseProvider.overrideWithValue(mockLoginUseCase),
],
);
await container.read(authProvider.notifier).login('user', 'pass');
expect(container.read(isAuthenticatedProvider), true);
});
```
### Widget Testing
```dart
testWidgets('Login page shows error on failure', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
],
child: MaterialApp(home: LoginPage()),
),
);
// Interact with widget
await tester.tap(find.text('Login'));
await tester.pump();
// Verify error is shown
expect(find.text('Invalid credentials'), findsOneWidget);
});
```
## Performance Optimization
### 1. Use Derived Providers
Instead of computing in build():
```dart
// ❌ Bad - computes every rebuild
@override
Widget build(BuildContext context, WidgetRef ref) {
final warehouses = ref.watch(warehousesListProvider);
final ngWarehouses = warehouses.where((w) => w.isNGWareHouse).toList();
return ListView(...);
}
// ✅ Good - computed once per state change
final ngWarehousesProvider = Provider<List<WarehouseEntity>>((ref) {
final warehouses = ref.watch(warehousesListProvider);
return warehouses.where((w) => w.isNGWareHouse).toList();
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ngWarehouses = ref.watch(ngWarehousesProvider);
return ListView(...);
}
```
### 2. Use select() for Partial State
```dart
// ❌ Bad - rebuilds on any state change
final authState = ref.watch(authProvider);
final isLoading = authState.isLoading;
// ✅ Good - rebuilds only when isLoading changes
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
```
### 3. Use autoDispose for Temporary Providers
```dart
final temporaryProvider = Provider.autoDispose<MyService>((ref) {
final service = MyService();
ref.onDispose(() {
service.dispose();
});
return service;
});
```
## Common Patterns
### Pattern: Feature Initialization
```dart
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
```
### Pattern: Conditional Navigation
```dart
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.isAuthenticated && !(previous?.isAuthenticated ?? false)) {
Navigator.pushReplacementNamed(context, '/home');
}
});
```
### Pattern: Error Handling
```dart
ref.listen<String?>(authErrorProvider, (previous, next) {
if (next != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next)),
);
}
});
```
### Pattern: Pull-to-Refresh
```dart
RefreshIndicator(
onRefresh: () async {
await ref.read(warehouseProvider.notifier).refresh();
},
child: ListView(...),
)
```
## Dependency Injection Benefits
1. **Testability**: Easy to mock dependencies
2. **Maintainability**: Clear dependency tree
3. **Scalability**: Add features without touching existing code
4. **Flexibility**: Swap implementations easily
5. **Readability**: Explicit dependencies
6. **Type Safety**: Compile-time checks
7. **Hot Reload**: Works seamlessly with Flutter
8. **DevTools**: Inspect state in real-time
## Summary
This DI architecture provides:
- Clear separation of concerns
- Predictable data flow
- Easy testing at all levels
- Type-safe dependency injection
- Reactive state management
- Scalable feature structure
For more details, see:
- [README.md](./README.md) - Comprehensive guide
- [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) - Quick lookup

253
lib/core/di/INDEX.md Normal file
View File

@@ -0,0 +1,253 @@
# Dependency Injection Documentation Index
This directory contains the complete Riverpod dependency injection setup for the warehouse management application.
## File Overview
### 📄 `providers.dart` (18 KB)
**Main dependency injection setup file**
Contains all Riverpod providers organized by feature:
- Core Providers (SecureStorage, ApiClient)
- Auth Feature Providers
- Warehouse Feature Providers
- Products Feature Providers
- Usage examples embedded in comments
**Use this file**: Import in your app to access all providers
```dart
import 'package:minhthu/core/di/providers.dart';
```
---
### 📖 `README.md` (13 KB)
**Comprehensive setup and usage guide**
Topics covered:
- Architecture overview
- Provider categories explanation
- Basic setup instructions
- Common usage patterns
- Feature implementation examples
- Advanced usage patterns
- Best practices
- Debugging tips
- Testing strategies
**Use this guide**: For understanding the overall DI architecture and learning how to use providers
---
### 📋 `QUICK_REFERENCE.md` (12 KB)
**Quick lookup for common operations**
Contains:
- Essential provider code snippets
- Widget setup patterns
- Common patterns for auth, warehouse, products
- Key methods by feature
- Provider types explanation
- Cheat sheet table
- Complete example flows
- Troubleshooting tips
**Use this guide**: When you need quick code examples or forgot syntax
---
### 🏗️ `ARCHITECTURE.md` (19 KB)
**Detailed architecture documentation**
Includes:
- Visual dependency graphs
- Layer-by-layer breakdown
- Data flow diagrams
- Provider types deep dive
- Best practices by layer
- Testing strategies
- Performance optimization
- Common patterns
- Architecture benefits summary
**Use this guide**: For understanding the design decisions and architecture patterns
---
### 🔄 `MIGRATION_GUIDE.md` (11 KB)
**Guide for migrating from other DI solutions**
Covers:
- GetIt to Riverpod migration
- Key differences comparison
- Step-by-step migration process
- Common patterns migration
- Testing migration
- State management migration
- Benefits of migration
- Common pitfalls and solutions
- Incremental migration strategy
**Use this guide**: If migrating from GetIt or other DI solutions
---
## Quick Start
### 1. Setup App
```dart
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
### 2. Use in Widgets
```dart
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isAuth = ref.watch(isAuthenticatedProvider);
return Container();
}
}
```
### 3. Access Providers
```dart
// Watch (reactive)
final data = ref.watch(someProvider);
// Read (one-time)
final data = ref.read(someProvider);
// Call method
ref.read(authProvider.notifier).login(user, pass);
```
## Documentation Roadmap
### For New Developers
1. Start with [README.md](./README.md) - Understand the basics
2. Try examples in [QUICK_REFERENCE.md](./QUICK_REFERENCE.md)
3. Study [ARCHITECTURE.md](./ARCHITECTURE.md) - Understand design
4. Keep [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) handy for coding
### For Team Leads
1. Review [ARCHITECTURE.md](./ARCHITECTURE.md) - Architecture decisions
2. Share [README.md](./README.md) with team
3. Use [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) for code reviews
4. Reference [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md) if changing DI
### For Testing
1. Check testing sections in [README.md](./README.md)
2. Review testing strategy in [ARCHITECTURE.md](./ARCHITECTURE.md)
3. See test examples in [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md)
## All Available Providers
### Core Infrastructure
- `secureStorageProvider` - Secure storage singleton
- `apiClientProvider` - HTTP client with auth
### Authentication
- `authProvider` - Main auth state
- `isAuthenticatedProvider` - Auth status boolean
- `currentUserProvider` - Current user data
- `isAuthLoadingProvider` - Loading state
- `authErrorProvider` - Error message
- `loginUseCaseProvider` - Login business logic
- `logoutUseCaseProvider` - Logout business logic
- `checkAuthStatusUseCaseProvider` - Check auth status
- `getCurrentUserUseCaseProvider` - Get current user
### Warehouse
- `warehouseProvider` - Main warehouse state
- `warehousesListProvider` - List of warehouses
- `selectedWarehouseProvider` - Selected warehouse
- `isWarehouseLoadingProvider` - Loading state
- `hasWarehousesProvider` - Has warehouses loaded
- `hasWarehouseSelectionProvider` - Has selection
- `warehouseErrorProvider` - Error message
- `getWarehousesUseCaseProvider` - Fetch warehouses
### Products
- `productsProvider` - Main products state
- `productsListProvider` - List of products
- `operationTypeProvider` - Import/Export type
- `productsWarehouseIdProvider` - Warehouse ID
- `productsWarehouseNameProvider` - Warehouse name
- `isProductsLoadingProvider` - Loading state
- `hasProductsProvider` - Has products loaded
- `productsCountProvider` - Products count
- `productsErrorProvider` - Error message
- `getProductsUseCaseProvider` - Fetch products
## Key Features
**Type-Safe**: Compile-time dependency checking
**Reactive**: Automatic UI updates on state changes
**Testable**: Easy mocking and overrides
**Clean Architecture**: Clear separation of concerns
**Well-Documented**: Comprehensive guides and examples
**Production-Ready**: Used in real warehouse app
**Scalable**: Easy to add new features
**Maintainable**: Clear structure and patterns
## Code Statistics
- **Total Providers**: 40+ providers
- **Features Covered**: Auth, Warehouse, Products
- **Lines of Code**: ~600 LOC in providers.dart
- **Documentation**: ~55 KB total documentation
- **Test Coverage**: Full testing examples provided
## Support & Resources
### Internal Resources
- [providers.dart](./providers.dart) - Source code
- [README.md](./README.md) - Main documentation
- [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) - Quick lookup
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Architecture guide
- [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md) - Migration help
### External Resources
- [Riverpod Official Docs](https://riverpod.dev)
- [Flutter State Management](https://docs.flutter.dev/development/data-and-backend/state-mgmt)
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
## Version History
- **v1.0** (2024-10-27) - Initial complete setup
- Core providers (Storage, API)
- Auth feature providers
- Warehouse feature providers
- Products feature providers
- Comprehensive documentation
## Contributing
When adding new features:
1. Follow the existing pattern (Data → Domain → Presentation)
2. Add providers in `providers.dart`
3. Update documentation
4. Add usage examples
5. Write tests
## Questions?
If you have questions about:
- **Usage**: Check [QUICK_REFERENCE.md](./QUICK_REFERENCE.md)
- **Architecture**: Check [ARCHITECTURE.md](./ARCHITECTURE.md)
- **Migration**: Check [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md)
- **Testing**: Check testing sections in docs
- **General**: Check [README.md](./README.md)
---
**Last Updated**: October 27, 2024
**Status**: Production Ready ✅
**Maintained By**: Development Team

View File

@@ -0,0 +1,569 @@
# Migration Guide to Riverpod DI
This guide helps you migrate from other dependency injection solutions (GetIt, Provider, etc.) to Riverpod.
## From GetIt to Riverpod
### Before (GetIt)
```dart
// Setup
final getIt = GetIt.instance;
void setupDI() {
// Core
getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
getIt.registerLazySingleton<ApiClient>(() => ApiClient(getIt()));
// Auth
getIt.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(getIt()),
);
getIt.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: getIt(),
secureStorage: getIt(),
),
);
getIt.registerLazySingleton<LoginUseCase>(
() => LoginUseCase(getIt()),
);
}
// Usage in widget
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final authRepo = getIt<AuthRepository>();
final loginUseCase = getIt<LoginUseCase>();
return Container();
}
}
```
### After (Riverpod)
```dart
// Setup (in lib/core/di/providers.dart)
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
return ApiClient(secureStorage);
});
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return AuthRemoteDataSourceImpl(apiClient);
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final remoteDataSource = ref.watch(authRemoteDataSourceProvider);
final secureStorage = ref.watch(secureStorageProvider);
return AuthRepositoryImpl(
remoteDataSource: remoteDataSource,
secureStorage: secureStorage,
);
});
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LoginUseCase(repository);
});
// Usage in widget
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authRepo = ref.watch(authRepositoryProvider);
final loginUseCase = ref.watch(loginUseCaseProvider);
return Container();
}
}
```
## Key Differences
| Aspect | GetIt | Riverpod |
|--------|-------|----------|
| Setup | Manual registration in setup function | Declarative provider definitions |
| Access | `getIt<Type>()` anywhere | `ref.watch(provider)` in widgets |
| Widget Base | `StatelessWidget` / `StatefulWidget` | `ConsumerWidget` / `ConsumerStatefulWidget` |
| Dependencies | Manual injection | Automatic via `ref.watch()` |
| Lifecycle | Manual disposal | Automatic disposal |
| Testing | Override with `getIt.registerFactory()` | Override with `ProviderScope` |
| Type Safety | Runtime errors if not registered | Compile-time errors |
| Reactivity | Manual with ChangeNotifier | Built-in with StateNotifier |
## Migration Steps
### Step 1: Wrap App with ProviderScope
```dart
// Before
void main() {
setupDI();
runApp(MyApp());
}
// After
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
### Step 2: Convert Widgets to ConsumerWidget
```dart
// Before
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container();
}
}
// After
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container();
}
}
```
### Step 3: Replace GetIt Calls
```dart
// Before
final useCase = getIt<LoginUseCase>();
final result = await useCase(request);
// After
final useCase = ref.watch(loginUseCaseProvider);
final result = await useCase(request);
```
### Step 4: Convert State Management
```dart
// Before (ChangeNotifier + Provider)
class AuthNotifier extends ChangeNotifier {
bool _isAuthenticated = false;
bool get isAuthenticated => _isAuthenticated;
void login() {
_isAuthenticated = true;
notifyListeners();
}
}
// Register
getIt.registerLazySingleton(() => AuthNotifier());
// Usage
final authNotifier = getIt<AuthNotifier>();
authNotifier.addListener(() {
// Handle change
});
// After (StateNotifier + Riverpod)
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(AuthState.initial());
void login() {
state = state.copyWith(isAuthenticated: true);
}
}
// Provider
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
// Usage
final authState = ref.watch(authProvider);
// Widget automatically rebuilds on state change
```
## Common Patterns Migration
### Pattern 1: Singleton Service
```dart
// Before (GetIt)
getIt.registerLazySingleton<MyService>(() => MyService());
// After (Riverpod)
final myServiceProvider = Provider<MyService>((ref) {
return MyService();
});
```
### Pattern 2: Factory (New Instance Each Time)
```dart
// Before (GetIt)
getIt.registerFactory<MyService>(() => MyService());
// After (Riverpod)
final myServiceProvider = Provider.autoDispose<MyService>((ref) {
return MyService();
});
```
### Pattern 3: Async Initialization
```dart
// Before (GetIt)
final myServiceFuture = getIt.getAsync<MyService>();
// After (Riverpod)
final myServiceProvider = FutureProvider<MyService>((ref) async {
final service = MyService();
await service.initialize();
return service;
});
```
### Pattern 4: Conditional Registration
```dart
// Before (GetIt)
if (isProduction) {
getIt.registerLazySingleton<ApiClient>(
() => ProductionApiClient(),
);
} else {
getIt.registerLazySingleton<ApiClient>(
() => MockApiClient(),
);
}
// After (Riverpod)
final apiClientProvider = Provider<ApiClient>((ref) {
if (isProduction) {
return ProductionApiClient();
} else {
return MockApiClient();
}
});
```
## Testing Migration
### Before (GetIt)
```dart
void main() {
setUp(() {
// Clear and re-register
getIt.reset();
getIt.registerLazySingleton<AuthRepository>(
() => MockAuthRepository(),
);
});
test('test case', () {
final repo = getIt<AuthRepository>();
// Test
});
}
```
### After (Riverpod)
```dart
void main() {
test('test case', () {
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
],
);
final repo = container.read(authRepositoryProvider);
// Test
container.dispose();
});
}
```
## Widget Testing Migration
### Before (GetIt + Provider)
```dart
testWidgets('widget test', (tester) async {
// Setup mocks
getIt.reset();
getIt.registerLazySingleton<AuthRepository>(
() => mockAuthRepository,
);
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => AuthNotifier(),
child: MaterialApp(home: LoginPage()),
),
);
// Test
});
```
### After (Riverpod)
```dart
testWidgets('widget test', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
],
child: MaterialApp(home: LoginPage()),
),
);
// Test
});
```
## State Management Migration
### From ChangeNotifier to StateNotifier
```dart
// Before
class CounterNotifier extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// Usage
final counter = context.watch<CounterNotifier>();
Text('${counter.count}');
// After
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() {
state = state + 1;
}
}
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
// Usage
final count = ref.watch(counterProvider);
Text('$count');
```
## Benefits of Migration
### 1. Type Safety
```dart
// GetIt - Runtime error if not registered
final service = getIt<MyService>(); // May crash at runtime
// Riverpod - Compile-time error
final service = ref.watch(myServiceProvider); // Compile-time check
```
### 2. Automatic Disposal
```dart
// GetIt - Manual disposal
class MyWidget extends StatefulWidget {
@override
void dispose() {
getIt<MyService>().dispose();
super.dispose();
}
}
// Riverpod - Automatic
final myServiceProvider = Provider.autoDispose<MyService>((ref) {
final service = MyService();
ref.onDispose(() => service.dispose());
return service;
});
```
### 3. Easy Testing
```dart
// GetIt - Need to reset and re-register
setUp(() {
getIt.reset();
getIt.registerLazySingleton<MyService>(() => MockMyService());
});
// Riverpod - Simple override
final container = ProviderContainer(
overrides: [
myServiceProvider.overrideWithValue(mockMyService),
],
);
```
### 4. Better Developer Experience
- No need to remember to register dependencies
- No need to call setup function
- Auto-completion works better
- Compile-time safety
- Built-in DevTools support
## Common Pitfalls
### Pitfall 1: Using ref.watch() in callbacks
```dart
// ❌ Wrong
ElevatedButton(
onPressed: () {
final user = ref.watch(currentUserProvider); // Error!
print(user);
},
child: Text('Print User'),
)
// ✅ Correct
ElevatedButton(
onPressed: () {
final user = ref.read(currentUserProvider);
print(user);
},
child: Text('Print User'),
)
```
### Pitfall 2: Not using ConsumerWidget
```dart
// ❌ Wrong - StatelessWidget doesn't have ref
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final data = ref.watch(dataProvider); // Error: ref not available
return Container();
}
}
// ✅ Correct - Use ConsumerWidget
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(dataProvider);
return Container();
}
}
```
### Pitfall 3: Calling methods in build()
```dart
// ❌ Wrong - Causes infinite loop
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.read(authProvider.notifier).checkAuthStatus(); // Infinite loop!
return Container();
}
// ✅ Correct - Call in initState
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(authProvider.notifier).checkAuthStatus();
});
}
```
### Pitfall 4: Not disposing ProviderContainer in tests
```dart
// ❌ Wrong - Memory leak
test('test case', () {
final container = ProviderContainer();
// Test
});
// ✅ Correct - Always dispose
test('test case', () {
final container = ProviderContainer();
addTearDown(container.dispose);
// Test
});
```
## Incremental Migration Strategy
You can migrate gradually:
1. **Phase 1: Add Riverpod**
- Add dependency
- Wrap app with ProviderScope
- Keep GetIt for now
2. **Phase 2: Migrate Core**
- Create core providers
- Migrate one feature at a time
- Both systems can coexist
3. **Phase 3: Migrate Features**
- Start with simplest feature
- Test thoroughly
- Move to next feature
4. **Phase 4: Remove GetIt**
- Once all migrated
- Remove GetIt setup
- Remove GetIt dependency
## Checklist
- [ ] Added `flutter_riverpod` dependency
- [ ] Wrapped app with `ProviderScope`
- [ ] Created `lib/core/di/providers.dart`
- [ ] Defined all providers
- [ ] Converted widgets to `ConsumerWidget`
- [ ] Replaced `getIt<T>()` with `ref.watch(provider)`
- [ ] Updated tests to use `ProviderContainer`
- [ ] Tested all features
- [ ] Removed GetIt setup code
- [ ] Removed GetIt dependency
## Need Help?
- Check [README.md](./README.md) for comprehensive guide
- See [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) for common patterns
- Review [ARCHITECTURE.md](./ARCHITECTURE.md) for understanding design
- Visit [Riverpod Documentation](https://riverpod.dev)
## Summary
Riverpod provides:
- ✅ Compile-time safety
- ✅ Better testing
- ✅ Automatic disposal
- ✅ Built-in state management
- ✅ No manual setup required
- ✅ Better developer experience
- ✅ Type-safe dependency injection
- ✅ Reactive by default
The migration effort is worth it for better code quality and maintainability!

View File

@@ -0,0 +1,508 @@
# Riverpod Providers Quick Reference
## Essential Providers at a Glance
### Authentication
```dart
// Check if user is logged in
final isAuth = ref.watch(isAuthenticatedProvider);
// Get current user
final user = ref.watch(currentUserProvider);
// Login
ref.read(authProvider.notifier).login(username, password);
// Logout
ref.read(authProvider.notifier).logout();
// Check auth on app start
ref.read(authProvider.notifier).checkAuthStatus();
// Listen to auth changes
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.isAuthenticated) {
// Navigate to home
}
if (next.error != null) {
// Show error
}
});
```
### Warehouse
```dart
// Load warehouses
ref.read(warehouseProvider.notifier).loadWarehouses();
// Get warehouses list
final warehouses = ref.watch(warehousesListProvider);
// Get selected warehouse
final selected = ref.watch(selectedWarehouseProvider);
// Select a warehouse
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Clear selection
ref.read(warehouseProvider.notifier).clearSelection();
// Check loading state
final isLoading = ref.watch(isWarehouseLoadingProvider);
// Get error
final error = ref.watch(warehouseErrorProvider);
```
### Products
```dart
// Load products
ref.read(productsProvider.notifier).loadProducts(
warehouseId,
warehouseName,
'import', // or 'export'
);
// Get products list
final products = ref.watch(productsListProvider);
// Refresh products
ref.read(productsProvider.notifier).refreshProducts();
// Clear products
ref.read(productsProvider.notifier).clearProducts();
// Check loading state
final isLoading = ref.watch(isProductsLoadingProvider);
// Get products count
final count = ref.watch(productsCountProvider);
// Get operation type
final type = ref.watch(operationTypeProvider);
// Get error
final error = ref.watch(productsErrorProvider);
```
## Widget Setup
### ConsumerWidget (Stateless)
```dart
class MyPage extends ConsumerWidget {
const MyPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(someProvider);
return Widget();
}
}
```
### ConsumerStatefulWidget (Stateful)
```dart
class MyPage extends ConsumerStatefulWidget {
const MyPage({Key? key}) : super(key: key);
@override
ConsumerState<MyPage> createState() => _MyPageState();
}
class _MyPageState extends ConsumerState<MyPage> {
@override
void initState() {
super.initState();
// Load data on init
Future.microtask(() {
ref.read(someProvider.notifier).loadData();
});
}
@override
Widget build(BuildContext context) {
final data = ref.watch(someProvider);
return Widget();
}
}
```
## Common Patterns
### Pattern 1: Display Loading State
```dart
final isLoading = ref.watch(isAuthLoadingProvider);
return isLoading
? CircularProgressIndicator()
: YourContent();
```
### Pattern 2: Handle Errors
```dart
final error = ref.watch(authErrorProvider);
return Column(
children: [
if (error != null)
Text(error, style: TextStyle(color: Colors.red)),
YourContent(),
],
);
```
### Pattern 3: Conditional Navigation
```dart
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.isAuthenticated) {
Navigator.pushReplacementNamed(context, '/home');
}
});
```
### Pattern 4: Pull to Refresh
```dart
RefreshIndicator(
onRefresh: () async {
await ref.read(warehouseProvider.notifier).refresh();
},
child: ListView(...),
)
```
### Pattern 5: Load Data on Page Open
```dart
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
```
## Key Methods by Feature
### Auth Methods
- `login(username, password)` - Authenticate user
- `logout()` - Sign out user
- `checkAuthStatus()` - Check if token exists
- `clearError()` - Clear error message
- `reset()` - Reset to initial state
### Warehouse Methods
- `loadWarehouses()` - Fetch all warehouses
- `selectWarehouse(warehouse)` - Select a warehouse
- `clearSelection()` - Clear selected warehouse
- `refresh()` - Reload warehouses
- `clearError()` - Clear error message
- `reset()` - Reset to initial state
### Products Methods
- `loadProducts(warehouseId, name, type)` - Fetch products
- `refreshProducts()` - Reload current products
- `clearProducts()` - Clear products list
## Provider Types Explained
### Provider (Read-only)
```dart
// For services, repositories, use cases
final myServiceProvider = Provider<MyService>((ref) {
return MyService();
});
// Usage
final service = ref.watch(myServiceProvider);
```
### StateNotifierProvider (Mutable State)
```dart
// For managing mutable state
final myStateProvider = StateNotifierProvider<MyNotifier, MyState>((ref) {
return MyNotifier();
});
// Usage - watch state
final state = ref.watch(myStateProvider);
// Usage - call methods
ref.read(myStateProvider.notifier).doSomething();
```
## Ref Methods
### ref.watch()
- Use in `build()` method
- Rebuilds widget when provider changes
- Reactive to state updates
```dart
final data = ref.watch(someProvider);
```
### ref.read()
- Use in event handlers, callbacks
- One-time read, no rebuild
- For calling methods
```dart
onPressed: () {
ref.read(authProvider.notifier).login(user, pass);
}
```
### ref.listen()
- Use for side effects
- Navigation, dialogs, snackbars
- Doesn't rebuild widget
```dart
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.error != null) {
showDialog(...);
}
});
```
## App Initialization
```dart
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends ConsumerStatefulWidget {
@override
ConsumerState<MyApp> createState() => _MyAppState();
}
class _MyAppState extends ConsumerState<MyApp> {
@override
void initState() {
super.initState();
// Check auth on app start
Future.microtask(() {
ref.read(authProvider.notifier).checkAuthStatus();
});
}
@override
Widget build(BuildContext context) {
final isAuthenticated = ref.watch(isAuthenticatedProvider);
return MaterialApp(
home: isAuthenticated
? WarehouseSelectionPage()
: LoginPage(),
);
}
}
```
## Complete Example Flow
### 1. Login Page
```dart
class LoginPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(isAuthLoadingProvider);
final error = ref.watch(authErrorProvider);
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.isAuthenticated) {
Navigator.pushReplacementNamed(context, '/warehouses');
}
});
return Scaffold(
body: Column(
children: [
if (error != null)
Text(error, style: TextStyle(color: Colors.red)),
TextField(controller: usernameController),
TextField(controller: passwordController, obscureText: true),
ElevatedButton(
onPressed: isLoading ? null : () {
ref.read(authProvider.notifier).login(
usernameController.text,
passwordController.text,
);
},
child: isLoading
? CircularProgressIndicator()
: Text('Login'),
),
],
),
);
}
}
```
### 2. Warehouse Selection Page
```dart
class WarehouseSelectionPage extends ConsumerStatefulWidget {
@override
ConsumerState<WarehouseSelectionPage> createState() => _State();
}
class _State extends ConsumerState<WarehouseSelectionPage> {
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
@override
Widget build(BuildContext context) {
final warehouses = ref.watch(warehousesListProvider);
final isLoading = ref.watch(isWarehouseLoadingProvider);
return Scaffold(
appBar: AppBar(
title: Text('Select Warehouse'),
actions: [
IconButton(
icon: Icon(Icons.logout),
onPressed: () {
ref.read(authProvider.notifier).logout();
},
),
],
),
body: isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: warehouses.length,
itemBuilder: (context, index) {
final warehouse = warehouses[index];
return ListTile(
title: Text(warehouse.name),
subtitle: Text(warehouse.code),
onTap: () {
ref.read(warehouseProvider.notifier)
.selectWarehouse(warehouse);
Navigator.pushNamed(context, '/operations');
},
);
},
),
);
}
}
```
### 3. Products Page
```dart
class ProductsPage extends ConsumerStatefulWidget {
final int warehouseId;
final String warehouseName;
final String operationType;
const ProductsPage({
required this.warehouseId,
required this.warehouseName,
required this.operationType,
});
@override
ConsumerState<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends ConsumerState<ProductsPage> {
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId,
widget.warehouseName,
widget.operationType,
);
});
}
@override
Widget build(BuildContext context) {
final products = ref.watch(productsListProvider);
final isLoading = ref.watch(isProductsLoadingProvider);
final error = ref.watch(productsErrorProvider);
return Scaffold(
appBar: AppBar(
title: Text('${widget.warehouseName} - ${widget.operationType}'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
ref.read(productsProvider.notifier).refreshProducts();
},
),
],
),
body: isLoading
? Center(child: CircularProgressIndicator())
: error != null
? Center(child: Text(error))
: RefreshIndicator(
onRefresh: () async {
await ref.read(productsProvider.notifier)
.refreshProducts();
},
child: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text(product.code),
trailing: Text('${product.piecesInStock} pcs'),
);
},
),
),
);
}
}
```
## Troubleshooting
### Provider Not Found
- Ensure `ProviderScope` wraps your app in `main.dart`
- Check that you're using `ConsumerWidget` or `ConsumerStatefulWidget`
### State Not Updating
- Use `ref.watch()` not `ref.read()` in build method
- Verify the provider is actually updating its state
### Null Value
- Check if data is loaded before accessing
- Use null-safe operators `?.` and `??`
### Infinite Loop
- Don't call `ref.read(provider.notifier).method()` directly in build
- Use `Future.microtask()` in initState or callbacks
## Cheat Sheet
| Task | Code |
|------|------|
| Watch state | `ref.watch(provider)` |
| Read once | `ref.read(provider)` |
| Call method | `ref.read(provider.notifier).method()` |
| Listen for changes | `ref.listen(provider, callback)` |
| Get loading | `ref.watch(isXxxLoadingProvider)` |
| Get error | `ref.watch(xxxErrorProvider)` |
| Check auth | `ref.watch(isAuthenticatedProvider)` |
| Get user | `ref.watch(currentUserProvider)` |
| Get warehouses | `ref.watch(warehousesListProvider)` |
| Get products | `ref.watch(productsListProvider)` |

497
lib/core/di/README.md Normal file
View File

@@ -0,0 +1,497 @@
# Dependency Injection with Riverpod
This directory contains the centralized dependency injection setup for the entire application using Riverpod.
## Overview
The `providers.dart` file sets up all Riverpod providers following Clean Architecture principles:
- **Data Layer**: Data sources and repositories
- **Domain Layer**: Use cases (business logic)
- **Presentation Layer**: State notifiers and UI state
## Architecture Pattern
```
UI (ConsumerWidget)
StateNotifier (Presentation)
UseCase (Domain)
Repository (Domain Interface)
RepositoryImpl (Data)
RemoteDataSource (Data)
ApiClient (Core)
```
## Provider Categories
### 1. Core Providers (Infrastructure)
- `secureStorageProvider` - Secure storage singleton
- `apiClientProvider` - HTTP client with auth interceptors
### 2. Auth Feature Providers
- `authProvider` - Main auth state (use this in UI)
- `isAuthenticatedProvider` - Quick auth status check
- `currentUserProvider` - Current user data
- `loginUseCaseProvider` - Login business logic
- `logoutUseCaseProvider` - Logout business logic
### 3. Warehouse Feature Providers
- `warehouseProvider` - Main warehouse state
- `warehousesListProvider` - List of warehouses
- `selectedWarehouseProvider` - Currently selected warehouse
- `getWarehousesUseCaseProvider` - Fetch warehouses logic
### 4. Products Feature Providers
- `productsProvider` - Main products state
- `productsListProvider` - List of products
- `operationTypeProvider` - Import/Export type
- `getProductsUseCaseProvider` - Fetch products logic
## Usage Guide
### Basic Setup
1. **Wrap your app with ProviderScope**:
```dart
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
2. **Use ConsumerWidget or ConsumerStatefulWidget**:
```dart
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Access providers here
final isAuthenticated = ref.watch(isAuthenticatedProvider);
return Scaffold(body: ...);
}
}
```
### Common Patterns
#### 1. Watch State (UI rebuilds when state changes)
```dart
final authState = ref.watch(authProvider);
final isLoading = ref.watch(isAuthLoadingProvider);
final products = ref.watch(productsListProvider);
```
#### 2. Read State (One-time read, no rebuild)
```dart
final currentUser = ref.read(currentUserProvider);
```
#### 3. Call Methods on StateNotifier
```dart
// Login
ref.read(authProvider.notifier).login(username, password);
// Logout
ref.read(authProvider.notifier).logout();
// Load warehouses
ref.read(warehouseProvider.notifier).loadWarehouses();
// Select warehouse
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Load products
ref.read(productsProvider.notifier).loadProducts(
warehouseId,
warehouseName,
'import',
);
```
#### 4. Listen to State Changes
```dart
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.isAuthenticated) {
// Navigate to home
}
if (next.error != null) {
// Show error dialog
}
});
```
## Feature Examples
### Authentication Flow
```dart
class LoginPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
// Listen for auth changes
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.isAuthenticated) {
Navigator.pushReplacementNamed(context, '/warehouses');
}
if (next.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.error!)),
);
}
});
return Scaffold(
body: Column(
children: [
TextField(
controller: usernameController,
decoration: InputDecoration(labelText: 'Username'),
),
TextField(
controller: passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
),
ElevatedButton(
onPressed: authState.isLoading
? null
: () {
ref.read(authProvider.notifier).login(
usernameController.text,
passwordController.text,
);
},
child: authState.isLoading
? CircularProgressIndicator()
: Text('Login'),
),
],
),
);
}
}
```
### Warehouse Selection Flow
```dart
class WarehouseSelectionPage extends ConsumerStatefulWidget {
@override
ConsumerState<WarehouseSelectionPage> createState() =>
_WarehouseSelectionPageState();
}
class _WarehouseSelectionPageState
extends ConsumerState<WarehouseSelectionPage> {
@override
void initState() {
super.initState();
// Load warehouses when page opens
Future.microtask(() {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
@override
Widget build(BuildContext context) {
final warehouses = ref.watch(warehousesListProvider);
final isLoading = ref.watch(isWarehouseLoadingProvider);
final error = ref.watch(warehouseErrorProvider);
return Scaffold(
appBar: AppBar(title: Text('Select Warehouse')),
body: isLoading
? Center(child: CircularProgressIndicator())
: error != null
? Center(child: Text(error))
: ListView.builder(
itemCount: warehouses.length,
itemBuilder: (context, index) {
final warehouse = warehouses[index];
return ListTile(
title: Text(warehouse.name),
subtitle: Text(warehouse.code),
trailing: Text('${warehouse.totalCount} items'),
onTap: () {
// Select warehouse
ref.read(warehouseProvider.notifier)
.selectWarehouse(warehouse);
// Navigate to operation selection
Navigator.pushNamed(
context,
'/operations',
arguments: warehouse,
);
},
);
},
),
);
}
}
```
### Products List Flow
```dart
class ProductsPage extends ConsumerStatefulWidget {
final int warehouseId;
final String warehouseName;
final String operationType; // 'import' or 'export'
const ProductsPage({
required this.warehouseId,
required this.warehouseName,
required this.operationType,
});
@override
ConsumerState<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends ConsumerState<ProductsPage> {
@override
void initState() {
super.initState();
// Load products when page opens
Future.microtask(() {
ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId,
widget.warehouseName,
widget.operationType,
);
});
}
@override
Widget build(BuildContext context) {
final products = ref.watch(productsListProvider);
final isLoading = ref.watch(isProductsLoadingProvider);
final error = ref.watch(productsErrorProvider);
return Scaffold(
appBar: AppBar(
title: Text('${widget.warehouseName} - ${widget.operationType}'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
ref.read(productsProvider.notifier).refreshProducts();
},
),
],
),
body: isLoading
? Center(child: CircularProgressIndicator())
: error != null
? Center(child: Text(error))
: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ProductListItem(product: product);
},
),
);
}
}
```
### Check Auth Status on App Start
```dart
class MyApp extends ConsumerStatefulWidget {
@override
ConsumerState<MyApp> createState() => _MyAppState();
}
class _MyAppState extends ConsumerState<MyApp> {
@override
void initState() {
super.initState();
// Check if user is already authenticated
Future.microtask(() {
ref.read(authProvider.notifier).checkAuthStatus();
});
}
@override
Widget build(BuildContext context) {
final isAuthenticated = ref.watch(isAuthenticatedProvider);
return MaterialApp(
home: isAuthenticated
? WarehouseSelectionPage()
: LoginPage(),
);
}
}
```
## Advanced Usage
### Custom Providers
Create custom computed providers for complex logic:
```dart
// Get warehouses filtered by type
final ngWarehousesProvider = Provider<List<WarehouseEntity>>((ref) {
final warehouses = ref.watch(warehousesListProvider);
return warehouses.where((w) => w.isNGWareHouse).toList();
});
// Get products count per operation type
final importProductsCountProvider = Provider<int>((ref) {
final products = ref.watch(productsListProvider);
final operationType = ref.watch(operationTypeProvider);
return operationType == 'import' ? products.length : 0;
});
```
### Override Providers (for testing)
```dart
testWidgets('Login page test', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Override with mock
authRepositoryProvider.overrideWithValue(mockAuthRepository),
],
child: LoginPage(),
),
);
});
```
## Best Practices
1. **Use ConsumerWidget**: Always use `ConsumerWidget` or `ConsumerStatefulWidget` to access providers.
2. **Watch in build()**: Only watch providers in the `build()` method for reactive updates.
3. **Read for actions**: Use `ref.read()` for one-time reads or calling methods.
4. **Listen for side effects**: Use `ref.listen()` for navigation, dialogs, snackbars.
5. **Avoid over-watching**: Don't watch entire state if you only need one field - use derived providers.
6. **Keep providers pure**: Don't perform side effects in provider definitions.
7. **Dispose properly**: StateNotifier automatically disposes, but be careful with custom providers.
## Debugging
### Enable Logging
```dart
void main() {
runApp(
ProviderScope(
observers: [ProviderLogger()],
child: MyApp(),
),
);
}
class ProviderLogger extends ProviderObserver {
@override
void didUpdateProvider(
ProviderBase provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
print('''
{
"provider": "${provider.name ?? provider.runtimeType}",
"newValue": "$newValue"
}''');
}
}
```
### Common Issues
1. **Provider not found**: Make sure `ProviderScope` wraps your app.
2. **State not updating**: Use `ref.watch()` instead of `ref.read()` in build method.
3. **Circular dependency**: Check provider dependencies - avoid circular references.
4. **Memory leaks**: Use `autoDispose` modifier for providers that should be disposed.
## Testing
### Unit Testing Providers
```dart
test('Auth provider login success', () async {
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
],
);
when(mockAuthRepository.login(any))
.thenAnswer((_) async => Right(mockUser));
await container.read(authProvider.notifier).login('user', 'pass');
expect(container.read(isAuthenticatedProvider), true);
expect(container.read(currentUserProvider), mockUser);
container.dispose();
});
```
### Widget Testing
```dart
testWidgets('Login button triggers login', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
],
child: MaterialApp(home: LoginPage()),
),
);
await tester.enterText(find.byKey(Key('username')), 'testuser');
await tester.enterText(find.byKey(Key('password')), 'password');
await tester.tap(find.byKey(Key('loginButton')));
await tester.pump();
verify(mockAuthRepository.login(any)).called(1);
});
```
## Migration Guide
If you were using GetIt or other DI solutions:
1. Replace GetIt registration with Riverpod providers
2. Change `GetIt.instance.get<T>()` to `ref.watch(provider)`
3. Use `ConsumerWidget` instead of regular `StatelessWidget`
4. Move initialization logic to `initState()` or provider initialization
## Resources
- [Riverpod Documentation](https://riverpod.dev)
- [Clean Architecture Guide](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Flutter State Management](https://docs.flutter.dev/development/data-and-backend/state-mgmt/options)
## Support
For questions or issues with DI setup, contact the development team or refer to the project documentation.

538
lib/core/di/providers.dart Normal file
View File

@@ -0,0 +1,538 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/auth/data/datasources/auth_remote_datasource.dart';
import '../../features/auth/data/repositories/auth_repository_impl.dart';
import '../../features/auth/domain/repositories/auth_repository.dart';
import '../../features/auth/domain/usecases/login_usecase.dart';
import '../../features/auth/presentation/providers/auth_provider.dart';
import '../../features/products/data/datasources/products_remote_datasource.dart';
import '../../features/products/data/repositories/products_repository_impl.dart';
import '../../features/products/domain/repositories/products_repository.dart';
import '../../features/products/domain/usecases/get_products_usecase.dart';
import '../../features/products/presentation/providers/products_provider.dart';
import '../../features/warehouse/data/datasources/warehouse_remote_datasource.dart';
import '../../features/warehouse/data/repositories/warehouse_repository_impl.dart';
import '../../features/warehouse/domain/repositories/warehouse_repository.dart';
import '../../features/warehouse/domain/usecases/get_warehouses_usecase.dart';
import '../../features/warehouse/presentation/providers/warehouse_provider.dart';
import '../network/api_client.dart';
import '../storage/secure_storage.dart';
/// ========================================================================
/// CORE PROVIDERS
/// ========================================================================
/// These are singleton providers for core infrastructure services
/// Secure storage provider (Singleton)
/// Provides secure storage for sensitive data like tokens
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
/// API client provider (Singleton)
/// Provides HTTP client with authentication and error handling
/// Depends on SecureStorage for token management
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
return ApiClient(secureStorage);
});
/// ========================================================================
/// AUTH FEATURE PROVIDERS
/// ========================================================================
/// Providers for authentication feature following clean architecture
// Data Layer
/// Auth remote data source provider
/// Handles API calls for authentication
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return AuthRemoteDataSourceImpl(apiClient);
});
/// Auth repository provider
/// Implements domain repository interface
/// Coordinates between data sources and handles error conversion
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final remoteDataSource = ref.watch(authRemoteDataSourceProvider);
final secureStorage = ref.watch(secureStorageProvider);
return AuthRepositoryImpl(
remoteDataSource: remoteDataSource,
secureStorage: secureStorage,
);
});
// Domain Layer
/// Login use case provider
/// Encapsulates login business logic
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LoginUseCase(repository);
});
/// Logout use case provider
/// Encapsulates logout business logic
final logoutUseCaseProvider = Provider<LogoutUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LogoutUseCase(repository);
});
/// Check auth status use case provider
/// Checks if user is authenticated
final checkAuthStatusUseCaseProvider = Provider<CheckAuthStatusUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return CheckAuthStatusUseCase(repository);
});
/// Get current user use case provider
/// Retrieves current user data from storage
final getCurrentUserUseCaseProvider = Provider<GetCurrentUserUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return GetCurrentUserUseCase(repository);
});
/// Refresh token use case provider
/// Refreshes access token using refresh token
final refreshTokenUseCaseProvider = Provider<RefreshTokenUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return RefreshTokenUseCase(repository);
});
// Presentation Layer
/// Auth state notifier provider
/// Manages authentication state across the app
/// This is the main provider to use in UI for auth state
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final loginUseCase = ref.watch(loginUseCaseProvider);
final logoutUseCase = ref.watch(logoutUseCaseProvider);
final checkAuthStatusUseCase = ref.watch(checkAuthStatusUseCaseProvider);
final getCurrentUserUseCase = ref.watch(getCurrentUserUseCaseProvider);
return AuthNotifier(
loginUseCase: loginUseCase,
logoutUseCase: logoutUseCase,
checkAuthStatusUseCase: checkAuthStatusUseCase,
getCurrentUserUseCase: getCurrentUserUseCase,
);
});
/// Convenient providers for auth state
/// Provider to check if user is authenticated
/// Usage: ref.watch(isAuthenticatedProvider)
final isAuthenticatedProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isAuthenticated;
});
/// Provider to get current user
/// Returns null if user is not authenticated
/// Usage: ref.watch(currentUserProvider)
final currentUserProvider = Provider((ref) {
final authState = ref.watch(authProvider);
return authState.user;
});
/// Provider to check if auth is loading
/// Usage: ref.watch(isAuthLoadingProvider)
final isAuthLoadingProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isLoading;
});
/// Provider to get auth error
/// Returns null if no error
/// Usage: ref.watch(authErrorProvider)
final authErrorProvider = Provider<String?>((ref) {
final authState = ref.watch(authProvider);
return authState.error;
});
/// ========================================================================
/// WAREHOUSE FEATURE PROVIDERS
/// ========================================================================
/// Providers for warehouse feature following clean architecture
// Data Layer
/// Warehouse remote data source provider
/// Handles API calls for warehouses
final warehouseRemoteDataSourceProvider =
Provider<WarehouseRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return WarehouseRemoteDataSourceImpl(apiClient);
});
/// Warehouse repository provider
/// Implements domain repository interface
final warehouseRepositoryProvider = Provider<WarehouseRepository>((ref) {
final remoteDataSource = ref.watch(warehouseRemoteDataSourceProvider);
return WarehouseRepositoryImpl(remoteDataSource);
});
// Domain Layer
/// Get warehouses use case provider
/// Encapsulates warehouse fetching business logic
final getWarehousesUseCaseProvider = Provider<GetWarehousesUseCase>((ref) {
final repository = ref.watch(warehouseRepositoryProvider);
return GetWarehousesUseCase(repository);
});
// Presentation Layer
/// Warehouse state notifier provider
/// Manages warehouse state including list and selection
final warehouseProvider =
StateNotifierProvider<WarehouseNotifier, WarehouseState>((ref) {
final getWarehousesUseCase = ref.watch(getWarehousesUseCaseProvider);
return WarehouseNotifier(getWarehousesUseCase);
});
/// Convenient providers for warehouse state
/// Provider to get list of warehouses
/// Usage: ref.watch(warehousesListProvider)
final warehousesListProvider = Provider((ref) {
final warehouseState = ref.watch(warehouseProvider);
return warehouseState.warehouses;
});
/// Provider to get selected warehouse
/// Returns null if no warehouse is selected
/// Usage: ref.watch(selectedWarehouseProvider)
final selectedWarehouseProvider = Provider((ref) {
final warehouseState = ref.watch(warehouseProvider);
return warehouseState.selectedWarehouse;
});
/// Provider to check if warehouses are loading
/// Usage: ref.watch(isWarehouseLoadingProvider)
final isWarehouseLoadingProvider = Provider<bool>((ref) {
final warehouseState = ref.watch(warehouseProvider);
return warehouseState.isLoading;
});
/// Provider to check if warehouses have been loaded
/// Usage: ref.watch(hasWarehousesProvider)
final hasWarehousesProvider = Provider<bool>((ref) {
final warehouseState = ref.watch(warehouseProvider);
return warehouseState.hasWarehouses;
});
/// Provider to check if a warehouse is selected
/// Usage: ref.watch(hasWarehouseSelectionProvider)
final hasWarehouseSelectionProvider = Provider<bool>((ref) {
final warehouseState = ref.watch(warehouseProvider);
return warehouseState.hasSelection;
});
/// Provider to get warehouse error
/// Returns null if no error
/// Usage: ref.watch(warehouseErrorProvider)
final warehouseErrorProvider = Provider<String?>((ref) {
final warehouseState = ref.watch(warehouseProvider);
return warehouseState.error;
});
/// ========================================================================
/// PRODUCTS FEATURE PROVIDERS
/// ========================================================================
/// Providers for products feature following clean architecture
// Data Layer
/// Products remote data source provider
/// Handles API calls for products
final productsRemoteDataSourceProvider =
Provider<ProductsRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return ProductsRemoteDataSourceImpl(apiClient);
});
/// Products repository provider
/// Implements domain repository interface
final productsRepositoryProvider = Provider<ProductsRepository>((ref) {
final remoteDataSource = ref.watch(productsRemoteDataSourceProvider);
return ProductsRepositoryImpl(remoteDataSource);
});
// Domain Layer
/// Get products use case provider
/// Encapsulates product fetching business logic
final getProductsUseCaseProvider = Provider<GetProductsUseCase>((ref) {
final repository = ref.watch(productsRepositoryProvider);
return GetProductsUseCase(repository);
});
// Presentation Layer
/// Products state notifier provider
/// Manages products state including list, loading, and errors
final productsProvider =
StateNotifierProvider<ProductsNotifier, ProductsState>((ref) {
final getProductsUseCase = ref.watch(getProductsUseCaseProvider);
return ProductsNotifier(getProductsUseCase);
});
/// Convenient providers for products state
/// Provider to get list of products
/// Usage: ref.watch(productsListProvider)
final productsListProvider = Provider((ref) {
final productsState = ref.watch(productsProvider);
return productsState.products;
});
/// Provider to get operation type (import/export)
/// Usage: ref.watch(operationTypeProvider)
final operationTypeProvider = Provider<String>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.operationType;
});
/// Provider to get warehouse ID for products
/// Returns null if no warehouse is set
/// Usage: ref.watch(productsWarehouseIdProvider)
final productsWarehouseIdProvider = Provider<int?>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.warehouseId;
});
/// Provider to get warehouse name for products
/// Returns null if no warehouse is set
/// Usage: ref.watch(productsWarehouseNameProvider)
final productsWarehouseNameProvider = Provider<String?>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.warehouseName;
});
/// Provider to check if products are loading
/// Usage: ref.watch(isProductsLoadingProvider)
final isProductsLoadingProvider = Provider<bool>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.isLoading;
});
/// Provider to check if products list has items
/// Usage: ref.watch(hasProductsProvider)
final hasProductsProvider = Provider<bool>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.products.isNotEmpty;
});
/// Provider to get products count
/// Usage: ref.watch(productsCountProvider)
final productsCountProvider = Provider<int>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.products.length;
});
/// Provider to get products error
/// Returns null if no error
/// Usage: ref.watch(productsErrorProvider)
final productsErrorProvider = Provider<String?>((ref) {
final productsState = ref.watch(productsProvider);
return productsState.error;
});
/// ========================================================================
/// USAGE EXAMPLES
/// ========================================================================
///
/// 1. Authentication Example:
/// ```dart
/// // In your LoginPage
/// class LoginPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final isAuthenticated = ref.watch(isAuthenticatedProvider);
/// final isLoading = ref.watch(isAuthLoadingProvider);
/// final error = ref.watch(authErrorProvider);
///
/// return Scaffold(
/// body: Column(
/// children: [
/// if (error != null) Text(error, style: errorStyle),
/// ElevatedButton(
/// onPressed: isLoading
/// ? null
/// : () => ref.read(authProvider.notifier).login(
/// username,
/// password,
/// ),
/// child: isLoading ? CircularProgressIndicator() : Text('Login'),
/// ),
/// ],
/// ),
/// );
/// }
/// }
/// ```
///
/// 2. Warehouse Selection Example:
/// ```dart
/// // In your WarehouseSelectionPage
/// class WarehouseSelectionPage extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final warehouses = ref.watch(warehousesListProvider);
/// final isLoading = ref.watch(isWarehouseLoadingProvider);
/// final selectedWarehouse = ref.watch(selectedWarehouseProvider);
///
/// // Load warehouses on first build
/// ref.listen(warehouseProvider, (previous, next) {
/// if (previous?.warehouses.isEmpty ?? true && !next.isLoading) {
/// ref.read(warehouseProvider.notifier).loadWarehouses();
/// }
/// });
///
/// return Scaffold(
/// body: isLoading
/// ? CircularProgressIndicator()
/// : ListView.builder(
/// itemCount: warehouses.length,
/// itemBuilder: (context, index) {
/// final warehouse = warehouses[index];
/// return ListTile(
/// title: Text(warehouse.name),
/// selected: selectedWarehouse?.id == warehouse.id,
/// onTap: () {
/// ref.read(warehouseProvider.notifier)
/// .selectWarehouse(warehouse);
/// },
/// );
/// },
/// ),
/// );
/// }
/// }
/// ```
///
/// 3. Products List Example:
/// ```dart
/// // In your ProductsPage
/// class ProductsPage extends ConsumerWidget {
/// final int warehouseId;
/// final String warehouseName;
/// final String operationType;
///
/// const ProductsPage({
/// required this.warehouseId,
/// required this.warehouseName,
/// required this.operationType,
/// });
///
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// final products = ref.watch(productsListProvider);
/// final isLoading = ref.watch(isProductsLoadingProvider);
/// final error = ref.watch(productsErrorProvider);
///
/// // Load products on first build
/// useEffect(() {
/// ref.read(productsProvider.notifier).loadProducts(
/// warehouseId,
/// warehouseName,
/// operationType,
/// );
/// return null;
/// }, []);
///
/// return Scaffold(
/// appBar: AppBar(
/// title: Text('$warehouseName - $operationType'),
/// ),
/// body: isLoading
/// ? CircularProgressIndicator()
/// : error != null
/// ? Text(error)
/// : ListView.builder(
/// itemCount: products.length,
/// itemBuilder: (context, index) {
/// final product = products[index];
/// return ListTile(
/// title: Text(product.name),
/// subtitle: Text(product.code),
/// );
/// },
/// ),
/// );
/// }
/// }
/// ```
///
/// 4. Checking Auth Status on App Start:
/// ```dart
/// // In your main.dart or root widget
/// class App extends ConsumerWidget {
/// @override
/// Widget build(BuildContext context, WidgetRef ref) {
/// // Check auth status when app starts
/// useEffect(() {
/// ref.read(authProvider.notifier).checkAuthStatus();
/// return null;
/// }, []);
///
/// final isAuthenticated = ref.watch(isAuthenticatedProvider);
///
/// return MaterialApp(
/// home: isAuthenticated ? WarehouseSelectionPage() : LoginPage(),
/// );
/// }
/// }
/// ```
///
/// 5. Logout Example:
/// ```dart
/// // In any widget
/// ElevatedButton(
/// onPressed: () {
/// ref.read(authProvider.notifier).logout();
/// },
/// child: Text('Logout'),
/// )
/// ```
///
/// ========================================================================
/// ARCHITECTURE NOTES
/// ========================================================================
///
/// This DI setup follows Clean Architecture principles:
///
/// 1. **Separation of Concerns**:
/// - Data Layer: Handles API calls and data storage
/// - Domain Layer: Contains business logic and use cases
/// - Presentation Layer: Manages UI state
///
/// 2. **Dependency Direction**:
/// - Presentation depends on Domain
/// - Data depends on Domain
/// - Domain depends on nothing (pure business logic)
///
/// 3. **Provider Hierarchy**:
/// - Core providers (Storage, API) are singletons
/// - Data sources depend on API client
/// - Repositories depend on data sources
/// - Use cases depend on repositories
/// - State notifiers depend on use cases
///
/// 4. **State Management**:
/// - StateNotifierProvider for mutable state
/// - Provider for immutable dependencies
/// - Convenient providers for derived state
///
/// 5. **Testability**:
/// - All dependencies are injected
/// - Easy to mock for testing
/// - Each layer can be tested independently
///
/// 6. **Scalability**:
/// - Add new features by following the same pattern
/// - Clear structure for team collaboration
/// - Easy to understand and maintain
///

View File

@@ -38,6 +38,15 @@ class CacheFailure extends Failure {
String toString() => 'CacheFailure: $message';
}
/// Failure that occurs during authentication
/// This includes login failures, invalid credentials, expired tokens, etc.
class AuthenticationFailure extends Failure {
const AuthenticationFailure(super.message);
@override
String toString() => 'AuthenticationFailure: $message';
}
/// Failure that occurs when input validation fails
class ValidationFailure extends Failure {
const ValidationFailure(super.message);

458
lib/core/network/README.md Normal file
View File

@@ -0,0 +1,458 @@
# API Client - Network Module
A robust API client for the Flutter warehouse management app, built on top of Dio with comprehensive error handling, authentication management, and request/response logging.
## Features
- **Automatic Token Management**: Automatically injects Bearer tokens from secure storage
- **401 Error Handling**: Automatically clears tokens and triggers logout on unauthorized access
- **Request/Response Logging**: Comprehensive logging for debugging with sensitive data redaction
- **Error Transformation**: Converts Dio exceptions to custom app exceptions
- **Timeout Configuration**: Configurable connection, receive, and send timeouts (30 seconds)
- **Secure Storage Integration**: Uses flutter_secure_storage for token management
- **Environment Support**: Easy base URL switching for different environments
## Files
- `api_client.dart` - Main API client implementation
- `api_response.dart` - Generic API response wrapper matching backend format
- `api_client_example.dart` - Comprehensive usage examples
- `README.md` - This documentation
## Installation
The API client requires the following dependencies (already added to `pubspec.yaml`):
```yaml
dependencies:
dio: ^5.3.2
flutter_secure_storage: ^9.0.0
```
## Quick Start
### 1. Initialize API Client
```dart
import 'package:minhthu/core/core.dart';
// Create secure storage instance
final secureStorage = SecureStorage();
// Create API client with unauthorized callback
final apiClient = ApiClient(
secureStorage,
onUnauthorized: () {
// Navigate to login screen
context.go('/login');
},
);
```
### 2. Make API Requests
#### GET Request
```dart
final response = await apiClient.get(
'/warehouses',
queryParameters: {'limit': 10},
);
```
#### POST Request
```dart
final response = await apiClient.post(
'/auth/login',
data: {
'username': 'user@example.com',
'password': 'password123',
},
);
```
#### PUT Request
```dart
final response = await apiClient.put(
'/products/123',
data: {'name': 'Updated Name'},
);
```
#### DELETE Request
```dart
final response = await apiClient.delete('/products/123');
```
## API Response Format
All API responses follow this standard format from the backend:
```dart
{
"Value": {...}, // The actual data
"IsSuccess": true, // Success flag
"IsFailure": false, // Failure flag
"Errors": [], // List of error messages
"ErrorCodes": [] // List of error codes
}
```
Use the `ApiResponse` class to parse responses:
```dart
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => User.fromJson(json), // Parse the Value field
);
if (apiResponse.isSuccess && apiResponse.value != null) {
final user = apiResponse.value;
print('Success: ${user.username}');
} else {
print('Error: ${apiResponse.getErrorMessage()}');
}
```
## Authentication Flow
### Login
```dart
// 1. Login via API
final response = await apiClient.post('/auth/login', data: credentials);
// 2. Parse response
final apiResponse = ApiResponse.fromJson(response.data, (json) => User.fromJson(json));
// 3. Save tokens (done by LoginUseCase)
if (apiResponse.isSuccess) {
final user = apiResponse.value!;
await secureStorage.saveAccessToken(user.accessToken);
await secureStorage.saveRefreshToken(user.refreshToken);
}
// 4. Subsequent requests automatically include Bearer token
```
### Automatic Token Injection
The API client automatically adds the Bearer token to all requests:
```dart
// You just make the request
final response = await apiClient.get('/warehouses');
// The interceptor automatically adds:
// Authorization: Bearer <token>
```
### 401 Error Handling
When a 401 Unauthorized error occurs:
1. Error is logged
2. All tokens are cleared from secure storage
3. `onUnauthorized` callback is triggered
4. App can navigate to login screen
```dart
// This is handled automatically - no manual intervention needed
// Just provide the callback when creating the client:
final apiClient = ApiClient(
secureStorage,
onUnauthorized: () {
// This will be called on 401 errors
context.go('/login');
},
);
```
## Error Handling
The API client transforms Dio exceptions into custom app exceptions:
```dart
try {
final response = await apiClient.get('/products');
} on NetworkException catch (e) {
// Handle network errors (timeout, no internet, etc.)
print('Network error: ${e.message}');
} on ServerException catch (e) {
// Handle server errors (4xx, 5xx)
print('Server error: ${e.message}');
if (e.code == '401') {
// Unauthorized - already handled by interceptor
}
} catch (e) {
// Handle unknown errors
print('Unknown error: $e');
}
```
### Error Types
- `NetworkException`: Connection timeouts, no internet, certificate errors
- `ServerException`: HTTP errors (400-599) with specific error codes
- 401: Unauthorized (automatically handled)
- 403: Forbidden
- 404: Not Found
- 422: Validation Error
- 429: Rate Limited
- 500+: Server Errors
## Logging
The API client provides comprehensive logging for debugging:
### Request Logging
```
REQUEST[GET] => https://api.example.com/warehouses
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
Query Params: {limit: 10}
Body: {...}
```
### Response Logging
```
RESPONSE[200] => https://api.example.com/warehouses
Data: {...}
```
### Error Logging
```
ERROR[401] => https://api.example.com/warehouses
Error Data: {Errors: [Unauthorized access], ErrorCodes: [AUTH_001]}
```
### Security
All sensitive headers (Authorization, api-key, token) are automatically redacted in logs:
```dart
// Logged as:
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
```
## Configuration
### Timeout Settings
Configure timeouts in `lib/core/constants/app_constants.dart`:
```dart
static const int connectionTimeout = 30000; // 30 seconds
static const int receiveTimeout = 30000; // 30 seconds
static const int sendTimeout = 30000; // 30 seconds
```
### Base URL
Configure base URL in `lib/core/constants/app_constants.dart`:
```dart
static const String apiBaseUrl = 'https://api.example.com';
```
Or update dynamically:
```dart
// For different environments
apiClient.updateBaseUrl('https://dev-api.example.com'); // Development
apiClient.updateBaseUrl('https://staging-api.example.com'); // Staging
apiClient.updateBaseUrl('https://api.example.com'); // Production
```
## API Endpoints
Define endpoints in `lib/core/constants/api_endpoints.dart`:
```dart
class ApiEndpoints {
static const String login = '/auth/login';
static const String warehouses = '/warehouses';
static const String products = '/products';
// Dynamic endpoints
static String productById(int id) => '/products/$id';
// Query parameters helper
static Map<String, dynamic> productQueryParams({
required int warehouseId,
required String type,
}) {
return {
'warehouseId': warehouseId,
'type': type,
};
}
}
```
## Utility Methods
### Test Connection
```dart
final isConnected = await apiClient.testConnection();
if (!isConnected) {
print('Cannot connect to API');
}
```
### Check Authentication
```dart
final isAuthenticated = await apiClient.isAuthenticated();
if (!isAuthenticated) {
// Navigate to login
}
```
### Get Current Token
```dart
final token = await apiClient.getAccessToken();
if (token != null) {
print('Token exists');
}
```
### Clear Authentication
```dart
// Logout - clears all tokens
await apiClient.clearAuth();
```
## Integration with Repository Pattern
The API client is designed to work with the repository pattern:
```dart
// Remote Data Source
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
final ApiClient apiClient;
WarehouseRemoteDataSourceImpl(this.apiClient);
@override
Future<List<Warehouse>> getWarehouses() async {
final response = await apiClient.get(ApiEndpoints.warehouses);
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
throw ServerException(apiResponse.getErrorMessage());
}
}
}
```
## Dependency Injection
Register the API client with GetIt:
```dart
final getIt = GetIt.instance;
// Register SecureStorage
getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
// Register ApiClient
getIt.registerLazySingleton<ApiClient>(
() => ApiClient(
getIt<SecureStorage>(),
onUnauthorized: () {
// Handle unauthorized access
},
),
);
```
## Best Practices
1. **Always use ApiResponse**: Parse all responses using the `ApiResponse` wrapper
2. **Handle errors gracefully**: Catch specific exception types for better error handling
3. **Use endpoints constants**: Define all endpoints in `api_endpoints.dart`
4. **Don't expose Dio**: Use the provided methods (get, post, put, delete) instead of accessing `dio` directly
5. **Test connection**: Use `testConnection()` before critical operations
6. **Log appropriately**: The client logs automatically, but you can add app-level logs too
## Testing
Mock the API client in tests:
```dart
class MockApiClient extends Mock implements ApiClient {}
void main() {
late MockApiClient mockApiClient;
setUp(() {
mockApiClient = MockApiClient();
});
test('should get warehouses', () async {
// Arrange
when(mockApiClient.get(any))
.thenAnswer((_) async => Response(
data: {'Value': [], 'IsSuccess': true},
statusCode: 200,
requestOptions: RequestOptions(path: '/warehouses'),
));
// Act & Assert
final response = await mockApiClient.get('/warehouses');
expect(response.statusCode, 200);
});
}
```
## Troubleshooting
### Token not being added to requests
- Ensure token is saved in secure storage
- Check if token is expired
- Verify `getAccessToken()` returns a value
### 401 errors not triggering logout
- Verify `onUnauthorized` callback is set
- Check error interceptor logs
- Ensure secure storage is properly initialized
### Connection timeouts
- Check network connectivity
- Verify base URL is correct
- Increase timeout values if needed
### Logging not appearing
- Use Flutter DevTools or console
- Check log level settings
- Ensure developer.log is not filtered
## Examples
See `api_client_example.dart` for comprehensive usage examples including:
- Login flow
- GET/POST/PUT/DELETE requests
- Error handling
- Custom options
- Request cancellation
- Environment switching
## Support
For issues or questions:
1. Check this documentation
2. Review `api_client_example.dart`
3. Check Flutter DevTools logs
4. Review backend API documentation

View File

@@ -1,12 +1,19 @@
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import '../constants/app_constants.dart';
import '../errors/exceptions.dart';
import '../storage/secure_storage.dart';
/// API client for making HTTP requests using Dio
/// Includes token management, request/response logging, and error handling
class ApiClient {
late final Dio _dio;
final SecureStorage _secureStorage;
ApiClient() {
// Callback for 401 unauthorized errors (e.g., to navigate to login)
void Function()? onUnauthorized;
ApiClient(this._secureStorage, {this.onUnauthorized}) {
_dio = Dio(
BaseOptions(
baseUrl: AppConstants.apiBaseUrl,
@@ -20,21 +27,45 @@ class ApiClient {
),
);
// Add request/response interceptors for logging and error handling
_setupInterceptors();
}
/// Setup all Dio interceptors
void _setupInterceptors() {
// Request interceptor - adds auth token and logs requests
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// Log request details in debug mode
handler.next(options);
onRequest: (options, handler) async {
// Add AccessToken header if available
final token = await _secureStorage.getAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['AccessToken'] = token;
}
// Add AppID header
options.headers['AppID'] = AppConstants.appId;
// Log request in debug mode
_logRequest(options);
return handler.next(options);
},
onResponse: (response, handler) {
// Log response details in debug mode
handler.next(response);
// Log response in debug mode
_logResponse(response);
return handler.next(response);
},
onError: (error, handler) {
// Handle different types of errors
_handleDioError(error);
handler.next(error);
onError: (error, handler) async {
// Log error in debug mode
_logError(error);
// Handle 401 unauthorized errors
if (error.response?.statusCode == 401) {
await _handle401Error();
}
return handler.next(error);
},
),
);
@@ -122,6 +153,23 @@ class ApiClient {
}
}
/// Handle 401 Unauthorized errors
Future<void> _handle401Error() async {
developer.log(
'401 Unauthorized - Clearing tokens and triggering logout',
name: 'ApiClient',
level: 900,
);
// Clear all tokens from secure storage
await _secureStorage.clearTokens();
// Trigger the unauthorized callback (e.g., navigate to login)
if (onUnauthorized != null) {
onUnauthorized!();
}
}
/// Handle Dio errors and convert them to custom exceptions
Exception _handleDioError(DioException error) {
switch (error.type) {
@@ -132,13 +180,47 @@ class ApiClient {
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
final message = error.response?.data?['message'] ?? 'Server error occurred';
// Try to extract error message from API response
String message = 'Server error occurred';
if (error.response?.data is Map) {
final data = error.response?.data as Map<String, dynamic>;
// Check for standard API error format
if (data['Errors'] != null && data['Errors'] is List && (data['Errors'] as List).isNotEmpty) {
message = (data['Errors'] as List).first.toString();
} else if (data['message'] != null) {
message = data['message'].toString();
} else if (data['error'] != null) {
message = data['error'].toString();
}
}
if (statusCode != null) {
if (statusCode >= 400 && statusCode < 500) {
return ServerException('Client error: $message (Status: $statusCode)');
} else if (statusCode >= 500) {
return ServerException('Server error: $message (Status: $statusCode)');
// Handle specific status codes
switch (statusCode) {
case 401:
return const ServerException('Unauthorized. Please login again.', code: '401');
case 403:
return const ServerException('Forbidden. You do not have permission.', code: '403');
case 404:
return ServerException('Resource not found: $message', code: '404');
case 422:
return ServerException('Validation error: $message', code: '422');
case 429:
return const ServerException('Too many requests. Please try again later.', code: '429');
case 500:
case 501:
case 502:
case 503:
case 504:
return ServerException('Server error: $message (Status: $statusCode)', code: statusCode.toString());
default:
if (statusCode >= 400 && statusCode < 500) {
return ServerException('Client error: $message (Status: $statusCode)', code: statusCode.toString());
}
return ServerException('HTTP error: $message (Status: $statusCode)', code: statusCode.toString());
}
}
return ServerException('HTTP error: $message');
@@ -153,23 +235,141 @@ class ApiClient {
return const NetworkException('Certificate verification failed');
case DioExceptionType.unknown:
default:
return ServerException('An unexpected error occurred: ${error.message}');
}
}
/// Add authorization header
void addAuthorizationHeader(String token) {
_dio.options.headers['Authorization'] = 'Bearer $token';
/// Log request details
void _logRequest(RequestOptions options) {
developer.log(
'REQUEST[${options.method}] => ${options.uri}',
name: 'ApiClient',
level: 800,
);
if (options.headers.isNotEmpty) {
developer.log(
'Headers: ${_sanitizeHeaders(options.headers)}',
name: 'ApiClient',
level: 800,
);
}
if (options.queryParameters.isNotEmpty) {
developer.log(
'Query Params: ${options.queryParameters}',
name: 'ApiClient',
level: 800,
);
}
if (options.data != null) {
developer.log(
'Body: ${options.data}',
name: 'ApiClient',
level: 800,
);
}
}
/// Remove authorization header
void removeAuthorizationHeader() {
_dio.options.headers.remove('Authorization');
/// Log response details
void _logResponse(Response response) {
developer.log(
'RESPONSE[${response.statusCode}] => ${response.requestOptions.uri}',
name: 'ApiClient',
level: 800,
);
developer.log(
'Data: ${response.data}',
name: 'ApiClient',
level: 800,
);
}
/// Log error details
void _logError(DioException error) {
developer.log(
'ERROR[${error.response?.statusCode}] => ${error.requestOptions.uri}',
name: 'ApiClient',
level: 1000,
error: error,
);
if (error.response?.data != null) {
developer.log(
'Error Data: ${error.response?.data}',
name: 'ApiClient',
level: 1000,
);
}
}
/// Sanitize headers to hide sensitive data in logs
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
final sanitized = Map<String, dynamic>.from(headers);
// Hide Authorization token
if (sanitized.containsKey('Authorization')) {
sanitized['Authorization'] = '***REDACTED***';
}
// Hide any other sensitive headers
final sensitiveKeys = ['api-key', 'x-api-key', 'token'];
for (final key in sensitiveKeys) {
if (sanitized.containsKey(key)) {
sanitized[key] = '***REDACTED***';
}
}
return sanitized;
}
/// Get the Dio instance (use carefully, prefer using the methods above)
Dio get dio => _dio;
/// Update base URL (useful for different environments)
void updateBaseUrl(String newBaseUrl) {
_dio.options.baseUrl = newBaseUrl;
developer.log(
'Base URL updated to: $newBaseUrl',
name: 'ApiClient',
level: 800,
);
}
/// Test connection to the API
Future<bool> testConnection() async {
try {
final response = await _dio.get('/health');
return response.statusCode == 200;
} catch (e) {
developer.log(
'Connection test failed: $e',
name: 'ApiClient',
level: 900,
);
return false;
}
}
/// Get current access token
Future<String?> getAccessToken() async {
return await _secureStorage.getAccessToken();
}
/// Check if user is authenticated
Future<bool> isAuthenticated() async {
return await _secureStorage.isAuthenticated();
}
/// Clear all authentication data
Future<void> clearAuth() async {
await _secureStorage.clearAll();
developer.log(
'Authentication data cleared',
name: 'ApiClient',
level: 800,
);
}
}

View File

@@ -0,0 +1,246 @@
import 'package:equatable/equatable.dart';
/// Generic API response wrapper that handles the standard API response format
///
/// All API responses follow this structure:
/// ```json
/// {
/// "Value": T,
/// "IsSuccess": bool,
/// "IsFailure": bool,
/// "Errors": List<String>,
/// "ErrorCodes": List<String>
/// }
/// ```
///
/// Usage:
/// ```dart
/// final response = ApiResponse.fromJson(
/// jsonData,
/// (json) => User.fromJson(json),
/// );
///
/// if (response.isSuccess && response.value != null) {
/// // Handle success
/// final user = response.value!;
/// } else {
/// // Handle error
/// final errorMessage = response.errors.first;
/// }
/// ```
class ApiResponse<T> extends Equatable {
/// The actual data/payload of the response
/// Can be null if the API call failed or returned no data
final T? value;
/// Indicates if the API call was successful
final bool isSuccess;
/// Indicates if the API call failed
final bool isFailure;
/// List of error messages if the call failed
final List<String> errors;
/// List of error codes for programmatic error handling
final List<String> errorCodes;
const ApiResponse({
this.value,
required this.isSuccess,
required this.isFailure,
this.errors = const [],
this.errorCodes = const [],
});
/// Create an ApiResponse from JSON
///
/// The [fromJsonT] function is used to deserialize the "Value" field.
/// If null, the value is used as-is.
///
/// Example:
/// ```dart
/// // For single object
/// ApiResponse.fromJson(json, (j) => User.fromJson(j))
///
/// // For list of objects
/// ApiResponse.fromJson(
/// json,
/// (j) => (j as List).map((e) => User.fromJson(e)).toList()
/// )
///
/// // For primitive types or no conversion needed
/// ApiResponse.fromJson(json, null)
/// ```
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic)? fromJsonT,
) {
return ApiResponse(
value: json['Value'] != null && fromJsonT != null
? fromJsonT(json['Value'])
: json['Value'] as T?,
isSuccess: json['IsSuccess'] ?? false,
isFailure: json['IsFailure'] ?? true,
errors: json['Errors'] != null
? List<String>.from(json['Errors'])
: const [],
errorCodes: json['ErrorCodes'] != null
? List<String>.from(json['ErrorCodes'])
: const [],
);
}
/// Create a successful response (useful for testing or manual creation)
factory ApiResponse.success(T value) {
return ApiResponse(
value: value,
isSuccess: true,
isFailure: false,
);
}
/// Create a failed response (useful for testing or manual creation)
factory ApiResponse.failure({
required List<String> errors,
List<String>? errorCodes,
}) {
return ApiResponse(
isSuccess: false,
isFailure: true,
errors: errors,
errorCodes: errorCodes ?? const [],
);
}
/// Check if response has data
bool get hasValue => value != null;
/// Get the first error message if available
String? get firstError => errors.isNotEmpty ? errors.first : null;
/// Get the first error code if available
String? get firstErrorCode => errorCodes.isNotEmpty ? errorCodes.first : null;
/// Get a combined error message from all errors
String get combinedErrorMessage {
if (errors.isEmpty) return 'An unknown error occurred';
return errors.join(', ');
}
/// Convert to a map (useful for serialization or debugging)
Map<String, dynamic> toJson(Object? Function(T)? toJsonT) {
return {
'Value': value != null && toJsonT != null ? toJsonT(value as T) : value,
'IsSuccess': isSuccess,
'IsFailure': isFailure,
'Errors': errors,
'ErrorCodes': errorCodes,
};
}
/// Create a copy with modified fields
ApiResponse<T> copyWith({
T? value,
bool? isSuccess,
bool? isFailure,
List<String>? errors,
List<String>? errorCodes,
}) {
return ApiResponse(
value: value ?? this.value,
isSuccess: isSuccess ?? this.isSuccess,
isFailure: isFailure ?? this.isFailure,
errors: errors ?? this.errors,
errorCodes: errorCodes ?? this.errorCodes,
);
}
@override
List<Object?> get props => [value, isSuccess, isFailure, errors, errorCodes];
@override
String toString() {
if (isSuccess) {
return 'ApiResponse.success(value: $value)';
} else {
return 'ApiResponse.failure(errors: $errors, errorCodes: $errorCodes)';
}
}
}
/// Extension to convert ApiResponse to nullable value easily
extension ApiResponseExtension<T> on ApiResponse<T> {
/// Get value if success, otherwise return null
T? get valueOrNull => isSuccess ? value : null;
/// Get value if success, otherwise throw exception with error message
T get valueOrThrow {
if (isSuccess && value != null) {
return value!;
}
throw Exception(combinedErrorMessage);
}
}
/// Specialized API response for list data with pagination
class PaginatedApiResponse<T> extends ApiResponse<List<T>> {
/// Current page number
final int currentPage;
/// Total number of pages
final int totalPages;
/// Total number of items
final int totalItems;
/// Number of items per page
final int pageSize;
/// Whether there is a next page
bool get hasNextPage => currentPage < totalPages;
/// Whether there is a previous page
bool get hasPreviousPage => currentPage > 1;
const PaginatedApiResponse({
super.value,
required super.isSuccess,
required super.isFailure,
super.errors,
super.errorCodes,
required this.currentPage,
required this.totalPages,
required this.totalItems,
required this.pageSize,
});
/// Create a PaginatedApiResponse from JSON
factory PaginatedApiResponse.fromJson(
Map<String, dynamic> json,
List<T> Function(dynamic) fromJsonList,
) {
final apiResponse = ApiResponse<List<T>>.fromJson(json, fromJsonList);
return PaginatedApiResponse(
value: apiResponse.value,
isSuccess: apiResponse.isSuccess,
isFailure: apiResponse.isFailure,
errors: apiResponse.errors,
errorCodes: apiResponse.errorCodes,
currentPage: json['CurrentPage'] ?? 1,
totalPages: json['TotalPages'] ?? 1,
totalItems: json['TotalItems'] ?? 0,
pageSize: json['PageSize'] ?? 20,
);
}
@override
List<Object?> get props => [
...super.props,
currentPage,
totalPages,
totalItems,
pageSize,
];
}

View File

@@ -0,0 +1,156 @@
# GoRouter Quick Reference
## Import
```dart
import 'package:minhthu/core/router/app_router.dart';
```
## Navigation Commands
### Basic Navigation
```dart
// Login page
context.goToLogin();
// Warehouses list
context.goToWarehouses();
// Operations (requires warehouse)
context.goToOperations(warehouse);
// Products (requires warehouse and operation type)
context.goToProducts(
warehouse: warehouse,
operationType: 'import', // or 'export'
);
// Go back
context.goBack();
```
### Named Routes (Alternative)
```dart
context.goToLoginNamed();
context.goToWarehousesNamed();
context.goToOperationsNamed(warehouse);
context.goToProductsNamed(
warehouse: warehouse,
operationType: 'export',
);
```
## Common Usage Patterns
### Warehouse Selection → Operations
```dart
onTap: () {
context.goToOperations(warehouse);
}
```
### Operation Selection → Products
```dart
// Import
onTap: () {
context.goToProducts(
warehouse: warehouse,
operationType: 'import',
);
}
// Export
onTap: () {
context.goToProducts(
warehouse: warehouse,
operationType: 'export',
);
}
```
### Logout
```dart
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await ref.read(authProvider.notifier).logout();
// Router auto-redirects to /login
},
)
```
## Route Paths
| Path | Name | Description |
|------|------|-------------|
| `/login` | `login` | Login page |
| `/warehouses` | `warehouses` | Warehouse list (protected) |
| `/operations` | `operations` | Operation selection (protected) |
| `/products` | `products` | Product list (protected) |
## Authentication
### Check Status
```dart
final isAuth = await SecureStorage().isAuthenticated();
```
### Auto-Redirect Rules
- Not authenticated → `/login`
- Authenticated on `/login``/warehouses`
- Missing parameters → Previous valid page
## Error Handling
### Missing Parameters
```dart
// Automatically redirected to safe page
// Error screen shown briefly
```
### Page Not Found
```dart
// Custom 404 page shown
// Can navigate back to login
```
## Complete Example
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:minhthu/core/router/app_router.dart';
class WarehouseCard extends ConsumerWidget {
final WarehouseEntity warehouse;
const WarehouseCard({required this.warehouse});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
child: ListTile(
title: Text(warehouse.name),
subtitle: Text(warehouse.code),
trailing: Icon(Icons.arrow_forward),
onTap: () {
// Navigate to operations
context.goToOperations(warehouse);
},
),
);
}
}
```
## Tips
1. **Use extension methods** - They provide type safety and auto-completion
2. **Let router handle auth** - Don't manually check authentication in pages
3. **Validate early** - Router validates parameters automatically
4. **Use named routes** - For better route management in large apps
## See Also
- Full documentation: `/lib/core/router/README.md`
- Setup guide: `/ROUTER_SETUP.md`
- Examples: `/lib/features/warehouse/presentation/pages/warehouse_selection_page_example.dart`

382
lib/core/router/README.md Normal file
View File

@@ -0,0 +1,382 @@
# App Router Documentation
Complete navigation setup for the warehouse management application using GoRouter.
## Overview
The app router implements authentication-based navigation with proper redirect logic:
- **Unauthenticated users** are redirected to `/login`
- **Authenticated users** on `/login` are redirected to `/warehouses`
- Type-safe parameter passing between routes
- Integration with SecureStorage for authentication checks
## App Flow
```
Login → Warehouses → Operations → Products
```
1. **Login**: User authenticates and token is stored
2. **Warehouses**: User selects a warehouse
3. **Operations**: User chooses Import or Export
4. **Products**: Display products based on warehouse and operation
## Routes
### `/login` - Login Page
- **Name**: `login`
- **Purpose**: User authentication
- **Parameters**: None
- **Redirect**: If authenticated → `/warehouses`
### `/warehouses` - Warehouse Selection Page
- **Name**: `warehouses`
- **Purpose**: Display list of warehouses
- **Parameters**: None
- **Protected**: Requires authentication
### `/operations` - Operation Selection Page
- **Name**: `operations`
- **Purpose**: Choose Import or Export operation
- **Parameters**:
- `extra`: `WarehouseEntity` object
- **Protected**: Requires authentication
- **Validation**: Redirects to `/warehouses` if warehouse data is missing
### `/products` - Products List Page
- **Name**: `products`
- **Purpose**: Display products for warehouse and operation
- **Parameters**:
- `extra`: `Map<String, dynamic>` containing:
- `warehouse`: `WarehouseEntity` object
- `warehouseName`: `String`
- `operationType`: `String` ('import' or 'export')
- **Protected**: Requires authentication
- **Validation**: Redirects to `/warehouses` if parameters are invalid
## Usage Examples
### Basic Navigation
```dart
import 'package:go_router/go_router.dart';
// Navigate to login
context.go('/login');
// Navigate to warehouses
context.go('/warehouses');
```
### Navigation with Extension Methods
```dart
import 'package:minhthu/core/router/app_router.dart';
// Navigate to login
context.goToLogin();
// Navigate to warehouses
context.goToWarehouses();
// Navigate to operations with warehouse
context.goToOperations(warehouse);
// Navigate to products with warehouse and operation type
context.goToProducts(
warehouse: warehouse,
operationType: 'import',
);
// Go back
context.goBack();
```
### Named Route Navigation
```dart
// Using named routes
context.goToLoginNamed();
context.goToWarehousesNamed();
context.goToOperationsNamed(warehouse);
context.goToProductsNamed(
warehouse: warehouse,
operationType: 'export',
);
```
## Integration with Warehouse Selection
### Example: Navigate from Warehouse to Operations
```dart
import 'package:flutter/material.dart';
import 'package:minhthu/core/router/app_router.dart';
import 'package:minhthu/features/warehouse/domain/entities/warehouse_entity.dart';
class WarehouseCard extends StatelessWidget {
final WarehouseEntity warehouse;
const WarehouseCard({required this.warehouse});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(warehouse.name),
subtitle: Text('Code: ${warehouse.code}'),
trailing: Icon(Icons.arrow_forward),
onTap: () {
// Navigate to operations page
context.goToOperations(warehouse);
},
),
);
}
}
```
### Example: Navigate from Operations to Products
```dart
import 'package:flutter/material.dart';
import 'package:minhthu/core/router/app_router.dart';
import 'package:minhthu/features/warehouse/domain/entities/warehouse_entity.dart';
class OperationButton extends StatelessWidget {
final WarehouseEntity warehouse;
final String operationType;
const OperationButton({
required this.warehouse,
required this.operationType,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
// Navigate to products page
context.goToProducts(
warehouse: warehouse,
operationType: operationType,
);
},
child: Text(operationType == 'import'
? 'Import Products'
: 'Export Products'),
);
}
}
```
## Authentication Integration
The router automatically checks authentication status on every navigation:
```dart
// In app_router.dart
Future<String?> _handleRedirect(
BuildContext context,
GoRouterState state,
) async {
// Check if user has access token
final isAuthenticated = await secureStorage.isAuthenticated();
final isOnLoginPage = state.matchedLocation == '/login';
// Redirect logic
if (!isAuthenticated && !isOnLoginPage) {
return '/login'; // Redirect to login
}
if (isAuthenticated && isOnLoginPage) {
return '/warehouses'; // Redirect to warehouses
}
return null; // Allow navigation
}
```
### SecureStorage Integration
The router uses `SecureStorage` to check authentication:
```dart
// Check if authenticated
final isAuthenticated = await secureStorage.isAuthenticated();
// This checks if access token exists
Future<bool> isAuthenticated() async {
final token = await getAccessToken();
return token != null && token.isNotEmpty;
}
```
## Reactive Navigation
The router automatically reacts to authentication state changes:
```dart
class GoRouterRefreshStream extends ChangeNotifier {
final Ref ref;
GoRouterRefreshStream(this.ref) {
// Listen to auth state changes
ref.listen(
authProvider, // From auth_dependency_injection.dart
(_, __) => notifyListeners(),
);
}
}
```
When authentication state changes (login/logout), the router:
1. Receives notification
2. Re-evaluates redirect logic
3. Automatically redirects to appropriate page
## Error Handling
### Missing Parameters
If route parameters are missing, the user is redirected:
```dart
GoRoute(
path: '/operations',
builder: (context, state) {
final warehouse = state.extra as WarehouseEntity?;
if (warehouse == null) {
// Show error and redirect
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Warehouse data is required',
);
}
return OperationSelectionPage(warehouse: warehouse);
},
),
```
### Page Not Found
Custom 404 error page:
```dart
errorBuilder: (context, state) {
return Scaffold(
appBar: AppBar(title: const Text('Page Not Found')),
body: Center(
child: Column(
children: [
Icon(Icons.error_outline, size: 64),
Text('Page "${state.uri.path}" does not exist'),
ElevatedButton(
onPressed: () => context.go('/login'),
child: const Text('Go to Login'),
),
],
),
),
);
}
```
## Setup in main.dart
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:minhthu/core/router/app_router.dart';
import 'package:minhthu/core/theme/app_theme.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Get router from provider
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Warehouse Manager',
theme: AppTheme.lightTheme,
routerConfig: router,
);
}
}
```
## Best Practices
### 1. Use Extension Methods
Prefer extension methods for type-safe navigation:
```dart
// Good
context.goToProducts(warehouse: warehouse, operationType: 'import');
// Avoid
context.go('/products', extra: {'warehouse': warehouse, 'operationType': 'import'});
```
### 2. Validate Parameters
Always validate route parameters:
```dart
final warehouse = state.extra as WarehouseEntity?;
if (warehouse == null) {
// Handle error
}
```
### 3. Handle Async Operations
Use post-frame callbacks for navigation in builders:
```dart
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
```
### 4. Logout Implementation
Clear storage and let router handle redirect:
```dart
Future<void> logout() async {
await ref.read(authProvider.notifier).logout();
// Router will automatically redirect to /login
}
```
## Troubleshooting
### Issue: Redirect loop
**Cause**: Authentication check is not working properly
**Solution**: Verify SecureStorage has access token
### Issue: Parameters are null
**Cause**: Wrong parameter passing format
**Solution**: Use extension methods with correct types
### Issue: Navigation doesn't update
**Cause**: Auth state changes not triggering refresh
**Solution**: Verify GoRouterRefreshStream is listening to authProvider
## Related Files
- `/lib/core/router/app_router.dart` - Main router configuration
- `/lib/core/storage/secure_storage.dart` - Authentication storage
- `/lib/features/auth/di/auth_dependency_injection.dart` - Auth providers
- `/lib/features/auth/presentation/pages/login_page.dart` - Login page
- `/lib/features/warehouse/presentation/pages/warehouse_selection_page.dart` - Warehouse page
- `/lib/features/operation/presentation/pages/operation_selection_page.dart` - Operation page
- `/lib/features/products/presentation/pages/products_page.dart` - Products page

View File

@@ -0,0 +1,360 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/pages/login_page.dart';
import '../../features/auth/di/auth_dependency_injection.dart';
import '../../features/warehouse/presentation/pages/warehouse_selection_page.dart';
import '../../features/operation/presentation/pages/operation_selection_page.dart';
import '../../features/products/presentation/pages/products_page.dart';
import '../../features/warehouse/domain/entities/warehouse_entity.dart';
import '../storage/secure_storage.dart';
/// Application router configuration using GoRouter
///
/// Implements authentication-based redirect logic:
/// - Unauthenticated users are redirected to /login
/// - Authenticated users on /login are redirected to /warehouses
/// - Proper parameter passing between routes
///
/// App Flow: Login → Warehouses → Operations → Products
class AppRouter {
final Ref ref;
final SecureStorage secureStorage;
AppRouter({
required this.ref,
required this.secureStorage,
});
late final GoRouter router = GoRouter(
debugLogDiagnostics: true,
initialLocation: '/login',
refreshListenable: GoRouterRefreshStream(ref),
redirect: _handleRedirect,
routes: [
// ==================== Auth Routes ====================
/// Login Route
/// Path: /login
/// Initial route for unauthenticated users
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
// ==================== Main App Routes ====================
/// Warehouse Selection Route
/// Path: /warehouses
/// Shows list of available warehouses after login
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => const WarehouseSelectionPage(),
),
/// Operation Selection Route
/// Path: /operations
/// Takes warehouse data as extra parameter
/// Shows Import/Export operation options for selected warehouse
GoRoute(
path: '/operations',
name: 'operations',
builder: (context, state) {
final warehouse = state.extra as WarehouseEntity?;
if (warehouse == null) {
// If no warehouse data, redirect to warehouses
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Warehouse data is required',
);
}
return OperationSelectionPage(warehouse: warehouse);
},
),
/// Products List Route
/// Path: /products
/// Takes warehouse, warehouseName, and operationType as extra parameter
/// Shows products for selected warehouse and operation
GoRoute(
path: '/products',
name: 'products',
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 parameters are required',
);
}
// Extract required parameters
final warehouse = params['warehouse'] as WarehouseEntity?;
final warehouseName = params['warehouseName'] as String?;
final operationType = params['operationType'] as String?;
// Validate parameters
if (warehouse == null || warehouseName == null || operationType == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Invalid product parameters',
);
}
return ProductsPage(
warehouseId: warehouse.id,
warehouseName: warehouseName,
operationType: operationType,
);
},
),
],
// ==================== Error Handling ====================
errorBuilder: (context, state) {
return Scaffold(
appBar: AppBar(
title: const Text('Page Not Found'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Page Not Found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'The page "${state.uri.path}" does not exist.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/login'),
child: const Text('Go to Login'),
),
],
),
),
);
},
);
/// Handle global redirect logic based on authentication status
///
/// Redirect rules:
/// 1. Check authentication status using SecureStorage
/// 2. If not authenticated and not on login page → redirect to /login
/// 3. If authenticated and on login page → redirect to /warehouses
/// 4. Otherwise, allow navigation
Future<String?> _handleRedirect(
BuildContext context,
GoRouterState state,
) async {
try {
// Check if user has access token
final isAuthenticated = await secureStorage.isAuthenticated();
final isOnLoginPage = state.matchedLocation == '/login';
// User is not authenticated
if (!isAuthenticated) {
// Allow access to login page
if (isOnLoginPage) {
return null;
}
// Redirect to login for all other pages
return '/login';
}
// User is authenticated
if (isAuthenticated) {
// Redirect away from login page to warehouses
if (isOnLoginPage) {
return '/warehouses';
}
// Allow access to all other pages
return null;
}
return null;
} catch (e) {
// On error, redirect to login for safety
debugPrint('Error in redirect: $e');
return '/login';
}
}
}
/// Provider for AppRouter
///
/// Creates and provides the GoRouter instance with dependencies
final appRouterProvider = Provider<GoRouter>((ref) {
final secureStorage = SecureStorage();
final appRouter = AppRouter(
ref: ref,
secureStorage: secureStorage,
);
return appRouter.router;
});
/// Helper class to refresh router when auth state changes
///
/// This allows GoRouter to react to authentication state changes
/// and re-evaluate redirect logic
class GoRouterRefreshStream extends ChangeNotifier {
final Ref ref;
GoRouterRefreshStream(this.ref) {
// Listen to auth state changes
// When auth state changes, notify GoRouter to re-evaluate redirects
ref.listen(
authProvider,
(_, __) => notifyListeners(),
);
}
}
/// Error screen widget for route parameter validation errors
class _ErrorScreen extends StatelessWidget {
final String message;
const _ErrorScreen({required this.message});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Error'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Navigation Error',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/warehouses'),
child: const Text('Go to Warehouses'),
),
],
),
),
);
}
}
/// Extension methods for easier type-safe navigation
///
/// Usage:
/// ```dart
/// context.goToLogin();
/// context.goToWarehouses();
/// context.goToOperations(warehouse);
/// context.goToProducts(warehouse, 'import');
/// ```
extension AppRouterExtension on BuildContext {
/// Navigate to login page
void goToLogin() => go('/login');
/// Navigate to warehouses list
void goToWarehouses() => go('/warehouses');
/// Navigate to operation selection with warehouse data
void goToOperations(WarehouseEntity warehouse) {
go('/operations', extra: warehouse);
}
/// Navigate to products list with required parameters
///
/// [warehouse] - Selected warehouse entity
/// [operationType] - Either 'import' or 'export'
void goToProducts({
required WarehouseEntity warehouse,
required String operationType,
}) {
go(
'/products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': operationType,
},
);
}
/// Pop current route
void goBack() => pop();
}
/// Extension for named route navigation
extension AppRouterNamedExtension on BuildContext {
/// Navigate to login page using named route
void goToLoginNamed() => goNamed('login');
/// Navigate to warehouses using named route
void goToWarehousesNamed() => goNamed('warehouses');
/// Navigate to operations using named route with warehouse
void goToOperationsNamed(WarehouseEntity warehouse) {
goNamed('operations', extra: warehouse);
}
/// Navigate to products using named route with parameters
void goToProductsNamed({
required WarehouseEntity warehouse,
required String operationType,
}) {
goNamed(
'products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': operationType,
},
);
}
}

View File

@@ -1,211 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../features/scanner/presentation/pages/home_page.dart';
import '../../features/scanner/presentation/pages/detail_page.dart';
/// Application router configuration using GoRouter
final GoRouter appRouter = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
routes: [
// Home route - Main scanner screen
GoRoute(
path: '/',
name: 'home',
builder: (BuildContext context, GoRouterState state) {
return const HomePage();
},
),
// Detail route - Edit scan data
GoRoute(
path: '/detail/:barcode',
name: 'detail',
builder: (BuildContext context, GoRouterState state) {
final barcode = state.pathParameters['barcode']!;
return DetailPage(barcode: barcode);
},
redirect: (BuildContext context, GoRouterState state) {
final barcode = state.pathParameters['barcode'];
// Ensure barcode is not empty
if (barcode == null || barcode.trim().isEmpty) {
return '/';
}
return null; // No redirect needed
},
),
// Settings route (optional for future expansion)
GoRoute(
path: '/settings',
name: 'settings',
builder: (BuildContext context, GoRouterState state) {
return const SettingsPlaceholderPage();
},
),
// About route (optional for future expansion)
GoRoute(
path: '/about',
name: 'about',
builder: (BuildContext context, GoRouterState state) {
return const AboutPlaceholderPage();
},
),
],
// Error handling
errorBuilder: (context, state) {
return Scaffold(
appBar: AppBar(
title: const Text('Page Not Found'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Page Not Found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'The page "${state.path}" does not exist.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Go Home'),
),
],
),
),
);
},
// Redirect handler for authentication or onboarding (optional)
redirect: (BuildContext context, GoRouterState state) {
// Add any global redirect logic here
// For example, redirect to onboarding or login if needed
return null; // No global redirect
},
);
/// Placeholder page for settings (for future implementation)
class SettingsPlaceholderPage extends StatelessWidget {
const SettingsPlaceholderPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.settings,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Settings',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
'Settings page coming soon',
style: TextStyle(
color: Colors.grey,
),
),
],
),
),
);
}
}
/// Placeholder page for about (for future implementation)
class AboutPlaceholderPage extends StatelessWidget {
const AboutPlaceholderPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('About'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Barcode Scanner App',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
'Version 1.0.0',
style: TextStyle(
color: Colors.grey,
),
),
],
),
),
);
}
}
/// Extension methods for easier navigation
extension AppRouterExtension on BuildContext {
/// Navigate to home page
void goHome() => go('/');
/// Navigate to detail page with barcode
void goToDetail(String barcode) => go('/detail/$barcode');
/// Navigate to settings
void goToSettings() => go('/settings');
/// Navigate to about page
void goToAbout() => go('/about');
}

View File

@@ -0,0 +1,198 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Secure storage service for managing sensitive data like tokens
///
/// Uses FlutterSecureStorage to encrypt and store data securely on the device.
/// This ensures tokens and other sensitive information are protected.
///
/// Usage:
/// ```dart
/// final storage = SecureStorage();
///
/// // Save token
/// await storage.saveAccessToken('your_token_here');
///
/// // Read token
/// final token = await storage.getAccessToken();
///
/// // Clear all data
/// await storage.clearAll();
/// ```
class SecureStorage {
// Private constructor for singleton pattern
SecureStorage._();
/// Singleton instance
static final SecureStorage _instance = SecureStorage._();
/// Factory constructor returns singleton instance
factory SecureStorage() => _instance;
/// FlutterSecureStorage instance with default options
static const FlutterSecureStorage _storage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);
// ==================== Storage Keys ====================
/// Key for storing access token
static const String _accessTokenKey = 'access_token';
/// Key for storing refresh token
static const String _refreshTokenKey = 'refresh_token';
/// Key for storing user ID
static const String _userIdKey = 'user_id';
/// Key for storing username
static const String _usernameKey = 'username';
// ==================== Token Management ====================
/// Save access token securely
Future<void> saveAccessToken(String token) async {
try {
await _storage.write(key: _accessTokenKey, value: token);
} catch (e) {
throw Exception('Failed to save access token: $e');
}
}
/// Get access token
Future<String?> getAccessToken() async {
try {
return await _storage.read(key: _accessTokenKey);
} catch (e) {
throw Exception('Failed to read access token: $e');
}
}
/// Save refresh token securely
Future<void> saveRefreshToken(String token) async {
try {
await _storage.write(key: _refreshTokenKey, value: token);
} catch (e) {
throw Exception('Failed to save refresh token: $e');
}
}
/// Get refresh token
Future<String?> getRefreshToken() async {
try {
return await _storage.read(key: _refreshTokenKey);
} catch (e) {
throw Exception('Failed to read refresh token: $e');
}
}
/// Save user ID
Future<void> saveUserId(String userId) async {
try {
await _storage.write(key: _userIdKey, value: userId);
} catch (e) {
throw Exception('Failed to save user ID: $e');
}
}
/// Get user ID
Future<String?> getUserId() async {
try {
return await _storage.read(key: _userIdKey);
} catch (e) {
throw Exception('Failed to read user ID: $e');
}
}
/// Save username
Future<void> saveUsername(String username) async {
try {
await _storage.write(key: _usernameKey, value: username);
} catch (e) {
throw Exception('Failed to save username: $e');
}
}
/// Get username
Future<String?> getUsername() async {
try {
return await _storage.read(key: _usernameKey);
} catch (e) {
throw Exception('Failed to read username: $e');
}
}
/// Check if user is authenticated (has valid access token)
Future<bool> isAuthenticated() async {
final token = await getAccessToken();
return token != null && token.isNotEmpty;
}
/// Clear all stored data (logout)
Future<void> clearAll() async {
try {
await _storage.deleteAll();
} catch (e) {
throw Exception('Failed to clear storage: $e');
}
}
/// Clear only auth tokens
Future<void> clearTokens() async {
try {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
} catch (e) {
throw Exception('Failed to clear tokens: $e');
}
}
/// Get all stored keys (useful for debugging)
Future<Map<String, String>> readAll() async {
try {
return await _storage.readAll();
} catch (e) {
throw Exception('Failed to read all data: $e');
}
}
/// Check if storage contains a specific key
Future<bool> containsKey(String key) async {
try {
return await _storage.containsKey(key: key);
} catch (e) {
throw Exception('Failed to check key: $e');
}
}
/// Write custom key-value pair
Future<void> write(String key, String value) async {
try {
await _storage.write(key: key, value: value);
} catch (e) {
throw Exception('Failed to write data: $e');
}
}
/// Read custom key
Future<String?> read(String key) async {
try {
return await _storage.read(key: key);
} catch (e) {
throw Exception('Failed to read data: $e');
}
}
/// Delete custom key
Future<void> delete(String key) async {
try {
await _storage.delete(key: key);
} catch (e) {
throw Exception('Failed to delete data: $e');
}
}
}

View File

@@ -9,7 +9,7 @@ class AppTheme {
// Color scheme for light theme
static const ColorScheme _lightColorScheme = ColorScheme(
brightness: Brightness.light,
primary: Color(0xFF1976D2), // Blue
primary: Color(0xFFB10E62), // Blue
onPrimary: Color(0xFFFFFFFF),
primaryContainer: Color(0xFFE3F2FD),
onPrimaryContainer: Color(0xFF0D47A1),

View File

@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
/// Custom button widget with loading state and consistent styling
///
/// This widget provides a reusable button component with:
/// - Loading indicator support
/// - Disabled state
/// - Customizable colors, icons, and text
/// - Consistent padding and styling
///
/// Usage:
/// ```dart
/// CustomButton(
/// text: 'Login',
/// onPressed: _handleLogin,
/// isLoading: _isLoading,
/// )
///
/// CustomButton.outlined(
/// text: 'Cancel',
/// onPressed: _handleCancel,
/// )
///
/// CustomButton.text(
/// text: 'Skip',
/// onPressed: _handleSkip,
/// )
/// ```
class CustomButton extends StatelessWidget {
/// Button text
final String text;
/// Callback when button is pressed
final VoidCallback? onPressed;
/// Whether the button is in loading state
final bool isLoading;
/// Optional icon to display before text
final IconData? icon;
/// Button style variant
final ButtonStyle? style;
/// Whether this is an outlined button
final bool isOutlined;
/// Whether this is a text button
final bool isTextButton;
/// Minimum button width (null for full width)
final double? minWidth;
/// Minimum button height
final double? minHeight;
/// Background color (only for elevated buttons)
final Color? backgroundColor;
/// Foreground/text color
final Color? foregroundColor;
/// Border color (only for outlined buttons)
final Color? borderColor;
/// Font size
final double? fontSize;
/// Font weight
final FontWeight? fontWeight;
const CustomButton({
super.key,
required this.text,
required this.onPressed,
this.isLoading = false,
this.icon,
this.style,
this.minWidth,
this.minHeight,
this.backgroundColor,
this.foregroundColor,
this.fontSize,
this.fontWeight,
}) : isOutlined = false,
isTextButton = false,
borderColor = null;
/// Create an outlined button variant
const CustomButton.outlined({
super.key,
required this.text,
required this.onPressed,
this.isLoading = false,
this.icon,
this.style,
this.minWidth,
this.minHeight,
this.foregroundColor,
this.borderColor,
this.fontSize,
this.fontWeight,
}) : isOutlined = true,
isTextButton = false,
backgroundColor = null;
/// Create a text button variant
const CustomButton.text({
super.key,
required this.text,
required this.onPressed,
this.isLoading = false,
this.icon,
this.style,
this.minWidth,
this.minHeight,
this.foregroundColor,
this.fontSize,
this.fontWeight,
}) : isOutlined = false,
isTextButton = true,
backgroundColor = null,
borderColor = null;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Determine if button should be disabled
final bool isDisabled = onPressed == null || isLoading;
// Build button content
Widget content;
if (isLoading) {
content = SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
isTextButton
? foregroundColor ?? colorScheme.primary
: foregroundColor ?? colorScheme.onPrimary,
),
),
);
} else if (icon != null) {
content = Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 20),
const SizedBox(width: 8),
Flexible(
child: Text(
text,
overflow: TextOverflow.ellipsis,
),
),
],
);
} else {
content = Text(text);
}
// Build button style
final ButtonStyle buttonStyle = style ??
(isTextButton
? _buildTextButtonStyle(context)
: isOutlined
? _buildOutlinedButtonStyle(context)
: _buildElevatedButtonStyle(context));
// Build appropriate button widget
if (isTextButton) {
return TextButton(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: content,
);
} else if (isOutlined) {
return OutlinedButton(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: content,
);
} else {
return ElevatedButton(
onPressed: isDisabled ? null : onPressed,
style: buttonStyle,
child: content,
);
}
}
/// Build elevated button style
ButtonStyle _buildElevatedButtonStyle(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? colorScheme.primary,
foregroundColor: foregroundColor ?? colorScheme.onPrimary,
minimumSize: Size(
minWidth ?? double.infinity,
minHeight ?? 48,
),
textStyle: TextStyle(
fontSize: fontSize ?? 16,
fontWeight: fontWeight ?? FontWeight.w600,
),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
);
}
/// Build outlined button style
ButtonStyle _buildOutlinedButtonStyle(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return OutlinedButton.styleFrom(
foregroundColor: foregroundColor ?? colorScheme.primary,
side: BorderSide(
color: borderColor ?? colorScheme.primary,
width: 1.5,
),
minimumSize: Size(
minWidth ?? double.infinity,
minHeight ?? 48,
),
textStyle: TextStyle(
fontSize: fontSize ?? 16,
fontWeight: fontWeight ?? FontWeight.w600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
);
}
/// Build text button style
ButtonStyle _buildTextButtonStyle(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return TextButton.styleFrom(
foregroundColor: foregroundColor ?? colorScheme.primary,
minimumSize: Size(
minWidth ?? 0,
minHeight ?? 48,
),
textStyle: TextStyle(
fontSize: fontSize ?? 16,
fontWeight: fontWeight ?? FontWeight.w600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
);
}
}
/// Icon button with loading state
class CustomIconButton extends StatelessWidget {
/// Icon to display
final IconData icon;
/// Callback when button is pressed
final VoidCallback? onPressed;
/// Whether the button is in loading state
final bool isLoading;
/// Icon size
final double? iconSize;
/// Icon color
final Color? color;
/// Background color
final Color? backgroundColor;
/// Tooltip text
final String? tooltip;
const CustomIconButton({
super.key,
required this.icon,
required this.onPressed,
this.isLoading = false,
this.iconSize,
this.color,
this.backgroundColor,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bool isDisabled = onPressed == null || isLoading;
Widget button = IconButton(
icon: isLoading
? SizedBox(
height: iconSize ?? 24,
width: iconSize ?? 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
color ?? theme.colorScheme.primary,
),
),
)
: Icon(
icon,
size: iconSize ?? 24,
color: color,
),
onPressed: isDisabled ? null : onPressed,
style: backgroundColor != null
? IconButton.styleFrom(
backgroundColor: backgroundColor,
)
: null,
);
if (tooltip != null) {
return Tooltip(
message: tooltip!,
child: button,
);
}
return button;
}
}

View File

@@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
/// Reusable loading indicator widget
///
/// Provides different loading indicator variants:
/// - Circular (default)
/// - Linear
/// - Overlay (full screen with backdrop)
/// - With message
///
/// Usage:
/// ```dart
/// // Simple circular indicator
/// LoadingIndicator()
///
/// // With custom size and color
/// LoadingIndicator(
/// size: 50,
/// color: Colors.blue,
/// )
///
/// // Linear indicator
/// LoadingIndicator.linear()
///
/// // Full screen overlay
/// LoadingIndicator.overlay(
/// message: 'Loading data...',
/// )
///
/// // Centered with message
/// LoadingIndicator.withMessage(
/// message: 'Please wait...',
/// )
/// ```
class LoadingIndicator extends StatelessWidget {
/// Size of the loading indicator
final double? size;
/// Color of the loading indicator
final Color? color;
/// Stroke width for circular indicator
final double strokeWidth;
/// Whether to use linear progress indicator
final bool isLinear;
/// Optional loading message
final String? message;
/// Text style for the message
final TextStyle? messageStyle;
/// Spacing between indicator and message
final double messageSpacing;
const LoadingIndicator({
super.key,
this.size,
this.color,
this.strokeWidth = 4.0,
this.message,
this.messageStyle,
this.messageSpacing = 16.0,
}) : isLinear = false;
/// Create a linear loading indicator
const LoadingIndicator.linear({
super.key,
this.color,
this.message,
this.messageStyle,
this.messageSpacing = 16.0,
}) : isLinear = true,
size = null,
strokeWidth = 4.0;
/// Create a full-screen loading overlay
static Widget overlay({
String? message,
Color? backgroundColor,
Color? indicatorColor,
TextStyle? messageStyle,
}) {
return _LoadingOverlay(
message: message,
backgroundColor: backgroundColor,
indicatorColor: indicatorColor,
messageStyle: messageStyle,
);
}
/// Create a loading indicator with a message below it
static Widget withMessage({
required String message,
double size = 40,
Color? color,
TextStyle? messageStyle,
double spacing = 16.0,
}) {
return LoadingIndicator(
size: size,
color: color,
message: message,
messageStyle: messageStyle,
messageSpacing: spacing,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final indicatorColor = color ?? theme.colorScheme.primary;
Widget indicator;
if (isLinear) {
indicator = LinearProgressIndicator(
color: indicatorColor,
backgroundColor: indicatorColor.withOpacity(0.1),
);
} else {
indicator = SizedBox(
width: size ?? 40,
height: size ?? 40,
child: CircularProgressIndicator(
color: indicatorColor,
strokeWidth: strokeWidth,
),
);
}
// If there's no message, return just the indicator
if (message == null) {
return indicator;
}
// If there's a message, wrap in column
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
indicator,
SizedBox(height: messageSpacing),
Text(
message!,
style: messageStyle ??
theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
);
}
}
/// Full-screen loading overlay
class _LoadingOverlay extends StatelessWidget {
final String? message;
final Color? backgroundColor;
final Color? indicatorColor;
final TextStyle? messageStyle;
const _LoadingOverlay({
this.message,
this.backgroundColor,
this.indicatorColor,
this.messageStyle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: backgroundColor ?? Colors.black.withOpacity(0.5),
child: Center(
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 50,
height: 50,
child: CircularProgressIndicator(
color: indicatorColor ?? theme.colorScheme.primary,
strokeWidth: 4,
),
),
if (message != null) ...[
const SizedBox(height: 24),
SizedBox(
width: 200,
child: Text(
message!,
style: messageStyle ?? theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
],
],
),
),
),
),
);
}
}
/// Shimmer loading effect for list items
class ShimmerLoading extends StatefulWidget {
/// Width of the shimmer container
final double? width;
/// Height of the shimmer container
final double height;
/// Border radius
final double borderRadius;
/// Base color
final Color? baseColor;
/// Highlight color
final Color? highlightColor;
const ShimmerLoading({
super.key,
this.width,
this.height = 16,
this.borderRadius = 4,
this.baseColor,
this.highlightColor,
});
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = widget.baseColor ?? theme.colorScheme.surfaceVariant;
final highlightColor =
widget.highlightColor ?? theme.colorScheme.surface;
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
baseColor,
highlightColor,
baseColor,
],
stops: [
0.0,
_animation.value,
1.0,
],
),
),
);
},
);
}
}
/// Loading state for list items
class ListLoadingIndicator extends StatelessWidget {
/// Number of shimmer items to show
final int itemCount;
/// Height of each item
final double itemHeight;
/// Spacing between items
final double spacing;
const ListLoadingIndicator({
super.key,
this.itemCount = 5,
this.itemHeight = 80,
this.spacing = 12,
});
@override
Widget build(BuildContext context) {
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: itemCount,
separatorBuilder: (context, index) => SizedBox(height: spacing),
itemBuilder: (context, index) => ShimmerLoading(
height: itemHeight,
width: double.infinity,
borderRadius: 8,
),
);
}
}

55
lib/docs/api.sh Normal file
View File

@@ -0,0 +1,55 @@
#Login curl
curl --request POST \
--url https://dotnet.elidev.info:8157/ws/PortalAuth/Login \
--header 'Accept: application/json' \
--header 'content-type: application/json' \
--data '{
"EmailPhone": "yesterday305@gmail.com",
"Password": "123456"
}'
#Get warehouse
curl --request POST \
--url https://dotnet.elidev.info:8157/ws/portalWareHouse/search \
--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/tUJhximq11MDXsTVC14MBElxk5NUqd10eo10/SNoknWov7pSXP7Djq4ITMpe4M6n5jT0RuCkChSJ+YBVfoRj5ooOCA5Cda/lyGRJ9aFUZddI43rz/FuJoAD9CsrbQyNamUw7LIvZloSMk7fPGyb5en+iIw9liv4lNyTYOolHc0jLBQJ6i/XaymB9s2gN3/78ryc7OH2q0VBWFbKCZrHL7L9e55YQFLYylHeg0VUaXHQ5pimWFzDPV4X5PVbENkjF7AAWmvwoJ/z7ebBFyri03MncAw+sxOROEb2RfoP2RfdxslhEDVKkG5qfJQ==' \
--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:143.0) Gecko/20100101 Firefox/143.0' \
--header 'content-type: application/json' \
--data '{
"pageIndex": 0,
"pageSize": 10,
"Name": null,
"Code": null,
"sortExpression": null,
"sortDirection": null
}'
#Get products
curl --request GET \
--url https://dotnet.elidev.info:8157/ws/portalProduct/getAllProduct \
--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 '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'

72
lib/docs/format.json Normal file
View File

@@ -0,0 +1,72 @@
{
"Value": [
{
"Id": 1,
"Name": "Kho nguyên vật liệu",
"Code": "001",
"Description": null,
"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": 8
},
{
"Id": 7,
"Name": "Kho thành phẩm",
"Code": "003",
"Description": null,
"IsNGWareHouse": false,
"TotalCount": 8
},
{
"Id": 8,
"Name": "Kho tiêu hao",
"Code": "004",
"Description": "Để chứa phụ tùng",
"IsNGWareHouse": false,
"TotalCount": 8
},
{
"Id": 9,
"Name": "Kho NG",
"Code": "005",
"Description": "Kho chứa sản phẩm lỗi",
"IsNGWareHouse": false,
"TotalCount": 8
},
{
"Id": 10,
"Name": "Kho bán thành phẩm chờ kiểm",
"Code": "006",
"Description": "Kho bán thành phẩm chờ kiểm",
"IsNGWareHouse": false,
"TotalCount": 8
},
{
"Id": 11,
"Name": "Kho xi mạ",
"Code": "007",
"Description": null,
"IsNGWareHouse": false,
"TotalCount": 8
},
{
"Id": 12,
"Name": "Kho QC",
"Code": "008",
"Description": "Quản lí QC",
"IsNGWareHouse": false,
"TotalCount": 8
}
],
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}

View File

@@ -0,0 +1,384 @@
# Authentication Feature Integration Guide
Quick guide to integrate the authentication feature into the warehouse management app.
## Prerequisites
Ensure these dependencies are in `pubspec.yaml`:
```yaml
dependencies:
flutter_riverpod: ^2.4.9
dartz: ^0.10.1
flutter_secure_storage: ^9.0.0
dio: ^5.3.2
equatable: ^2.0.5
go_router: ^12.1.3
```
## Step 1: Update Main App
### Update `lib/main.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/routing/app_router.dart';
import 'core/theme/app_theme.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Warehouse Manager',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
routerConfig: appRouter,
debugShowCheckedModeBanner: false,
);
}
}
```
## Step 2: Update Router Configuration
### Update `lib/core/routing/app_router.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/auth.dart';
import '../../features/auth/di/auth_dependency_injection.dart';
// Create a global key for navigator
final navigatorKey = GlobalKey<NavigatorState>();
// Router provider
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/login',
routes: [
// Login route
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
// Warehouses route (protected)
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => const WarehouseSelectionPage(), // TODO: Create this
),
// Add more routes as needed...
],
// Redirect logic for authentication
redirect: (context, state) {
// Get auth state from provider container
final container = ProviderScope.containerOf(context);
final authState = container.read(authProvider);
final isAuthenticated = authState.isAuthenticated;
final isLoggingIn = state.matchedLocation == '/login';
// If not authenticated and not on login page, redirect to login
if (!isAuthenticated && !isLoggingIn) {
return '/login';
}
// If authenticated and on login page, redirect to warehouses
if (isAuthenticated && isLoggingIn) {
return '/warehouses';
}
// No redirect needed
return null;
},
);
});
// Export the router instance
final appRouter = GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/login',
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => const Scaffold(
body: Center(child: Text('Warehouses Page - TODO')),
),
),
],
);
```
## Step 3: Configure API Base URL
### Update `lib/core/constants/app_constants.dart`
```dart
class AppConstants {
// API Configuration
static const String apiBaseUrl = 'https://your-api-url.com';
static const int connectionTimeout = 30000;
static const int receiveTimeout = 30000;
static const int sendTimeout = 30000;
// Other constants...
}
```
## Step 4: Create Protected Route Wrapper (Optional)
### Create `lib/core/widgets/protected_route.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/di/auth_dependency_injection.dart';
class ProtectedRoute extends ConsumerWidget {
final Widget child;
const ProtectedRoute({
super.key,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
// Show loading while checking auth
if (authState.isLoading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
// Redirect to login if not authenticated
if (!authState.isAuthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/login');
});
return const SizedBox.shrink();
}
// Show protected content
return child;
}
}
```
## Step 5: Add Logout Button (Optional)
### Example usage in any page:
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:minhthu/features/auth/di/auth_dependency_injection.dart';
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
// Show confirmation dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(authProvider.notifier).logout();
},
child: const Text('Logout'),
),
],
),
);
},
),
],
),
body: const Center(
child: Text('Settings'),
),
);
}
}
```
## Step 6: Handle API Client Setup
### Update `lib/core/di/core_providers.dart` (create if needed)
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../network/api_client.dart';
import '../storage/secure_storage.dart';
/// Provider for SecureStorage singleton
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
/// Provider for ApiClient
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
final apiClient = ApiClient(secureStorage);
// Set up unauthorized callback to handle 401 errors
apiClient.onUnauthorized = () {
// Navigate to login when unauthorized
// This can be enhanced with proper navigation context
};
return apiClient;
});
```
## Step 7: Test the Integration
### Manual Testing Checklist
1. **Login Flow**
- [ ] App starts on login page
- [ ] Form validation works
- [ ] Login with valid credentials succeeds
- [ ] Navigate to warehouses page after login
- [ ] Tokens saved in secure storage
2. **Error Handling**
- [ ] Invalid credentials show error message
- [ ] Network errors display properly
- [ ] Error messages are user-friendly
3. **Persistence**
- [ ] Close and reopen app stays logged in
- [ ] Tokens persisted in secure storage
- [ ] Auto-redirect to warehouses if authenticated
4. **Logout**
- [ ] Logout clears tokens
- [ ] Redirect to login page after logout
- [ ] Cannot access protected routes after logout
5. **Loading States**
- [ ] Loading indicator shows during login
- [ ] Form disabled during loading
- [ ] No double submissions
## Step 8: Environment Configuration (Optional)
### Create `lib/core/config/environment.dart`
```dart
enum Environment {
development,
staging,
production,
}
class EnvironmentConfig {
static Environment current = Environment.development;
static String get apiBaseUrl {
switch (current) {
case Environment.development:
return 'https://dev-api.example.com';
case Environment.staging:
return 'https://staging-api.example.com';
case Environment.production:
return 'https://api.example.com';
}
}
}
```
## Troubleshooting
### Issue: "Provider not found"
**Solution**: Ensure `ProviderScope` wraps your app in `main.dart`
### Issue: "Navigation doesn't work"
**Solution**: Verify router configuration and route names match
### Issue: "Secure storage error"
**Solution**:
- Add platform-specific configurations
- Check app permissions
- Clear app data and reinstall
### Issue: "API calls fail"
**Solution**:
- Verify API base URL in `app_constants.dart`
- Check network connectivity
- Verify API endpoint paths in `api_endpoints.dart`
## Next Steps
1. **Create Warehouse Feature** - Follow similar pattern
2. **Add Token Refresh** - Implement auto token refresh
3. **Add Remember Me** - Optional persistent login
4. **Add Biometric Auth** - Face ID / Touch ID
5. **Add Unit Tests** - Test use cases and repositories
6. **Add Widget Tests** - Test UI components
## Additional Resources
- [Riverpod Documentation](https://riverpod.dev/)
- [Go Router Documentation](https://pub.dev/packages/go_router)
- [Clean Architecture Guide](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Flutter Secure Storage](https://pub.dev/packages/flutter_secure_storage)
## Support
For issues or questions:
1. Check the main README in `lib/features/auth/README.md`
2. Review the CLAUDE.md for project guidelines
3. Check existing code examples in the codebase

View File

@@ -0,0 +1,252 @@
# Authentication Feature - Quick Reference
## Import
```dart
import 'package:minhthu/features/auth/auth.dart';
```
## Common Usage Patterns
### 1. Login
```dart
ref.read(authProvider.notifier).login(username, password);
```
### 2. Logout
```dart
ref.read(authProvider.notifier).logout();
```
### 3. Check if Authenticated
```dart
final isAuthenticated = ref.watch(isAuthenticatedProvider);
```
### 4. Get Current User
```dart
final user = ref.watch(currentUserProvider);
if (user != null) {
print('Logged in as: ${user.username}');
}
```
### 5. Watch Auth State
```dart
final authState = ref.watch(authProvider);
if (authState.isLoading) {
return LoadingIndicator();
}
if (authState.error != null) {
return ErrorView(message: authState.error!);
}
if (authState.isAuthenticated) {
return HomeView(user: authState.user!);
}
return LoginView();
```
### 6. Listen to Auth Changes
```dart
ref.listen(authProvider, (previous, next) {
if (next.isAuthenticated) {
context.go('/home');
} else if (next.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.error!)),
);
}
});
```
## Key Classes
### AuthState
```dart
class AuthState {
final UserEntity? user;
final bool isAuthenticated;
final bool isLoading;
final String? error;
}
```
### UserEntity
```dart
class UserEntity {
final String userId;
final String username;
final String accessToken;
final String? refreshToken;
}
```
### LoginRequestModel
```dart
final request = LoginRequestModel(
username: 'john.doe',
password: 'secure123',
);
```
## Providers
| Provider | Type | Description |
|----------|------|-------------|
| `authProvider` | `StateNotifier<AuthState>` | Main auth state |
| `isAuthenticatedProvider` | `bool` | Check auth status |
| `currentUserProvider` | `UserEntity?` | Get current user |
| `isAuthLoadingProvider` | `bool` | Check loading state |
| `authErrorProvider` | `String?` | Get error message |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/login` | POST | Login |
| `/api/v1/auth/logout` | POST | Logout |
| `/api/v1/auth/refresh` | POST | Refresh token |
## Error Types
- `ValidationFailure` - Invalid input
- `AuthenticationFailure` - Login failed
- `NetworkFailure` - Network error
- `ServerFailure` - Server error
## Protected Route Example
```dart
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
if (!authState.isAuthenticated) {
return LoginPage();
}
return Scaffold(
appBar: AppBar(
title: Text('Protected Page'),
actions: [
IconButton(
icon: Icon(Icons.logout),
onPressed: () => ref.read(authProvider.notifier).logout(),
),
],
),
body: Center(
child: Text('Hello, ${authState.user!.username}!'),
),
);
}
}
```
## Common Patterns
### Check Auth on App Start
```dart
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(authProvider.notifier).checkAuthStatus();
});
}
```
### Show Loading Overlay
```dart
if (ref.watch(isAuthLoadingProvider)) {
return Stack(
children: [
yourContent,
LoadingIndicator.overlay(),
],
);
}
```
### Conditional Navigation
```dart
ref.listen(authProvider, (previous, next) {
if (!previous!.isAuthenticated && next.isAuthenticated) {
context.go('/home');
} else if (previous.isAuthenticated && !next.isAuthenticated) {
context.go('/login');
}
});
```
## Testing Helpers
```dart
// Mock auth state
final mockAuthState = AuthState.authenticated(
UserEntity(
userId: '123',
username: 'test',
accessToken: 'token',
),
);
// Create test container
final container = ProviderContainer(
overrides: [
authProvider.overrideWith((ref) => MockAuthNotifier()),
],
);
```
## Files Reference
| Layer | File | Purpose |
|-------|------|---------|
| **Data** | `login_request_model.dart` | Request DTO |
| | `user_model.dart` | User DTO |
| | `auth_remote_datasource.dart` | API calls |
| | `auth_repository_impl.dart` | Repository impl |
| **Domain** | `user_entity.dart` | Domain entity |
| | `auth_repository.dart` | Repository interface |
| | `login_usecase.dart` | Business logic |
| **Presentation** | `login_page.dart` | Login UI |
| | `login_form.dart` | Form widget |
| | `auth_provider.dart` | State management |
| **DI** | `auth_dependency_injection.dart` | Providers setup |
## Troubleshooting Quick Fixes
| Issue | Solution |
|-------|----------|
| Provider not found | Add `ProviderScope` to main.dart |
| Navigation fails | Check router configuration |
| Tokens not saved | Verify secure storage setup |
| API calls fail | Check base URL in constants |
| State not updating | Use `ConsumerWidget` |
## Performance Tips
1. Use `ref.read()` for one-time operations
2. Use `ref.watch()` for reactive updates
3. Use `ref.listen()` for side effects
4. Avoid rebuilding entire tree - scope providers
5. Use `select()` for partial state watching
## Security Checklist
- [x] Tokens in secure storage
- [x] Password fields obscured
- [x] No logging of sensitive data
- [x] Token auto-added to headers
- [x] Token cleared on logout
- [x] Input validation
- [ ] HTTPS only (configure in production)
- [ ] Token expiration handling
- [ ] Rate limiting
- [ ] Biometric auth (optional)

380
lib/features/auth/README.md Normal file
View File

@@ -0,0 +1,380 @@
# Authentication Feature
Complete authentication implementation following clean architecture principles for the warehouse management app.
## Architecture Overview
```
auth/
├── data/ # Data layer
│ ├── datasources/ # API and local data sources
│ │ └── auth_remote_datasource.dart
│ ├── models/ # Data transfer objects
│ │ ├── login_request_model.dart
│ │ └── user_model.dart
│ ├── repositories/ # Repository implementations
│ │ └── auth_repository_impl.dart
│ └── data.dart # Barrel export
├── domain/ # Domain layer (business logic)
│ ├── entities/ # Business entities
│ │ └── user_entity.dart
│ ├── repositories/ # Repository interfaces
│ │ └── auth_repository.dart
│ ├── usecases/ # Use cases
│ │ └── login_usecase.dart
│ └── domain.dart # Barrel export
├── presentation/ # Presentation layer (UI)
│ ├── pages/ # Screen widgets
│ │ └── login_page.dart
│ ├── providers/ # State management
│ │ └── auth_provider.dart
│ ├── widgets/ # Reusable widgets
│ │ └── login_form.dart
│ └── presentation.dart # Barrel export
├── di/ # Dependency injection
│ └── auth_dependency_injection.dart
├── auth.dart # Main barrel export
└── README.md # This file
```
## Features
### Implemented
- ✅ User login with username/password
- ✅ Token storage in secure storage
- ✅ Authentication state management
- ✅ Form validation
- ✅ Error handling with user-friendly messages
- ✅ Loading states
- ✅ Auto-navigation after successful login
- ✅ Check authentication status on app start
- ✅ Logout functionality
- ✅ Token refresh (prepared for future use)
### Pending
- ⏳ Integration with actual API endpoints
- ⏳ Biometric authentication
- ⏳ Remember me functionality
- ⏳ Password recovery
## Data Flow
### Login Flow
```
1. User enters credentials in LoginPage
2. LoginForm validates input
3. AuthNotifier.login() is called
4. LoginUseCase validates and processes request
5. AuthRepository calls AuthRemoteDataSource
6. API response is converted to UserModel
7. Tokens saved to SecureStorage
8. AuthState updated to authenticated
9. Navigation to warehouses page
```
### Logout Flow
```
1. User triggers logout
2. AuthNotifier.logout() is called
3. LogoutUseCase calls AuthRepository
4. API logout call (optional, can fail)
5. SecureStorage cleared
6. AuthState reset to initial
7. Navigation to login page
```
## Usage
### Basic Import
```dart
import 'package:minhthu/features/auth/auth.dart';
```
### Using in UI
```dart
// In a ConsumerWidget or ConsumerStatefulWidget
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch auth state
final authState = ref.watch(authProvider);
// Check authentication
if (authState.isAuthenticated) {
return AuthenticatedView(user: authState.user!);
}
// Handle loading
if (authState.isLoading) {
return LoadingIndicator();
}
// Show error
if (authState.error != null) {
return ErrorView(message: authState.error!);
}
return LoginView();
}
}
```
### Perform Login
```dart
// In your widget
void handleLogin(String username, String password) {
ref.read(authProvider.notifier).login(username, password);
}
```
### Perform Logout
```dart
void handleLogout() {
ref.read(authProvider.notifier).logout();
}
```
### Check Auth Status
```dart
void checkIfAuthenticated() async {
await ref.read(authProvider.notifier).checkAuthStatus();
}
```
### Listen to Auth Changes
```dart
ref.listen(authProvider, (previous, next) {
if (next.isAuthenticated) {
// Navigate to home
context.go('/home');
} else if (next.error != null) {
// Show error snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.error!)),
);
}
});
```
## API Integration
### Expected API Response Format
```json
{
"Value": {
"userId": "string",
"username": "string",
"accessToken": "string",
"refreshToken": "string"
},
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
### Login Request
```json
POST /api/v1/auth/login
{
"username": "string",
"password": "string"
}
```
### Logout Request
```json
POST /api/v1/auth/logout
Authorization: Bearer {accessToken}
```
### Refresh Token Request
```json
POST /api/v1/auth/refresh
{
"refreshToken": "string"
}
```
## State Management
### AuthState
```dart
class AuthState {
final UserEntity? user; // Current user or null
final bool isAuthenticated; // Authentication status
final bool isLoading; // Loading indicator
final String? error; // Error message
}
```
### State Transitions
```
Initial State → Loading → Authenticated (success)
→ Error (failure)
```
## Testing
### Unit Tests (TODO)
```dart
// Test use cases
test('login with valid credentials returns user', () async {
// Arrange
final useCase = LoginUseCase(mockRepository);
final request = LoginRequestModel(
username: 'testuser',
password: 'password123',
);
// Act
final result = await useCase(request);
// Assert
expect(result.isRight(), true);
});
// Test repository
test('repository saves tokens on successful login', () async {
// Test implementation
});
```
### Widget Tests (TODO)
```dart
testWidgets('login page shows form fields', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(home: LoginPage()),
),
);
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('Username'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
});
```
## Error Handling
### Validation Errors
- Empty username/password
- Username too short (< 3 characters)
- Password too short (< 6 characters)
### Network Errors
- Connection timeout
- No internet connection
- Server unreachable
### Authentication Errors
- Invalid credentials
- Account locked
- Token expired
### Display Errors
All errors are displayed in a user-friendly format in the UI with appropriate styling.
## Security Considerations
### Implemented
- Tokens stored in secure storage (encrypted)
- Password field obscured
- Auth token added to API headers automatically
- Token cleared on logout
- No sensitive data logged
### Best Practices
- Never log passwords or tokens
- Use HTTPS for all API calls
- Implement token refresh before expiration
- Clear sensitive data on logout
- Validate all user input
## Dependencies
### Core Dependencies
- `flutter_riverpod` - State management
- `dartz` - Functional programming (Either type)
- `flutter_secure_storage` - Secure token storage
- `dio` - HTTP client (via ApiClient)
- `equatable` - Value equality
- `go_router` - Navigation
### Internal Dependencies
- `core/network/api_client.dart` - HTTP client wrapper
- `core/storage/secure_storage.dart` - Secure storage wrapper
- `core/errors/failures.dart` - Error types
- `core/errors/exceptions.dart` - Exception types
- `core/widgets/custom_button.dart` - Button widget
- `core/widgets/loading_indicator.dart` - Loading widget
## Troubleshooting
### Common Issues
**Issue: Login always fails**
- Check API endpoint configuration in `api_endpoints.dart`
- Verify API is running and accessible
- Check network connectivity
- Verify request/response format matches API
**Issue: Tokens not persisted**
- Verify secure storage is initialized
- Check device storage permissions
- Clear app data and try again
**Issue: Navigation doesn't work after login**
- Verify router configuration includes `/warehouses` route
- Check if listener in LoginPage is properly set up
- Ensure ProviderScope wraps the app
**Issue: State not updating in UI**
- Ensure using ConsumerWidget or ConsumerStatefulWidget
- Verify provider is being watched, not just read
- Check if state is properly copied in copyWith
## Future Enhancements
### Planned Features
1. **Biometric Authentication**
- Face ID / Touch ID support
- Fallback to password
2. **Token Auto-Refresh**
- Background token refresh
- Seamless reauthentication
3. **Multi-factor Authentication**
- OTP support
- SMS verification
4. **Remember Me**
- Optional persistent login
- Secure device storage
5. **Password Reset**
- Email-based reset flow
- Security questions
## Contributing
When modifying this feature:
1. Follow clean architecture principles
2. Maintain separation of concerns (data/domain/presentation)
3. Add tests for new functionality
4. Update this README with changes
5. Follow existing code style and patterns
## Related Files
- App Router: `lib/core/routing/app_router.dart`
- API Endpoints: `lib/core/constants/api_endpoints.dart`
- App Theme: `lib/core/theme/app_theme.dart`
- Main App: `lib/main.dart`

View File

@@ -0,0 +1,15 @@
/// Barrel file for auth feature exports
///
/// Main entry point for the authentication feature
// Dependency injection
export 'di/auth_dependency_injection.dart';
// Domain layer (public interface)
export 'domain/domain.dart';
// Presentation layer (UI components)
export 'presentation/presentation.dart';
// Data layer (usually not exported publicly, but included for completeness)
// export 'data/data.dart';

View File

@@ -0,0 +1,13 @@
/// Barrel file for auth data layer exports
///
/// Provides clean imports for data layer components
// Data sources
export 'datasources/auth_remote_datasource.dart';
// Models
export 'models/login_request_model.dart';
export 'models/user_model.dart';
// Repositories
export 'repositories/auth_repository_impl.dart';

View File

@@ -0,0 +1,147 @@
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/login_request_model.dart';
import '../models/user_model.dart';
/// Abstract interface for authentication remote data source
///
/// Defines the contract for authentication-related API operations
abstract class AuthRemoteDataSource {
/// Login with username and password
///
/// Throws [ServerException] if the login fails
/// Returns [UserModel] on successful login
Future<UserModel> login(LoginRequestModel request);
/// Logout current user
///
/// Throws [ServerException] if logout fails
Future<void> logout();
/// Refresh access token using refresh token
///
/// Throws [ServerException] if refresh fails
/// Returns new [UserModel] with updated tokens
Future<UserModel> refreshToken(String refreshToken);
}
/// Implementation of AuthRemoteDataSource using ApiClient
///
/// Handles all authentication-related API calls
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final ApiClient apiClient;
AuthRemoteDataSourceImpl(this.apiClient);
@override
Future<UserModel> login(LoginRequestModel request) async {
try {
// Make POST request to login endpoint
final response = await apiClient.post(
ApiEndpoints.login,
data: request.toJson(),
);
// Parse API response with ApiResponse wrapper
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => UserModel.fromJson(
json as Map<String, dynamic>,
username: request.username, // Pass username since API doesn't return it
),
);
// Check if login was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Extract error message from API response
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Login failed';
throw ServerException(
errorMessage,
code: apiResponse.errorCodes.isNotEmpty
? apiResponse.errorCodes.first
: null,
);
}
} on ServerException {
rethrow;
} catch (e) {
throw ServerException('Failed to login: ${e.toString()}');
}
}
@override
Future<void> logout() async {
try {
// Make POST request to logout endpoint
final response = await apiClient.post(ApiEndpoints.logout);
// Parse API response
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
null,
);
// Check if logout was successful
if (!apiResponse.isSuccess) {
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Logout failed';
throw ServerException(
errorMessage,
code: apiResponse.errorCodes.isNotEmpty
? apiResponse.errorCodes.first
: null,
);
}
} on ServerException {
rethrow;
} catch (e) {
throw ServerException('Failed to logout: ${e.toString()}');
}
}
@override
Future<UserModel> refreshToken(String refreshToken) async {
try {
// Make POST request to refresh token endpoint
final response = await apiClient.post(
ApiEndpoints.refreshToken,
data: {'refreshToken': refreshToken},
);
// Parse API response
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => UserModel.fromJson(json as Map<String, dynamic>),
);
// Check if refresh was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Token refresh failed';
throw ServerException(
errorMessage,
code: apiResponse.errorCodes.isNotEmpty
? apiResponse.errorCodes.first
: null,
);
}
} on ServerException {
rethrow;
} catch (e) {
throw ServerException('Failed to refresh token: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
/// Login request model for authentication
///
/// Contains the credentials required for user login
class LoginRequestModel extends Equatable {
/// Username for authentication
final String username;
/// Password for authentication
final String password;
const LoginRequestModel({
required this.username,
required this.password,
});
/// Convert to JSON for API request
Map<String, dynamic> toJson() {
return {
'EmailPhone': username,
'Password': password,
};
}
/// Create a copy with modified fields
LoginRequestModel copyWith({
String? username,
String? password,
}) {
return LoginRequestModel(
username: username ?? this.username,
password: password ?? this.password,
);
}
@override
List<Object?> get props => [username, password];
@override
String toString() => 'LoginRequestModel(username: $username)';
}

View File

@@ -0,0 +1,81 @@
import '../../domain/entities/user_entity.dart';
/// User model that extends UserEntity for data layer
///
/// Handles JSON serialization/deserialization for API responses
class UserModel extends UserEntity {
const UserModel({
required super.userId,
required super.username,
required super.accessToken,
super.refreshToken,
});
/// Create UserModel from JSON response
///
/// Expected JSON format from API:
/// ```json
/// {
/// "AccessToken": "string"
/// }
/// ```
factory UserModel.fromJson(Map<String, dynamic> json, {String? username}) {
return UserModel(
userId: username ?? 'user', // Use username as userId or default
username: username ?? 'user',
accessToken: json['AccessToken'] as String,
refreshToken: null, // API doesn't provide refresh token
);
}
/// Convert UserModel to JSON
Map<String, dynamic> toJson() {
return {
'userId': userId,
'username': username,
'accessToken': accessToken,
'refreshToken': refreshToken,
};
}
/// Create UserModel from UserEntity
factory UserModel.fromEntity(UserEntity entity) {
return UserModel(
userId: entity.userId,
username: entity.username,
accessToken: entity.accessToken,
refreshToken: entity.refreshToken,
);
}
/// Convert to UserEntity
UserEntity toEntity() {
return UserEntity(
userId: userId,
username: username,
accessToken: accessToken,
refreshToken: refreshToken,
);
}
/// Create a copy with modified fields
@override
UserModel copyWith({
String? userId,
String? username,
String? accessToken,
String? refreshToken,
}) {
return UserModel(
userId: userId ?? this.userId,
username: username ?? this.username,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
);
}
@override
String toString() {
return 'UserModel(userId: $userId, username: $username, hasRefreshToken: ${refreshToken != null})';
}
}

View File

@@ -0,0 +1,134 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/storage/secure_storage.dart';
import '../../domain/entities/user_entity.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
import '../models/login_request_model.dart';
/// Implementation of AuthRepository
///
/// Coordinates between remote data source and local storage
/// Handles error conversion from exceptions to failures
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final SecureStorage secureStorage;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.secureStorage,
});
@override
Future<Either<Failure, UserEntity>> login(LoginRequestModel request) async {
try {
// Call remote data source to login
final userModel = await remoteDataSource.login(request);
// Save tokens to secure storage
await secureStorage.saveAccessToken(userModel.accessToken);
await secureStorage.saveUserId(userModel.userId);
await secureStorage.saveUsername(userModel.username);
if (userModel.refreshToken != null) {
await secureStorage.saveRefreshToken(userModel.refreshToken!);
}
// Return user entity
return Right(userModel.toEntity());
} on ServerException catch (e) {
return Left(AuthenticationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Login failed: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> logout() async {
try {
// Call remote data source to logout (optional - can fail silently)
try {
await remoteDataSource.logout();
} catch (e) {
// Ignore remote logout errors, still clear local data
}
// Clear all local authentication data
await secureStorage.clearAll();
return const Right(null);
} catch (e) {
return Left(UnknownFailure('Logout failed: ${e.toString()}'));
}
}
@override
Future<Either<Failure, UserEntity>> refreshToken(String refreshToken) async {
try {
// Call remote data source to refresh token
final userModel = await remoteDataSource.refreshToken(refreshToken);
// Update tokens in secure storage
await secureStorage.saveAccessToken(userModel.accessToken);
if (userModel.refreshToken != null) {
await secureStorage.saveRefreshToken(userModel.refreshToken!);
}
return Right(userModel.toEntity());
} on ServerException catch (e) {
return Left(AuthenticationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Token refresh failed: ${e.toString()}'));
}
}
@override
Future<bool> isAuthenticated() async {
try {
return await secureStorage.isAuthenticated();
} catch (e) {
return false;
}
}
@override
Future<Either<Failure, UserEntity>> getCurrentUser() async {
try {
final userId = await secureStorage.getUserId();
final username = await secureStorage.getUsername();
final accessToken = await secureStorage.getAccessToken();
final refreshToken = await secureStorage.getRefreshToken();
if (userId == null || username == null || accessToken == null) {
return const Left(AuthenticationFailure('No user data found'));
}
final user = UserEntity(
userId: userId,
username: username,
accessToken: accessToken,
refreshToken: refreshToken,
);
return Right(user);
} catch (e) {
return Left(CacheFailure('Failed to get user data: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> clearAuthData() async {
try {
await secureStorage.clearAll();
return const Right(null);
} catch (e) {
return Left(CacheFailure('Failed to clear auth data: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/storage/secure_storage.dart';
import '../data/datasources/auth_remote_datasource.dart';
import '../data/repositories/auth_repository_impl.dart';
import '../domain/repositories/auth_repository.dart';
import '../domain/usecases/login_usecase.dart';
import '../presentation/providers/auth_provider.dart';
/// Dependency injection setup for authentication feature
///
/// This file contains all Riverpod providers for the auth feature
/// following clean architecture principles
// ==================== Data Layer ====================
/// Provider for AuthRemoteDataSource
///
/// Depends on ApiClient from core
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
// TODO: Replace with actual ApiClient provider when available
final apiClient = ApiClient(SecureStorage());
return AuthRemoteDataSourceImpl(apiClient);
});
/// Provider for SecureStorage
///
/// Singleton instance
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
// ==================== Domain Layer ====================
/// Provider for AuthRepository
///
/// Depends on AuthRemoteDataSource and SecureStorage
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final remoteDataSource = ref.watch(authRemoteDataSourceProvider);
final secureStorage = ref.watch(secureStorageProvider);
return AuthRepositoryImpl(
remoteDataSource: remoteDataSource,
secureStorage: secureStorage,
);
});
/// Provider for LoginUseCase
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LoginUseCase(repository);
});
/// Provider for LogoutUseCase
final logoutUseCaseProvider = Provider<LogoutUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LogoutUseCase(repository);
});
/// Provider for CheckAuthStatusUseCase
final checkAuthStatusUseCaseProvider = Provider<CheckAuthStatusUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return CheckAuthStatusUseCase(repository);
});
/// Provider for GetCurrentUserUseCase
final getCurrentUserUseCaseProvider = Provider<GetCurrentUserUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return GetCurrentUserUseCase(repository);
});
/// Provider for RefreshTokenUseCase
final refreshTokenUseCaseProvider = Provider<RefreshTokenUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return RefreshTokenUseCase(repository);
});
// ==================== Presentation Layer ====================
/// Provider for AuthNotifier (State Management)
///
/// This is the main provider that UI will interact with
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final loginUseCase = ref.watch(loginUseCaseProvider);
final logoutUseCase = ref.watch(logoutUseCaseProvider);
final checkAuthStatusUseCase = ref.watch(checkAuthStatusUseCaseProvider);
final getCurrentUserUseCase = ref.watch(getCurrentUserUseCaseProvider);
return AuthNotifier(
loginUseCase: loginUseCase,
logoutUseCase: logoutUseCase,
checkAuthStatusUseCase: checkAuthStatusUseCase,
getCurrentUserUseCase: getCurrentUserUseCase,
);
});
// ==================== Convenience Providers ====================
/// Provider to check if user is authenticated
///
/// Returns boolean indicating authentication status
final isAuthenticatedProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isAuthenticated;
});
/// Provider to get current user
///
/// Returns UserEntity if authenticated, null otherwise
final currentUserProvider = Provider((ref) {
final authState = ref.watch(authProvider);
return authState.user;
});
/// Provider to check if auth operation is loading
final isAuthLoadingProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isLoading;
});
/// Provider to get auth error message
final authErrorProvider = Provider<String?>((ref) {
final authState = ref.watch(authProvider);
return authState.error;
});

View File

@@ -0,0 +1,12 @@
/// Barrel file for auth domain layer exports
///
/// Provides clean imports for domain layer components
// Entities
export 'entities/user_entity.dart';
// Repositories
export 'repositories/auth_repository.dart';
// Use cases
export 'usecases/login_usecase.dart';

View File

@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
/// User entity representing authenticated user in the domain layer
///
/// This is a pure domain model with no external dependencies
class UserEntity extends Equatable {
/// Unique user identifier
final String userId;
/// Username
final String username;
/// Access token for API authentication
final String accessToken;
/// Refresh token for renewing access token
final String? refreshToken;
const UserEntity({
required this.userId,
required this.username,
required this.accessToken,
this.refreshToken,
});
/// Create a copy with modified fields
UserEntity copyWith({
String? userId,
String? username,
String? accessToken,
String? refreshToken,
}) {
return UserEntity(
userId: userId ?? this.userId,
username: username ?? this.username,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
);
}
@override
List<Object?> get props => [userId, username, accessToken, refreshToken];
@override
String toString() {
return 'UserEntity(userId: $userId, username: $username, hasRefreshToken: ${refreshToken != null})';
}
}

View File

@@ -0,0 +1,46 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../data/models/login_request_model.dart';
import '../entities/user_entity.dart';
/// Abstract repository interface for authentication operations
///
/// This defines the contract that the data layer must implement.
/// Returns Either<Failure, Success> for proper error handling.
abstract class AuthRepository {
/// Login with username and password
///
/// Returns [Right(UserEntity)] on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, UserEntity>> login(LoginRequestModel request);
/// Logout current user
///
/// Returns [Right(void)] on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, void>> logout();
/// Refresh access token
///
/// Returns [Right(UserEntity)] with new tokens on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, UserEntity>> refreshToken(String refreshToken);
/// Check if user is authenticated
///
/// Returns true if valid access token exists
Future<bool> isAuthenticated();
/// Get current user from local storage
///
/// Returns [Right(UserEntity)] if user data exists
/// Returns [Left(Failure)] if no user data found
Future<Either<Failure, UserEntity>> getCurrentUser();
/// Clear authentication data (logout locally)
///
/// Returns [Right(void)] on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, void>> clearAuthData();
}

View File

@@ -0,0 +1,126 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../data/models/login_request_model.dart';
import '../entities/user_entity.dart';
import '../repositories/auth_repository.dart';
/// Use case for user login
///
/// Encapsulates the business logic for authentication
/// Validates input, calls repository, and handles the response
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
/// Execute login operation
///
/// [request] - Login credentials (username and password)
///
/// Returns [Right(UserEntity)] on successful login
/// Returns [Left(Failure)] on error:
/// - [ValidationFailure] if credentials are invalid
/// - [AuthenticationFailure] if login fails
/// - [NetworkFailure] if network error occurs
Future<Either<Failure, UserEntity>> call(LoginRequestModel request) async {
// Validate input
final validationError = _validateInput(request);
if (validationError != null) {
return Left(validationError);
}
// Call repository to perform login
return await repository.login(request);
}
/// Validate login request input
///
/// Returns [ValidationFailure] if validation fails, null otherwise
ValidationFailure? _validateInput(LoginRequestModel request) {
// Validate username
if (request.username.trim().isEmpty) {
return const ValidationFailure('Username is required');
}
if (request.username.length < 3) {
return const ValidationFailure('Username must be at least 3 characters');
}
// Validate password
if (request.password.isEmpty) {
return const ValidationFailure('Password is required');
}
if (request.password.length < 6) {
return const ValidationFailure('Password must be at least 6 characters');
}
return null;
}
}
/// Use case for user logout
class LogoutUseCase {
final AuthRepository repository;
LogoutUseCase(this.repository);
/// Execute logout operation
///
/// Returns [Right(void)] on successful logout
/// Returns [Left(Failure)] on error
Future<Either<Failure, void>> call() async {
return await repository.logout();
}
}
/// Use case for checking authentication status
class CheckAuthStatusUseCase {
final AuthRepository repository;
CheckAuthStatusUseCase(this.repository);
/// Check if user is authenticated
///
/// Returns true if user has valid access token
Future<bool> call() async {
return await repository.isAuthenticated();
}
}
/// Use case for getting current user
class GetCurrentUserUseCase {
final AuthRepository repository;
GetCurrentUserUseCase(this.repository);
/// Get current authenticated user
///
/// Returns [Right(UserEntity)] if user is authenticated
/// Returns [Left(Failure)] if no user found or error occurs
Future<Either<Failure, UserEntity>> call() async {
return await repository.getCurrentUser();
}
}
/// Use case for refreshing access token
class RefreshTokenUseCase {
final AuthRepository repository;
RefreshTokenUseCase(this.repository);
/// Refresh access token using refresh token
///
/// [refreshToken] - The refresh token
///
/// Returns [Right(UserEntity)] with new tokens on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, UserEntity>> call(String refreshToken) async {
if (refreshToken.isEmpty) {
return const Left(ValidationFailure('Refresh token is required'));
}
return await repository.refreshToken(refreshToken);
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../di/auth_dependency_injection.dart';
import '../widgets/login_form.dart';
/// Login page for user authentication
///
/// Displays login form and handles authentication flow
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
@override
void initState() {
super.initState();
// Check authentication status on page load
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAuthStatus();
});
}
/// Check if user is already authenticated
Future<void> _checkAuthStatus() async {
ref.read(authProvider.notifier).checkAuthStatus();
}
/// Handle login button press
void _handleLogin(String username, String password) {
ref.read(authProvider.notifier).login(username, password);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final authState = ref.watch(authProvider);
final isLoading = authState.isLoading;
final error = authState.error;
// Listen for authentication state changes
ref.listen(authProvider, (previous, next) {
if (next.isAuthenticated) {
// Navigate to warehouses page on successful login
context.go('/warehouses');
}
});
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo/icon
Icon(
Icons.warehouse_outlined,
size: 80,
color: theme.colorScheme.primary,
),
const SizedBox(height: 24),
// App title
Text(
'Warehouse Manager',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Subtitle
Text(
'Login to continue',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Error message (show before form)
if (error != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
error,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
),
if (error != null) const SizedBox(height: 16),
// Login form (includes button)
LoginForm(
onSubmit: _handleLogin,
isLoading: isLoading,
),
const SizedBox(height: 24),
// Additional info or version
Text(
'Version 1.0.0',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,12 @@
/// Barrel file for auth presentation layer exports
///
/// Provides clean imports for presentation layer components
// Pages
export 'pages/login_page.dart';
// Providers
export 'providers/auth_provider.dart';
// Widgets
export 'widgets/login_form.dart';

View File

@@ -0,0 +1,190 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/login_request_model.dart';
import '../../domain/entities/user_entity.dart';
import '../../domain/usecases/login_usecase.dart';
/// Authentication state
///
/// Represents the current authentication status and user data
class AuthState extends Equatable {
/// Current authenticated user (null if not authenticated)
final UserEntity? user;
/// Whether user is authenticated
final bool isAuthenticated;
/// Whether an authentication operation is in progress
final bool isLoading;
/// Error message if authentication fails
final String? error;
const AuthState({
this.user,
this.isAuthenticated = false,
this.isLoading = false,
this.error,
});
/// Initial state (not authenticated, not loading)
const AuthState.initial()
: user = null,
isAuthenticated = false,
isLoading = false,
error = null;
/// Loading state
const AuthState.loading()
: user = null,
isAuthenticated = false,
isLoading = true,
error = null;
/// Authenticated state with user data
const AuthState.authenticated(UserEntity user)
: user = user,
isAuthenticated = true,
isLoading = false,
error = null;
/// Error state
const AuthState.error(String message)
: user = null,
isAuthenticated = false,
isLoading = false,
error = message;
/// Create a copy with modified fields
AuthState copyWith({
UserEntity? user,
bool? isAuthenticated,
bool? isLoading,
String? error,
}) {
return AuthState(
user: user ?? this.user,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
@override
List<Object?> get props => [user, isAuthenticated, isLoading, error];
@override
String toString() {
return 'AuthState(isAuthenticated: $isAuthenticated, isLoading: $isLoading, error: $error, user: $user)';
}
}
/// Auth state notifier that manages authentication state
///
/// Handles login, logout, and authentication status checks
class AuthNotifier extends StateNotifier<AuthState> {
final LoginUseCase loginUseCase;
final LogoutUseCase logoutUseCase;
final CheckAuthStatusUseCase checkAuthStatusUseCase;
final GetCurrentUserUseCase getCurrentUserUseCase;
AuthNotifier({
required this.loginUseCase,
required this.logoutUseCase,
required this.checkAuthStatusUseCase,
required this.getCurrentUserUseCase,
}) : super(const AuthState.initial());
/// Login with username and password
///
/// Updates state to loading, then either authenticated or error
Future<void> login(String username, String password) async {
// Set loading state
state = const AuthState.loading();
// Create login request
final request = LoginRequestModel(
username: username,
password: password,
);
// Call login use case
final result = await loginUseCase(request);
// Handle result
result.fold(
(failure) {
// Login failed - set error state
state = AuthState.error(failure.message);
},
(user) {
// Login successful - set authenticated state
state = AuthState.authenticated(user);
},
);
}
/// Logout current user
///
/// Clears authentication data and returns to initial state
Future<void> logout() async {
// Set loading state
state = state.copyWith(isLoading: true, error: null);
// Call logout use case
final result = await logoutUseCase();
// Handle result
result.fold(
(failure) {
// Logout failed - but still reset to initial state
// (local data should be cleared even if API call fails)
state = const AuthState.initial();
},
(_) {
// Logout successful - reset to initial state
state = const AuthState.initial();
},
);
}
/// Check authentication status on app start
///
/// Loads user data from storage if authenticated
Future<void> checkAuthStatus() async {
// Check if user is authenticated
final isAuthenticated = await checkAuthStatusUseCase();
if (isAuthenticated) {
// Try to load user data
final result = await getCurrentUserUseCase();
result.fold(
(failure) {
// Failed to load user data - reset to initial state
state = const AuthState.initial();
},
(user) {
// User data loaded - set authenticated state
state = AuthState.authenticated(user);
},
);
} else {
// Not authenticated - initial state
state = const AuthState.initial();
}
}
/// Clear error message
void clearError() {
if (state.error != null) {
state = state.copyWith(error: null);
}
}
/// Reset to initial state
void reset() {
state = const AuthState.initial();
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
/// Reusable login form widget with validation
///
/// Handles username and password input with proper validation
class LoginForm extends StatefulWidget {
/// Callback when login button is pressed
final void Function(String username, String password) onSubmit;
/// Whether the form is in loading state
final bool isLoading;
const LoginForm({
super.key,
required this.onSubmit,
this.isLoading = false,
});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController(text: "yesterday305@gmail.com");
final _passwordController = TextEditingController(text: '123456');
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _handleSubmit() {
// Validate form
if (_formKey.currentState?.validate() ?? false) {
// Call submit callback
widget.onSubmit(
_usernameController.text.trim(),
_passwordController.text,
);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Username field
TextFormField(
controller: _usernameController,
enabled: !widget.isLoading,
decoration: InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
if (value.trim().length < 3) {
return 'Username must be at least 3 characters';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
enabled: !widget.isLoading,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSubmit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 24),
// Login button
FilledButton.icon(
onPressed: widget.isLoading ? null : _handleSubmit,
icon: widget.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login),
label: Text(widget.isLoading ? 'Logging in...' : 'Login'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
);
}
}
/// Simple text field widget for login forms
class LoginTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final IconData? prefixIcon;
final bool obscureText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final String? Function(String?)? validator;
final void Function(String)? onFieldSubmitted;
final bool enabled;
final Widget? suffixIcon;
const LoginTextField({
super.key,
required this.controller,
required this.label,
required this.hint,
this.prefixIcon,
this.obscureText = false,
this.keyboardType,
this.textInputAction,
this.validator,
this.onFieldSubmitted,
this.enabled = true,
this.suffixIcon,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
enabled: enabled,
obscureText: obscureText,
keyboardType: keyboardType,
textInputAction: textInputAction,
onFieldSubmitted: onFieldSubmitted,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: enabled
? Theme.of(context).colorScheme.surface
: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
),
validator: validator,
);
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../warehouse/domain/entities/warehouse_entity.dart';
import '../../../../core/constants/app_constants.dart';
import '../widgets/operation_card.dart';
/// Operation Selection Page
/// Allows users to choose between import and export operations for a selected warehouse
class OperationSelectionPage extends ConsumerWidget {
final WarehouseEntity warehouse;
const OperationSelectionPage({
super.key,
required this.warehouse,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Select Operation'),
elevation: 0,
),
body: SafeArea(
child: Column(
children: [
// Warehouse information header
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppConstants.defaultPadding),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Warehouse',
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
warehouse.name,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.qr_code,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Code: ${warehouse.code}',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 16),
Icon(
Icons.inventory_2_outlined,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Items: ${warehouse.totalCount}',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
// Operation cards
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppConstants.largePadding,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Import Products Card
OperationCard(
title: 'Import Products',
icon: Icons.arrow_downward_rounded,
backgroundColor: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
iconColor: colorScheme.tertiary,
onTap: () => _navigateToProducts(
context,
warehouse,
'import',
),
),
const SizedBox(height: AppConstants.defaultPadding),
// Export Products Card
OperationCard(
title: 'Export Products',
icon: Icons.arrow_upward_rounded,
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
iconColor: colorScheme.primary,
onTap: () => _navigateToProducts(
context,
warehouse,
'export',
),
),
],
),
),
),
],
),
),
);
}
/// Navigate to products page with warehouse and operation type
void _navigateToProducts(
BuildContext context,
WarehouseEntity warehouse,
String operationType,
) {
context.goNamed(
'products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': operationType,
},
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_constants.dart';
/// Reusable operation card widget
/// Large, tappable card with icon and text for operation selection
class OperationCard extends StatelessWidget {
final String title;
final IconData icon;
final VoidCallback onTap;
final Color? backgroundColor;
final Color? iconColor;
const OperationCard({
super.key,
required this.title,
required this.icon,
required this.onTap,
this.backgroundColor,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(
horizontal: AppConstants.defaultPadding,
vertical: AppConstants.smallPadding,
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: Container(
padding: const EdgeInsets.all(AppConstants.largePadding),
height: 180,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon container with background
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: backgroundColor ??
colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 48,
color: iconColor ?? colorScheme.primary,
),
),
const SizedBox(height: AppConstants.defaultPadding),
// Title
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,62 @@
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/product_model.dart';
/// Abstract interface for products remote data source
abstract class ProductsRemoteDataSource {
/// Fetch products from the API
///
/// [warehouseId] - The ID of the warehouse
/// [type] - The operation type ('import' or 'export')
///
/// Returns List<ProductModel>
/// Throws [ServerException] if the API call fails
Future<List<ProductModel>> getProducts(int warehouseId, String type);
}
/// Implementation of ProductsRemoteDataSource using ApiClient
class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
final ApiClient apiClient;
ProductsRemoteDataSourceImpl(this.apiClient);
@override
Future<List<ProductModel>> getProducts(int warehouseId, String type) async {
try {
// Make API call to get all products
final response = await apiClient.get('/portalProduct/getAllProduct');
// Parse the API response using ApiResponse wrapper
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => (json as List)
.map((e) => ProductModel.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 products',
);
}
} 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 products: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,203 @@
import '../../domain/entities/product_entity.dart';
/// Product model - data transfer object
/// Extends ProductEntity and adds serialization capabilities
class ProductModel extends ProductEntity {
const ProductModel({
required super.id,
required super.name,
required super.code,
required super.fullName,
super.description,
super.lotCode,
super.lotNumber,
super.logo,
super.barcode,
required super.quantity,
required super.totalQuantity,
required super.passedQuantity,
super.passedQuantityWeight,
required super.issuedQuantity,
super.issuedQuantityWeight,
required super.piecesInStock,
required super.weightInStock,
required super.weight,
required super.pieces,
required super.conversionRate,
super.percent,
super.price,
required super.isActive,
required super.isConfirm,
super.productStatusId,
required super.productTypeId,
super.orderId,
super.parentId,
super.receiverStageId,
super.order,
super.startDate,
super.endDate,
super.productions,
super.customerProducts,
super.productStages,
super.childrenProducts,
super.productStageWareHouses,
super.productStageDetailWareHouses,
super.productExportExcelSheetDataModels,
super.materialLabels,
super.materials,
super.images,
super.attachmentFiles,
});
/// Create ProductModel from JSON
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['Id'] ?? 0,
name: json['Name'] ?? '',
code: json['Code'] ?? '',
fullName: json['FullName'] ?? '',
description: json['Description'],
lotCode: json['LotCode'],
lotNumber: json['LotNumber'],
logo: json['Logo'],
barcode: json['Barcode'],
quantity: json['Quantity'] ?? 0,
totalQuantity: json['TotalQuantity'] ?? 0,
passedQuantity: json['PassedQuantity'] ?? 0,
passedQuantityWeight: json['PassedQuantityWeight']?.toDouble(),
issuedQuantity: json['IssuedQuantity'] ?? 0,
issuedQuantityWeight: json['IssuedQuantityWeight']?.toDouble(),
piecesInStock: json['PiecesInStock'] ?? 0,
weightInStock: (json['WeightInStock'] ?? 0).toDouble(),
weight: (json['Weight'] ?? 0).toDouble(),
pieces: json['Pieces'] ?? 0,
conversionRate: (json['ConversionRate'] ?? 0).toDouble(),
percent: json['Percent']?.toDouble(),
price: json['Price']?.toDouble(),
isActive: json['IsActive'] ?? true,
isConfirm: json['IsConfirm'] ?? false,
productStatusId: json['ProductStatusId'],
productTypeId: json['ProductTypeId'] ?? 0,
orderId: json['OrderId'],
parentId: json['ParentId'],
receiverStageId: json['ReceiverStageId'],
order: json['Order'],
startDate: json['StartDate'],
endDate: json['EndDate'],
productions: json['Productions'] ?? [],
customerProducts: json['CustomerProducts'] ?? [],
productStages: json['ProductStages'] ?? [],
childrenProducts: json['ChildrenProducts'],
productStageWareHouses: json['ProductStageWareHouses'],
productStageDetailWareHouses: json['ProductStageDetailWareHouses'],
productExportExcelSheetDataModels:
json['ProductExportExcelSheetDataModels'],
materialLabels: json['MaterialLabels'],
materials: json['Materials'],
images: json['Images'],
attachmentFiles: json['AttachmentFiles'],
);
}
/// Convert ProductModel to JSON
Map<String, dynamic> toJson() {
return {
'Id': id,
'Name': name,
'Code': code,
'FullName': fullName,
'Description': description,
'LotCode': lotCode,
'LotNumber': lotNumber,
'Logo': logo,
'Barcode': barcode,
'Quantity': quantity,
'TotalQuantity': totalQuantity,
'PassedQuantity': passedQuantity,
'PassedQuantityWeight': passedQuantityWeight,
'IssuedQuantity': issuedQuantity,
'IssuedQuantityWeight': issuedQuantityWeight,
'PiecesInStock': piecesInStock,
'WeightInStock': weightInStock,
'Weight': weight,
'Pieces': pieces,
'ConversionRate': conversionRate,
'Percent': percent,
'Price': price,
'IsActive': isActive,
'IsConfirm': isConfirm,
'ProductStatusId': productStatusId,
'ProductTypeId': productTypeId,
'OrderId': orderId,
'ParentId': parentId,
'ReceiverStageId': receiverStageId,
'Order': order,
'StartDate': startDate,
'EndDate': endDate,
'Productions': productions,
'CustomerProducts': customerProducts,
'ProductStages': productStages,
'ChildrenProducts': childrenProducts,
'ProductStageWareHouses': productStageWareHouses,
'ProductStageDetailWareHouses': productStageDetailWareHouses,
'ProductExportExcelSheetDataModels': productExportExcelSheetDataModels,
'MaterialLabels': materialLabels,
'Materials': materials,
'Images': images,
'AttachmentFiles': attachmentFiles,
};
}
/// Convert ProductModel to ProductEntity
ProductEntity toEntity() => this;
/// Create ProductModel from ProductEntity
factory ProductModel.fromEntity(ProductEntity entity) {
return ProductModel(
id: entity.id,
name: entity.name,
code: entity.code,
fullName: entity.fullName,
description: entity.description,
lotCode: entity.lotCode,
lotNumber: entity.lotNumber,
logo: entity.logo,
barcode: entity.barcode,
quantity: entity.quantity,
totalQuantity: entity.totalQuantity,
passedQuantity: entity.passedQuantity,
passedQuantityWeight: entity.passedQuantityWeight,
issuedQuantity: entity.issuedQuantity,
issuedQuantityWeight: entity.issuedQuantityWeight,
piecesInStock: entity.piecesInStock,
weightInStock: entity.weightInStock,
weight: entity.weight,
pieces: entity.pieces,
conversionRate: entity.conversionRate,
percent: entity.percent,
price: entity.price,
isActive: entity.isActive,
isConfirm: entity.isConfirm,
productStatusId: entity.productStatusId,
productTypeId: entity.productTypeId,
orderId: entity.orderId,
parentId: entity.parentId,
receiverStageId: entity.receiverStageId,
order: entity.order,
startDate: entity.startDate,
endDate: entity.endDate,
productions: entity.productions,
customerProducts: entity.customerProducts,
productStages: entity.productStages,
childrenProducts: entity.childrenProducts,
productStageWareHouses: entity.productStageWareHouses,
productStageDetailWareHouses: entity.productStageDetailWareHouses,
productExportExcelSheetDataModels:
entity.productExportExcelSheetDataModels,
materialLabels: entity.materialLabels,
materials: entity.materials,
images: entity.images,
attachmentFiles: entity.attachmentFiles,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/product_entity.dart';
import '../../domain/repositories/products_repository.dart';
import '../datasources/products_remote_datasource.dart';
/// Implementation of ProductsRepository
/// Handles data operations and error conversion
class ProductsRepositoryImpl implements ProductsRepository {
final ProductsRemoteDataSource remoteDataSource;
ProductsRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, List<ProductEntity>>> getProducts(
int warehouseId,
String type,
) async {
try {
// Fetch products from remote data source
final products = await remoteDataSource.getProducts(warehouseId, type);
// Convert models to entities and return success
return Right(products.map((model) => model.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,154 @@
import 'package:equatable/equatable.dart';
/// Product entity - pure domain model
/// Represents a product in the warehouse management system
class ProductEntity extends Equatable {
final int id;
final String name;
final String code;
final String fullName;
final String? description;
final String? lotCode;
final String? lotNumber;
final String? logo;
final String? barcode;
// Quantity fields
final int quantity;
final int totalQuantity;
final int passedQuantity;
final double? passedQuantityWeight;
final int issuedQuantity;
final double? issuedQuantityWeight;
final int piecesInStock;
final double weightInStock;
// Weight and pieces
final double weight;
final int pieces;
final double conversionRate;
final double? percent;
// Price and status
final double? price;
final bool isActive;
final bool isConfirm;
final int? productStatusId;
final int productTypeId;
// Relations
final int? orderId;
final int? parentId;
final int? receiverStageId;
final dynamic order;
// Dates
final String? startDate;
final String? endDate;
// Lists
final List<dynamic> productions;
final List<dynamic> customerProducts;
final List<dynamic> productStages;
final dynamic childrenProducts;
final dynamic productStageWareHouses;
final dynamic productStageDetailWareHouses;
final dynamic productExportExcelSheetDataModels;
final dynamic materialLabels;
final dynamic materials;
final dynamic images;
final dynamic attachmentFiles;
const ProductEntity({
required this.id,
required this.name,
required this.code,
required this.fullName,
this.description,
this.lotCode,
this.lotNumber,
this.logo,
this.barcode,
required this.quantity,
required this.totalQuantity,
required this.passedQuantity,
this.passedQuantityWeight,
required this.issuedQuantity,
this.issuedQuantityWeight,
required this.piecesInStock,
required this.weightInStock,
required this.weight,
required this.pieces,
required this.conversionRate,
this.percent,
this.price,
required this.isActive,
required this.isConfirm,
this.productStatusId,
required this.productTypeId,
this.orderId,
this.parentId,
this.receiverStageId,
this.order,
this.startDate,
this.endDate,
this.productions = const [],
this.customerProducts = const [],
this.productStages = const [],
this.childrenProducts,
this.productStageWareHouses,
this.productStageDetailWareHouses,
this.productExportExcelSheetDataModels,
this.materialLabels,
this.materials,
this.images,
this.attachmentFiles,
});
@override
List<Object?> get props => [
id,
name,
code,
fullName,
description,
lotCode,
lotNumber,
logo,
barcode,
quantity,
totalQuantity,
passedQuantity,
passedQuantityWeight,
issuedQuantity,
issuedQuantityWeight,
piecesInStock,
weightInStock,
weight,
pieces,
conversionRate,
percent,
price,
isActive,
isConfirm,
productStatusId,
productTypeId,
orderId,
parentId,
receiverStageId,
order,
startDate,
endDate,
productions,
customerProducts,
productStages,
childrenProducts,
productStageWareHouses,
productStageDetailWareHouses,
productExportExcelSheetDataModels,
materialLabels,
materials,
images,
attachmentFiles,
];
}

View File

@@ -0,0 +1,18 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_entity.dart';
/// Abstract repository interface for products
/// Defines the contract for product data operations
abstract class ProductsRepository {
/// Get products for a specific warehouse and operation type
///
/// [warehouseId] - The ID of the warehouse
/// [type] - The operation type ('import' or 'export')
///
/// Returns Either<Failure, List<ProductEntity>>
Future<Either<Failure, List<ProductEntity>>> getProducts(
int warehouseId,
String type,
);
}

View File

@@ -0,0 +1,25 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_entity.dart';
import '../repositories/products_repository.dart';
/// Use case for getting products
/// Encapsulates the business logic for fetching products
class GetProductsUseCase {
final ProductsRepository repository;
GetProductsUseCase(this.repository);
/// Execute the use case
///
/// [warehouseId] - The ID of the warehouse to get products from
/// [type] - The operation type ('import' or 'export')
///
/// Returns Either<Failure, List<ProductEntity>>
Future<Either<Failure, List<ProductEntity>>> call(
int warehouseId,
String type,
) async {
return await repository.getProducts(warehouseId, type);
}
}

View File

@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../widgets/product_list_item.dart';
/// Products list page
/// Displays products for a specific warehouse and operation type
class ProductsPage extends ConsumerStatefulWidget {
final int warehouseId;
final String warehouseName;
final String operationType;
const ProductsPage({
super.key,
required this.warehouseId,
required this.warehouseName,
required this.operationType,
});
@override
ConsumerState<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends ConsumerState<ProductsPage> {
@override
void initState() {
super.initState();
// Load products when page is initialized
Future.microtask(() {
ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId,
widget.warehouseName,
widget.operationType,
);
});
}
Future<void> _onRefresh() async {
await ref.read(productsProvider.notifier).refreshProducts();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
// Watch the products state
final productsState = ref.watch(productsProvider);
final products = productsState.products;
final isLoading = productsState.isLoading;
final error = productsState.error;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Products (${_getOperationTypeDisplay()})',
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,
products: products,
theme: theme,
),
);
}
/// Build the body based on the current state
Widget _buildBody({
required bool isLoading,
required String? error,
required List products,
required ThemeData theme,
}) {
return Column(
children: [
// Info header
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: Row(
children: [
Icon(
widget.operationType == 'import'
? Icons.arrow_downward
: Icons.arrow_upward,
color: widget.operationType == 'import'
? Colors.green
: Colors.orange,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getOperationTypeDisplay(),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'Warehouse: ${widget.warehouseName}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
// Content area
Expanded(
child: _buildContent(
isLoading: isLoading,
error: error,
products: products,
theme: theme,
),
),
],
);
}
/// Build content based on state
Widget _buildContent({
required bool isLoading,
required String? error,
required List products,
required ThemeData theme,
}) {
// Loading state
if (isLoading && products.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading products...'),
],
),
);
}
// Error state
if (error != null && products.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
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'),
),
],
),
),
);
}
// Empty state
if (products.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No Products',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'No products found for this warehouse and operation type.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
],
),
),
);
}
// Success state - show products list
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
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),
),
);
},
);
},
),
);
}
/// Get display text for operation type
String _getOperationTypeDisplay() {
return widget.operationType == 'import'
? 'Import Products'
: 'Export Products';
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/product_entity.dart';
import '../../domain/usecases/get_products_usecase.dart';
/// Products state class
/// Holds the current state of the products feature
class ProductsState {
final List<ProductEntity> products;
final String operationType;
final int? warehouseId;
final String? warehouseName;
final bool isLoading;
final String? error;
const ProductsState({
this.products = const [],
this.operationType = 'import',
this.warehouseId,
this.warehouseName,
this.isLoading = false,
this.error,
});
ProductsState copyWith({
List<ProductEntity>? products,
String? operationType,
int? warehouseId,
String? warehouseName,
bool? isLoading,
String? error,
}) {
return ProductsState(
products: products ?? this.products,
operationType: operationType ?? this.operationType,
warehouseId: warehouseId ?? this.warehouseId,
warehouseName: warehouseName ?? this.warehouseName,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Products notifier
/// Manages the products state and business logic
class ProductsNotifier extends StateNotifier<ProductsState> {
final GetProductsUseCase getProductsUseCase;
ProductsNotifier(this.getProductsUseCase) : super(const ProductsState());
/// Load products for a specific warehouse and operation type
///
/// [warehouseId] - The ID of the warehouse
/// [warehouseName] - The name of the warehouse (for display)
/// [type] - The operation type ('import' or 'export')
Future<void> loadProducts(
int warehouseId,
String warehouseName,
String type,
) async {
// Set loading state
state = state.copyWith(
isLoading: true,
error: null,
warehouseId: warehouseId,
warehouseName: warehouseName,
operationType: type,
);
// Call the use case
final result = await getProductsUseCase(warehouseId, type);
// Handle the result
result.fold(
(failure) {
// Handle failure
state = state.copyWith(
isLoading: false,
error: failure.message,
products: [],
);
},
(products) {
// Handle success
state = state.copyWith(
isLoading: false,
error: null,
products: products,
);
},
);
}
/// Clear products list
void clearProducts() {
state = const ProductsState();
}
/// Refresh products
Future<void> refreshProducts() async {
if (state.warehouseId != null) {
await loadProducts(
state.warehouseId!,
state.warehouseName ?? '',
state.operationType,
);
}
}
}

View File

@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import '../../domain/entities/product_entity.dart';
/// Reusable product list item widget
/// Displays key product information in a card layout
class ProductListItem extends StatelessWidget {
final ProductEntity product;
final VoidCallback? onTap;
const ProductListItem({
super.key,
required this.product,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product name and code
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.fullName,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Code: ${product.code}',
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
// Active status indicator
if (product.isActive)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Active',
style: textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Weight and pieces information
Row(
children: [
Expanded(
child: _InfoItem(
label: 'Weight',
value: '${product.weight.toStringAsFixed(2)} kg',
icon: Icons.fitness_center,
),
),
const SizedBox(width: 16),
Expanded(
child: _InfoItem(
label: 'Pieces',
value: product.pieces.toString(),
icon: Icons.inventory_2,
),
),
],
),
const SizedBox(height: 12),
// In stock information
Row(
children: [
Expanded(
child: _InfoItem(
label: 'In Stock (Pieces)',
value: product.piecesInStock.toString(),
icon: Icons.warehouse,
color: product.piecesInStock > 0
? Colors.green
: Colors.orange,
),
),
const SizedBox(width: 16),
Expanded(
child: _InfoItem(
label: 'In Stock (Weight)',
value: '${product.weightInStock.toStringAsFixed(2)} kg',
icon: Icons.scale,
color: product.weightInStock > 0
? Colors.green
: Colors.orange,
),
),
],
),
const SizedBox(height: 12),
// Conversion rate
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Conversion Rate',
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
product.conversionRate.toStringAsFixed(2),
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
// Barcode if available
if (product.barcode != null && product.barcode!.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.qr_code,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Barcode: ${product.barcode}',
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
],
),
),
),
);
}
}
/// Helper widget for displaying info items
class _InfoItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color? color;
const _InfoItem({
required this.label,
required this.value,
required this.icon,
this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final effectiveColor = color ?? theme.colorScheme.primary;
return Row(
children: [
Icon(
icon,
size: 20,
color: effectiveColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
value,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: effectiveColor,
),
),
],
),
),
],
);
}
}

View File

@@ -1,6 +0,0 @@
// Data layer exports
export 'datasources/scanner_local_datasource.dart';
export 'datasources/scanner_remote_datasource.dart';
export 'models/save_request_model.dart';
export 'models/scan_item.dart';
export 'repositories/scanner_repository_impl.dart';

View File

@@ -1,229 +0,0 @@
import 'package:hive_ce/hive.dart';
import '../../../../core/errors/exceptions.dart';
import '../models/scan_item.dart';
/// Abstract local data source for scanner operations
abstract class ScannerLocalDataSource {
/// Save scan to local storage
Future<void> saveScan(ScanItem scan);
/// Get all scans from local storage
Future<List<ScanItem>> getAllScans();
/// Get scan by barcode from local storage
Future<ScanItem?> getScanByBarcode(String barcode);
/// Update scan in local storage
Future<void> updateScan(ScanItem scan);
/// Delete scan from local storage
Future<void> deleteScan(String barcode);
/// Clear all scans from local storage
Future<void> clearAllScans();
}
/// Implementation of ScannerLocalDataSource using Hive
class ScannerLocalDataSourceImpl implements ScannerLocalDataSource {
static const String _boxName = 'scans';
Box<ScanItem>? _box;
/// Initialize Hive box
Future<Box<ScanItem>> _getBox() async {
if (_box == null || !_box!.isOpen) {
try {
_box = await Hive.openBox<ScanItem>(_boxName);
} catch (e) {
throw CacheException('Failed to open Hive box: ${e.toString()}');
}
}
return _box!;
}
@override
Future<void> saveScan(ScanItem scan) async {
try {
final box = await _getBox();
// Use barcode as key to avoid duplicates
await box.put(scan.barcode, scan);
// Optional: Log the save operation
// print('Scan saved locally: ${scan.barcode}');
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to save scan locally: ${e.toString()}');
}
}
@override
Future<List<ScanItem>> getAllScans() async {
try {
final box = await _getBox();
// Get all values from the box
final scans = box.values.toList();
// Sort by timestamp (most recent first)
scans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return scans;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scans from local storage: ${e.toString()}');
}
}
@override
Future<ScanItem?> getScanByBarcode(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final box = await _getBox();
// Get scan by barcode key
return box.get(barcode);
} on ValidationException {
rethrow;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scan by barcode: ${e.toString()}');
}
}
@override
Future<void> updateScan(ScanItem scan) async {
try {
final box = await _getBox();
// Check if scan exists
if (!box.containsKey(scan.barcode)) {
throw CacheException('Scan with barcode ${scan.barcode} not found');
}
// Update the scan
await box.put(scan.barcode, scan);
// Optional: Log the update operation
// print('Scan updated locally: ${scan.barcode}');
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to update scan locally: ${e.toString()}');
}
}
@override
Future<void> deleteScan(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final box = await _getBox();
// Check if scan exists
if (!box.containsKey(barcode)) {
throw CacheException('Scan with barcode $barcode not found');
}
// Delete the scan
await box.delete(barcode);
// Optional: Log the delete operation
// print('Scan deleted locally: $barcode');
} on ValidationException {
rethrow;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to delete scan locally: ${e.toString()}');
}
}
@override
Future<void> clearAllScans() async {
try {
final box = await _getBox();
// Clear all scans
await box.clear();
// Optional: Log the clear operation
// print('All scans cleared from local storage');
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to clear all scans: ${e.toString()}');
}
}
/// Get scans count (utility method)
Future<int> getScansCount() async {
try {
final box = await _getBox();
return box.length;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scans count: ${e.toString()}');
}
}
/// Check if scan exists (utility method)
Future<bool> scanExists(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return false;
}
final box = await _getBox();
return box.containsKey(barcode);
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to check if scan exists: ${e.toString()}');
}
}
/// Get scans within date range (utility method)
Future<List<ScanItem>> getScansByDateRange({
required DateTime startDate,
required DateTime endDate,
}) async {
try {
final allScans = await getAllScans();
// Filter by date range
final filteredScans = allScans.where((scan) {
return scan.timestamp.isAfter(startDate) &&
scan.timestamp.isBefore(endDate);
}).toList();
return filteredScans;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scans by date range: ${e.toString()}');
}
}
/// Close the Hive box (call this when app is closing)
Future<void> dispose() async {
if (_box != null && _box!.isOpen) {
await _box!.close();
_box = null;
}
}
}

View File

@@ -1,148 +0,0 @@
import '../../../../core/network/api_client.dart';
import '../../../../core/errors/exceptions.dart';
import '../models/save_request_model.dart';
/// Abstract remote data source for scanner operations
abstract class ScannerRemoteDataSource {
/// Save scan data to remote server
Future<void> saveScan(SaveRequestModel request);
/// Get scan data from remote server (optional for future use)
Future<Map<String, dynamic>?> getScanData(String barcode);
}
/// Implementation of ScannerRemoteDataSource using HTTP API
class ScannerRemoteDataSourceImpl implements ScannerRemoteDataSource {
final ApiClient apiClient;
ScannerRemoteDataSourceImpl({required this.apiClient});
@override
Future<void> saveScan(SaveRequestModel request) async {
try {
// Validate request before sending
if (!request.isValid) {
throw ValidationException('Invalid request data: ${request.validationErrors.join(', ')}');
}
final response = await apiClient.post(
'/api/scans',
data: request.toJson(),
);
// Check if the response indicates success
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to save scan: $errorMessage');
}
// Log successful save (in production, use proper logging)
// print('Scan saved successfully: ${request.barcode}');
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
// Handle any unexpected errors
throw ServerException('Unexpected error occurred while saving scan: ${e.toString()}');
}
}
@override
Future<Map<String, dynamic>?> getScanData(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final response = await apiClient.get(
'/api/scans/$barcode',
);
if (response.statusCode == 404) {
// Scan not found is not an error, just return null
return null;
}
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to get scan data: $errorMessage');
}
return response.data as Map<String, dynamic>?;
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw ServerException('Unexpected error occurred while getting scan data: ${e.toString()}');
}
}
/// Update scan data on remote server (optional for future use)
Future<void> updateScan(String barcode, SaveRequestModel request) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
if (!request.isValid) {
throw ValidationException('Invalid request data: ${request.validationErrors.join(', ')}');
}
final response = await apiClient.put(
'/api/scans/$barcode',
data: request.toJson(),
);
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to update scan: $errorMessage');
}
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw ServerException('Unexpected error occurred while updating scan: ${e.toString()}');
}
}
/// Delete scan data from remote server (optional for future use)
Future<void> deleteScan(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final response = await apiClient.delete('/api/scans/$barcode');
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to delete scan: $errorMessage');
}
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw ServerException('Unexpected error occurred while deleting scan: ${e.toString()}');
}
}
}

View File

@@ -1,134 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/scan_entity.dart';
part 'save_request_model.g.dart';
/// API request model for saving scan data to the server
@JsonSerializable()
class SaveRequestModel {
final String barcode;
final String field1;
final String field2;
final String field3;
final String field4;
SaveRequestModel({
required this.barcode,
required this.field1,
required this.field2,
required this.field3,
required this.field4,
});
/// Create from domain entity
factory SaveRequestModel.fromEntity(ScanEntity entity) {
return SaveRequestModel(
barcode: entity.barcode,
field1: entity.field1,
field2: entity.field2,
field3: entity.field3,
field4: entity.field4,
);
}
/// Create from parameters
factory SaveRequestModel.fromParams({
required String barcode,
required String field1,
required String field2,
required String field3,
required String field4,
}) {
return SaveRequestModel(
barcode: barcode,
field1: field1,
field2: field2,
field3: field3,
field4: field4,
);
}
/// Create from JSON
factory SaveRequestModel.fromJson(Map<String, dynamic> json) =>
_$SaveRequestModelFromJson(json);
/// Convert to JSON for API requests
Map<String, dynamic> toJson() => _$SaveRequestModelToJson(this);
/// Create a copy with updated fields
SaveRequestModel copyWith({
String? barcode,
String? field1,
String? field2,
String? field3,
String? field4,
}) {
return SaveRequestModel(
barcode: barcode ?? this.barcode,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
);
}
/// Validate the request data
bool get isValid {
return barcode.trim().isNotEmpty &&
field1.trim().isNotEmpty &&
field2.trim().isNotEmpty &&
field3.trim().isNotEmpty &&
field4.trim().isNotEmpty;
}
/// Get validation errors
List<String> get validationErrors {
final errors = <String>[];
if (barcode.trim().isEmpty) {
errors.add('Barcode is required');
}
if (field1.trim().isEmpty) {
errors.add('Field 1 is required');
}
if (field2.trim().isEmpty) {
errors.add('Field 2 is required');
}
if (field3.trim().isEmpty) {
errors.add('Field 3 is required');
}
if (field4.trim().isEmpty) {
errors.add('Field 4 is required');
}
return errors;
}
@override
String toString() {
return 'SaveRequestModel{barcode: $barcode, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SaveRequestModel &&
runtimeType == other.runtimeType &&
barcode == other.barcode &&
field1 == other.field1 &&
field2 == other.field2 &&
field3 == other.field3 &&
field4 == other.field4;
@override
int get hashCode =>
barcode.hashCode ^
field1.hashCode ^
field2.hashCode ^
field3.hashCode ^
field4.hashCode;
}

View File

@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'save_request_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SaveRequestModel _$SaveRequestModelFromJson(Map<String, dynamic> json) =>
SaveRequestModel(
barcode: json['barcode'] as String,
field1: json['field1'] as String,
field2: json['field2'] as String,
field3: json['field3'] as String,
field4: json['field4'] as String,
);
Map<String, dynamic> _$SaveRequestModelToJson(SaveRequestModel instance) =>
<String, dynamic>{
'barcode': instance.barcode,
'field1': instance.field1,
'field2': instance.field2,
'field3': instance.field3,
'field4': instance.field4,
};

View File

@@ -1,131 +0,0 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/scan_entity.dart';
part 'scan_item.g.dart';
/// Data model for ScanEntity with Hive annotations for local storage
/// This is the data layer representation that can be persisted
@HiveType(typeId: 0)
class ScanItem extends HiveObject {
@HiveField(0)
final String barcode;
@HiveField(1)
final DateTime timestamp;
@HiveField(2)
final String field1;
@HiveField(3)
final String field2;
@HiveField(4)
final String field3;
@HiveField(5)
final String field4;
ScanItem({
required this.barcode,
required this.timestamp,
this.field1 = '',
this.field2 = '',
this.field3 = '',
this.field4 = '',
});
/// Convert from domain entity to data model
factory ScanItem.fromEntity(ScanEntity entity) {
return ScanItem(
barcode: entity.barcode,
timestamp: entity.timestamp,
field1: entity.field1,
field2: entity.field2,
field3: entity.field3,
field4: entity.field4,
);
}
/// Convert to domain entity
ScanEntity toEntity() {
return ScanEntity(
barcode: barcode,
timestamp: timestamp,
field1: field1,
field2: field2,
field3: field3,
field4: field4,
);
}
/// Create from JSON (useful for API responses)
factory ScanItem.fromJson(Map<String, dynamic> json) {
return ScanItem(
barcode: json['barcode'] ?? '',
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'])
: DateTime.now(),
field1: json['field1'] ?? '',
field2: json['field2'] ?? '',
field3: json['field3'] ?? '',
field4: json['field4'] ?? '',
);
}
/// Convert to JSON (useful for API requests)
Map<String, dynamic> toJson() {
return {
'barcode': barcode,
'timestamp': timestamp.toIso8601String(),
'field1': field1,
'field2': field2,
'field3': field3,
'field4': field4,
};
}
/// Create a copy with updated fields
ScanItem copyWith({
String? barcode,
DateTime? timestamp,
String? field1,
String? field2,
String? field3,
String? field4,
}) {
return ScanItem(
barcode: barcode ?? this.barcode,
timestamp: timestamp ?? this.timestamp,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
);
}
@override
String toString() {
return 'ScanItem{barcode: $barcode, timestamp: $timestamp, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScanItem &&
runtimeType == other.runtimeType &&
barcode == other.barcode &&
timestamp == other.timestamp &&
field1 == other.field1 &&
field2 == other.field2 &&
field3 == other.field3 &&
field4 == other.field4;
@override
int get hashCode =>
barcode.hashCode ^
timestamp.hashCode ^
field1.hashCode ^
field2.hashCode ^
field3.hashCode ^
field4.hashCode;
}

View File

@@ -1,56 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scan_item.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ScanItemAdapter extends TypeAdapter<ScanItem> {
@override
final int typeId = 0;
@override
ScanItem read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ScanItem(
barcode: fields[0] as String,
timestamp: fields[1] as DateTime,
field1: fields[2] as String,
field2: fields[3] as String,
field3: fields[4] as String,
field4: fields[5] as String,
);
}
@override
void write(BinaryWriter writer, ScanItem obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.barcode)
..writeByte(1)
..write(obj.timestamp)
..writeByte(2)
..write(obj.field1)
..writeByte(3)
..write(obj.field2)
..writeByte(4)
..write(obj.field3)
..writeByte(5)
..write(obj.field4);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScanItemAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,265 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
import '../../domain/entities/scan_entity.dart';
import '../../domain/repositories/scanner_repository.dart';
import '../datasources/scanner_local_datasource.dart';
import '../datasources/scanner_remote_datasource.dart';
import '../models/save_request_model.dart';
import '../models/scan_item.dart';
/// Implementation of ScannerRepository
/// This class handles the coordination between remote and local data sources
class ScannerRepositoryImpl implements ScannerRepository {
final ScannerRemoteDataSource remoteDataSource;
final ScannerLocalDataSource localDataSource;
ScannerRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, void>> saveScan({
required String barcode,
required String field1,
required String field2,
required String field3,
required String field4,
}) async {
try {
// Create the request model
final request = SaveRequestModel.fromParams(
barcode: barcode,
field1: field1,
field2: field2,
field3: field3,
field4: field4,
);
// Validate the request
if (!request.isValid) {
return Left(ValidationFailure(request.validationErrors.join(', ')));
}
// Save to remote server
await remoteDataSource.saveScan(request);
// If remote save succeeds, we return success
// Local save will be handled separately by the use case if needed
return const Right(null);
} on ValidationException catch (e) {
return Left(ValidationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to save scan: ${e.toString()}'));
}
}
@override
Future<Either<Failure, List<ScanEntity>>> getScanHistory() async {
try {
// Get scans from local storage
final scanItems = await localDataSource.getAllScans();
// Convert to domain entities
final entities = scanItems.map((item) => item.toEntity()).toList();
return Right(entities);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> saveScanLocally(ScanEntity scan) async {
try {
// Convert entity to data model
final scanItem = ScanItem.fromEntity(scan);
// Save to local storage
await localDataSource.saveScan(scanItem);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to save scan locally: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> deleteScanLocally(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return const Left(ValidationFailure('Barcode cannot be empty'));
}
// Delete from local storage
await localDataSource.deleteScan(barcode);
return const Right(null);
} on ValidationException catch (e) {
return Left(ValidationFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to delete scan: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> clearScanHistory() async {
try {
// Clear all scans from local storage
await localDataSource.clearAllScans();
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to clear scan history: ${e.toString()}'));
}
}
@override
Future<Either<Failure, ScanEntity?>> getScanByBarcode(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return const Left(ValidationFailure('Barcode cannot be empty'));
}
// Get scan from local storage
final scanItem = await localDataSource.getScanByBarcode(barcode);
if (scanItem == null) {
return const Right(null);
}
// Convert to domain entity
final entity = scanItem.toEntity();
return Right(entity);
} on ValidationException catch (e) {
return Left(ValidationFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to get scan by barcode: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> updateScanLocally(ScanEntity scan) async {
try {
// Convert entity to data model
final scanItem = ScanItem.fromEntity(scan);
// Update in local storage
await localDataSource.updateScan(scanItem);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to update scan: ${e.toString()}'));
}
}
/// Additional utility methods for repository
/// Get scans count
Future<Either<Failure, int>> getScansCount() async {
try {
if (localDataSource is ScannerLocalDataSourceImpl) {
final impl = localDataSource as ScannerLocalDataSourceImpl;
final count = await impl.getScansCount();
return Right(count);
}
// Fallback: get all scans and count them
final result = await getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) => Right(scans.length),
);
} catch (e) {
return Left(UnknownFailure('Failed to get scans count: ${e.toString()}'));
}
}
/// Check if scan exists locally
Future<Either<Failure, bool>> scanExistsLocally(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return const Left(ValidationFailure('Barcode cannot be empty'));
}
if (localDataSource is ScannerLocalDataSourceImpl) {
final impl = localDataSource as ScannerLocalDataSourceImpl;
final exists = await impl.scanExists(barcode);
return Right(exists);
}
// Fallback: get scan by barcode
final result = await getScanByBarcode(barcode);
return result.fold(
(failure) => Left(failure),
(scan) => Right(scan != null),
);
} catch (e) {
return Left(UnknownFailure('Failed to check if scan exists: ${e.toString()}'));
}
}
/// Get scans by date range
Future<Either<Failure, List<ScanEntity>>> getScansByDateRange({
required DateTime startDate,
required DateTime endDate,
}) async {
try {
if (localDataSource is ScannerLocalDataSourceImpl) {
final impl = localDataSource as ScannerLocalDataSourceImpl;
final scanItems = await impl.getScansByDateRange(
startDate: startDate,
endDate: endDate,
);
// Convert to domain entities
final entities = scanItems.map((item) => item.toEntity()).toList();
return Right(entities);
}
// Fallback: get all scans and filter
final result = await getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
final filteredScans = scans
.where((scan) =>
scan.timestamp.isAfter(startDate) &&
scan.timestamp.isBefore(endDate))
.toList();
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scans by date range: ${e.toString()}'));
}
}
}

View File

@@ -1,5 +0,0 @@
// Domain layer exports
export 'entities/scan_entity.dart';
export 'repositories/scanner_repository.dart';
export 'usecases/get_scan_history_usecase.dart';
export 'usecases/save_scan_usecase.dart';

View File

@@ -1,71 +0,0 @@
import 'package:equatable/equatable.dart';
/// Domain entity representing a scan item
/// This is the business logic representation without any external dependencies
class ScanEntity extends Equatable {
final String barcode;
final DateTime timestamp;
final String field1;
final String field2;
final String field3;
final String field4;
const ScanEntity({
required this.barcode,
required this.timestamp,
this.field1 = '',
this.field2 = '',
this.field3 = '',
this.field4 = '',
});
/// Create a copy with updated fields
ScanEntity copyWith({
String? barcode,
DateTime? timestamp,
String? field1,
String? field2,
String? field3,
String? field4,
}) {
return ScanEntity(
barcode: barcode ?? this.barcode,
timestamp: timestamp ?? this.timestamp,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
);
}
/// Check if the entity has any form data
bool get hasFormData {
return field1.isNotEmpty ||
field2.isNotEmpty ||
field3.isNotEmpty ||
field4.isNotEmpty;
}
/// Check if all form fields are filled
bool get isFormComplete {
return field1.isNotEmpty &&
field2.isNotEmpty &&
field3.isNotEmpty &&
field4.isNotEmpty;
}
@override
List<Object> get props => [
barcode,
timestamp,
field1,
field2,
field3,
field4,
];
@override
String toString() {
return 'ScanEntity{barcode: $barcode, timestamp: $timestamp, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
}

View File

@@ -1,34 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/scan_entity.dart';
/// Abstract repository interface for scanner operations
/// This defines the contract that the data layer must implement
abstract class ScannerRepository {
/// Save scan data to remote server
Future<Either<Failure, void>> saveScan({
required String barcode,
required String field1,
required String field2,
required String field3,
required String field4,
});
/// Get scan history from local storage
Future<Either<Failure, List<ScanEntity>>> getScanHistory();
/// Save scan to local storage
Future<Either<Failure, void>> saveScanLocally(ScanEntity scan);
/// Delete a scan from local storage
Future<Either<Failure, void>> deleteScanLocally(String barcode);
/// Clear all scan history from local storage
Future<Either<Failure, void>> clearScanHistory();
/// Get a specific scan by barcode from local storage
Future<Either<Failure, ScanEntity?>> getScanByBarcode(String barcode);
/// Update a scan in local storage
Future<Either<Failure, void>> updateScanLocally(ScanEntity scan);
}

View File

@@ -1,113 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/scan_entity.dart';
import '../repositories/scanner_repository.dart';
/// Use case for retrieving scan history
/// Handles the business logic for fetching scan history from local storage
class GetScanHistoryUseCase {
final ScannerRepository repository;
GetScanHistoryUseCase(this.repository);
/// Execute the get scan history operation
///
/// Returns a list of scan entities sorted by timestamp (most recent first)
Future<Either<Failure, List<ScanEntity>>> call() async {
try {
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Sort scans by timestamp (most recent first)
final sortedScans = List<ScanEntity>.from(scans);
sortedScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(sortedScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
/// Get scan history filtered by date range
Future<Either<Failure, List<ScanEntity>>> getHistoryInDateRange({
required DateTime startDate,
required DateTime endDate,
}) async {
try {
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Filter scans by date range
final filteredScans = scans
.where((scan) =>
scan.timestamp.isAfter(startDate) &&
scan.timestamp.isBefore(endDate))
.toList();
// Sort by timestamp (most recent first)
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
/// Get scans that have form data (non-empty fields)
Future<Either<Failure, List<ScanEntity>>> getScansWithFormData() async {
try {
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Filter scans that have form data
final filteredScans = scans.where((scan) => scan.hasFormData).toList();
// Sort by timestamp (most recent first)
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
/// Search scans by barcode pattern
Future<Either<Failure, List<ScanEntity>>> searchByBarcode(String pattern) async {
try {
if (pattern.trim().isEmpty) {
return const Right([]);
}
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Filter scans by barcode pattern (case-insensitive)
final filteredScans = scans
.where((scan) =>
scan.barcode.toLowerCase().contains(pattern.toLowerCase()))
.toList();
// Sort by timestamp (most recent first)
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to search scans: ${e.toString()}'));
}
}
}

View File

@@ -1,109 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/scan_entity.dart';
import '../repositories/scanner_repository.dart';
/// Use case for saving scan data
/// Handles the business logic for saving scan information to both remote and local storage
class SaveScanUseCase {
final ScannerRepository repository;
SaveScanUseCase(this.repository);
/// Execute the save scan operation
///
/// First saves to remote server, then saves locally only if remote save succeeds
/// This ensures data consistency and allows for offline-first behavior
Future<Either<Failure, void>> call(SaveScanParams params) async {
// Validate input parameters
final validationResult = _validateParams(params);
if (validationResult != null) {
return Left(ValidationFailure(validationResult));
}
try {
// Save to remote server first
final remoteResult = await repository.saveScan(
barcode: params.barcode,
field1: params.field1,
field2: params.field2,
field3: params.field3,
field4: params.field4,
);
return remoteResult.fold(
(failure) => Left(failure),
(_) async {
// If remote save succeeds, save to local storage
final scanEntity = ScanEntity(
barcode: params.barcode,
timestamp: DateTime.now(),
field1: params.field1,
field2: params.field2,
field3: params.field3,
field4: params.field4,
);
final localResult = await repository.saveScanLocally(scanEntity);
return localResult.fold(
(failure) {
// Log the local save failure but don't fail the entire operation
// since remote save succeeded
return const Right(null);
},
(_) => const Right(null),
);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to save scan: ${e.toString()}'));
}
}
/// Validate the input parameters
String? _validateParams(SaveScanParams params) {
if (params.barcode.trim().isEmpty) {
return 'Barcode cannot be empty';
}
if (params.field1.trim().isEmpty) {
return 'Field 1 cannot be empty';
}
if (params.field2.trim().isEmpty) {
return 'Field 2 cannot be empty';
}
if (params.field3.trim().isEmpty) {
return 'Field 3 cannot be empty';
}
if (params.field4.trim().isEmpty) {
return 'Field 4 cannot be empty';
}
return null;
}
}
/// Parameters for the SaveScanUseCase
class SaveScanParams {
final String barcode;
final String field1;
final String field2;
final String field3;
final String field4;
SaveScanParams({
required this.barcode,
required this.field1,
required this.field2,
required this.field3,
required this.field4,
});
@override
String toString() {
return 'SaveScanParams{barcode: $barcode, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
}

View File

@@ -1,334 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/models/scan_item.dart';
import '../providers/form_provider.dart';
import '../providers/scanner_provider.dart';
/// Detail page for editing scan data with 4 text fields and Save/Print buttons
class DetailPage extends ConsumerStatefulWidget {
final String barcode;
const DetailPage({
required this.barcode,
super.key,
});
@override
ConsumerState<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends ConsumerState<DetailPage> {
late final TextEditingController _field1Controller;
late final TextEditingController _field2Controller;
late final TextEditingController _field3Controller;
late final TextEditingController _field4Controller;
@override
void initState() {
super.initState();
_field1Controller = TextEditingController();
_field2Controller = TextEditingController();
_field3Controller = TextEditingController();
_field4Controller = TextEditingController();
// Initialize controllers with existing data if available
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadExistingData();
});
}
@override
void dispose() {
_field1Controller.dispose();
_field2Controller.dispose();
_field3Controller.dispose();
_field4Controller.dispose();
super.dispose();
}
/// Load existing data from history if available
void _loadExistingData() {
final history = ref.read(scanHistoryProvider);
final existingScan = history.firstWhere(
(item) => item.barcode == widget.barcode,
orElse: () => ScanItem(barcode: widget.barcode, timestamp: DateTime.now()),
);
_field1Controller.text = existingScan.field1;
_field2Controller.text = existingScan.field2;
_field3Controller.text = existingScan.field3;
_field4Controller.text = existingScan.field4;
// Update form provider with existing data
final formNotifier = ref.read(formProviderFamily(widget.barcode).notifier);
formNotifier.populateWithScanItem(existingScan);
}
@override
Widget build(BuildContext context) {
final formState = ref.watch(formProviderFamily(widget.barcode));
final formNotifier = ref.read(formProviderFamily(widget.barcode).notifier);
// Listen to form state changes for navigation
ref.listen<FormDetailState>(
formProviderFamily(widget.barcode),
(previous, next) {
if (next.isSaveSuccess && (previous?.isSaveSuccess != true)) {
_showSuccessAndNavigateBack(context);
}
},
);
return Scaffold(
appBar: AppBar(
title: const Text('Edit Details'),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: Column(
children: [
// Barcode Header
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Barcode',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
widget.barcode,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
),
),
// Form Fields
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Field 1
_buildTextField(
controller: _field1Controller,
label: 'Field 1',
onChanged: formNotifier.updateField1,
),
const SizedBox(height: 16),
// Field 2
_buildTextField(
controller: _field2Controller,
label: 'Field 2',
onChanged: formNotifier.updateField2,
),
const SizedBox(height: 16),
// Field 3
_buildTextField(
controller: _field3Controller,
label: 'Field 3',
onChanged: formNotifier.updateField3,
),
const SizedBox(height: 16),
// Field 4
_buildTextField(
controller: _field4Controller,
label: 'Field 4',
onChanged: formNotifier.updateField4,
),
const SizedBox(height: 24),
// Error Message
if (formState.error != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.error,
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
formState.error!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
const SizedBox(height: 16),
],
],
),
),
),
// Action Buttons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: SafeArea(
child: Row(
children: [
// Save Button
Expanded(
child: ElevatedButton(
onPressed: formState.isLoading ? null : () => _saveData(formNotifier),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
minimumSize: const Size.fromHeight(48),
),
child: formState.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Save'),
),
),
const SizedBox(width: 16),
// Print Button
Expanded(
child: OutlinedButton(
onPressed: formState.isLoading ? null : () => _printData(formNotifier),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('Print'),
),
),
],
),
),
),
],
),
);
}
/// Build text field widget
Widget _buildTextField({
required TextEditingController controller,
required String label,
required void Function(String) onChanged,
}) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
),
textCapitalization: TextCapitalization.sentences,
onChanged: onChanged,
);
}
/// Save form data
Future<void> _saveData(FormNotifier formNotifier) async {
// Clear any previous errors
formNotifier.clearError();
// Attempt to save
await formNotifier.saveData();
}
/// Print form data
Future<void> _printData(FormNotifier formNotifier) async {
try {
await formNotifier.printData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Print dialog opened'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Print failed: ${e.toString()}'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
/// Show success message and navigate back
void _showSuccessAndNavigateBack(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Data saved successfully!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
// Navigate back after a short delay
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) {
context.pop();
}
});
}
}

View File

@@ -1,193 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/scanner_provider.dart';
import '../widgets/barcode_scanner_widget.dart';
import '../widgets/scan_result_display.dart';
import '../widgets/scan_history_list.dart';
/// Home page with barcode scanner, result display, and history list
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scannerState = ref.watch(scannerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Barcode Scanner'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
ref.read(scannerProvider.notifier).refreshHistory();
},
tooltip: 'Refresh History',
),
],
),
body: Column(
children: [
// Barcode Scanner Section (Top Half)
Expanded(
flex: 1,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const BarcodeScannerWidget(),
),
),
// Scan Result Display
ScanResultDisplay(
barcode: scannerState.currentBarcode,
onTap: scannerState.currentBarcode != null
? () => _navigateToDetail(context, scannerState.currentBarcode!)
: null,
),
// Divider
const Divider(height: 1),
// History Section (Bottom Half)
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// History Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Scan History',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (scannerState.history.isNotEmpty)
Text(
'${scannerState.history.length} items',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
// History List
Expanded(
child: _buildHistorySection(context, ref, scannerState),
),
],
),
),
),
],
),
);
}
/// Build history section based on current state
Widget _buildHistorySection(
BuildContext context,
WidgetRef ref,
ScannerState scannerState,
) {
if (scannerState.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (scannerState.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading history',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
scannerState.error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
ref.read(scannerProvider.notifier).refreshHistory();
},
child: const Text('Retry'),
),
],
),
);
}
if (scannerState.history.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.qr_code_scanner,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No scans yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Start scanning barcodes to see your history here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
return ScanHistoryList(
history: scannerState.history,
onItemTap: (scanItem) => _navigateToDetail(context, scanItem.barcode),
);
}
/// Navigate to detail page with barcode
void _navigateToDetail(BuildContext context, String barcode) {
context.push('/detail/$barcode');
}
}

View File

@@ -1,127 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce/hive.dart';
import '../../../../core/network/api_client.dart';
import '../../data/datasources/scanner_local_datasource.dart';
import '../../data/datasources/scanner_remote_datasource.dart';
import '../../data/models/scan_item.dart';
import '../../data/repositories/scanner_repository_impl.dart';
import '../../domain/repositories/scanner_repository.dart';
import '../../domain/usecases/get_scan_history_usecase.dart';
import '../../domain/usecases/save_scan_usecase.dart';
/// Network layer providers
final dioProvider = Provider<Dio>((ref) {
final dio = Dio();
dio.options.baseUrl = 'https://api.example.com'; // Replace with actual API URL
dio.options.connectTimeout = const Duration(seconds: 30);
dio.options.receiveTimeout = const Duration(seconds: 30);
dio.options.headers['Content-Type'] = 'application/json';
dio.options.headers['Accept'] = 'application/json';
// Add interceptors for logging, authentication, etc.
dio.interceptors.add(
LogInterceptor(
requestBody: true,
responseBody: true,
logPrint: (obj) {
// Log to console in debug mode using debugPrint
// This will only log in debug mode
},
),
);
return dio;
});
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
/// Local storage providers
final hiveBoxProvider = Provider<Box<ScanItem>>((ref) {
return Hive.box<ScanItem>('scans');
});
/// Settings box provider
final settingsBoxProvider = Provider<Box>((ref) {
return Hive.box('settings');
});
/// Data source providers
final scannerRemoteDataSourceProvider = Provider<ScannerRemoteDataSource>((ref) {
return ScannerRemoteDataSourceImpl(apiClient: ref.watch(apiClientProvider));
});
final scannerLocalDataSourceProvider = Provider<ScannerLocalDataSource>((ref) {
return ScannerLocalDataSourceImpl();
});
/// Repository providers
final scannerRepositoryProvider = Provider<ScannerRepository>((ref) {
return ScannerRepositoryImpl(
remoteDataSource: ref.watch(scannerRemoteDataSourceProvider),
localDataSource: ref.watch(scannerLocalDataSourceProvider),
);
});
/// Use case providers
final saveScanUseCaseProvider = Provider<SaveScanUseCase>((ref) {
return SaveScanUseCase(ref.watch(scannerRepositoryProvider));
});
final getScanHistoryUseCaseProvider = Provider<GetScanHistoryUseCase>((ref) {
return GetScanHistoryUseCase(ref.watch(scannerRepositoryProvider));
});
/// Additional utility providers
final currentTimestampProvider = Provider<DateTime>((ref) {
return DateTime.now();
});
/// Provider for checking network connectivity
final networkStatusProvider = Provider<bool>((ref) {
// This would typically use connectivity_plus package
// For now, returning true as a placeholder
return true;
});
/// Provider for app configuration
final appConfigProvider = Provider<Map<String, dynamic>>((ref) {
return {
'apiBaseUrl': 'https://api.example.com',
'apiTimeout': 30000,
'maxHistoryItems': 100,
'enableLogging': !const bool.fromEnvironment('dart.vm.product'),
};
});
/// Provider for error handling configuration
final errorHandlingConfigProvider = Provider<Map<String, String>>((ref) {
return {
'networkError': 'Network connection failed. Please check your internet connection.',
'serverError': 'Server error occurred. Please try again later.',
'validationError': 'Please check your input and try again.',
'unknownError': 'An unexpected error occurred. Please try again.',
};
});
/// Provider for checking if required dependencies are initialized
final dependenciesInitializedProvider = Provider<bool>((ref) {
try {
// Check if all critical dependencies are available
ref.read(scannerRepositoryProvider);
ref.read(saveScanUseCaseProvider);
ref.read(getScanHistoryUseCaseProvider);
return true;
} catch (e) {
return false;
}
});
/// Helper provider for getting localized error messages
final errorMessageProvider = Provider.family<String, String>((ref, errorKey) {
final config = ref.watch(errorHandlingConfigProvider);
return config[errorKey] ?? config['unknownError']!;
});

View File

@@ -1,253 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/scan_item.dart';
import '../../domain/usecases/save_scan_usecase.dart';
import 'dependency_injection.dart';
import 'scanner_provider.dart';
/// State for the form functionality
class FormDetailState {
final String barcode;
final String field1;
final String field2;
final String field3;
final String field4;
final bool isLoading;
final bool isSaveSuccess;
final String? error;
const FormDetailState({
required this.barcode,
this.field1 = '',
this.field2 = '',
this.field3 = '',
this.field4 = '',
this.isLoading = false,
this.isSaveSuccess = false,
this.error,
});
FormDetailState copyWith({
String? barcode,
String? field1,
String? field2,
String? field3,
String? field4,
bool? isLoading,
bool? isSaveSuccess,
String? error,
}) {
return FormDetailState(
barcode: barcode ?? this.barcode,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
isLoading: isLoading ?? this.isLoading,
isSaveSuccess: isSaveSuccess ?? this.isSaveSuccess,
error: error,
);
}
/// Check if all required fields are filled
bool get isValid {
return barcode.trim().isNotEmpty &&
field1.trim().isNotEmpty &&
field2.trim().isNotEmpty &&
field3.trim().isNotEmpty &&
field4.trim().isNotEmpty;
}
/// Get validation error messages
List<String> get validationErrors {
final errors = <String>[];
if (barcode.trim().isEmpty) {
errors.add('Barcode is required');
}
if (field1.trim().isEmpty) {
errors.add('Field 1 is required');
}
if (field2.trim().isEmpty) {
errors.add('Field 2 is required');
}
if (field3.trim().isEmpty) {
errors.add('Field 3 is required');
}
if (field4.trim().isEmpty) {
errors.add('Field 4 is required');
}
return errors;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FormDetailState &&
runtimeType == other.runtimeType &&
barcode == other.barcode &&
field1 == other.field1 &&
field2 == other.field2 &&
field3 == other.field3 &&
field4 == other.field4 &&
isLoading == other.isLoading &&
isSaveSuccess == other.isSaveSuccess &&
error == other.error;
@override
int get hashCode =>
barcode.hashCode ^
field1.hashCode ^
field2.hashCode ^
field3.hashCode ^
field4.hashCode ^
isLoading.hashCode ^
isSaveSuccess.hashCode ^
error.hashCode;
}
/// Form state notifier
class FormNotifier extends StateNotifier<FormDetailState> {
final SaveScanUseCase _saveScanUseCase;
final Ref _ref;
FormNotifier(
this._saveScanUseCase,
this._ref,
String barcode,
) : super(FormDetailState(barcode: barcode));
/// Update field 1
void updateField1(String value) {
state = state.copyWith(field1: value, error: null);
}
/// Update field 2
void updateField2(String value) {
state = state.copyWith(field2: value, error: null);
}
/// Update field 3
void updateField3(String value) {
state = state.copyWith(field3: value, error: null);
}
/// Update field 4
void updateField4(String value) {
state = state.copyWith(field4: value, error: null);
}
/// Update barcode
void updateBarcode(String value) {
state = state.copyWith(barcode: value, error: null);
}
/// Clear all fields
void clearFields() {
state = FormDetailState(barcode: state.barcode);
}
/// Populate form with existing scan data
void populateWithScanItem(ScanItem scanItem) {
state = state.copyWith(
barcode: scanItem.barcode,
field1: scanItem.field1,
field2: scanItem.field2,
field3: scanItem.field3,
field4: scanItem.field4,
error: null,
);
}
/// Save form data to server and local storage
Future<void> saveData() async {
if (!state.isValid) {
final errors = state.validationErrors;
state = state.copyWith(error: errors.join(', '));
return;
}
state = state.copyWith(isLoading: true, error: null, isSaveSuccess: false);
final params = SaveScanParams(
barcode: state.barcode,
field1: state.field1,
field2: state.field2,
field3: state.field3,
field4: state.field4,
);
final result = await _saveScanUseCase.call(params);
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
isSaveSuccess: false,
),
(_) {
state = state.copyWith(
isLoading: false,
isSaveSuccess: true,
error: null,
);
// Update the scanner history with saved data
final savedScanItem = ScanItem(
barcode: state.barcode,
timestamp: DateTime.now(),
field1: state.field1,
field2: state.field2,
field3: state.field3,
field4: state.field4,
);
_ref.read(scannerProvider.notifier).updateScanItem(savedScanItem);
},
);
}
/// Print form data
Future<void> printData() async {
try {
} catch (e) {
state = state.copyWith(error: 'Failed to print: ${e.toString()}');
}
}
/// Clear error message
void clearError() {
state = state.copyWith(error: null);
}
/// Reset save success state
void resetSaveSuccess() {
state = state.copyWith(isSaveSuccess: false);
}
}
/// Provider factory for form state (requires barcode parameter)
final formProviderFamily = StateNotifierProvider.family<FormNotifier, FormDetailState, String>(
(ref, barcode) => FormNotifier(
ref.watch(saveScanUseCaseProvider),
ref,
barcode,
),
);
/// Convenience provider for accessing form state with a specific barcode
/// This should be used with Provider.of or ref.watch(formProvider(barcode))
Provider<FormNotifier> formProvider(String barcode) {
return Provider<FormNotifier>((ref) {
return ref.watch(formProviderFamily(barcode).notifier);
});
}
/// Convenience provider for accessing form state
Provider<FormDetailState> formStateProvider(String barcode) {
return Provider<FormDetailState>((ref) {
return ref.watch(formProviderFamily(barcode));
});
}

View File

@@ -1,163 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/scan_item.dart';
import '../../domain/usecases/get_scan_history_usecase.dart';
import 'dependency_injection.dart';
/// State for the scanner functionality
class ScannerState {
final String? currentBarcode;
final List<ScanItem> history;
final bool isLoading;
final String? error;
const ScannerState({
this.currentBarcode,
this.history = const [],
this.isLoading = false,
this.error,
});
ScannerState copyWith({
String? currentBarcode,
List<ScanItem>? history,
bool? isLoading,
String? error,
}) {
return ScannerState(
currentBarcode: currentBarcode ?? this.currentBarcode,
history: history ?? this.history,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScannerState &&
runtimeType == other.runtimeType &&
currentBarcode == other.currentBarcode &&
history == other.history &&
isLoading == other.isLoading &&
error == other.error;
@override
int get hashCode =>
currentBarcode.hashCode ^
history.hashCode ^
isLoading.hashCode ^
error.hashCode;
}
/// Scanner state notifier
class ScannerNotifier extends StateNotifier<ScannerState> {
final GetScanHistoryUseCase _getScanHistoryUseCase;
ScannerNotifier(this._getScanHistoryUseCase) : super(const ScannerState()) {
_loadHistory();
}
/// Load scan history from local storage
Future<void> _loadHistory() async {
state = state.copyWith(isLoading: true, error: null);
final result = await _getScanHistoryUseCase();
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
),
(history) => state = state.copyWith(
isLoading: false,
history: history.map((entity) => ScanItem.fromEntity(entity)).toList(),
),
);
}
/// Update current scanned barcode
void updateBarcode(String barcode) {
if (barcode.trim().isEmpty) return;
state = state.copyWith(currentBarcode: barcode);
// Add to history if not already present
final existingIndex = state.history.indexWhere((item) => item.barcode == barcode);
if (existingIndex == -1) {
final newScanItem = ScanItem(
barcode: barcode,
timestamp: DateTime.now(),
);
final updatedHistory = [newScanItem, ...state.history];
state = state.copyWith(history: updatedHistory);
} else {
// Move existing item to top
final existingItem = state.history[existingIndex];
final updatedHistory = List<ScanItem>.from(state.history);
updatedHistory.removeAt(existingIndex);
updatedHistory.insert(0, existingItem.copyWith(timestamp: DateTime.now()));
state = state.copyWith(history: updatedHistory);
}
}
/// Clear current barcode
void clearBarcode() {
state = state.copyWith(currentBarcode: null);
}
/// Refresh history from storage
Future<void> refreshHistory() async {
await _loadHistory();
}
/// Add or update scan item in history
void updateScanItem(ScanItem scanItem) {
final existingIndex = state.history.indexWhere(
(item) => item.barcode == scanItem.barcode,
);
List<ScanItem> updatedHistory;
if (existingIndex != -1) {
// Update existing item
updatedHistory = List<ScanItem>.from(state.history);
updatedHistory[existingIndex] = scanItem;
} else {
// Add new item at the beginning
updatedHistory = [scanItem, ...state.history];
}
state = state.copyWith(history: updatedHistory);
}
/// Clear error message
void clearError() {
state = state.copyWith(error: null);
}
}
/// Provider for scanner state
final scannerProvider = StateNotifierProvider<ScannerNotifier, ScannerState>(
(ref) => ScannerNotifier(
ref.watch(getScanHistoryUseCaseProvider),
),
);
/// Provider for current barcode (for easy access)
final currentBarcodeProvider = Provider<String?>((ref) {
return ref.watch(scannerProvider).currentBarcode;
});
/// Provider for scan history (for easy access)
final scanHistoryProvider = Provider<List<ScanItem>>((ref) {
return ref.watch(scannerProvider).history;
});
/// Provider for scanner loading state
final scannerLoadingProvider = Provider<bool>((ref) {
return ref.watch(scannerProvider).isLoading;
});
/// Provider for scanner error state
final scannerErrorProvider = Provider<String?>((ref) {
return ref.watch(scannerProvider).error;
});

View File

@@ -1,344 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../providers/scanner_provider.dart';
/// Widget that provides barcode scanning functionality using device camera
class BarcodeScannerWidget extends ConsumerStatefulWidget {
const BarcodeScannerWidget({super.key});
@override
ConsumerState<BarcodeScannerWidget> createState() => _BarcodeScannerWidgetState();
}
class _BarcodeScannerWidgetState extends ConsumerState<BarcodeScannerWidget>
with WidgetsBindingObserver {
late MobileScannerController _controller;
bool _isStarted = false;
String? _lastScannedCode;
DateTime? _lastScanTime;
bool _isTorchOn = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controller = MobileScannerController(
formats: [
BarcodeFormat.code128,
],
facing: CameraFacing.back,
torchEnabled: false,
);
_startScanner();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.paused:
_stopScanner();
break;
case AppLifecycleState.resumed:
_startScanner();
break;
case AppLifecycleState.detached:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break;
}
}
Future<void> _startScanner() async {
if (!_isStarted && mounted) {
try {
await _controller.start();
setState(() {
_isStarted = true;
});
} catch (e) {
debugPrint('Failed to start scanner: $e');
}
}
}
Future<void> _stopScanner() async {
if (_isStarted) {
try {
await _controller.stop();
setState(() {
_isStarted = false;
});
} catch (e) {
debugPrint('Failed to stop scanner: $e');
}
}
}
void _onBarcodeDetected(BarcodeCapture capture) {
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final barcode = barcodes.first;
final code = barcode.rawValue;
if (code != null && code.isNotEmpty) {
// Prevent duplicate scans within 2 seconds
final now = DateTime.now();
if (_lastScannedCode == code &&
_lastScanTime != null &&
now.difference(_lastScanTime!).inSeconds < 2) {
return;
}
_lastScannedCode = code;
_lastScanTime = now;
// Update scanner provider with new barcode
ref.read(scannerProvider.notifier).updateBarcode(code);
// Provide haptic feedback
_provideHapticFeedback();
}
}
}
void _provideHapticFeedback() {
// Haptic feedback is handled by the system
// You can add custom vibration here if needed
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(0),
),
child: Stack(
children: [
// Camera View
ClipRRect(
borderRadius: BorderRadius.circular(0),
child: MobileScanner(
controller: _controller,
onDetect: _onBarcodeDetected,
),
),
// Overlay with scanner frame
_buildScannerOverlay(context),
// Control buttons
_buildControlButtons(context),
],
),
);
}
/// Build scanner overlay with frame and guidance
Widget _buildScannerOverlay(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Stack(
children: [
// Dark overlay with cutout
Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: Container(
width: 250,
height: 150,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
color: Colors.transparent,
),
),
),
),
),
// Instructions
Positioned(
bottom: 60,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Text(
'Position barcode within the frame',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
/// Build control buttons (torch, camera switch)
Widget _buildControlButtons(BuildContext context) {
return Positioned(
top: 16,
right: 16,
child: Column(
children: [
// Torch Toggle
Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
_isTorchOn ? Icons.flash_on : Icons.flash_off,
color: Colors.white,
),
onPressed: _toggleTorch,
),
),
const SizedBox(height: 12),
// Camera Switch
Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(
Icons.cameraswitch,
color: Colors.white,
),
onPressed: _switchCamera,
),
),
],
),
);
}
/// Build error widget when camera fails
Widget _buildErrorWidget(MobileScannerException error) {
return Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt_outlined,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Camera Error',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_getErrorMessage(error),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _restartScanner,
child: const Text('Retry'),
),
],
),
),
);
}
/// Build placeholder while camera is loading
Widget _buildPlaceholderWidget() {
return Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
);
}
/// Get user-friendly error message
String _getErrorMessage(MobileScannerException error) {
switch (error.errorCode) {
case MobileScannerErrorCode.permissionDenied:
return 'Camera permission is required to scan barcodes. Please enable camera access in settings.';
case MobileScannerErrorCode.unsupported:
return 'Your device does not support barcode scanning.';
default:
return 'Unable to access camera. Please check your device settings and try again.';
}
}
/// Toggle torch/flashlight
void _toggleTorch() async {
try {
await _controller.toggleTorch();
setState(() {
_isTorchOn = !_isTorchOn;
});
} catch (e) {
debugPrint('Failed to toggle torch: $e');
}
}
/// Switch between front and back camera
void _switchCamera() async {
try {
await _controller.switchCamera();
} catch (e) {
debugPrint('Failed to switch camera: $e');
}
}
/// Restart scanner after error
void _restartScanner() async {
try {
await _controller.stop();
await _controller.start();
setState(() {
_isStarted = true;
});
} catch (e) {
debugPrint('Failed to restart scanner: $e');
}
}
}

View File

@@ -1,236 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../data/models/scan_item.dart';
/// Widget to display a scrollable list of scan history items
class ScanHistoryList extends StatelessWidget {
final List<ScanItem> history;
final Function(ScanItem)? onItemTap;
final Function(ScanItem)? onItemLongPress;
final bool showTimestamp;
const ScanHistoryList({
required this.history,
this.onItemTap,
this.onItemLongPress,
this.showTimestamp = true,
super.key,
});
@override
Widget build(BuildContext context) {
if (history.isEmpty) {
return _buildEmptyState(context);
}
return ListView.builder(
itemCount: history.length,
padding: const EdgeInsets.only(top: 8),
itemBuilder: (context, index) {
final scanItem = history[index];
return _buildHistoryItem(context, scanItem, index);
},
);
}
/// Build individual history item
Widget _buildHistoryItem(BuildContext context, ScanItem scanItem, int index) {
final hasData = scanItem.field1.isNotEmpty ||
scanItem.field2.isNotEmpty ||
scanItem.field3.isNotEmpty ||
scanItem.field4.isNotEmpty;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
elevation: 1,
child: InkWell(
onTap: onItemTap != null ? () => onItemTap!(scanItem) : null,
onLongPress: onItemLongPress != null ? () => onItemLongPress!(scanItem) : null,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: [
// Icon indicating scan status
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: hasData
? Colors.green.withOpacity(0.1)
: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
hasData ? Icons.check_circle : Icons.qr_code,
size: 20,
color: hasData
? Colors.green
: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
// Barcode and details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barcode
Text(
scanItem.barcode,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Status and timestamp
Row(
children: [
// Status indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: hasData
? Colors.green.withOpacity(0.2)
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
hasData ? 'Saved' : 'Scanned',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: hasData
? Colors.green.shade700
: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
if (showTimestamp) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
_formatTimestamp(scanItem.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
// Data preview (if available)
if (hasData) ...[
const SizedBox(height: 4),
Text(
_buildDataPreview(scanItem),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Chevron icon
if (onItemTap != null)
Icon(
Icons.chevron_right,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
/// Build empty state when no history is available
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'No scan history',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Scanned barcodes will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
/// Format timestamp for display
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays > 0) {
return DateFormat('MMM dd, yyyy').format(timestamp);
} else if (difference.inHours > 0) {
return '${difference.inHours}h ago';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}m ago';
} else {
return 'Just now';
}
}
/// Build preview of saved data
String _buildDataPreview(ScanItem scanItem) {
final fields = [
scanItem.field1,
scanItem.field2,
scanItem.field3,
scanItem.field4,
].where((field) => field.isNotEmpty).toList();
if (fields.isEmpty) {
return 'No data saved';
}
return fields.join('');
}
}

View File

@@ -1,240 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Widget to display the most recent scan result with tap to edit functionality
class ScanResultDisplay extends StatelessWidget {
final String? barcode;
final VoidCallback? onTap;
final VoidCallback? onCopy;
const ScanResultDisplay({
required this.barcode,
this.onTap,
this.onCopy,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: barcode != null ? _buildScannedResult(context) : _buildEmptyState(context),
);
}
/// Build widget when barcode is scanned
Widget _buildScannedResult(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
width: 1,
),
),
child: Row(
children: [
// Barcode icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.qr_code,
size: 24,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
// Barcode text and label
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Last Scanned',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
barcode!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (onTap != null) ...[
const SizedBox(height: 4),
Text(
'Tap to edit',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
// Action buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Copy button
IconButton(
icon: Icon(
Icons.copy,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onPressed: () => _copyToClipboard(context),
tooltip: 'Copy to clipboard',
visualDensity: VisualDensity.compact,
),
// Edit button (if tap is enabled)
if (onTap != null)
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
);
}
/// Build empty state when no barcode is scanned
Widget _buildEmptyState(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Placeholder icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.qr_code_scanner,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
// Placeholder text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No barcode scanned',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
'Point camera at barcode to scan',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
// Scan animation (optional visual feedback)
_buildScanAnimation(context),
],
),
);
}
/// Build scanning animation indicator
Widget _buildScanAnimation(BuildContext context) {
return TweenAnimationBuilder<double>(
duration: const Duration(seconds: 2),
tween: Tween(begin: 0.0, end: 1.0),
builder: (context, value, child) {
return Opacity(
opacity: (1.0 - value).clamp(0.3, 1.0),
child: Container(
width: 4,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
);
},
onEnd: () {
// Restart animation (this creates a continuous effect)
},
);
}
/// Copy barcode to clipboard
void _copyToClipboard(BuildContext context) {
if (barcode != null) {
Clipboard.setData(ClipboardData(text: barcode!));
// Show feedback
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied "$barcode" to clipboard'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'OK',
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
// Call custom onCopy callback if provided
onCopy?.call();
}
}

View File

@@ -0,0 +1,398 @@
# 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

@@ -0,0 +1,649 @@
# 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

@@ -0,0 +1,76 @@
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/warehouse_model.dart';
/// Abstract interface for warehouse remote data source
abstract class WarehouseRemoteDataSource {
/// Get all warehouses from the API
///
/// Returns [List<WarehouseModel>] on success
/// Throws [ServerException] on API errors
/// Throws [NetworkException] on network errors
Future<List<WarehouseModel>> getWarehouses();
}
/// Implementation of warehouse remote data source
/// Uses ApiClient to make HTTP requests to the backend
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
final ApiClient apiClient;
WarehouseRemoteDataSourceImpl(this.apiClient);
@override
Future<List<WarehouseModel>> getWarehouses() async {
try {
// Make POST request to /portalWareHouse/search endpoint
final response = await apiClient.post(
'/portalWareHouse/search',
data: {
'pageIndex': 0,
'pageSize': 100,
'Name': null,
'Code': null,
'sortExpression': null,
'sortDirection': null,
},
);
// Parse the API response wrapper
final apiResponse = ApiResponse.fromJson(
response.data,
(json) {
// Handle the list of warehouses
if (json is List) {
return json.map((e) => WarehouseModel.fromJson(e)).toList();
}
throw const ServerException('Invalid response format: expected List');
},
);
// Check if API call was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Extract error message from API response
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get warehouses';
throw ServerException(
errorMessage,
code: apiResponse.firstErrorCode,
);
}
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
// Wrap any unexpected errors
throw ServerException(
'Unexpected error while fetching warehouses: ${e.toString()}',
);
}
}
}

View File

@@ -0,0 +1,100 @@
import '../../domain/entities/warehouse_entity.dart';
/// Warehouse data model
/// Extends domain entity and adds JSON serialization
/// Matches the API response format from backend
class WarehouseModel extends WarehouseEntity {
const WarehouseModel({
required super.id,
required super.name,
required super.code,
super.description,
required super.isNGWareHouse,
required super.totalCount,
});
/// Create a WarehouseModel from JSON
///
/// JSON format from API:
/// ```json
/// {
/// "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
/// }
/// ```
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,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'Id': id,
'Name': name,
'Code': code,
'Description': description,
'IsNGWareHouse': isNGWareHouse,
'TotalCount': totalCount,
};
}
/// Create from domain entity
factory WarehouseModel.fromEntity(WarehouseEntity entity) {
return WarehouseModel(
id: entity.id,
name: entity.name,
code: entity.code,
description: entity.description,
isNGWareHouse: entity.isNGWareHouse,
totalCount: entity.totalCount,
);
}
/// Convert to domain entity
WarehouseEntity toEntity() {
return WarehouseEntity(
id: id,
name: name,
code: code,
description: description,
isNGWareHouse: isNGWareHouse,
totalCount: totalCount,
);
}
/// Create a copy with modified fields
@override
WarehouseModel copyWith({
int? id,
String? name,
String? code,
String? description,
bool? isNGWareHouse,
int? totalCount,
}) {
return WarehouseModel(
id: id ?? this.id,
name: name ?? this.name,
code: code ?? this.code,
description: description ?? this.description,
isNGWareHouse: isNGWareHouse ?? this.isNGWareHouse,
totalCount: totalCount ?? this.totalCount,
);
}
@override
String toString() {
return 'WarehouseModel(id: $id, name: $name, code: $code, totalCount: $totalCount)';
}
}

View File

@@ -0,0 +1,39 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/warehouse_entity.dart';
import '../../domain/repositories/warehouse_repository.dart';
import '../datasources/warehouse_remote_datasource.dart';
/// Implementation of WarehouseRepository
/// Coordinates between data sources and domain layer
/// Converts exceptions to failures for proper error handling
class WarehouseRepositoryImpl implements WarehouseRepository {
final WarehouseRemoteDataSource remoteDataSource;
WarehouseRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses() async {
try {
// Fetch warehouses from remote data source
final warehouses = await remoteDataSource.getWarehouses();
// Convert models to entities
final entities = warehouses
.map((model) => model.toEntity())
.toList();
return Right(entities);
} on ServerException catch (e) {
// Convert server exceptions to server failures
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
// Convert network exceptions to network failures
return Left(NetworkFailure(e.message));
} catch (e) {
// Handle any unexpected errors
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,61 @@
import 'package:equatable/equatable.dart';
/// Warehouse domain entity
/// Pure business model with no dependencies on data layer
class WarehouseEntity extends Equatable {
final int id;
final String name;
final String code;
final String? description;
final bool isNGWareHouse;
final int totalCount;
const WarehouseEntity({
required this.id,
required this.name,
required this.code,
this.description,
required this.isNGWareHouse,
required this.totalCount,
});
@override
List<Object?> get props => [
id,
name,
code,
description,
isNGWareHouse,
totalCount,
];
@override
String toString() {
return 'WarehouseEntity(id: $id, name: $name, code: $code, totalCount: $totalCount)';
}
/// Check if warehouse has items
bool get hasItems => totalCount > 0;
/// Check if this is an NG (Not Good/Defect) warehouse
bool get isNGType => isNGWareHouse;
/// Create a copy with modified fields
WarehouseEntity copyWith({
int? id,
String? name,
String? code,
String? description,
bool? isNGWareHouse,
int? totalCount,
}) {
return WarehouseEntity(
id: id ?? this.id,
name: name ?? this.name,
code: code ?? this.code,
description: description ?? this.description,
isNGWareHouse: isNGWareHouse ?? this.isNGWareHouse,
totalCount: totalCount ?? this.totalCount,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/warehouse_entity.dart';
/// Abstract repository interface for warehouse operations
/// Defines the contract for warehouse data operations
/// Implementations should handle data sources and convert exceptions to failures
abstract class WarehouseRepository {
/// Get all warehouses from the remote data source
///
/// Returns [Either<Failure, List<WarehouseEntity>>]
/// - Right: List of warehouses on success
/// - Left: Failure object with error details
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses();
}

View File

@@ -0,0 +1,32 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/warehouse_entity.dart';
import '../repositories/warehouse_repository.dart';
/// Use case for getting all warehouses
/// Encapsulates the business logic for fetching warehouses
///
/// Usage:
/// ```dart
/// final useCase = GetWarehousesUseCase(repository);
/// final result = await useCase();
///
/// result.fold(
/// (failure) => print('Error: ${failure.message}'),
/// (warehouses) => print('Loaded ${warehouses.length} warehouses'),
/// );
/// ```
class GetWarehousesUseCase {
final WarehouseRepository repository;
GetWarehousesUseCase(this.repository);
/// Execute the use case
///
/// Returns [Either<Failure, List<WarehouseEntity>>]
/// - Right: List of warehouses on success
/// - Left: Failure object with error details
Future<Either<Failure, List<WarehouseEntity>>> call() async {
return await repository.getWarehouses();
}
}

Some files were not shown because too many files have changed in this diff Show More