Compare commits
13 Commits
73b77c27de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff639fc42 | ||
|
|
1cfdd2c0c6 | ||
| ff25363a19 | |||
| 9df4b79a66 | |||
| 2a6ec8f6b8 | |||
| f47700ad2b | |||
| 68cc5c0df3 | |||
| 2495330bf5 | |||
|
|
efcc6306b0 | ||
|
|
c12869b01f | ||
|
|
cb4df363ab | ||
| 2905668358 | |||
| f32e1c16fb |
@@ -1,452 +0,0 @@
|
|||||||
# 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!
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
# 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
426
ROUTER_SETUP.md
@@ -1,426 +0,0 @@
|
|||||||
# 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.
|
|
||||||
2151
assets/fonts/NotoSans-Bold.ttf
Normal file
2151
assets/fonts/NotoSans-Bold.ttf
Normal file
File diff suppressed because one or more lines are too long
2151
assets/fonts/NotoSans-Regular.ttf
Normal file
2151
assets/fonts/NotoSans-Regular.ttf
Normal file
File diff suppressed because one or more lines are too long
@@ -8,6 +8,8 @@ PODS:
|
|||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- printing (1.0.0):
|
||||||
|
- Flutter
|
||||||
- sqflite_darwin (0.0.4):
|
- sqflite_darwin (0.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -17,6 +19,7 @@ DEPENDENCIES:
|
|||||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- printing (from `.symlinks/plugins/printing/ios`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
@@ -28,6 +31,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
printing:
|
||||||
|
:path: ".symlinks/plugins/printing/ios"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
|
||||||
@@ -36,6 +41,7 @@ SPEC CHECKSUMS:
|
|||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
|
printing: 233e1b73bd1f4a05615548e9b5a324c98588640b
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class ApiEndpoints {
|
|||||||
/// Response: List of users
|
/// Response: List of users
|
||||||
static const String users = '/PortalUser/GetAllMemberUserShortInfo';
|
static const String users = '/PortalUser/GetAllMemberUserShortInfo';
|
||||||
|
|
||||||
|
/// Get current logged-in user
|
||||||
|
/// GET: /PortalUser/GetCurrentUser?getDep=false (requires auth token)
|
||||||
|
/// Response: Current user details
|
||||||
|
static const String getCurrentUser = '/PortalUser/GetCurrentUser?getDep=false';
|
||||||
|
|
||||||
// ==================== Warehouse Endpoints ====================
|
// ==================== Warehouse Endpoints ====================
|
||||||
|
|
||||||
/// Get all warehouses
|
/// Get all warehouses
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../features/auth/data/repositories/auth_repository_impl.dart';
|
|||||||
import '../../features/auth/domain/repositories/auth_repository.dart';
|
import '../../features/auth/domain/repositories/auth_repository.dart';
|
||||||
import '../../features/auth/domain/usecases/login_usecase.dart';
|
import '../../features/auth/domain/usecases/login_usecase.dart';
|
||||||
import '../../features/auth/presentation/providers/auth_provider.dart';
|
import '../../features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
import '../../features/products/data/datasources/products_local_datasource.dart';
|
||||||
import '../../features/products/data/datasources/products_remote_datasource.dart';
|
import '../../features/products/data/datasources/products_remote_datasource.dart';
|
||||||
import '../../features/products/data/repositories/products_repository_impl.dart';
|
import '../../features/products/data/repositories/products_repository_impl.dart';
|
||||||
import '../../features/products/domain/entities/product_stage_entity.dart';
|
import '../../features/products/domain/entities/product_stage_entity.dart';
|
||||||
@@ -256,6 +257,12 @@ final warehouseErrorProvider = Provider<String?>((ref) {
|
|||||||
|
|
||||||
// Data Layer
|
// Data Layer
|
||||||
|
|
||||||
|
/// Products local data source provider
|
||||||
|
/// Handles local storage operations for products using Hive
|
||||||
|
final productsLocalDataSourceProvider = Provider<ProductsLocalDataSource>((ref) {
|
||||||
|
return ProductsLocalDataSourceImpl();
|
||||||
|
});
|
||||||
|
|
||||||
/// Products remote data source provider
|
/// Products remote data source provider
|
||||||
/// Handles API calls for products
|
/// Handles API calls for products
|
||||||
final productsRemoteDataSourceProvider =
|
final productsRemoteDataSourceProvider =
|
||||||
@@ -266,9 +273,14 @@ final productsRemoteDataSourceProvider =
|
|||||||
|
|
||||||
/// Products repository provider
|
/// Products repository provider
|
||||||
/// Implements domain repository interface
|
/// Implements domain repository interface
|
||||||
|
/// Coordinates between local and remote data sources
|
||||||
final productsRepositoryProvider = Provider<ProductsRepository>((ref) {
|
final productsRepositoryProvider = Provider<ProductsRepository>((ref) {
|
||||||
final remoteDataSource = ref.watch(productsRemoteDataSourceProvider);
|
final remoteDataSource = ref.watch(productsRemoteDataSourceProvider);
|
||||||
return ProductsRepositoryImpl(remoteDataSource);
|
final localDataSource = ref.watch(productsLocalDataSourceProvider);
|
||||||
|
return ProductsRepositoryImpl(
|
||||||
|
remoteDataSource: remoteDataSource,
|
||||||
|
localDataSource: localDataSource,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Domain Layer
|
// Domain Layer
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class AppRouter {
|
|||||||
context.go('/warehouses');
|
context.go('/warehouses');
|
||||||
});
|
});
|
||||||
return const _ErrorScreen(
|
return const _ErrorScreen(
|
||||||
message: 'Warehouse data is required',
|
message: 'Yêu cầu dữ liệu kho',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class AppRouter {
|
|||||||
context.go('/warehouses');
|
context.go('/warehouses');
|
||||||
});
|
});
|
||||||
return const _ErrorScreen(
|
return const _ErrorScreen(
|
||||||
message: 'Invalid product parameters',
|
message: 'Tham số sản phẩm không hợp lệ',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class AppRouter {
|
|||||||
context.go('/warehouses');
|
context.go('/warehouses');
|
||||||
});
|
});
|
||||||
return const _ErrorScreen(
|
return const _ErrorScreen(
|
||||||
message: 'Invalid product detail parameters',
|
message: 'Tham số chi tiết sản phẩm không hợp lệ',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ class AppRouter {
|
|||||||
errorBuilder: (context, state) {
|
errorBuilder: (context, state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Page Not Found'),
|
title: const Text('Không tìm thấy trang'),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -176,12 +176,12 @@ class AppRouter {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Page Not Found',
|
'Không tìm thấy trang',
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'The page "${state.uri.path}" does not exist.',
|
'Trang "${state.uri.path}" không tồn tại.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -190,7 +190,7 @@ class AppRouter {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => context.go('/login'),
|
onPressed: () => context.go('/login'),
|
||||||
child: const Text('Go to Login'),
|
child: const Text('Về trang đăng nhập'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -283,7 +283,7 @@ class _ErrorScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Error'),
|
title: const Text('Lỗi'),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -296,7 +296,7 @@ class _ErrorScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Navigation Error',
|
'Lỗi điều hướng',
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -313,7 +313,7 @@ class _ErrorScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => context.go('/warehouses'),
|
onPressed: () => context.go('/warehouses'),
|
||||||
child: const Text('Go to Warehouses'),
|
child: const Text('Về trang kho'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
407
lib/core/services/print_service.dart
Normal file
407
lib/core/services/print_service.dart
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
|
||||||
|
/// Service for generating and printing warehouse export forms
|
||||||
|
class PrintService {
|
||||||
|
/// Generate and print a warehouse export form
|
||||||
|
static Future<void> printWarehouseExport({
|
||||||
|
required BuildContext context,
|
||||||
|
required String warehouseName,
|
||||||
|
required int productId,
|
||||||
|
required String productCode,
|
||||||
|
required String productName,
|
||||||
|
String? stageName,
|
||||||
|
required double passedKg,
|
||||||
|
required int passedPcs,
|
||||||
|
required double issuedKg,
|
||||||
|
required int issuedPcs,
|
||||||
|
String? responsibleName,
|
||||||
|
String? receiverName,
|
||||||
|
String? barcodeData,
|
||||||
|
}) async {
|
||||||
|
// Load Vietnamese-compatible fonts using PdfGoogleFonts
|
||||||
|
// Noto Sans has excellent Vietnamese character support
|
||||||
|
final fontRegular = await PdfGoogleFonts.notoSansRegular();
|
||||||
|
final fontBold = await PdfGoogleFonts.notoSansBold();
|
||||||
|
|
||||||
|
final pdf = pw.Document();
|
||||||
|
|
||||||
|
// Format current date
|
||||||
|
final dt = DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now());
|
||||||
|
|
||||||
|
// Add page to PDF with theme for Vietnamese font support
|
||||||
|
pdf.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: const pw.EdgeInsets.all(12),
|
||||||
|
theme: pw.ThemeData.withFont(
|
||||||
|
base: fontRegular,
|
||||||
|
bold: fontBold,
|
||||||
|
),
|
||||||
|
build: (pw.Context pdfContext) {
|
||||||
|
return pw.Container(
|
||||||
|
padding: const pw.EdgeInsets.all(12),
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Title
|
||||||
|
pw.Center(
|
||||||
|
child: pw.Column(
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'PHIẾU XUẤT KHO',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
pw.Text(
|
||||||
|
'Công ty TNHH Cơ Khí Chính Xác Minh Thư',
|
||||||
|
style: const pw.TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Text(
|
||||||
|
warehouseName,
|
||||||
|
style: const pw.TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Text(
|
||||||
|
'Ngày: $dt',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Product information box
|
||||||
|
pw.Container(
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||||
|
borderRadius: pw.BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Column(
|
||||||
|
children: [
|
||||||
|
pw.Row(
|
||||||
|
children: [
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'ProductId',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
'$productId',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Mã sản phẩm',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
productCode,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
pw.Row(
|
||||||
|
children: [
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Tên sản phẩm',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
productName,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Công đoạn',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
stageName ?? '-',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Quantities box
|
||||||
|
pw.Container(
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||||
|
borderRadius: pw.BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Số lượng:',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
pw.Table(
|
||||||
|
border: pw.TableBorder.all(
|
||||||
|
color: PdfColors.black,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
pw.TableRow(
|
||||||
|
decoration: const pw.BoxDecoration(
|
||||||
|
color: PdfColors.grey300,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text(
|
||||||
|
'Loại',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text(
|
||||||
|
'KG',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text(
|
||||||
|
'PCS',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Passed quantity row
|
||||||
|
pw.TableRow(
|
||||||
|
children: [
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text('Hàng đạt'),
|
||||||
|
),
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text(passedKg.toStringAsFixed(2)),
|
||||||
|
),
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text('$passedPcs'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Issued quantity row
|
||||||
|
pw.TableRow(
|
||||||
|
children: [
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text('Hàng lỗi'),
|
||||||
|
),
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text(issuedKg.toStringAsFixed(2)),
|
||||||
|
),
|
||||||
|
pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Text('$issuedPcs'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Responsible person box
|
||||||
|
pw.Container(
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||||
|
borderRadius: pw.BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Row(
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Nhân viên kho: ',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
responsibleName ?? '-',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
|
||||||
|
|
||||||
|
pw.Container(
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||||
|
borderRadius: pw.BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
child: pw.Row(
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Nhân viên tiếp nhận: ',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
receiverName ?? '-',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Barcode section
|
||||||
|
if (barcodeData != null && barcodeData.isNotEmpty)
|
||||||
|
pw.Center(
|
||||||
|
child: pw.BarcodeWidget(
|
||||||
|
barcode: pw.Barcode.code128(),
|
||||||
|
data: barcodeData,
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.Spacer(),
|
||||||
|
|
||||||
|
// Footer signature section
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
pw.Container(
|
||||||
|
width: 150,
|
||||||
|
child: pw.Column(
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
'Người nhận',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 40),
|
||||||
|
pw.Container(
|
||||||
|
height: 1,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show print preview dialog
|
||||||
|
await Printing.layoutPdf(
|
||||||
|
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||||
|
name: 'warehouse_export_${productCode}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
lib/core/services/sunmi_service.dart
Normal file
210
lib/core/services/sunmi_service.dart
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sunmi_printer_plus/sunmi_printer_plus.dart';
|
||||||
|
|
||||||
|
/// Service for printing to Sunmi thermal printers
|
||||||
|
class SunmiService {
|
||||||
|
/// Print warehouse export form to Sunmi printer
|
||||||
|
static Future<void> printWarehouseExport({
|
||||||
|
required BuildContext context,
|
||||||
|
required String warehouseName,
|
||||||
|
required int productId,
|
||||||
|
required String productCode,
|
||||||
|
required String productName,
|
||||||
|
String? stageName,
|
||||||
|
required double passedKg,
|
||||||
|
required int passedPcs,
|
||||||
|
required double issuedKg,
|
||||||
|
required int issuedPcs,
|
||||||
|
String? responsibleName,
|
||||||
|
String? receiverName,
|
||||||
|
String? barcodeData,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Format current date
|
||||||
|
final dt = DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now());
|
||||||
|
|
||||||
|
// Title - PHIẾU XUẤT KHO
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'PHIEU XUAT KHO',
|
||||||
|
style: SunmiTextStyle(
|
||||||
|
align: SunmiPrintAlign.CENTER,
|
||||||
|
bold: true,
|
||||||
|
fontSize: 48,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Company name
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Cong ty TNHH Co Khi Chinh Xac Minh Thu',
|
||||||
|
style: SunmiTextStyle(
|
||||||
|
align: SunmiPrintAlign.CENTER,
|
||||||
|
fontSize: 32,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Warehouse name
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
warehouseName,
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.CENTER),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Date
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Ngay: $dt',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.CENTER),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(2);
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
await SunmiPrinter.line();
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Product information
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'THONG TIN SAN PHAM',
|
||||||
|
style: SunmiTextStyle(
|
||||||
|
align: SunmiPrintAlign.LEFT,
|
||||||
|
bold: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// ProductId
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'ProductId: $productId',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Product Code
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Ma san pham: $productCode',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Product Name
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Ten san pham: $productName',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Stage Name
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Cong doan: ${stageName ?? '-'}',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(2);
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
await SunmiPrinter.line();
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Quantities
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'SO LUONG',
|
||||||
|
style: SunmiTextStyle(
|
||||||
|
align: SunmiPrintAlign.LEFT,
|
||||||
|
bold: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Loai KG PCS',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.line();
|
||||||
|
|
||||||
|
// Passed quantity (Hàng đạt)
|
||||||
|
final passedLine =
|
||||||
|
'Hang dat ${passedKg.toStringAsFixed(2).padLeft(7)} ${passedPcs.toString().padLeft(5)}';
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
passedLine,
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Issued quantity (Hàng lỗi)
|
||||||
|
final issuedLine =
|
||||||
|
'Hang loi ${issuedKg.toStringAsFixed(2).padLeft(7)} ${issuedPcs.toString().padLeft(5)}';
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
issuedLine,
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(2);
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
await SunmiPrinter.line();
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Responsible person
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Nhan vien kho: ${responsibleName ?? '-'}',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(1);
|
||||||
|
|
||||||
|
// Receiver
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Nhan vien tiep nhan: ${receiverName ?? '-'}',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(2);
|
||||||
|
|
||||||
|
// Barcode
|
||||||
|
if (barcodeData != null && barcodeData.isNotEmpty) {
|
||||||
|
await SunmiPrinter.line();
|
||||||
|
await SunmiPrinter.printBarCode(
|
||||||
|
barcodeData,
|
||||||
|
style: SunmiBarcodeStyle(
|
||||||
|
type: SunmiBarcodeType.CODE128,
|
||||||
|
textPos: SunmiBarcodeTextPos.TEXT_UNDER,
|
||||||
|
height: 100,
|
||||||
|
align: SunmiPrintAlign.CENTER,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
await SunmiPrinter.line();
|
||||||
|
await SunmiPrinter.printText(
|
||||||
|
'Nguoi nhan',
|
||||||
|
style: SunmiTextStyle(align: SunmiPrintAlign.CENTER),
|
||||||
|
);
|
||||||
|
await SunmiPrinter.lineWrap(4);
|
||||||
|
|
||||||
|
// Cut paper
|
||||||
|
await SunmiPrinter.cutPaper();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Đã in thành công!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Show error message
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Lỗi khi in: ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@ class SecureStorage {
|
|||||||
/// Key for storing username
|
/// Key for storing username
|
||||||
static const String _usernameKey = 'username';
|
static const String _usernameKey = 'username';
|
||||||
|
|
||||||
|
/// Key for storing email
|
||||||
|
static const String _emailKey = 'email';
|
||||||
|
|
||||||
|
/// Key for storing current user ID
|
||||||
|
static const String _currentUserIdKey = 'current_user_id';
|
||||||
|
|
||||||
// ==================== Token Management ====================
|
// ==================== Token Management ====================
|
||||||
|
|
||||||
/// Save access token securely
|
/// Save access token securely
|
||||||
@@ -126,6 +132,43 @@ class SecureStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save email
|
||||||
|
Future<void> saveEmail(String email) async {
|
||||||
|
try {
|
||||||
|
await _storage.write(key: _emailKey, value: email);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to save email: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get email
|
||||||
|
Future<String?> getEmail() async {
|
||||||
|
try {
|
||||||
|
return await _storage.read(key: _emailKey);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to read email: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save current user ID
|
||||||
|
Future<void> saveCurrentUserId(int userId) async {
|
||||||
|
try {
|
||||||
|
await _storage.write(key: _currentUserIdKey, value: userId.toString());
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to save current user ID: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current user ID
|
||||||
|
Future<int?> getCurrentUserId() async {
|
||||||
|
try {
|
||||||
|
final value = await _storage.read(key: _currentUserIdKey);
|
||||||
|
return value != null ? int.tryParse(value) : null;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to read current user ID: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if user is authenticated (has valid access token)
|
/// Check if user is authenticated (has valid access token)
|
||||||
Future<bool> isAuthenticated() async {
|
Future<bool> isAuthenticated() async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
|
|||||||
86
lib/core/utils/text_utils.dart
Normal file
86
lib/core/utils/text_utils.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/// Utility functions for text processing
|
||||||
|
class TextUtils {
|
||||||
|
/// Convert Vietnamese characters to English (non-accented) characters
|
||||||
|
/// Example: "Tuấn" -> "tuan", "Hồ Chí Minh" -> "ho chi minh"
|
||||||
|
static String removeVietnameseAccents(String text) {
|
||||||
|
if (text.isEmpty) return text;
|
||||||
|
|
||||||
|
// Convert to lowercase for consistent comparison
|
||||||
|
String result = text.toLowerCase();
|
||||||
|
|
||||||
|
// Map of Vietnamese characters to their non-accented equivalents
|
||||||
|
const vietnameseMap = {
|
||||||
|
// a with accents
|
||||||
|
'á': 'a', 'à': 'a', 'ả': 'a', 'ã': 'a', 'ạ': 'a',
|
||||||
|
'ă': 'a', 'ắ': 'a', 'ằ': 'a', 'ẳ': 'a', 'ẵ': 'a', 'ặ': 'a',
|
||||||
|
'â': 'a', 'ấ': 'a', 'ầ': 'a', 'ẩ': 'a', 'ẫ': 'a', 'ậ': 'a',
|
||||||
|
|
||||||
|
// e with accents
|
||||||
|
'é': 'e', 'è': 'e', 'ẻ': 'e', 'ẽ': 'e', 'ẹ': 'e',
|
||||||
|
'ê': 'e', 'ế': 'e', 'ề': 'e', 'ể': 'e', 'ễ': 'e', 'ệ': 'e',
|
||||||
|
|
||||||
|
// i with accents
|
||||||
|
'í': 'i', 'ì': 'i', 'ỉ': 'i', 'ĩ': 'i', 'ị': 'i',
|
||||||
|
|
||||||
|
// o with accents
|
||||||
|
'ó': 'o', 'ò': 'o', 'ỏ': 'o', 'õ': 'o', 'ọ': 'o',
|
||||||
|
'ô': 'o', 'ố': 'o', 'ồ': 'o', 'ổ': 'o', 'ỗ': 'o', 'ộ': 'o',
|
||||||
|
'ơ': 'o', 'ớ': 'o', 'ờ': 'o', 'ở': 'o', 'ỡ': 'o', 'ợ': 'o',
|
||||||
|
|
||||||
|
// u with accents
|
||||||
|
'ú': 'u', 'ù': 'u', 'ủ': 'u', 'ũ': 'u', 'ụ': 'u',
|
||||||
|
'ư': 'u', 'ứ': 'u', 'ừ': 'u', 'ử': 'u', 'ữ': 'u', 'ự': 'u',
|
||||||
|
|
||||||
|
// y with accents
|
||||||
|
'ý': 'y', 'ỳ': 'y', 'ỷ': 'y', 'ỹ': 'y', 'ỵ': 'y',
|
||||||
|
|
||||||
|
// d with stroke
|
||||||
|
'đ': 'd',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace each Vietnamese character with its non-accented equivalent
|
||||||
|
vietnameseMap.forEach((vietnamese, english) {
|
||||||
|
result = result.replaceAll(vietnamese, english);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize text for search (lowercase + remove accents)
|
||||||
|
static String normalizeForSearch(String text) {
|
||||||
|
return removeVietnameseAccents(text.toLowerCase().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a text contains a search term (Vietnamese-aware, case-insensitive)
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// containsVietnameseSearch("Nguyễn Văn Tuấn", "tuan") // returns true
|
||||||
|
/// containsVietnameseSearch("tuan@example.com", "TUAN") // returns true
|
||||||
|
/// ```
|
||||||
|
static bool containsVietnameseSearch(String text, String searchTerm) {
|
||||||
|
if (searchTerm.isEmpty) return true;
|
||||||
|
if (text.isEmpty) return false;
|
||||||
|
|
||||||
|
final normalizedText = normalizeForSearch(text);
|
||||||
|
final normalizedSearch = normalizeForSearch(searchTerm);
|
||||||
|
|
||||||
|
return normalizedText.contains(normalizedSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any of the provided texts contains the search term
|
||||||
|
static bool containsVietnameseSearchInAny(
|
||||||
|
List<String> texts,
|
||||||
|
String searchTerm,
|
||||||
|
) {
|
||||||
|
if (searchTerm.isEmpty) return true;
|
||||||
|
|
||||||
|
for (final text in texts) {
|
||||||
|
if (containsVietnameseSearch(text, searchTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,3 +128,24 @@ curl --request GET \
|
|||||||
--header 'Sec-Fetch-Mode: cors' \
|
--header 'Sec-Fetch-Mode: cors' \
|
||||||
--header 'Sec-Fetch-Site: same-site' \
|
--header 'Sec-Fetch-Site: same-site' \
|
||||||
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0'
|
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0'
|
||||||
|
|
||||||
|
|
||||||
|
#Get current user
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://dotnet.elidev.info:8157/ws/PortalUser/GetCurrentUser?getDep=false' \
|
||||||
|
--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/tUJhximq11MDXsSXanpYM7jesjr8mAG4qjYN6z6c1Gl5N0dhcDF4HeEaIlNIgZ75FqtXZnLqvhHPyk6L2iR2ZT15nobZxLzOUad4a0OymUDUv7xuEBdEk5kmzZLDpbOxrKiyMpGSlbBhEoBMoA0u6ZKtBGQfCJ02s6Ri0WhLLM4XJCjGrpoEkTUuZ7YG39Zva19HGV0kkxeFYkG0lnZBO6jCggem5f+S2NQvXP/kUrWX1GeQFCq5PScvwJexLsbh0LKC2MGovkecoBKtNIK21V6ztvWL8lThJAl9' \
|
||||||
|
--header 'AppID: Minhthu2016' \
|
||||||
|
--header 'Connection: keep-alive' \
|
||||||
|
--header 'Origin: https://dotnet.elidev.info:8158' \
|
||||||
|
--header 'Priority: u=0' \
|
||||||
|
--header 'Referer: https://dotnet.elidev.info:8158/' \
|
||||||
|
--header 'Sec-Fetch-Dest: empty' \
|
||||||
|
--header 'Sec-Fetch-Mode: cors' \
|
||||||
|
--header 'Sec-Fetch-Site: same-site' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0' \
|
||||||
|
--header 'content-type: application/json' \
|
||||||
|
--data ''
|
||||||
106
lib/docs/import.html
Normal file
106
lib/docs/import.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>Phiếu xuất kho</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<style>
|
||||||
|
:root { --fg:#111; --muted:#666; --border:#000; --primary:#2563eb; }
|
||||||
|
html,body { margin:0; padding:0; font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; color:var(--fg); }
|
||||||
|
.wrap { max-width:720px; margin:0 auto; padding:12px; }
|
||||||
|
.actions { position: sticky; top:0; background:#fff; padding:8px 0; display:flex; gap:8px; justify-content:flex-end; border-bottom:0.1mm solid var(--border); }
|
||||||
|
.actions button { padding:6px 12px; cursor:pointer; border:0.1mm solid var(--border); background:#fff; border-radius:6px; }
|
||||||
|
h1 { font-size:20px; margin:8px 0 4px; text-align:center; }
|
||||||
|
.meta { text-align:center; color:var(--muted); margin-bottom:8px; }
|
||||||
|
.box { border:0.1mm solid var(--border); border-radius:8px; padding:8px; margin:8px 0; }
|
||||||
|
.row { display:flex; gap:8px; margin:6px 0; }
|
||||||
|
.row > div { flex:1; }
|
||||||
|
.label { color:var(--muted); font-size:12px; }
|
||||||
|
.value { font-weight:600; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:6px; }
|
||||||
|
th, td { border:0.1mm solid var(--border); padding:8px; text-align:left; }
|
||||||
|
th { background:#f8fafc; }
|
||||||
|
.barcode { text-align:center; margin:12px 0; }
|
||||||
|
.footer { display:flex; gap:12px; margin:8px 0 4px; }
|
||||||
|
.sign { flex:1; text-align:center; color:var(--muted); padding-top:24px; }
|
||||||
|
/* Ensure printer keeps border colors/thickness */
|
||||||
|
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
/* Print margins and padding */
|
||||||
|
@page {
|
||||||
|
size: auto;
|
||||||
|
margin: 3mm 0mm; /* outer page margin */
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.actions { display:none; }
|
||||||
|
.wrap { padding:0 4px ; }
|
||||||
|
th { background:#eee; } /* light gray still visible on most printers */
|
||||||
|
/* Force black borders on print */
|
||||||
|
.box, table, th, td { border-color:#000 !important; border-width:0.1mm !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="printAndClose()">In</button>
|
||||||
|
<button onclick="window.close()">Đóng</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>PHIẾU XUẤT KHO</h1>
|
||||||
|
<h3>Công ty TNHH Cơ Khí Chính Xác Minh Thư</h3>
|
||||||
|
<h4>${wareHouseText}</h4>
|
||||||
|
<div class="meta">Ngày: ${dt}</div>
|
||||||
|
<div class="box">
|
||||||
|
<div class="row">
|
||||||
|
<div><div class="label">ProductId</div><div class="value">${productId}</div></div>
|
||||||
|
<div><div class="label">Mã sản phẩm</div><div class="value">${productCode}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><div class="label">Tên sản phẩm</div><div class="value">${productName}</div></div>
|
||||||
|
<div><div class="label">Công đoạn</div><div class="value">${stageName || '-'}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<div class="label">Số lượng:</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Loại</th><th>KG</th><th>PCS</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Hàng đạt</td>
|
||||||
|
<td>${Number(qty.passedKg || 0)}</td>
|
||||||
|
<td>${Number(qty.passedPcs || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hàng lỗi</td>
|
||||||
|
<td>${Number(qty.issuedKg || 0)}</td>
|
||||||
|
<td>${Number(qty.issuedPcs || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<div class="row">
|
||||||
|
<div><div class="label">Nhân viên kho</div><div class="value">${responsibleName || '-'}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="barcode">
|
||||||
|
${barcodeDataUrl ? `<img alt="Barcode" src="${barcodeDataUrl}" />` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="sign">
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let printed = false;
|
||||||
|
function printAndClose() { printed = true; window.print(); }
|
||||||
|
window.addEventListener('afterprint', () => setTimeout(() => window.close(), 200));
|
||||||
|
window.addEventListener('focus', () => { if (printed) setTimeout(() => window.close(), 400); });
|
||||||
|
window.onload = () => { printAndClose(); };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -31,6 +31,8 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
await secureStorage.saveAccessToken(userModel.accessToken);
|
await secureStorage.saveAccessToken(userModel.accessToken);
|
||||||
await secureStorage.saveUserId(userModel.userId);
|
await secureStorage.saveUserId(userModel.userId);
|
||||||
await secureStorage.saveUsername(userModel.username);
|
await secureStorage.saveUsername(userModel.username);
|
||||||
|
// Save email (username is the email from login)
|
||||||
|
await secureStorage.saveEmail(request.username);
|
||||||
|
|
||||||
if (userModel.refreshToken != null) {
|
if (userModel.refreshToken != null) {
|
||||||
await secureStorage.saveRefreshToken(userModel.refreshToken!);
|
await secureStorage.saveRefreshToken(userModel.refreshToken!);
|
||||||
@@ -50,15 +52,9 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Either<Failure, void>> logout() async {
|
Future<Either<Failure, void>> logout() async {
|
||||||
try {
|
try {
|
||||||
// Call remote data source to logout (optional - can fail silently)
|
// Just clear access token from secure storage
|
||||||
try {
|
// No API call needed
|
||||||
await remoteDataSource.logout();
|
await secureStorage.clearTokens();
|
||||||
} catch (e) {
|
|
||||||
// Ignore remote logout errors, still clear local data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all local authentication data
|
|
||||||
await secureStorage.clearAll();
|
|
||||||
|
|
||||||
return const Right(null);
|
return const Right(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
|
|
||||||
// App title
|
// App title
|
||||||
Text(
|
Text(
|
||||||
'Warehouse Manager',
|
'Quản lý kho',
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
@@ -90,7 +90,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
|
|
||||||
// Subtitle
|
// Subtitle
|
||||||
Text(
|
Text(
|
||||||
'Login to continue',
|
'Đăng nhập để tiếp tục',
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -129,24 +129,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
///
|
///
|
||||||
/// Clears authentication data and returns to initial state
|
/// Clears authentication data and returns to initial state
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
// Set loading state
|
// Clear tokens from secure storage
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
await logoutUseCase();
|
||||||
|
|
||||||
// Call logout use case
|
// Always reset to initial state (clear local data even if API call fails)
|
||||||
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();
|
state = const AuthState.initial();
|
||||||
},
|
|
||||||
(_) {
|
|
||||||
// Logout successful - reset to initial state
|
|
||||||
state = const AuthState.initial();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check authentication status on app start
|
/// Check authentication status on app start
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
enabled: !widget.isLoading,
|
enabled: !widget.isLoading,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Username',
|
labelText: 'Tên đăng nhập',
|
||||||
hintText: 'Enter your username',
|
hintText: 'Nhập tên đăng nhập',
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -67,10 +67,10 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Username is required';
|
return 'Vui lòng nhập tên đăng nhập';
|
||||||
}
|
}
|
||||||
if (value.trim().length < 3) {
|
if (value.trim().length < 3) {
|
||||||
return 'Username must be at least 3 characters';
|
return 'Tên đăng nhập phải có ít nhất 3 ký tự';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -84,8 +84,8 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
enabled: !widget.isLoading,
|
enabled: !widget.isLoading,
|
||||||
obscureText: _obscurePassword,
|
obscureText: _obscurePassword,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Password',
|
labelText: 'Mật khẩu',
|
||||||
hintText: 'Enter your password',
|
hintText: 'Nhập mật khẩu',
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
@@ -107,10 +107,10 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
onFieldSubmitted: (_) => _handleSubmit(),
|
onFieldSubmitted: (_) => _handleSubmit(),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Password is required';
|
return 'Vui lòng nhập mật khẩu';
|
||||||
}
|
}
|
||||||
if (value.length < 6) {
|
if (value.length < 6) {
|
||||||
return 'Password must be at least 6 characters';
|
return 'Mật khẩu phải có ít nhất 6 ký tự';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -131,7 +131,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.login),
|
: const Icon(Icons.login),
|
||||||
label: Text(widget.isLoading ? 'Logging in...' : 'Login'),
|
label: Text(widget.isLoading ? 'Đang đăng nhập...' : 'Đăng nhập'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import '../models/product_model.dart';
|
||||||
|
|
||||||
|
/// Abstract interface for products local data source
|
||||||
|
abstract class ProductsLocalDataSource {
|
||||||
|
/// Get cached products for a specific warehouse and operation type
|
||||||
|
///
|
||||||
|
/// [warehouseId] - The ID of the warehouse
|
||||||
|
/// [type] - The operation type ('import' or 'export')
|
||||||
|
///
|
||||||
|
/// Returns List<ProductModel> from cache or empty list if not found
|
||||||
|
Future<List<ProductModel>> getCachedProducts(int warehouseId, String type);
|
||||||
|
|
||||||
|
/// Cache products for a specific warehouse and operation type
|
||||||
|
///
|
||||||
|
/// [warehouseId] - The ID of the warehouse
|
||||||
|
/// [type] - The operation type ('import' or 'export')
|
||||||
|
/// [products] - List of products to cache
|
||||||
|
Future<void> cacheProducts(
|
||||||
|
int warehouseId,
|
||||||
|
String type,
|
||||||
|
List<ProductModel> products,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Clear all cached products
|
||||||
|
Future<void> clearCache();
|
||||||
|
|
||||||
|
/// Clear cached products for a specific warehouse and operation type
|
||||||
|
Future<void> clearCachedProducts(int warehouseId, String type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of ProductsLocalDataSource using Hive
|
||||||
|
class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||||
|
static const String _boxName = 'products_cache';
|
||||||
|
Box<String>? _box;
|
||||||
|
|
||||||
|
/// Initialize the Hive box
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_box == null || !_box!.isOpen) {
|
||||||
|
_box = await Hive.openBox<String>(_boxName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate cache key for warehouse and operation type
|
||||||
|
String _getCacheKey(int warehouseId, String type) {
|
||||||
|
return 'products_${warehouseId}_$type';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductModel>> getCachedProducts(
|
||||||
|
int warehouseId,
|
||||||
|
String type,
|
||||||
|
) async {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
final key = _getCacheKey(warehouseId, type);
|
||||||
|
final cachedData = _box?.get(key);
|
||||||
|
|
||||||
|
if (cachedData == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode JSON string to list
|
||||||
|
final jsonList = jsonDecode(cachedData) as List;
|
||||||
|
|
||||||
|
// Convert JSON list to ProductModel list
|
||||||
|
return jsonList
|
||||||
|
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, return empty list
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cacheProducts(
|
||||||
|
int warehouseId,
|
||||||
|
String type,
|
||||||
|
List<ProductModel> products,
|
||||||
|
) async {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
final key = _getCacheKey(warehouseId, type);
|
||||||
|
|
||||||
|
// Convert products to JSON list
|
||||||
|
final jsonList = products.map((product) => product.toJson()).toList();
|
||||||
|
|
||||||
|
// Encode to JSON string and save
|
||||||
|
final jsonString = jsonEncode(jsonList);
|
||||||
|
await _box?.put(key, jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
await init();
|
||||||
|
await _box?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearCachedProducts(int warehouseId, String type) async {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
final key = _getCacheKey(warehouseId, type);
|
||||||
|
await _box?.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
|
|||||||
// The API returns a list of stages for the product
|
// The API returns a list of stages for the product
|
||||||
final list = json as List;
|
final list = json as List;
|
||||||
if (list.isEmpty) {
|
if (list.isEmpty) {
|
||||||
throw const ServerException('Product stages not found');
|
throw const ServerException('Không tìm thấy sản phẩm');
|
||||||
}
|
}
|
||||||
// Parse all stages from the list
|
// Parse all stages from the list
|
||||||
return list
|
return list
|
||||||
|
|||||||
@@ -4,32 +4,69 @@ import '../../../../core/errors/failures.dart';
|
|||||||
import '../../domain/entities/product_entity.dart';
|
import '../../domain/entities/product_entity.dart';
|
||||||
import '../../domain/entities/product_stage_entity.dart';
|
import '../../domain/entities/product_stage_entity.dart';
|
||||||
import '../../domain/repositories/products_repository.dart';
|
import '../../domain/repositories/products_repository.dart';
|
||||||
|
import '../datasources/products_local_datasource.dart';
|
||||||
import '../datasources/products_remote_datasource.dart';
|
import '../datasources/products_remote_datasource.dart';
|
||||||
import '../models/create_product_warehouse_request.dart';
|
import '../models/create_product_warehouse_request.dart';
|
||||||
import '../models/product_detail_request_model.dart';
|
import '../models/product_detail_request_model.dart';
|
||||||
|
|
||||||
/// Implementation of ProductsRepository
|
/// Implementation of ProductsRepository
|
||||||
/// Handles data operations and error conversion
|
/// Handles data operations and error conversion
|
||||||
|
/// Uses local-first approach: loads from cache first, only fetches from API on explicit refresh
|
||||||
class ProductsRepositoryImpl implements ProductsRepository {
|
class ProductsRepositoryImpl implements ProductsRepository {
|
||||||
final ProductsRemoteDataSource remoteDataSource;
|
final ProductsRemoteDataSource remoteDataSource;
|
||||||
|
final ProductsLocalDataSource localDataSource;
|
||||||
|
|
||||||
ProductsRepositoryImpl(this.remoteDataSource);
|
ProductsRepositoryImpl({
|
||||||
|
required this.remoteDataSource,
|
||||||
|
required this.localDataSource,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
||||||
int warehouseId,
|
int warehouseId,
|
||||||
String type,
|
String type, {
|
||||||
) async {
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Fetch products from remote data source
|
// If not forcing refresh, try to get from cache first
|
||||||
|
if (!forceRefresh) {
|
||||||
|
final cachedProducts =
|
||||||
|
await localDataSource.getCachedProducts(warehouseId, type);
|
||||||
|
|
||||||
|
// If we have cached data, return it immediately
|
||||||
|
if (cachedProducts.isNotEmpty) {
|
||||||
|
return Right(cachedProducts.map((model) => model.toEntity()).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If forcing refresh or no cached data, fetch from remote
|
||||||
final products = await remoteDataSource.getProducts(warehouseId, type);
|
final products = await remoteDataSource.getProducts(warehouseId, type);
|
||||||
|
|
||||||
|
// Cache the fetched products for future use
|
||||||
|
await localDataSource.cacheProducts(warehouseId, type, products);
|
||||||
|
|
||||||
// Convert models to entities and return success
|
// Convert models to entities and return success
|
||||||
return Right(products.map((model) => model.toEntity()).toList());
|
return Right(products.map((model) => model.toEntity()).toList());
|
||||||
} on ServerException catch (e) {
|
} on ServerException catch (e) {
|
||||||
|
// If remote fetch fails, try to return cached data as fallback
|
||||||
|
if (forceRefresh) {
|
||||||
|
final cachedProducts =
|
||||||
|
await localDataSource.getCachedProducts(warehouseId, type);
|
||||||
|
if (cachedProducts.isNotEmpty) {
|
||||||
|
// Return cached data with a note that it might be outdated
|
||||||
|
return Right(cachedProducts.map((model) => model.toEntity()).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
// Convert ServerException to ServerFailure
|
// Convert ServerException to ServerFailure
|
||||||
return Left(ServerFailure(e.message));
|
return Left(ServerFailure(e.message));
|
||||||
} on NetworkException catch (e) {
|
} on NetworkException catch (e) {
|
||||||
|
// If network fails, try to return cached data as fallback
|
||||||
|
final cachedProducts =
|
||||||
|
await localDataSource.getCachedProducts(warehouseId, type);
|
||||||
|
if (cachedProducts.isNotEmpty) {
|
||||||
|
// Return cached data when network is unavailable
|
||||||
|
return Right(cachedProducts.map((model) => model.toEntity()).toList());
|
||||||
|
}
|
||||||
// Convert NetworkException to NetworkFailure
|
// Convert NetworkException to NetworkFailure
|
||||||
return Left(NetworkFailure(e.message));
|
return Left(NetworkFailure(e.message));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class ProductStageEntity extends Equatable {
|
|||||||
|
|
||||||
/// Get display name for the stage
|
/// Get display name for the stage
|
||||||
/// Returns "No Stage" if stageName is null
|
/// Returns "No Stage" if stageName is null
|
||||||
String get displayName => stageName ?? 'No Stage';
|
String get displayName => stageName ?? 'Không tên';
|
||||||
|
|
||||||
/// Check if this is a valid stage (has a stage name)
|
/// Check if this is a valid stage (has a stage name)
|
||||||
bool get hasStage => stageName != null && stageName!.isNotEmpty;
|
bool get hasStage => stageName != null && stageName!.isNotEmpty;
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ abstract class ProductsRepository {
|
|||||||
///
|
///
|
||||||
/// [warehouseId] - The ID of the warehouse
|
/// [warehouseId] - The ID of the warehouse
|
||||||
/// [type] - The operation type ('import' or 'export')
|
/// [type] - The operation type ('import' or 'export')
|
||||||
|
/// [forceRefresh] - If true, fetch from API even if cache exists
|
||||||
///
|
///
|
||||||
/// Returns Either<Failure, List<ProductEntity>>
|
/// Returns Either<Failure, List<ProductEntity>>
|
||||||
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
||||||
int warehouseId,
|
int warehouseId,
|
||||||
String type,
|
String type, {
|
||||||
);
|
bool forceRefresh = false,
|
||||||
|
});
|
||||||
|
|
||||||
/// Get product stages for a product in a warehouse
|
/// Get product stages for a product in a warehouse
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ class GetProductsUseCase {
|
|||||||
///
|
///
|
||||||
/// [warehouseId] - The ID of the warehouse to get products from
|
/// [warehouseId] - The ID of the warehouse to get products from
|
||||||
/// [type] - The operation type ('import' or 'export')
|
/// [type] - The operation type ('import' or 'export')
|
||||||
|
/// [forceRefresh] - If true, bypass cache and fetch from API
|
||||||
///
|
///
|
||||||
/// Returns Either<Failure, List<ProductEntity>>
|
/// Returns Either<Failure, List<ProductEntity>>
|
||||||
Future<Either<Failure, List<ProductEntity>>> call(
|
Future<Either<Failure, List<ProductEntity>>> call(
|
||||||
int warehouseId,
|
int warehouseId,
|
||||||
String type,
|
String type, {
|
||||||
) async {
|
bool forceRefresh = false,
|
||||||
return await repository.getProducts(warehouseId, type);
|
}) async {
|
||||||
|
return await repository.getProducts(
|
||||||
|
warehouseId,
|
||||||
|
type,
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,21 +56,23 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
_isTabSwitching = true; // Mark that tab is switching
|
_isTabSwitching = true; // Mark that tab is switching
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load products for new operation type
|
// Load products for new operation type from cache (forceRefresh: false)
|
||||||
ref.read(productsProvider.notifier).loadProducts(
|
ref.read(productsProvider.notifier).loadProducts(
|
||||||
widget.warehouseId,
|
widget.warehouseId,
|
||||||
widget.warehouseName,
|
widget.warehouseName,
|
||||||
_currentOperationType,
|
_currentOperationType,
|
||||||
|
forceRefresh: false, // Load from cache when switching tabs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load products when page is initialized
|
// Load products from cache when page is initialized (forceRefresh: false)
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
ref.read(productsProvider.notifier).loadProducts(
|
ref.read(productsProvider.notifier).loadProducts(
|
||||||
widget.warehouseId,
|
widget.warehouseId,
|
||||||
widget.warehouseName,
|
widget.warehouseName,
|
||||||
_currentOperationType,
|
_currentOperationType,
|
||||||
|
forceRefresh: false, // Load from cache on initial load
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Scan Barcode',
|
'Quét mã vạch',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -161,7 +163,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
color: Colors.grey.shade900,
|
color: Colors.grey.shade900,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Position the Code 128 barcode within the frame to scan',
|
'Đặt mã vạch Code 128 vào khung để quét',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -199,7 +201,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
// Invalid barcode format
|
// Invalid barcode format
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Invalid barcode format: "$barcode"'),
|
content: Text('Định dạng mã vạch không hợp lệ: "$barcode"'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'OK',
|
label: 'OK',
|
||||||
@@ -212,11 +214,12 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to product detail with productId and optional stageId
|
// Navigate to product detail with productId and optional stageId
|
||||||
|
// Use the currently selected tab's operation type
|
||||||
context.goToProductDetail(
|
context.goToProductDetail(
|
||||||
warehouseId: widget.warehouseId,
|
warehouseId: widget.warehouseId,
|
||||||
productId: productId,
|
productId: productId,
|
||||||
warehouseName: widget.warehouseName,
|
warehouseName: widget.warehouseName,
|
||||||
operationType: widget.operationType,
|
operationType: _currentOperationType,
|
||||||
stageId: stageId,
|
stageId: stageId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,7 +253,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Products',
|
'Sản phẩm',
|
||||||
style: textTheme.titleMedium,
|
style: textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -265,7 +268,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
onPressed: _onRefresh,
|
onPressed: _onRefresh,
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Làm mới',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
@@ -273,11 +276,11 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(
|
Tab(
|
||||||
icon: Icon(Icons.arrow_downward),
|
icon: Icon(Icons.arrow_downward),
|
||||||
text: 'Import',
|
text: 'Nhập kho',
|
||||||
),
|
),
|
||||||
Tab(
|
Tab(
|
||||||
icon: Icon(Icons.arrow_upward),
|
icon: Icon(Icons.arrow_upward),
|
||||||
text: 'Export',
|
text: 'Xuất kho',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -291,7 +294,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
floatingActionButton: products.isNotEmpty
|
floatingActionButton: products.isNotEmpty
|
||||||
? FloatingActionButton(
|
? FloatingActionButton(
|
||||||
onPressed: _showBarcodeScanner,
|
onPressed: _showBarcodeScanner,
|
||||||
tooltip: 'Scan Barcode',
|
tooltip: 'Quét mã vạch',
|
||||||
child: const Icon(Icons.qr_code_scanner),
|
child: const Icon(Icons.qr_code_scanner),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -336,14 +339,14 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_currentOperationType == 'import'
|
_currentOperationType == 'import'
|
||||||
? 'Import Products'
|
? 'Nhập kho'
|
||||||
: 'Export Products',
|
: 'Xuất kho',
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Warehouse: ${widget.warehouseName}',
|
'Kho: ${widget.warehouseName}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -385,7 +388,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(),
|
CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text('Loading products...'),
|
Text('Đang tải sản phẩm...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -406,7 +409,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Error',
|
'Lỗi',
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
color: theme.colorScheme.error,
|
color: theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
@@ -421,7 +424,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _onRefresh,
|
onPressed: _onRefresh,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Retry'),
|
label: const Text('Thử lại'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -444,12 +447,12 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No Products',
|
'Không có sản phẩm',
|
||||||
style: theme.textTheme.titleLarge,
|
style: theme.textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'No products found for this warehouse and operation type.',
|
'Không tìm thấy sản phẩm nào cho kho và loại thao tác này.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
@@ -459,7 +462,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _onRefresh,
|
onPressed: _onRefresh,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Làm mới'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -478,12 +481,12 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
|||||||
return ProductListItem(
|
return ProductListItem(
|
||||||
product: product,
|
product: product,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Navigate to product detail page
|
// Navigate to product detail page with current tab's operation type
|
||||||
context.goToProductDetail(
|
context.goToProductDetail(
|
||||||
warehouseId: widget.warehouseId,
|
warehouseId: widget.warehouseId,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
warehouseName: widget.warehouseName,
|
warehouseName: widget.warehouseName,
|
||||||
operationType: widget.operationType,
|
operationType: _currentOperationType,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
|||||||
/// [warehouseId] - The ID of the warehouse
|
/// [warehouseId] - The ID of the warehouse
|
||||||
/// [warehouseName] - The name of the warehouse (for display)
|
/// [warehouseName] - The name of the warehouse (for display)
|
||||||
/// [type] - The operation type ('import' or 'export')
|
/// [type] - The operation type ('import' or 'export')
|
||||||
|
/// [forceRefresh] - If true, bypass cache and fetch from API
|
||||||
Future<void> loadProducts(
|
Future<void> loadProducts(
|
||||||
int warehouseId,
|
int warehouseId,
|
||||||
String warehouseName,
|
String warehouseName,
|
||||||
String type,
|
String type, {
|
||||||
) async {
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
// Set loading state
|
// Set loading state
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -66,8 +68,12 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
|||||||
operationType: type,
|
operationType: type,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Call the use case
|
// Call the use case with forceRefresh flag
|
||||||
final result = await getProductsUseCase(warehouseId, type);
|
final result = await getProductsUseCase(
|
||||||
|
warehouseId,
|
||||||
|
type,
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle the result
|
// Handle the result
|
||||||
result.fold(
|
result.fold(
|
||||||
@@ -95,13 +101,14 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
|||||||
state = const ProductsState();
|
state = const ProductsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh products
|
/// Refresh products - forces fetch from API
|
||||||
Future<void> refreshProducts() async {
|
Future<void> refreshProducts() async {
|
||||||
if (state.warehouseId != null) {
|
if (state.warehouseId != null) {
|
||||||
await loadProducts(
|
await loadProducts(
|
||||||
state.warehouseId!,
|
state.warehouseId!,
|
||||||
state.warehouseName ?? '',
|
state.warehouseName ?? '',
|
||||||
state.operationType,
|
state.operationType,
|
||||||
|
forceRefresh: true, // Always force refresh when explicitly requested
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Code: ${product.code}',
|
'Mã: ${product.code}',
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
@@ -65,7 +65,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Active',
|
'Hoạt động',
|
||||||
style: textTheme.labelSmall?.copyWith(
|
style: textTheme.labelSmall?.copyWith(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -84,7 +84,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _InfoItem(
|
child: _InfoItem(
|
||||||
label: 'Weight',
|
label: 'Khối lượng',
|
||||||
value: '${product.weight.toStringAsFixed(2)} kg',
|
value: '${product.weight.toStringAsFixed(2)} kg',
|
||||||
icon: Icons.fitness_center,
|
icon: Icons.fitness_center,
|
||||||
),
|
),
|
||||||
@@ -92,7 +92,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _InfoItem(
|
child: _InfoItem(
|
||||||
label: 'Pieces',
|
label: 'Số lượng',
|
||||||
value: product.pieces.toString(),
|
value: product.pieces.toString(),
|
||||||
icon: Icons.inventory_2,
|
icon: Icons.inventory_2,
|
||||||
),
|
),
|
||||||
@@ -107,7 +107,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _InfoItem(
|
child: _InfoItem(
|
||||||
label: 'In Stock (Pieces)',
|
label: 'Tồn kho (SL)',
|
||||||
value: product.piecesInStock.toString(),
|
value: product.piecesInStock.toString(),
|
||||||
icon: Icons.warehouse,
|
icon: Icons.warehouse,
|
||||||
color: product.piecesInStock > 0
|
color: product.piecesInStock > 0
|
||||||
@@ -118,7 +118,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _InfoItem(
|
child: _InfoItem(
|
||||||
label: 'In Stock (Weight)',
|
label: 'Tồn kho (KL)',
|
||||||
value: '${product.weightInStock.toStringAsFixed(2)} kg',
|
value: '${product.weightInStock.toStringAsFixed(2)} kg',
|
||||||
icon: Icons.scale,
|
icon: Icons.scale,
|
||||||
color: product.weightInStock > 0
|
color: product.weightInStock > 0
|
||||||
@@ -142,7 +142,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Conversion Rate',
|
'Tỷ lệ chuyển đổi',
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -170,7 +170,7 @@ class ProductListItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Barcode: ${product.barcode}',
|
'Mã vạch: ${product.barcode}',
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import '../models/user_model.dart';
|
|||||||
abstract class UsersRemoteDataSource {
|
abstract class UsersRemoteDataSource {
|
||||||
/// Fetch all users from the API
|
/// Fetch all users from the API
|
||||||
Future<List<UserModel>> getUsers();
|
Future<List<UserModel>> getUsers();
|
||||||
|
|
||||||
|
/// Get current logged-in user
|
||||||
|
Future<UserModel> getCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of UsersRemoteDataSource using ApiClient
|
/// Implementation of UsersRemoteDataSource using ApiClient
|
||||||
@@ -54,4 +57,41 @@ class UsersRemoteDataSourceImpl implements UsersRemoteDataSource {
|
|||||||
throw ServerException('Failed to get users: ${e.toString()}');
|
throw ServerException('Failed to get users: ${e.toString()}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserModel> getCurrentUser() async {
|
||||||
|
try {
|
||||||
|
// Make API call to get current user
|
||||||
|
final response = await apiClient.get(ApiEndpoints.getCurrentUser);
|
||||||
|
|
||||||
|
// Parse the API response using ApiResponse wrapper
|
||||||
|
final apiResponse = ApiResponse.fromJson(
|
||||||
|
response.data as Map<String, dynamic>,
|
||||||
|
(json) => UserModel.fromJson(json as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 current user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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 current user: ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ class UsersNotifier extends StateNotifier<UsersState> {
|
|||||||
UsersNotifier({
|
UsersNotifier({
|
||||||
required this.getUsersUseCase,
|
required this.getUsersUseCase,
|
||||||
required this.syncUsersUseCase,
|
required this.syncUsersUseCase,
|
||||||
}) : super(const UsersState());
|
}) : super(const UsersState()) {
|
||||||
|
// Load local users on initialization
|
||||||
|
getUsers();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get users from local storage (or API if not cached)
|
/// Get users from local storage (or API if not cached)
|
||||||
Future<void> getUsers() async {
|
Future<void> getUsers() async {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/di/providers.dart';
|
import '../../../../core/di/providers.dart';
|
||||||
import '../../../../core/router/app_router.dart';
|
import '../../../../core/router/app_router.dart';
|
||||||
|
import '../../../../core/storage/secure_storage.dart';
|
||||||
import '../widgets/warehouse_card.dart';
|
import '../widgets/warehouse_card.dart';
|
||||||
|
import '../widgets/warehouse_drawer.dart';
|
||||||
|
|
||||||
/// Warehouse selection page
|
/// Warehouse selection page
|
||||||
/// Displays a list of warehouses and allows user to select one
|
/// Displays a list of warehouses and allows user to select one
|
||||||
@@ -26,14 +28,35 @@ class _WarehouseSelectionPageState
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Load warehouses and sync users when page is first created
|
// Load warehouses when page is first created
|
||||||
Future.microtask(() {
|
Future.microtask(() async {
|
||||||
ref.read(warehouseProvider.notifier).loadWarehouses();
|
ref.read(warehouseProvider.notifier).loadWarehouses();
|
||||||
// Sync users from API and save to local storage
|
// Users are automatically loaded from local storage by UsersNotifier
|
||||||
ref.read(usersProvider.notifier).syncUsers();
|
|
||||||
|
// Get current user and store user ID
|
||||||
|
await _getCurrentUserAndStoreId();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get current user from API and store user ID in secure storage
|
||||||
|
Future<void> _getCurrentUserAndStoreId() async {
|
||||||
|
try {
|
||||||
|
final secureStorage = SecureStorage();
|
||||||
|
final usersDataSource = ref.read(usersRemoteDataSourceProvider);
|
||||||
|
|
||||||
|
// Call API to get current user
|
||||||
|
final currentUser = await usersDataSource.getCurrentUser();
|
||||||
|
|
||||||
|
// Store the current user ID
|
||||||
|
await secureStorage.saveCurrentUserId(currentUser.id);
|
||||||
|
|
||||||
|
debugPrint('Current user ID stored: ${currentUser.id}');
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - this is not critical
|
||||||
|
debugPrint('Error getting current user: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Watch warehouse state
|
// Watch warehouse state
|
||||||
@@ -44,17 +67,18 @@ class _WarehouseSelectionPageState
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Select Warehouse'),
|
title: const Text('Chọn kho'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(warehouseProvider.notifier).loadWarehouses();
|
ref.read(warehouseProvider.notifier).loadWarehouses();
|
||||||
},
|
},
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Làm mới',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
drawer: const WarehouseDrawer(),
|
||||||
body: _buildBody(
|
body: _buildBody(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
@@ -77,7 +101,7 @@ class _WarehouseSelectionPageState
|
|||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(),
|
CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text('Loading warehouses...'),
|
Text('Đang tải danh sách kho...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -98,7 +122,7 @@ class _WarehouseSelectionPageState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Error Loading Warehouses',
|
'Lỗi tải danh sách kho',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -113,7 +137,7 @@ class _WarehouseSelectionPageState
|
|||||||
ref.read(warehouseProvider.notifier).loadWarehouses();
|
ref.read(warehouseProvider.notifier).loadWarehouses();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Retry'),
|
label: const Text('Thử lại'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -136,12 +160,12 @@ class _WarehouseSelectionPageState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No Warehouses Available',
|
'Không có kho',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'There are no warehouses to display.',
|
'Không có kho nào để hiển thị.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
@@ -151,7 +175,7 @@ class _WarehouseSelectionPageState
|
|||||||
ref.read(warehouseProvider.notifier).loadWarehouses();
|
ref.read(warehouseProvider.notifier).loadWarehouses();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Refresh'),
|
label: const Text('Làm mới'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class WarehouseCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Code: ${warehouse.code}',
|
'Mã: ${warehouse.code}',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -68,7 +68,7 @@ class WarehouseCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Items: ${warehouse.totalCount}',
|
'Sản phẩm: ${warehouse.totalCount}',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -104,7 +104,7 @@ class WarehouseCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'NG Warehouse',
|
'Kho NG',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../core/di/providers.dart';
|
||||||
|
|
||||||
|
/// Drawer for warehouse selection page
|
||||||
|
/// Contains app settings and sync options
|
||||||
|
class WarehouseDrawer extends ConsumerWidget {
|
||||||
|
const WarehouseDrawer({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
final usersState = ref.watch(usersProvider);
|
||||||
|
final user = authState.user;
|
||||||
|
|
||||||
|
return Drawer(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header with user info
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 32,
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 32,
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
user?.username ?? 'User',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Quản lý kho',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Menu items
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
children: [
|
||||||
|
// Sync Users button
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.sync,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: const Text('Đồng bộ người dùng'),
|
||||||
|
subtitle: Text(
|
||||||
|
usersState.users.isEmpty
|
||||||
|
? 'Chưa có dữ liệu'
|
||||||
|
: '${usersState.users.length} người dùng',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
trailing: usersState.isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.cloud_download,
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
onTap: usersState.isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
// Close drawer first
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 16),
|
||||||
|
Text('Đang đồng bộ...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync users from API
|
||||||
|
await ref.read(usersProvider.notifier).syncUsers();
|
||||||
|
|
||||||
|
// Show success or error message
|
||||||
|
if (context.mounted) {
|
||||||
|
final error = ref.read(usersProvider).error;
|
||||||
|
if (error != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error, color: Colors.white),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Text('Lỗi: $error')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'OK',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final count = ref.read(usersProvider).users.length;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Text('Đã đồng bộ $count người dùng'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Settings (placeholder)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Cài đặt'),
|
||||||
|
subtitle: const Text('Tùy chỉnh ứng dụng'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
// TODO: Navigate to settings page
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Tính năng đang phát triển'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// About (placeholder)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('Thông tin'),
|
||||||
|
subtitle: const Text('Về ứng dụng'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showAboutDialog(
|
||||||
|
context: context,
|
||||||
|
applicationName: 'Quản lý kho',
|
||||||
|
applicationVersion: '1.0.0',
|
||||||
|
applicationIcon: const Icon(Icons.warehouse, size: 48),
|
||||||
|
children: [
|
||||||
|
const Text('Hệ thống quản lý kho và theo dõi sản phẩm.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Logout button at bottom
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.logout,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Đăng xuất',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
// Capture references BEFORE closing drawer (drawer will be disposed)
|
||||||
|
final authNotifier = ref.read(authProvider.notifier);
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
|
||||||
|
navigator.pop(); // Close drawer
|
||||||
|
|
||||||
|
// Show logout confirmation dialog and get result
|
||||||
|
final shouldLogout = await _showLogoutDialog(context);
|
||||||
|
|
||||||
|
// If user confirmed, logout and navigate to login
|
||||||
|
if (shouldLogout == true) {
|
||||||
|
await authNotifier.logout();
|
||||||
|
|
||||||
|
// Navigate to login screen using captured router
|
||||||
|
router.go('/login');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _showLogoutDialog(BuildContext context) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Đăng xuất'),
|
||||||
|
content: const Text('Bạn có chắc chắn muốn đăng xuất?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Hủy'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('Đăng xuất'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class MyApp extends ConsumerWidget {
|
|||||||
final router = ref.watch(appRouterProvider);
|
final router = ref.watch(appRouterProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Warehouse Manager',
|
title: 'MinhThu',
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
|
|||||||
88
pubspec.lock
88
pubspec.lock
@@ -49,6 +49,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.0"
|
||||||
|
barcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: barcode
|
||||||
|
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.9"
|
||||||
|
barcode_widget:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: barcode_widget
|
||||||
|
sha256: "6f2c5b08659b1a5f4d88d183e6007133ea2f96e50e7b8bb628f03266c3931427"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
bidi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bidi
|
||||||
|
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.13"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -265,6 +289,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.1"
|
||||||
|
dropdown_search:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dropdown_search
|
||||||
|
sha256: c29b3e5147a82a06a4a08b3b574c51cb48cc17ad89893d53ee72a6f86643622e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.2"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -448,6 +480,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.2.5"
|
version: "13.2.5"
|
||||||
|
google_fonts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_fonts
|
||||||
|
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.2"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -664,6 +704,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -712,6 +760,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
pdf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pdf
|
||||||
|
sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.11.3"
|
||||||
|
pdf_widget_wrapper:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pdf_widget_wrapper
|
||||||
|
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.4"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -752,6 +816,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.0.3"
|
||||||
|
printing:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: printing
|
||||||
|
sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.14.2"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -768,6 +840,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.5.0"
|
||||||
|
qr:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: qr
|
||||||
|
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -949,6 +1029,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sunmi_printer_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sunmi_printer_plus
|
||||||
|
sha256: "77293b7da16bdf3805c5a24ea41731978e8a31da99c3fca38a658a0778450b78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ dependencies:
|
|||||||
shimmer: ^3.0.0
|
shimmer: ^3.0.0
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
cupertino_icons: ^1.0.6
|
cupertino_icons: ^1.0.6
|
||||||
|
dropdown_search: ^6.0.1
|
||||||
|
|
||||||
|
# Printing & PDF
|
||||||
|
printing: ^5.13.4
|
||||||
|
pdf: ^3.11.3
|
||||||
|
barcode_widget: ^2.0.4
|
||||||
|
google_fonts: ^6.2.1
|
||||||
|
sunmi_printer_plus: ^4.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -57,6 +65,7 @@ flutter:
|
|||||||
# Assets
|
# Assets
|
||||||
assets:
|
assets:
|
||||||
- assets/app_icon.jpg
|
- assets/app_icon.jpg
|
||||||
|
- assets/fonts/
|
||||||
|
|
||||||
# Flutter Launcher Icons Configuration
|
# Flutter Launcher Icons Configuration
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
|
|||||||
Reference in New Issue
Block a user