Compare commits
17 Commits
e14ae56c3c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff639fc42 | ||
|
|
1cfdd2c0c6 | ||
| ff25363a19 | |||
| 9df4b79a66 | |||
| 2a6ec8f6b8 | |||
| f47700ad2b | |||
| 68cc5c0df3 | |||
| 2495330bf5 | |||
|
|
efcc6306b0 | ||
|
|
c12869b01f | ||
|
|
cb4df363ab | ||
| 2905668358 | |||
| f32e1c16fb | |||
|
|
73b77c27de | ||
|
|
4b35d236df | ||
|
|
5cfc56f40d | ||
|
|
0010446298 |
@@ -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
@@ -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.
|
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
BIN
assets/app_icon.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
2151
assets/fonts/NotoSans-Bold.ttf
Normal file
2151
assets/fonts/NotoSans-Regular.ttf
Normal file
@@ -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
|
||||||
|
|||||||
@@ -540,7 +540,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -597,7 +597,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 997 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 21 KiB |
@@ -34,6 +34,18 @@ class ApiEndpoints {
|
|||||||
/// GET: (requires auth token)
|
/// GET: (requires auth token)
|
||||||
static const String profile = '$apiVersion/auth/profile';
|
static const String profile = '$apiVersion/auth/profile';
|
||||||
|
|
||||||
|
// ==================== User Endpoints ====================
|
||||||
|
|
||||||
|
/// Get all users (short info)
|
||||||
|
/// GET: /PortalUser/GetAllMemberUserShortInfo (requires auth token)
|
||||||
|
/// Response: List of users
|
||||||
|
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
|
||||||
@@ -53,17 +65,30 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
// ==================== Product Endpoints ====================
|
// ==================== Product Endpoints ====================
|
||||||
|
|
||||||
/// Get products for a warehouse
|
/// Get products for import (all products)
|
||||||
/// GET: /portalProduct/getAllProduct (requires auth token)
|
/// GET: /portalProduct/getAllProduct (requires auth token)
|
||||||
/// Response: List of products
|
/// Response: List of products
|
||||||
static const String products = '/portalProduct/getAllProduct';
|
static const String products = '/portalProduct/getAllProduct';
|
||||||
|
|
||||||
|
/// Get products for export (products in specific warehouse)
|
||||||
|
/// GET: /portalWareHouse/GetAllProductsInWareHouse?warehouseId={id} (requires auth token)
|
||||||
|
/// Query param: warehouseId (int)
|
||||||
|
/// Response: List of products in warehouse
|
||||||
|
static String productsForExport(int warehouseId) =>
|
||||||
|
'/portalWareHouse/GetAllProductsInWareHouse?warehouseId=$warehouseId';
|
||||||
|
|
||||||
/// Get product stage in warehouse
|
/// Get product stage in warehouse
|
||||||
/// POST: /portalWareHouse/GetProductStageInWareHouse
|
/// POST: /portalWareHouse/GetProductStageInWareHouse
|
||||||
/// Body: { "WareHouseId": int, "ProductId": int }
|
/// Body: { "WareHouseId": int, "ProductId": int }
|
||||||
/// Response: Product details with stage information
|
/// Response: Product details with stage information
|
||||||
static const String productDetail = '/portalWareHouse/GetProductStageInWareHouse';
|
static const String productDetail = '/portalWareHouse/GetProductStageInWareHouse';
|
||||||
|
|
||||||
|
/// Create product warehouse (import/export)
|
||||||
|
/// POST: /portalWareHouse/createProductWareHouse
|
||||||
|
/// Body: Array of product warehouse creation objects
|
||||||
|
/// Response: Created product warehouse record
|
||||||
|
static const String createProductWarehouse = '/portalWareHouse/createProductWareHouse';
|
||||||
|
|
||||||
/// Get product by ID
|
/// Get product by ID
|
||||||
/// GET: (requires auth token)
|
/// GET: (requires auth token)
|
||||||
/// Parameter: productId
|
/// Parameter: productId
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -18,6 +19,14 @@ import '../../features/warehouse/data/repositories/warehouse_repository_impl.dar
|
|||||||
import '../../features/warehouse/domain/repositories/warehouse_repository.dart';
|
import '../../features/warehouse/domain/repositories/warehouse_repository.dart';
|
||||||
import '../../features/warehouse/domain/usecases/get_warehouses_usecase.dart';
|
import '../../features/warehouse/domain/usecases/get_warehouses_usecase.dart';
|
||||||
import '../../features/warehouse/presentation/providers/warehouse_provider.dart';
|
import '../../features/warehouse/presentation/providers/warehouse_provider.dart';
|
||||||
|
import '../../features/users/data/datasources/users_local_datasource.dart';
|
||||||
|
import '../../features/users/data/datasources/users_remote_datasource.dart';
|
||||||
|
import '../../features/users/data/repositories/users_repository_impl.dart';
|
||||||
|
import '../../features/users/domain/repositories/users_repository.dart';
|
||||||
|
import '../../features/users/domain/usecases/get_users_usecase.dart';
|
||||||
|
import '../../features/users/domain/usecases/sync_users_usecase.dart';
|
||||||
|
import '../../features/users/presentation/providers/users_provider.dart';
|
||||||
|
import '../../features/users/presentation/providers/users_state.dart';
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
import '../storage/secure_storage.dart';
|
import '../storage/secure_storage.dart';
|
||||||
|
|
||||||
@@ -248,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 =
|
||||||
@@ -258,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
|
||||||
@@ -392,6 +412,105 @@ final productDetailErrorProvider = Provider.family<String?, String>((ref, key) {
|
|||||||
return state.error;
|
return state.error;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// ========================================================================
|
||||||
|
/// USERS FEATURE PROVIDERS
|
||||||
|
/// ========================================================================
|
||||||
|
/// Providers for users feature following clean architecture
|
||||||
|
|
||||||
|
// Data Layer
|
||||||
|
|
||||||
|
/// Users local data source provider
|
||||||
|
/// Handles local storage operations for users using Hive
|
||||||
|
final usersLocalDataSourceProvider = Provider<UsersLocalDataSource>((ref) {
|
||||||
|
return UsersLocalDataSourceImpl();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Users remote data source provider
|
||||||
|
/// Handles API calls for users
|
||||||
|
final usersRemoteDataSourceProvider = Provider<UsersRemoteDataSource>((ref) {
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
return UsersRemoteDataSourceImpl(apiClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Users repository provider
|
||||||
|
/// Implements domain repository interface
|
||||||
|
/// Coordinates between local and remote data sources
|
||||||
|
final usersRepositoryProvider = Provider<UsersRepository>((ref) {
|
||||||
|
final remoteDataSource = ref.watch(usersRemoteDataSourceProvider);
|
||||||
|
final localDataSource = ref.watch(usersLocalDataSourceProvider);
|
||||||
|
return UsersRepositoryImpl(
|
||||||
|
remoteDataSource: remoteDataSource,
|
||||||
|
localDataSource: localDataSource,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Domain Layer
|
||||||
|
|
||||||
|
/// Get users use case provider
|
||||||
|
/// Encapsulates user fetching business logic (local-first strategy)
|
||||||
|
final getUsersUseCaseProvider = Provider<GetUsersUseCase>((ref) {
|
||||||
|
final repository = ref.watch(usersRepositoryProvider);
|
||||||
|
return GetUsersUseCase(repository);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Sync users use case provider
|
||||||
|
/// Encapsulates user syncing business logic (force refresh from API)
|
||||||
|
final syncUsersUseCaseProvider = Provider<SyncUsersUseCase>((ref) {
|
||||||
|
final repository = ref.watch(usersRepositoryProvider);
|
||||||
|
return SyncUsersUseCase(repository);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Presentation Layer
|
||||||
|
|
||||||
|
/// Users state notifier provider
|
||||||
|
/// Manages users state including list, loading, and errors
|
||||||
|
final usersProvider = StateNotifierProvider<UsersNotifier, UsersState>((ref) {
|
||||||
|
final getUsersUseCase = ref.watch(getUsersUseCaseProvider);
|
||||||
|
final syncUsersUseCase = ref.watch(syncUsersUseCaseProvider);
|
||||||
|
return UsersNotifier(
|
||||||
|
getUsersUseCase: getUsersUseCase,
|
||||||
|
syncUsersUseCase: syncUsersUseCase,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convenient providers for users state
|
||||||
|
|
||||||
|
/// Provider to get list of users
|
||||||
|
/// Usage: ref.watch(usersListProvider)
|
||||||
|
final usersListProvider = Provider((ref) {
|
||||||
|
final usersState = ref.watch(usersProvider);
|
||||||
|
return usersState.users;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to check if users are loading
|
||||||
|
/// Usage: ref.watch(isUsersLoadingProvider)
|
||||||
|
final isUsersLoadingProvider = Provider<bool>((ref) {
|
||||||
|
final usersState = ref.watch(usersProvider);
|
||||||
|
return usersState.isLoading;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to check if users list has items
|
||||||
|
/// Usage: ref.watch(hasUsersProvider)
|
||||||
|
final hasUsersProvider = Provider<bool>((ref) {
|
||||||
|
final usersState = ref.watch(usersProvider);
|
||||||
|
return usersState.users.isNotEmpty;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to get users count
|
||||||
|
/// Usage: ref.watch(usersCountProvider)
|
||||||
|
final usersCountProvider = Provider<int>((ref) {
|
||||||
|
final usersState = ref.watch(usersProvider);
|
||||||
|
return usersState.users.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider to get users error
|
||||||
|
/// Returns null if no error
|
||||||
|
/// Usage: ref.watch(usersErrorProvider)
|
||||||
|
final usersErrorProvider = Provider<String?>((ref) {
|
||||||
|
final usersState = ref.watch(usersProvider);
|
||||||
|
return usersState.error;
|
||||||
|
});
|
||||||
|
|
||||||
/// ========================================================================
|
/// ========================================================================
|
||||||
/// USAGE EXAMPLES
|
/// USAGE EXAMPLES
|
||||||
/// ========================================================================
|
/// ========================================================================
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,42 +81,34 @@ class AppRouter {
|
|||||||
),
|
),
|
||||||
|
|
||||||
/// Products List Route
|
/// Products List Route
|
||||||
/// Path: /products
|
/// Path: /products/:warehouseId/:operationType
|
||||||
/// Takes warehouse, warehouseName, and operationType as extra parameter
|
/// Query params: name (warehouse name)
|
||||||
/// Shows products for selected warehouse and operation
|
/// Shows products for selected warehouse and operation
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/products',
|
path: '/products/:warehouseId/:operationType',
|
||||||
name: 'products',
|
name: 'products',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final params = state.extra as Map<String, dynamic>?;
|
// Extract path parameters
|
||||||
|
final warehouseIdStr = state.pathParameters['warehouseId'];
|
||||||
|
final operationType = state.pathParameters['operationType'];
|
||||||
|
|
||||||
if (params == null) {
|
// Extract query parameter
|
||||||
// If no params, redirect to warehouses
|
final warehouseName = state.uri.queryParameters['name'];
|
||||||
|
|
||||||
|
// Parse and validate parameters
|
||||||
|
final warehouseId = int.tryParse(warehouseIdStr ?? '');
|
||||||
|
|
||||||
|
if (warehouseId == null || warehouseName == null || operationType == null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.go('/warehouses');
|
context.go('/warehouses');
|
||||||
});
|
});
|
||||||
return const _ErrorScreen(
|
return const _ErrorScreen(
|
||||||
message: 'Product parameters are required',
|
message: 'Tham số sản phẩm không hợp lệ',
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract required parameters
|
|
||||||
final warehouse = params['warehouse'] as WarehouseEntity?;
|
|
||||||
final warehouseName = params['warehouseName'] as String?;
|
|
||||||
final operationType = params['operationType'] as String?;
|
|
||||||
|
|
||||||
// Validate parameters
|
|
||||||
if (warehouse == null || warehouseName == null || operationType == null) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
context.go('/warehouses');
|
|
||||||
});
|
|
||||||
return const _ErrorScreen(
|
|
||||||
message: 'Invalid product parameters',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProductsPage(
|
return ProductsPage(
|
||||||
warehouseId: warehouse.id,
|
warehouseId: warehouseId,
|
||||||
warehouseName: warehouseName,
|
warehouseName: warehouseName,
|
||||||
operationType: operationType,
|
operationType: operationType,
|
||||||
);
|
);
|
||||||
@@ -124,40 +116,34 @@ class AppRouter {
|
|||||||
),
|
),
|
||||||
|
|
||||||
/// Product Detail Route
|
/// Product Detail Route
|
||||||
/// Path: /product-detail
|
/// Path: /product-detail/:warehouseId/:productId/:operationType
|
||||||
/// Takes warehouseId, productId, warehouseName, and optional stageId as extra parameter
|
/// Query params: name (warehouse name), stageId (optional)
|
||||||
/// Shows detailed information for a specific product
|
/// Shows detailed information for a specific product
|
||||||
/// If stageId is provided, only that stage is shown, otherwise all stages are shown
|
/// If stageId is provided, only that stage is shown, otherwise all stages are shown
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/product-detail',
|
path: '/product-detail/:warehouseId/:productId/:operationType',
|
||||||
name: 'product-detail',
|
name: 'product-detail',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final params = state.extra as Map<String, dynamic>?;
|
// Extract path parameters
|
||||||
|
final warehouseIdStr = state.pathParameters['warehouseId'];
|
||||||
|
final productIdStr = state.pathParameters['productId'];
|
||||||
|
final operationType = state.pathParameters['operationType'] ?? 'import';
|
||||||
|
|
||||||
if (params == null) {
|
// Extract query parameters
|
||||||
// If no params, redirect to warehouses
|
final warehouseName = state.uri.queryParameters['name'];
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
final stageIdStr = state.uri.queryParameters['stageId'];
|
||||||
context.go('/warehouses');
|
|
||||||
});
|
|
||||||
return const _ErrorScreen(
|
|
||||||
message: 'Product detail parameters are required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract required parameters
|
// Parse and validate parameters
|
||||||
final warehouseId = params['warehouseId'] as int?;
|
final warehouseId = int.tryParse(warehouseIdStr ?? '');
|
||||||
final productId = params['productId'] as int?;
|
final productId = int.tryParse(productIdStr ?? '');
|
||||||
final warehouseName = params['warehouseName'] as String?;
|
final stageId = stageIdStr != null ? int.tryParse(stageIdStr) : null;
|
||||||
// Extract optional stageId
|
|
||||||
final stageId = params['stageId'] as int?;
|
|
||||||
|
|
||||||
// Validate parameters
|
|
||||||
if (warehouseId == null || productId == null || warehouseName == null) {
|
if (warehouseId == null || productId == null || warehouseName == null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
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ệ',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +152,7 @@ class AppRouter {
|
|||||||
productId: productId,
|
productId: productId,
|
||||||
warehouseName: warehouseName,
|
warehouseName: warehouseName,
|
||||||
stageId: stageId,
|
stageId: stageId,
|
||||||
|
operationType: operationType,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -176,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(
|
||||||
@@ -189,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,
|
||||||
),
|
),
|
||||||
@@ -203,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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -296,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(
|
||||||
@@ -309,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),
|
||||||
@@ -326,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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -360,18 +347,11 @@ extension AppRouterExtension on BuildContext {
|
|||||||
///
|
///
|
||||||
/// [warehouse] - Selected warehouse entity
|
/// [warehouse] - Selected warehouse entity
|
||||||
/// [operationType] - Either 'import' or 'export'
|
/// [operationType] - Either 'import' or 'export'
|
||||||
void goToProducts({
|
void pushToProducts({
|
||||||
required WarehouseEntity warehouse,
|
required WarehouseEntity warehouse,
|
||||||
required String operationType,
|
required String operationType,
|
||||||
}) {
|
}) {
|
||||||
go(
|
push('/products/${warehouse.id}/$operationType?name=${Uri.encodeQueryComponent(warehouse.name)}');
|
||||||
'/products',
|
|
||||||
extra: {
|
|
||||||
'warehouse': warehouse,
|
|
||||||
'warehouseName': warehouse.name,
|
|
||||||
'operationType': operationType,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to product detail page
|
/// Navigate to product detail page
|
||||||
@@ -379,22 +359,23 @@ extension AppRouterExtension on BuildContext {
|
|||||||
/// [warehouseId] - ID of the warehouse
|
/// [warehouseId] - ID of the warehouse
|
||||||
/// [productId] - ID of the product to view
|
/// [productId] - ID of the product to view
|
||||||
/// [warehouseName] - Name of the warehouse (for display)
|
/// [warehouseName] - Name of the warehouse (for display)
|
||||||
|
/// [operationType] - Either 'import' or 'export'
|
||||||
/// [stageId] - Optional ID of specific stage to show (if null, show all stages)
|
/// [stageId] - Optional ID of specific stage to show (if null, show all stages)
|
||||||
void goToProductDetail({
|
void goToProductDetail({
|
||||||
required int warehouseId,
|
required int warehouseId,
|
||||||
required int productId,
|
required int productId,
|
||||||
required String warehouseName,
|
required String warehouseName,
|
||||||
|
required String operationType,
|
||||||
int? stageId,
|
int? stageId,
|
||||||
}) {
|
}) {
|
||||||
push(
|
final queryParams = <String, String>{
|
||||||
'/product-detail',
|
'name': warehouseName,
|
||||||
extra: {
|
if (stageId != null) 'stageId': stageId.toString(),
|
||||||
'warehouseId': warehouseId,
|
};
|
||||||
'productId': productId,
|
final queryString = queryParams.entries
|
||||||
'warehouseName': warehouseName,
|
.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}')
|
||||||
if (stageId != null) 'stageId': stageId,
|
.join('&');
|
||||||
},
|
push('/product-detail/$warehouseId/$productId/$operationType?$queryString');
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop current route
|
/// Pop current route
|
||||||
@@ -421,11 +402,13 @@ extension AppRouterNamedExtension on BuildContext {
|
|||||||
}) {
|
}) {
|
||||||
goNamed(
|
goNamed(
|
||||||
'products',
|
'products',
|
||||||
extra: {
|
pathParameters: {
|
||||||
'warehouse': warehouse,
|
'warehouseId': warehouse.id.toString(),
|
||||||
'warehouseName': warehouse.name,
|
|
||||||
'operationType': operationType,
|
'operationType': operationType,
|
||||||
},
|
},
|
||||||
|
queryParameters: {
|
||||||
|
'name': warehouse.name,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ curl --request POST \
|
|||||||
}'
|
}'
|
||||||
|
|
||||||
|
|
||||||
#Get products
|
#Get products for import
|
||||||
curl --request GET \
|
curl --request GET \
|
||||||
--url https://dotnet.elidev.info:8157/ws/portalProduct/getAllProduct \
|
--url https://dotnet.elidev.info:8157/ws/portalProduct/getAllProduct \
|
||||||
--compressed \
|
--compressed \
|
||||||
@@ -54,6 +54,22 @@ curl --request GET \
|
|||||||
--header 'Sec-Fetch-Site: same-site' \
|
--header 'Sec-Fetch-Site: same-site' \
|
||||||
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0'
|
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0'
|
||||||
|
|
||||||
|
|
||||||
|
#Get product for export
|
||||||
|
curl 'https://dotnet.elidev.info:8157/ws/portalWareHouse/GetAllProductsInWareHouse?warehouseId=1' \
|
||||||
|
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0' \
|
||||||
|
-H 'Accept: application/json, text/plain, */*' \
|
||||||
|
-H 'Accept-Language: en-US,en;q=0.5' \
|
||||||
|
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
|
||||||
|
-H 'AccessToken: 1k5fXyQVXtGkfjwS4g2USldGyLSA7Zwa2jdj5tLe+3YfSDlk02aYgqsFh5xArkdL4N529x7IYJOGrLJJgLBPNVgD51zFfEYBzfJmMH2RUm7iegvDJaMCLISySw0zd6kcsqeJi7vtuybgY2NDPxDgiSOj4wX417PzB8AVg5bl1ZAAJ3LcVAqqtA1PDTU5ZU1QQYapNeBNxAHjnd2ojTZK1GJBIyY5Gd8P9gB880ppAKq8manNMZYsa4d8tkYf0SJUul2aqLIWJAwDGORpPmfjqkN4hMh85xAfPTZi6m4DdI0u2rHDMLaZ8eIsV16qA8wimSDnWi0VeG0SZ4ugbCdJAi3t1/uICTftiy06PJEkulBLV+h2xS/7SlmEY2xoN5ISi++3FNqsFPGa9QH6akGu2C7IXEUBCg3iGJx0uL+vULmVqk5OJIXdqiKVQ366hvhPlK2AM1zbh49x/ngibe08483WTL5uAY/fsKuBxQCpTc2368Gqhpd7QRtZFKpzikhyTWsR3nQIi6ExSstCeFbe8ehgo0PuTPZNHH5IHTc49snH6IZrSbR+F62Wu/D+4DlvMTK/ktG6LVQ3r3jSJC5MAQDV5Q9WK3RvsWMPvZrsaVW/Exz0GBgWP4W0adADg7MFSlnGDOJm6I4fCLHZIJCUww50L6iNmzvrdibrQT5jKACVgNquMZCfeZlf3m2BwUx9T6J45lAePpJ+QaMh+2voFqRiOLi98MLqOG6TW7z96sadzFVR9YU1xwM51jQDjnUlrXt0+msq29Jqt8LoCyQsG4r3RgS/tUJhximq11MDXsSXanpYM7jesjr8mAG4qjYN6z6c1Gl5N0dhcDF4HeEaIlNIgZ75FqtXZnLqvhHPyk6L2iR2ZT15nobZxLzOUad4a0OymUDUv7xuEBdEk5kmzZLDpbOxrKiyMpGSlbBhEoBMoA0u6ZKtBGQfCJ02s6Ri0WhLLM4XJCjGrpoEkTUuZ7YG39Zva19HGV0kkxeFYkG0lnZBO6jCggem5f+S2NQvXP/kUrWX1GeQFCq5PScvwJexLsbh0LKC2MGovkecoBKtNIK21V6ztvWL8lThJAl9' \
|
||||||
|
-H 'AppID: Minhthu2016' \
|
||||||
|
-H 'Origin: https://dotnet.elidev.info:8158' \
|
||||||
|
-H 'Connection: keep-alive' \
|
||||||
|
-H 'Referer: https://dotnet.elidev.info:8158/' \
|
||||||
|
-H 'Sec-Fetch-Dest: empty' \
|
||||||
|
-H 'Sec-Fetch-Mode: cors' \
|
||||||
|
-H 'Sec-Fetch-Site: same-site'
|
||||||
|
|
||||||
#Get product by id
|
#Get product by id
|
||||||
curl --request POST \
|
curl --request POST \
|
||||||
--url https://dotnet.elidev.info:8157/ws/portalWareHouse/GetProductStageInWareHouse \
|
--url https://dotnet.elidev.info:8157/ws/portalWareHouse/GetProductStageInWareHouse \
|
||||||
@@ -76,3 +92,60 @@ curl --request POST \
|
|||||||
"WareHouseId": 7,
|
"WareHouseId": 7,
|
||||||
"ProductId": 11
|
"ProductId": 11
|
||||||
}'
|
}'
|
||||||
|
|
||||||
|
#Create import product
|
||||||
|
curl 'https://dotnet.elidev.info:8157/ws/portalWareHouse/createProductWareHouse' \
|
||||||
|
-X POST \
|
||||||
|
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0' \
|
||||||
|
-H 'Accept: application/json, text/plain, */*' \
|
||||||
|
-H 'Accept-Language: en-US,en;q=0.5' \
|
||||||
|
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
|
||||||
|
-H 'AccessToken: 1k5fXyQVXtGkfjwS4g2USldGyLSA7Zwa2jdj5tLe+3YfSDlk02aYgqsFh5xArkdL4N529x7IYJOGrLJJgLBPNVgD51zFfEYBzfJmMH2RUm7iegvDJaMCLISySw0zd6kcsqeJi7vtuybgY2NDPxDgiSOj4wX417PzB8AVg5bl1ZAAJ3LcVAqqtA1PDTU5ZU1QQYapNeBNxAHjnd2ojTZK1GJBIyY5Gd8P9gB880ppAKq8manNMZYsa4d8tkYf0SJUul2aqLIWJAwDGORpPmfjqkN4hMh85xAfPTZi6m4DdI0u2rHDMLaZ8eIsV16qA8wimSDnWi0VeG0SZ4ugbCdJAi3t1/uICTftiy06PJEkulBLV+h2xS/7SlmEY2xoN5ISi++3FNqsFPGa9QH6akGu2C7IXEUBCg3iGJx0uL+vULmVqk5OJIXdqiKVQ366hvhPlK2AM1zbh49x/ngibe08483WTL5uAY/fsKuBxQCpTc2368Gqhpd7QRtZFKpzikhyTWsR3nQIi6ExSstCeFbe8ehgo0PuTPZNHH5IHTc49snH6IZrSbR+F62Wu/D+4DlvMTK/ktG6LVQ3r3jSJC5MAQDV5Q9WK3RvsWMPvZrsaVW/Exz0GBgWP4W0adADg7MFSlnGDOJm6I4fCLHZIJCUww50L6iNmzvrdibrQT5jKACVgNquMZCfeZlf3m2BwUx9T6J45lAePpJ+QaMh+2voFqRiOLi98MLqOG6TW7z96sadzFVR9YU1xwM51jQDjnUlrXt0+msq29Jqt8LoCyQsG4r3RgS/tUJhximq11MDXsSXanpYM7jesjr8mAG4qjYN6z6c1Gl5N0dhcDF4HeEaIlNIgZ75FqtXZnLqvhHPyk6L2iR2ZT15nobZxLzOUad4a0OymUDUv7xuEBdEk5kmzZLDpbOxrKiyMpGSlbBhEoBMoA0u6ZKtBGQfCJ02s6Ri0WhLLM4XJCjGrpoEkTUuZ7YG39Zva19HGV0kkxeFYkG0lnZBO6jCggem5f+S2NQvXP/kUrWX1GeQFCq5PScvwJexLsbh0LKC2MGovkecoBKtNIK21V6ztvWL8lThJAl9' \
|
||||||
|
-H 'AppID: Minhthu2016' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Origin: https://dotnet.elidev.info:8158' \
|
||||||
|
-H 'Connection: keep-alive' \
|
||||||
|
-H 'Referer: https://dotnet.elidev.info:8158/' \
|
||||||
|
-H 'Sec-Fetch-Dest: empty' \
|
||||||
|
-H 'Sec-Fetch-Mode: cors' \
|
||||||
|
-H 'Sec-Fetch-Site: same-site' \
|
||||||
|
-H 'Priority: u=0' \
|
||||||
|
--data-raw $'[{"TypeId":4,"ProductId":11,"StageId":3,"OrderId":null,"RecordDate":"2025-10-28T08:19:20.418Z","PassedQuantityWeight":0.5,"PassedQuantity":5,"IssuedQuantityWeight":0.1,"IssuedQuantity":1,"ResponsibleUserId":12043,"Description":"","ProductName":"Th\xe9p 435","ProductCode":"SCM435","StockPassedQuantityWeight":0,"StockPassedQuantity":0,"StockIssuedQuantity":0,"StockIssuedQuantityWeight":0,"ReceiverUserId":12120,"ActionTypeId":1,"WareHouseId":1,"ProductStageId":3,"IsConfirm":true}]'
|
||||||
|
|
||||||
|
#Get users
|
||||||
|
curl --request GET \
|
||||||
|
--url https://dotnet.elidev.info:8157/ws/PortalUser/GetAllMemberUserShortInfo \
|
||||||
|
--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/tUJhximq11MDXsSrJm6RubpKWl/MnF3QxcLTSwFE/SrGoGhnJnH5ILxZdyMN4PJjELeq3g8V5nEEm6lE/WNyvRMskel+Ods3XQIvE6o8KblUmFeM1rOIBkJbUVX7Ghaj0RNpvar86fF85BEozLBcED2XGkDANhGivuhqyrDpEOYjCwuC0eOjdj92fxlTTyo33ioR3xKcYFVlgMTlRX26sQDayf8hsPIBoDQtMHGKFfC2BJx4ujKTxtead8uz7c+CrlJuTbUkk+bp+wEUvKW5TvlX8HXKJWVLm05qZ7KmSLpsp35Iih2tZbBU+g==' \
|
||||||
|
--header 'AppID: Minhthu2016' \
|
||||||
|
--header 'Connection: keep-alive' \
|
||||||
|
--header 'Origin: https://dotnet.elidev.info:8158' \
|
||||||
|
--header 'Referer: https://dotnet.elidev.info:8158/' \
|
||||||
|
--header 'Sec-Fetch-Dest: empty' \
|
||||||
|
--header 'Sec-Fetch-Mode: cors' \
|
||||||
|
--header 'Sec-Fetch-Site: same-site' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv: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
@@ -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) {
|
||||||
|
|||||||
@@ -62,17 +62,23 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// App logo/icon
|
// App logo/icon
|
||||||
Icon(
|
Center(
|
||||||
Icons.warehouse_outlined,
|
child: ClipRRect(
|
||||||
size: 80,
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: theme.colorScheme.primary,
|
child: Image.asset(
|
||||||
|
'assets/app_icon.jpg',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -84,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import '../../../../core/constants/api_endpoints.dart';
|
|||||||
import '../../../../core/errors/exceptions.dart';
|
import '../../../../core/errors/exceptions.dart';
|
||||||
import '../../../../core/network/api_client.dart';
|
import '../../../../core/network/api_client.dart';
|
||||||
import '../../../../core/network/api_response.dart';
|
import '../../../../core/network/api_response.dart';
|
||||||
|
import '../models/create_product_warehouse_request.dart';
|
||||||
import '../models/product_detail_request_model.dart';
|
import '../models/product_detail_request_model.dart';
|
||||||
import '../models/product_model.dart';
|
import '../models/product_model.dart';
|
||||||
import '../models/product_stage_model.dart';
|
import '../models/product_stage_model.dart';
|
||||||
@@ -24,6 +25,14 @@ abstract class ProductsRemoteDataSource {
|
|||||||
/// Returns List<ProductStageModel> with all stages for the product
|
/// Returns List<ProductStageModel> with all stages for the product
|
||||||
/// Throws [ServerException] if the API call fails
|
/// Throws [ServerException] if the API call fails
|
||||||
Future<List<ProductStageModel>> getProductDetail(ProductDetailRequestModel request);
|
Future<List<ProductStageModel>> getProductDetail(ProductDetailRequestModel request);
|
||||||
|
|
||||||
|
/// Create product warehouse entry (import/export operation)
|
||||||
|
///
|
||||||
|
/// [request] - Request containing all product warehouse details
|
||||||
|
///
|
||||||
|
/// Returns void on success
|
||||||
|
/// Throws [ServerException] if the API call fails
|
||||||
|
Future<void> createProductWarehouse(CreateProductWarehouseRequest request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of ProductsRemoteDataSource using ApiClient
|
/// Implementation of ProductsRemoteDataSource using ApiClient
|
||||||
@@ -35,8 +44,13 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
|
|||||||
@override
|
@override
|
||||||
Future<List<ProductModel>> getProducts(int warehouseId, String type) async {
|
Future<List<ProductModel>> getProducts(int warehouseId, String type) async {
|
||||||
try {
|
try {
|
||||||
// Make API call to get all products
|
// Choose endpoint based on operation type
|
||||||
final response = await apiClient.get('/portalProduct/getAllProduct');
|
final endpoint = type == 'export'
|
||||||
|
? ApiEndpoints.productsForExport(warehouseId)
|
||||||
|
: ApiEndpoints.products;
|
||||||
|
|
||||||
|
// Make API call to get products
|
||||||
|
final response = await apiClient.get(endpoint);
|
||||||
|
|
||||||
// Parse the API response using ApiResponse wrapper
|
// Parse the API response using ApiResponse wrapper
|
||||||
final apiResponse = ApiResponse.fromJson(
|
final apiResponse = ApiResponse.fromJson(
|
||||||
@@ -88,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
|
||||||
@@ -121,4 +135,47 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
|
|||||||
throw ServerException('Failed to get product stages: ${e.toString()}');
|
throw ServerException('Failed to get product stages: ${e.toString()}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createProductWarehouse(
|
||||||
|
CreateProductWarehouseRequest request) async {
|
||||||
|
try {
|
||||||
|
// The API expects an array of requests
|
||||||
|
final requestData = [request.toJson()];
|
||||||
|
|
||||||
|
// Make API call to create product warehouse
|
||||||
|
final response = await apiClient.post(
|
||||||
|
ApiEndpoints.createProductWarehouse,
|
||||||
|
data: requestData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse the API response
|
||||||
|
final apiResponse = ApiResponse.fromJson(
|
||||||
|
response.data as Map<String, dynamic>,
|
||||||
|
(json) => json, // We don't need to parse the response value
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the API call was successful
|
||||||
|
if (!apiResponse.isSuccess) {
|
||||||
|
// Throw exception with error message from API
|
||||||
|
throw ServerException(
|
||||||
|
apiResponse.errors.isNotEmpty
|
||||||
|
? apiResponse.errors.first
|
||||||
|
: 'Failed to create product warehouse entry',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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 create product warehouse entry: ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/// Request model for creating product warehouse (import/export)
|
||||||
|
class CreateProductWarehouseRequest {
|
||||||
|
final int typeId;
|
||||||
|
final int productId;
|
||||||
|
final int stageId;
|
||||||
|
final int? orderId;
|
||||||
|
final String recordDate;
|
||||||
|
final double passedQuantityWeight;
|
||||||
|
final int passedQuantity;
|
||||||
|
final double issuedQuantityWeight;
|
||||||
|
final int issuedQuantity;
|
||||||
|
final int responsibleUserId;
|
||||||
|
final String description;
|
||||||
|
final String productName;
|
||||||
|
final String productCode;
|
||||||
|
final double stockPassedQuantityWeight;
|
||||||
|
final int stockPassedQuantity;
|
||||||
|
final int stockIssuedQuantity;
|
||||||
|
final double stockIssuedQuantityWeight;
|
||||||
|
final int receiverUserId;
|
||||||
|
final int actionTypeId;
|
||||||
|
final int wareHouseId;
|
||||||
|
final int productStageId;
|
||||||
|
final bool isConfirm;
|
||||||
|
|
||||||
|
CreateProductWarehouseRequest({
|
||||||
|
required this.typeId,
|
||||||
|
required this.productId,
|
||||||
|
required this.stageId,
|
||||||
|
this.orderId,
|
||||||
|
required this.recordDate,
|
||||||
|
required this.passedQuantityWeight,
|
||||||
|
required this.passedQuantity,
|
||||||
|
required this.issuedQuantityWeight,
|
||||||
|
required this.issuedQuantity,
|
||||||
|
required this.responsibleUserId,
|
||||||
|
this.description = '',
|
||||||
|
required this.productName,
|
||||||
|
required this.productCode,
|
||||||
|
this.stockPassedQuantityWeight = 0.0,
|
||||||
|
this.stockPassedQuantity = 0,
|
||||||
|
this.stockIssuedQuantity = 0,
|
||||||
|
this.stockIssuedQuantityWeight = 0.0,
|
||||||
|
required this.receiverUserId,
|
||||||
|
required this.actionTypeId,
|
||||||
|
required this.wareHouseId,
|
||||||
|
required this.productStageId,
|
||||||
|
this.isConfirm = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'TypeId': typeId,
|
||||||
|
'ProductId': productId,
|
||||||
|
'StageId': stageId,
|
||||||
|
'OrderId': orderId,
|
||||||
|
'RecordDate': recordDate,
|
||||||
|
'PassedQuantityWeight': passedQuantityWeight,
|
||||||
|
'PassedQuantity': passedQuantity,
|
||||||
|
'IssuedQuantityWeight': issuedQuantityWeight,
|
||||||
|
'IssuedQuantity': issuedQuantity,
|
||||||
|
'ResponsibleUserId': responsibleUserId,
|
||||||
|
'Description': description,
|
||||||
|
'ProductName': productName,
|
||||||
|
'ProductCode': productCode,
|
||||||
|
'StockPassedQuantityWeight': stockPassedQuantityWeight,
|
||||||
|
'StockPassedQuantity': stockPassedQuantity,
|
||||||
|
'StockIssuedQuantity': stockIssuedQuantity,
|
||||||
|
'StockIssuedQuantityWeight': stockIssuedQuantityWeight,
|
||||||
|
'ReceiverUserId': receiverUserId,
|
||||||
|
'ActionTypeId': actionTypeId,
|
||||||
|
'WareHouseId': wareHouseId,
|
||||||
|
'ProductStageId': productStageId,
|
||||||
|
'IsConfirm': isConfirm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ class ProductStageModel extends ProductStageEntity {
|
|||||||
required super.passedQuantityWeight,
|
required super.passedQuantityWeight,
|
||||||
required super.stageName,
|
required super.stageName,
|
||||||
required super.createdDate,
|
required super.createdDate,
|
||||||
|
super.productName,
|
||||||
|
super.productCode,
|
||||||
|
super.stageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Create ProductStageModel from JSON
|
/// Create ProductStageModel from JSON
|
||||||
@@ -27,6 +30,9 @@ class ProductStageModel extends ProductStageEntity {
|
|||||||
passedQuantityWeight: (json['PassedQuantityWeight'] as num).toDouble(),
|
passedQuantityWeight: (json['PassedQuantityWeight'] as num).toDouble(),
|
||||||
stageName: json['StageName'] as String?,
|
stageName: json['StageName'] as String?,
|
||||||
createdDate: json['CreatedDate'] as String,
|
createdDate: json['CreatedDate'] as String,
|
||||||
|
productName: json['ProductName'] as String? ?? '',
|
||||||
|
productCode: json['ProductCode'] as String? ?? '',
|
||||||
|
stageId: json['StageId'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +48,9 @@ class ProductStageModel extends ProductStageEntity {
|
|||||||
'PassedQuantityWeight': passedQuantityWeight,
|
'PassedQuantityWeight': passedQuantityWeight,
|
||||||
'StageName': stageName,
|
'StageName': stageName,
|
||||||
'CreatedDate': createdDate,
|
'CreatedDate': createdDate,
|
||||||
|
'ProductName': productName,
|
||||||
|
'ProductCode': productCode,
|
||||||
|
'StageId': stageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +66,9 @@ class ProductStageModel extends ProductStageEntity {
|
|||||||
passedQuantityWeight: passedQuantityWeight,
|
passedQuantityWeight: passedQuantityWeight,
|
||||||
stageName: stageName,
|
stageName: stageName,
|
||||||
createdDate: createdDate,
|
createdDate: createdDate,
|
||||||
|
productName: productName,
|
||||||
|
productCode: productCode,
|
||||||
|
stageId: stageId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,31 +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/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) {
|
||||||
@@ -65,4 +103,26 @@ class ProductsRepositoryImpl implements ProductsRepository {
|
|||||||
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
|
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> createProductWarehouse(
|
||||||
|
CreateProductWarehouseRequest request,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
// Call remote data source to create product warehouse
|
||||||
|
await remoteDataSource.createProductWarehouse(request);
|
||||||
|
|
||||||
|
// Return success
|
||||||
|
return const Right(null);
|
||||||
|
} on ServerException catch (e) {
|
||||||
|
// Convert ServerException to ServerFailure
|
||||||
|
return Left(ServerFailure(e.message));
|
||||||
|
} on NetworkException catch (e) {
|
||||||
|
// Convert NetworkException to NetworkFailure
|
||||||
|
return Left(NetworkFailure(e.message));
|
||||||
|
} catch (e) {
|
||||||
|
// Handle any other exceptions
|
||||||
|
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ class ProductStageEntity extends Equatable {
|
|||||||
final double passedQuantityWeight;
|
final double passedQuantityWeight;
|
||||||
final String? stageName;
|
final String? stageName;
|
||||||
final String createdDate;
|
final String createdDate;
|
||||||
|
final String productName;
|
||||||
|
final String productCode;
|
||||||
|
final int? stageId;
|
||||||
|
|
||||||
const ProductStageEntity({
|
const ProductStageEntity({
|
||||||
required this.productId,
|
required this.productId,
|
||||||
@@ -23,11 +26,14 @@ class ProductStageEntity extends Equatable {
|
|||||||
required this.passedQuantityWeight,
|
required this.passedQuantityWeight,
|
||||||
required this.stageName,
|
required this.stageName,
|
||||||
required this.createdDate,
|
required this.createdDate,
|
||||||
|
this.productName = '',
|
||||||
|
this.productCode = '',
|
||||||
|
this.stageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -49,6 +55,9 @@ class ProductStageEntity extends Equatable {
|
|||||||
passedQuantityWeight,
|
passedQuantityWeight,
|
||||||
stageName,
|
stageName,
|
||||||
createdDate,
|
createdDate,
|
||||||
|
productName,
|
||||||
|
productCode,
|
||||||
|
stageId,
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dartz/dartz.dart';
|
import 'package:dartz/dartz.dart';
|
||||||
import '../../../../core/errors/failures.dart';
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../../data/models/create_product_warehouse_request.dart';
|
||||||
import '../entities/product_entity.dart';
|
import '../entities/product_entity.dart';
|
||||||
import '../entities/product_stage_entity.dart';
|
import '../entities/product_stage_entity.dart';
|
||||||
|
|
||||||
@@ -10,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
|
||||||
///
|
///
|
||||||
@@ -27,4 +30,13 @@ abstract class ProductsRepository {
|
|||||||
int warehouseId,
|
int warehouseId,
|
||||||
int productId,
|
int productId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Create product warehouse entry (import/export operation)
|
||||||
|
///
|
||||||
|
/// [request] - Request containing all product warehouse details
|
||||||
|
///
|
||||||
|
/// Returns Either<Failure, void>
|
||||||
|
Future<Either<Failure, void>> createProductWarehouse(
|
||||||
|
CreateProductWarehouseRequest request,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../../../../core/router/app_router.dart';
|
|||||||
import '../widgets/product_list_item.dart';
|
import '../widgets/product_list_item.dart';
|
||||||
|
|
||||||
/// Products list page
|
/// Products list page
|
||||||
/// Displays products for a specific warehouse and operation type
|
/// Displays products for a specific warehouse with import/export tabs
|
||||||
class ProductsPage extends ConsumerStatefulWidget {
|
class ProductsPage extends ConsumerStatefulWidget {
|
||||||
final int warehouseId;
|
final int warehouseId;
|
||||||
final String warehouseName;
|
final String warehouseName;
|
||||||
@@ -24,20 +24,65 @@ class ProductsPage extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<ProductsPage> createState() => _ProductsPageState();
|
ConsumerState<ProductsPage> createState() => _ProductsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProductsPageState extends ConsumerState<ProductsPage> {
|
class _ProductsPageState extends ConsumerState<ProductsPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
String _currentOperationType = 'import';
|
||||||
|
bool _isTabSwitching = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Load products when page is initialized
|
|
||||||
|
// Initialize tab controller
|
||||||
|
_tabController = TabController(
|
||||||
|
length: 2,
|
||||||
|
vsync: this,
|
||||||
|
initialIndex: widget.operationType == 'export' ? 1 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
_currentOperationType = widget.operationType;
|
||||||
|
|
||||||
|
// Listen to tab changes
|
||||||
|
_tabController.addListener(() {
|
||||||
|
if (_tabController.indexIsChanging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newOperationType = _tabController.index == 0 ? 'import' : 'export';
|
||||||
|
if (_currentOperationType != newOperationType) {
|
||||||
|
setState(() {
|
||||||
|
_currentOperationType = newOperationType;
|
||||||
|
_isTabSwitching = true; // Mark that tab is switching
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load products for new operation type from cache (forceRefresh: false)
|
||||||
|
ref.read(productsProvider.notifier).loadProducts(
|
||||||
|
widget.warehouseId,
|
||||||
|
widget.warehouseName,
|
||||||
|
_currentOperationType,
|
||||||
|
forceRefresh: false, // Load from cache when switching tabs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
widget.operationType,
|
_currentOperationType,
|
||||||
|
forceRefresh: false, // Load from cache on initial load
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onRefresh() async {
|
Future<void> _onRefresh() async {
|
||||||
await ref.read(productsProvider.notifier).refreshProducts();
|
await ref.read(productsProvider.notifier).refreshProducts();
|
||||||
}
|
}
|
||||||
@@ -78,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,
|
||||||
@@ -118,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,
|
||||||
@@ -156,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',
|
||||||
@@ -169,10 +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: _currentOperationType,
|
||||||
stageId: stageId,
|
stageId: stageId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,13 +235,25 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
|||||||
final isLoading = productsState.isLoading;
|
final isLoading = productsState.isLoading;
|
||||||
final error = productsState.error;
|
final error = productsState.error;
|
||||||
|
|
||||||
|
// Listen to products state changes to clear tab switching flag
|
||||||
|
ref.listen(productsProvider, (previous, next) {
|
||||||
|
// Clear tab switching flag when loading completes
|
||||||
|
if (previous?.isLoading == true && next.isLoading == false) {
|
||||||
|
if (_isTabSwitching) {
|
||||||
|
setState(() {
|
||||||
|
_isTabSwitching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Products (${_getOperationTypeDisplay()})',
|
'Sản phẩm',
|
||||||
style: textTheme.titleMedium,
|
style: textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -209,9 +268,22 @@ 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(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.arrow_downward),
|
||||||
|
text: 'Nhập kho',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.arrow_upward),
|
||||||
|
text: 'Xuất kho',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: _buildBody(
|
body: _buildBody(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
@@ -222,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,
|
||||||
@@ -253,10 +325,10 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
widget.operationType == 'import'
|
_currentOperationType == 'import'
|
||||||
? Icons.arrow_downward
|
? Icons.arrow_downward
|
||||||
: Icons.arrow_upward,
|
: Icons.arrow_upward,
|
||||||
color: widget.operationType == 'import'
|
color: _currentOperationType == 'import'
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: Colors.orange,
|
: Colors.orange,
|
||||||
),
|
),
|
||||||
@@ -266,13 +338,15 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_getOperationTypeDisplay(),
|
_currentOperationType == 'import'
|
||||||
|
? 'Nhập kho'
|
||||||
|
: '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,
|
||||||
),
|
),
|
||||||
@@ -304,15 +378,17 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
|||||||
required List products,
|
required List products,
|
||||||
required ThemeData theme,
|
required ThemeData theme,
|
||||||
}) {
|
}) {
|
||||||
// Loading state
|
// Loading state - show when:
|
||||||
if (isLoading && products.isEmpty) {
|
// 1. Loading and no products, OR
|
||||||
|
// 2. Tab is switching (loading new products for different operation type)
|
||||||
|
if (isLoading && (products.isEmpty || _isTabSwitching)) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(),
|
CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text('Loading products...'),
|
Text('Đang tải sản phẩm...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -333,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,
|
||||||
),
|
),
|
||||||
@@ -348,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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -371,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,
|
||||||
@@ -386,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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -405,11 +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: _currentOperationType,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -417,11 +494,4 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get display text for operation type
|
|
||||||
String _getOperationTypeDisplay() {
|
|
||||||
return widget.operationType == 'import'
|
|
||||||
? 'Import Products'
|
|
||||||
: 'Export Products';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
|
||||||
|
import '../models/user_model.dart';
|
||||||
|
|
||||||
|
/// Abstract interface for users local data source
|
||||||
|
abstract class UsersLocalDataSource {
|
||||||
|
/// Get all users from local storage
|
||||||
|
Future<List<UserModel>> getUsers();
|
||||||
|
|
||||||
|
/// Save users to local storage
|
||||||
|
Future<void> saveUsers(List<UserModel> users);
|
||||||
|
|
||||||
|
/// Clear all users from local storage
|
||||||
|
Future<void> clearUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of UsersLocalDataSource using Hive
|
||||||
|
class UsersLocalDataSourceImpl implements UsersLocalDataSource {
|
||||||
|
static const String _boxName = 'users';
|
||||||
|
|
||||||
|
Future<Box<UserModel>> get _box async {
|
||||||
|
if (!Hive.isBoxOpen(_boxName)) {
|
||||||
|
return await Hive.openBox<UserModel>(_boxName);
|
||||||
|
}
|
||||||
|
return Hive.box<UserModel>(_boxName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<UserModel>> getUsers() async {
|
||||||
|
try {
|
||||||
|
final box = await _box;
|
||||||
|
return box.values.toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get users from local storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveUsers(List<UserModel> users) async {
|
||||||
|
try {
|
||||||
|
final box = await _box;
|
||||||
|
await box.clear();
|
||||||
|
|
||||||
|
// Save users with their ID as key
|
||||||
|
for (final user in users) {
|
||||||
|
await box.put(user.id, user);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to save users to local storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearUsers() async {
|
||||||
|
try {
|
||||||
|
final box = await _box;
|
||||||
|
await box.clear();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to clear users from local storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/errors/exceptions.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../../../core/network/api_response.dart';
|
||||||
|
import '../models/user_model.dart';
|
||||||
|
|
||||||
|
/// Abstract interface for users remote data source
|
||||||
|
abstract class UsersRemoteDataSource {
|
||||||
|
/// Fetch all users from the API
|
||||||
|
Future<List<UserModel>> getUsers();
|
||||||
|
|
||||||
|
/// Get current logged-in user
|
||||||
|
Future<UserModel> getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of UsersRemoteDataSource using ApiClient
|
||||||
|
class UsersRemoteDataSourceImpl implements UsersRemoteDataSource {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
UsersRemoteDataSourceImpl(this.apiClient);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<UserModel>> getUsers() async {
|
||||||
|
try {
|
||||||
|
// Make API call to get all users
|
||||||
|
final response = await apiClient.get(ApiEndpoints.users);
|
||||||
|
|
||||||
|
// Parse the API response using ApiResponse wrapper
|
||||||
|
final apiResponse = ApiResponse.fromJson(
|
||||||
|
response.data as Map<String, dynamic>,
|
||||||
|
(json) => (json as List)
|
||||||
|
.map((e) => UserModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the API call was successful
|
||||||
|
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||||
|
return apiResponse.value!;
|
||||||
|
} else {
|
||||||
|
// Throw exception with error message from API
|
||||||
|
throw ServerException(
|
||||||
|
apiResponse.errors.isNotEmpty
|
||||||
|
? apiResponse.errors.first
|
||||||
|
: 'Failed to get users',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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 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()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
lib/features/users/data/models/user_model.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/user_entity.dart';
|
||||||
|
|
||||||
|
part 'user_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: 1)
|
||||||
|
class UserModel extends UserEntity {
|
||||||
|
@HiveField(0)
|
||||||
|
@override
|
||||||
|
final int id;
|
||||||
|
|
||||||
|
@HiveField(1)
|
||||||
|
@override
|
||||||
|
final String firstName;
|
||||||
|
|
||||||
|
@HiveField(2)
|
||||||
|
@override
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
@HiveField(3)
|
||||||
|
@override
|
||||||
|
final String? plateNumber;
|
||||||
|
|
||||||
|
@HiveField(4)
|
||||||
|
@override
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
@HiveField(5)
|
||||||
|
@override
|
||||||
|
final String phone;
|
||||||
|
|
||||||
|
@HiveField(6)
|
||||||
|
@override
|
||||||
|
final bool isParent;
|
||||||
|
|
||||||
|
@HiveField(7)
|
||||||
|
@override
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
@HiveField(8)
|
||||||
|
@override
|
||||||
|
final String fullNameEmail;
|
||||||
|
|
||||||
|
@HiveField(9)
|
||||||
|
@override
|
||||||
|
final String? referralCode;
|
||||||
|
|
||||||
|
@HiveField(10)
|
||||||
|
@override
|
||||||
|
final String? avatar;
|
||||||
|
|
||||||
|
@HiveField(11)
|
||||||
|
@override
|
||||||
|
final int departmentId;
|
||||||
|
|
||||||
|
@HiveField(12)
|
||||||
|
@override
|
||||||
|
final bool isWareHouseUser;
|
||||||
|
|
||||||
|
@HiveField(13)
|
||||||
|
@override
|
||||||
|
final int? wareHouseId;
|
||||||
|
|
||||||
|
@HiveField(14)
|
||||||
|
@override
|
||||||
|
final int roleId;
|
||||||
|
|
||||||
|
const UserModel({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.name,
|
||||||
|
this.plateNumber,
|
||||||
|
required this.email,
|
||||||
|
required this.phone,
|
||||||
|
this.isParent = false,
|
||||||
|
required this.fullName,
|
||||||
|
required this.fullNameEmail,
|
||||||
|
this.referralCode,
|
||||||
|
this.avatar,
|
||||||
|
required this.departmentId,
|
||||||
|
this.isWareHouseUser = false,
|
||||||
|
this.wareHouseId,
|
||||||
|
required this.roleId,
|
||||||
|
}) : super(
|
||||||
|
id: id,
|
||||||
|
firstName: firstName,
|
||||||
|
name: name,
|
||||||
|
plateNumber: plateNumber,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
isParent: isParent,
|
||||||
|
fullName: fullName,
|
||||||
|
fullNameEmail: fullNameEmail,
|
||||||
|
referralCode: referralCode,
|
||||||
|
avatar: avatar,
|
||||||
|
departmentId: departmentId,
|
||||||
|
isWareHouseUser: isWareHouseUser,
|
||||||
|
wareHouseId: wareHouseId,
|
||||||
|
roleId: roleId,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserModel(
|
||||||
|
id: json['Id'] ?? 0,
|
||||||
|
firstName: json['FirstName'] ?? '',
|
||||||
|
name: json['Name'] ?? '',
|
||||||
|
plateNumber: json['PlateNumber'],
|
||||||
|
email: json['Email'] ?? '',
|
||||||
|
phone: json['Phone'] ?? '',
|
||||||
|
isParent: json['IsParent'] ?? false,
|
||||||
|
fullName: json['FullName'] ?? '',
|
||||||
|
fullNameEmail: json['FullNameEmail'] ?? '',
|
||||||
|
referralCode: json['ReferralCode'],
|
||||||
|
avatar: json['Avatar'],
|
||||||
|
departmentId: json['DepartmentId'] ?? 0,
|
||||||
|
isWareHouseUser: json['IsWareHouseUser'] ?? false,
|
||||||
|
wareHouseId: json['WareHouseId'],
|
||||||
|
roleId: json['RoleId'] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'Id': id,
|
||||||
|
'FirstName': firstName,
|
||||||
|
'Name': name,
|
||||||
|
'PlateNumber': plateNumber,
|
||||||
|
'Email': email,
|
||||||
|
'Phone': phone,
|
||||||
|
'IsParent': isParent,
|
||||||
|
'FullName': fullName,
|
||||||
|
'FullNameEmail': fullNameEmail,
|
||||||
|
'ReferralCode': referralCode,
|
||||||
|
'Avatar': avatar,
|
||||||
|
'DepartmentId': departmentId,
|
||||||
|
'IsWareHouseUser': isWareHouseUser,
|
||||||
|
'WareHouseId': wareHouseId,
|
||||||
|
'RoleId': roleId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
UserEntity toEntity() {
|
||||||
|
return UserEntity(
|
||||||
|
id: id,
|
||||||
|
firstName: firstName,
|
||||||
|
name: name,
|
||||||
|
plateNumber: plateNumber,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
isParent: isParent,
|
||||||
|
fullName: fullName,
|
||||||
|
fullNameEmail: fullNameEmail,
|
||||||
|
referralCode: referralCode,
|
||||||
|
avatar: avatar,
|
||||||
|
departmentId: departmentId,
|
||||||
|
isWareHouseUser: isWareHouseUser,
|
||||||
|
wareHouseId: wareHouseId,
|
||||||
|
roleId: roleId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
lib/features/users/data/models/user_model.g.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'user_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class UserModelAdapter extends TypeAdapter<UserModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
UserModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return UserModel(
|
||||||
|
id: (fields[0] as num).toInt(),
|
||||||
|
firstName: fields[1] as String,
|
||||||
|
name: fields[2] as String,
|
||||||
|
plateNumber: fields[3] as String?,
|
||||||
|
email: fields[4] as String,
|
||||||
|
phone: fields[5] as String,
|
||||||
|
isParent: fields[6] == null ? false : fields[6] as bool,
|
||||||
|
fullName: fields[7] as String,
|
||||||
|
fullNameEmail: fields[8] as String,
|
||||||
|
referralCode: fields[9] as String?,
|
||||||
|
avatar: fields[10] as String?,
|
||||||
|
departmentId: (fields[11] as num).toInt(),
|
||||||
|
isWareHouseUser: fields[12] == null ? false : fields[12] as bool,
|
||||||
|
wareHouseId: (fields[13] as num?)?.toInt(),
|
||||||
|
roleId: (fields[14] as num).toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, UserModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(15)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.id)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.firstName)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.name)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.plateNumber)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.email)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.phone)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.isParent)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.fullName)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.fullNameEmail)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.referralCode)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.avatar)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.departmentId)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.isWareHouseUser)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.wareHouseId)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is UserModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/exceptions.dart';
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../../domain/entities/user_entity.dart';
|
||||||
|
import '../../domain/repositories/users_repository.dart';
|
||||||
|
import '../datasources/users_local_datasource.dart';
|
||||||
|
import '../datasources/users_remote_datasource.dart';
|
||||||
|
|
||||||
|
/// Implementation of UsersRepository
|
||||||
|
class UsersRepositoryImpl implements UsersRepository {
|
||||||
|
final UsersRemoteDataSource remoteDataSource;
|
||||||
|
final UsersLocalDataSource localDataSource;
|
||||||
|
|
||||||
|
UsersRepositoryImpl({
|
||||||
|
required this.remoteDataSource,
|
||||||
|
required this.localDataSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, List<UserEntity>>> getUsers() async {
|
||||||
|
try {
|
||||||
|
// Try to get users from local storage first
|
||||||
|
final localUsers = await localDataSource.getUsers();
|
||||||
|
|
||||||
|
if (localUsers.isNotEmpty) {
|
||||||
|
// Return local users if available
|
||||||
|
return Right(localUsers.map((model) => model.toEntity()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no local users, fetch from API
|
||||||
|
return await syncUsers();
|
||||||
|
} catch (e) {
|
||||||
|
return Left(CacheFailure(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, List<UserEntity>>> syncUsers() async {
|
||||||
|
try {
|
||||||
|
// Fetch users from API
|
||||||
|
final users = await remoteDataSource.getUsers();
|
||||||
|
|
||||||
|
// Save to local storage
|
||||||
|
await localDataSource.saveUsers(users);
|
||||||
|
|
||||||
|
// Return as entities
|
||||||
|
return Right(users.map((model) => model.toEntity()).toList());
|
||||||
|
} on ServerException catch (e) {
|
||||||
|
return Left(ServerFailure(e.message));
|
||||||
|
} on NetworkException catch (e) {
|
||||||
|
return Left(NetworkFailure(e.message));
|
||||||
|
} catch (e) {
|
||||||
|
return Left(ServerFailure(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> clearUsers() async {
|
||||||
|
try {
|
||||||
|
await localDataSource.clearUsers();
|
||||||
|
return const Right(null);
|
||||||
|
} catch (e) {
|
||||||
|
return Left(CacheFailure(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
lib/features/users/domain/entities/user_entity.dart
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// User entity representing a user in the system
|
||||||
|
class UserEntity extends Equatable {
|
||||||
|
final int id;
|
||||||
|
final String firstName;
|
||||||
|
final String name;
|
||||||
|
final String? plateNumber;
|
||||||
|
final String email;
|
||||||
|
final String phone;
|
||||||
|
final bool isParent;
|
||||||
|
final String fullName;
|
||||||
|
final String fullNameEmail;
|
||||||
|
final String? referralCode;
|
||||||
|
final String? avatar;
|
||||||
|
final int departmentId;
|
||||||
|
final bool isWareHouseUser;
|
||||||
|
final int? wareHouseId;
|
||||||
|
final int roleId;
|
||||||
|
|
||||||
|
const UserEntity({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.name,
|
||||||
|
this.plateNumber,
|
||||||
|
required this.email,
|
||||||
|
required this.phone,
|
||||||
|
this.isParent = false,
|
||||||
|
required this.fullName,
|
||||||
|
required this.fullNameEmail,
|
||||||
|
this.referralCode,
|
||||||
|
this.avatar,
|
||||||
|
required this.departmentId,
|
||||||
|
this.isWareHouseUser = false,
|
||||||
|
this.wareHouseId,
|
||||||
|
required this.roleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
firstName,
|
||||||
|
name,
|
||||||
|
plateNumber,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
isParent,
|
||||||
|
fullName,
|
||||||
|
fullNameEmail,
|
||||||
|
referralCode,
|
||||||
|
avatar,
|
||||||
|
departmentId,
|
||||||
|
isWareHouseUser,
|
||||||
|
wareHouseId,
|
||||||
|
roleId,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Get display name
|
||||||
|
String get displayName {
|
||||||
|
if (fullName.isNotEmpty) return fullName;
|
||||||
|
if (name.isNotEmpty) return name;
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get initials from name (for avatar display)
|
||||||
|
String get initials {
|
||||||
|
if (firstName.isNotEmpty && name.isNotEmpty) {
|
||||||
|
return (firstName.substring(0, 1) + name.substring(0, 1)).toUpperCase();
|
||||||
|
}
|
||||||
|
final parts = fullName.trim().split(' ');
|
||||||
|
if (parts.isEmpty) return '?';
|
||||||
|
if (parts.length == 1) {
|
||||||
|
return parts[0].substring(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
return (parts[0].substring(0, 1) + parts[parts.length - 1].substring(0, 1))
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/features/users/domain/repositories/users_repository.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../entities/user_entity.dart';
|
||||||
|
|
||||||
|
/// Abstract repository interface for users
|
||||||
|
abstract class UsersRepository {
|
||||||
|
/// Get all users (from local storage if available, otherwise from API)
|
||||||
|
Future<Either<Failure, List<UserEntity>>> getUsers();
|
||||||
|
|
||||||
|
/// Sync users from API and save to local storage
|
||||||
|
Future<Either<Failure, List<UserEntity>>> syncUsers();
|
||||||
|
|
||||||
|
/// Clear all users from local storage
|
||||||
|
Future<Either<Failure, void>> clearUsers();
|
||||||
|
}
|
||||||
18
lib/features/users/domain/usecases/get_users_usecase.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../entities/user_entity.dart';
|
||||||
|
import '../repositories/users_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for getting users
|
||||||
|
/// Follows local-first strategy: returns local users if available,
|
||||||
|
/// otherwise syncs from API
|
||||||
|
class GetUsersUseCase {
|
||||||
|
final UsersRepository repository;
|
||||||
|
|
||||||
|
GetUsersUseCase(this.repository);
|
||||||
|
|
||||||
|
Future<Either<Failure, List<UserEntity>>> call() async {
|
||||||
|
return await repository.getUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
lib/features/users/domain/usecases/sync_users_usecase.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../entities/user_entity.dart';
|
||||||
|
import '../repositories/users_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for syncing users from API
|
||||||
|
/// Forces refresh from API and saves to local storage
|
||||||
|
class SyncUsersUseCase {
|
||||||
|
final UsersRepository repository;
|
||||||
|
|
||||||
|
SyncUsersUseCase(this.repository);
|
||||||
|
|
||||||
|
Future<Either<Failure, List<UserEntity>>> call() async {
|
||||||
|
return await repository.syncUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../domain/usecases/get_users_usecase.dart';
|
||||||
|
import '../../domain/usecases/sync_users_usecase.dart';
|
||||||
|
import 'users_state.dart';
|
||||||
|
|
||||||
|
/// State notifier for users
|
||||||
|
class UsersNotifier extends StateNotifier<UsersState> {
|
||||||
|
final GetUsersUseCase getUsersUseCase;
|
||||||
|
final SyncUsersUseCase syncUsersUseCase;
|
||||||
|
|
||||||
|
UsersNotifier({
|
||||||
|
required this.getUsersUseCase,
|
||||||
|
required this.syncUsersUseCase,
|
||||||
|
}) : super(const UsersState()) {
|
||||||
|
// Load local users on initialization
|
||||||
|
getUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get users from local storage (or API if not cached)
|
||||||
|
Future<void> getUsers() async {
|
||||||
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
|
final result = await getUsersUseCase();
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
(failure) => state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: failure.message,
|
||||||
|
),
|
||||||
|
(users) => state = state.copyWith(
|
||||||
|
users: users,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync users from API (force refresh)
|
||||||
|
Future<void> syncUsers() async {
|
||||||
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
|
final result = await syncUsersUseCase();
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
(failure) => state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: failure.message,
|
||||||
|
),
|
||||||
|
(users) => state = state.copyWith(
|
||||||
|
users: users,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
lib/features/users/presentation/providers/users_state.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/user_entity.dart';
|
||||||
|
|
||||||
|
/// State for users feature
|
||||||
|
class UsersState extends Equatable {
|
||||||
|
final List<UserEntity> users;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const UsersState({
|
||||||
|
this.users = const [],
|
||||||
|
this.isLoading = false,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
UsersState copyWith({
|
||||||
|
List<UserEntity>? users,
|
||||||
|
bool? isLoading,
|
||||||
|
String? error,
|
||||||
|
}) {
|
||||||
|
return UsersState(
|
||||||
|
users: users ?? this.users,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [users, isLoading, error];
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../../../core/di/providers.dart';
|
import '../../../../core/di/providers.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
|
||||||
@@ -27,11 +29,34 @@ class _WarehouseSelectionPageState
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Load warehouses 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();
|
||||||
|
// Users are automatically loaded from local storage by UsersNotifier
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -42,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,
|
||||||
@@ -75,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...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -96,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),
|
||||||
@@ -111,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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -134,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,
|
||||||
),
|
),
|
||||||
@@ -149,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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -170,11 +196,14 @@ class _WarehouseSelectionPageState
|
|||||||
return WarehouseCard(
|
return WarehouseCard(
|
||||||
warehouse: warehouse,
|
warehouse: warehouse,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Select warehouse and navigate to operations
|
// Select warehouse and navigate directly to products page
|
||||||
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
||||||
|
|
||||||
// Navigate to operations page
|
// Navigate to products page with warehouse data
|
||||||
context.push('/operations', extra: warehouse);
|
context.pushToProducts(
|
||||||
|
warehouse: warehouse,
|
||||||
|
operationType: 'import', // Default to import
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/hive_registrar.g.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Generated by Hive CE
|
||||||
|
// Do not modify
|
||||||
|
// Check in to version control
|
||||||
|
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:minhthu/features/users/data/models/user_model.dart';
|
||||||
|
|
||||||
|
extension HiveRegistrar on HiveInterface {
|
||||||
|
void registerAdapters() {
|
||||||
|
registerAdapter(UserModelAdapter());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
||||||
|
void registerAdapters() {
|
||||||
|
registerAdapter(UserModelAdapter());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'core/router/app_router.dart';
|
import 'core/router/app_router.dart';
|
||||||
|
import 'features/users/data/models/user_model.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize Hive
|
||||||
|
final appDocumentDir = await getApplicationDocumentsDirectory();
|
||||||
|
await Hive.initFlutter(appDocumentDir.path);
|
||||||
|
|
||||||
|
// Register Hive adapters
|
||||||
|
Hive.registerAdapter(UserModelAdapter());
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
child: MyApp(),
|
child: MyApp(),
|
||||||
@@ -23,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,
|
||||||
|
|||||||
146
pubspec.lock
@@ -25,6 +25,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.4"
|
version: "0.13.4"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.7"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,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:
|
||||||
@@ -153,6 +185,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -249,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:
|
||||||
@@ -302,6 +350,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.1"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -424,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:
|
||||||
@@ -480,6 +544,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.4"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -632,8 +704,16 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_parsing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -680,6 +760,30 @@ 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:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -704,6 +808,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
version: "1.5.1"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
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:
|
||||||
@@ -720,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:
|
||||||
@@ -901,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:
|
||||||
@@ -1013,6 +1149,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
31
pubspec.yaml
@@ -21,6 +21,7 @@ dependencies:
|
|||||||
dartz: ^0.10.1
|
dartz: ^0.10.1
|
||||||
get_it: ^7.6.4
|
get_it: ^7.6.4
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^9.0.0
|
||||||
|
path_provider: ^2.1.0
|
||||||
|
|
||||||
# Data Classes & Serialization
|
# Data Classes & Serialization
|
||||||
freezed_annotation: ^2.4.1
|
freezed_annotation: ^2.4.1
|
||||||
@@ -34,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:
|
||||||
@@ -47,5 +56,27 @@ dev_dependencies:
|
|||||||
hive_ce_generator:
|
hive_ce_generator:
|
||||||
riverpod_generator: ^2.4.0
|
riverpod_generator: ^2.4.0
|
||||||
|
|
||||||
|
# App Icon Generation
|
||||||
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
# Assets
|
||||||
|
assets:
|
||||||
|
- assets/app_icon.jpg
|
||||||
|
- assets/fonts/
|
||||||
|
|
||||||
|
# Flutter Launcher Icons Configuration
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: "assets/app_icon.jpg"
|
||||||
|
min_sdk_android: 21
|
||||||
|
|
||||||
|
# Android adaptive icon
|
||||||
|
adaptive_icon_background: "#FFFFFF"
|
||||||
|
adaptive_icon_foreground: "assets/app_icon.jpg"
|
||||||
|
|
||||||
|
# iOS configuration
|
||||||
|
remove_alpha_ios: true
|
||||||