Compare commits
11 Commits
2905668358
...
sunmi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff639fc42 | ||
|
|
1cfdd2c0c6 | ||
| ff25363a19 | |||
| 9df4b79a66 | |||
| 2a6ec8f6b8 | |||
| f47700ad2b | |||
| 68cc5c0df3 | |||
| 2495330bf5 | |||
|
|
efcc6306b0 | ||
|
|
c12869b01f | ||
|
|
cb4df363ab |
@@ -1,452 +0,0 @@
|
||||
# API Client Setup - Complete Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
I have created a robust API client for your Flutter warehouse management app with comprehensive features including:
|
||||
|
||||
- **Automatic token management** from secure storage
|
||||
- **401 error handling** with automatic token clearing
|
||||
- **Request/response logging** with sensitive data redaction
|
||||
- **Configurable timeouts** (30 seconds)
|
||||
- **Proper error transformation** to custom exceptions
|
||||
- **Support for all HTTP methods** (GET, POST, PUT, DELETE)
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Core Network Files
|
||||
|
||||
#### `/lib/core/network/api_client.dart`
|
||||
- Main API client implementation using Dio
|
||||
- Automatic Bearer token injection from secure storage
|
||||
- Request/response/error interceptors with comprehensive logging
|
||||
- 401 error handler that clears tokens and triggers logout callback
|
||||
- Methods: `get()`, `post()`, `put()`, `delete()`
|
||||
- Utility methods: `testConnection()`, `isAuthenticated()`, `getAccessToken()`, `clearAuth()`
|
||||
|
||||
#### `/lib/core/network/api_response.dart`
|
||||
- Generic API response wrapper matching your backend format
|
||||
- Structure: `Value`, `IsSuccess`, `IsFailure`, `Errors`, `ErrorCodes`
|
||||
- Helper methods: `hasData`, `getErrorMessage()`, `getAllErrorsAsString()`, `hasErrorCode()`
|
||||
|
||||
#### `/lib/core/network/api_client_example.dart`
|
||||
- Comprehensive usage examples for all scenarios
|
||||
- Examples for: Login, GET/POST/PUT/DELETE requests, error handling, etc.
|
||||
|
||||
#### `/lib/core/network/README.md`
|
||||
- Complete documentation for the API client
|
||||
- Usage guides, best practices, troubleshooting
|
||||
|
||||
### 2. Secure Storage
|
||||
|
||||
#### `/lib/core/storage/secure_storage.dart`
|
||||
- Singleton wrapper for flutter_secure_storage
|
||||
- Token management: `saveAccessToken()`, `getAccessToken()`, `clearTokens()`
|
||||
- User data: `saveUserId()`, `saveUsername()`, etc.
|
||||
- Utility methods: `isAuthenticated()`, `clearAll()`, `containsKey()`
|
||||
- Platform-specific secure options (Android: encrypted shared prefs, iOS: Keychain)
|
||||
|
||||
### 3. Constants
|
||||
|
||||
#### `/lib/core/constants/api_endpoints.dart`
|
||||
- Centralized API endpoint definitions
|
||||
- Authentication: `/auth/login`, `/auth/logout`
|
||||
- Warehouses: `/warehouses`
|
||||
- Products: `/products` with query parameter helpers
|
||||
- Scans: `/api/scans`
|
||||
|
||||
### 4. Core Exports
|
||||
|
||||
#### `/lib/core/core.dart` (Updated)
|
||||
- Added exports for new modules:
|
||||
- `api_endpoints.dart`
|
||||
- `api_response.dart`
|
||||
- `secure_storage.dart`
|
||||
|
||||
### 5. Dependencies
|
||||
|
||||
#### `pubspec.yaml` (Updated)
|
||||
- Added `flutter_secure_storage: ^9.0.0`
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Automatic Token Management
|
||||
|
||||
The API client automatically injects Bearer tokens into all requests:
|
||||
|
||||
```dart
|
||||
// Initialize with secure storage
|
||||
final secureStorage = SecureStorage();
|
||||
final apiClient = ApiClient(secureStorage);
|
||||
|
||||
// Token is automatically added to all requests
|
||||
final response = await apiClient.get('/warehouses');
|
||||
// Request header: Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 2. 401 Error Handling
|
||||
|
||||
When a 401 Unauthorized error occurs:
|
||||
1. Error is logged to console
|
||||
2. All tokens are cleared from secure storage
|
||||
3. `onUnauthorized` callback is triggered
|
||||
4. App can navigate to login screen
|
||||
|
||||
```dart
|
||||
final apiClient = ApiClient(
|
||||
secureStorage,
|
||||
onUnauthorized: () {
|
||||
// This callback is triggered on 401 errors
|
||||
context.go('/login'); // Navigate to login
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Comprehensive Logging
|
||||
|
||||
All requests, responses, and errors are logged with sensitive data redacted:
|
||||
|
||||
```
|
||||
REQUEST[GET] => https://api.example.com/warehouses
|
||||
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
|
||||
|
||||
RESPONSE[200] => https://api.example.com/warehouses
|
||||
Data: {...}
|
||||
|
||||
ERROR[401] => https://api.example.com/products
|
||||
401 Unauthorized - Clearing tokens and triggering logout
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
Dio exceptions are transformed into custom app exceptions:
|
||||
|
||||
```dart
|
||||
try {
|
||||
final response = await apiClient.get('/products');
|
||||
} on NetworkException catch (e) {
|
||||
// Timeout, no internet, etc.
|
||||
print('Network error: ${e.message}');
|
||||
} on ServerException catch (e) {
|
||||
// 4xx, 5xx errors
|
||||
print('Server error: ${e.message}');
|
||||
print('Error code: ${e.code}'); // e.g., '401', '404', '500'
|
||||
}
|
||||
```
|
||||
|
||||
Specific error codes:
|
||||
- `401`: Unauthorized (automatically handled)
|
||||
- `403`: Forbidden
|
||||
- `404`: Not Found
|
||||
- `422`: Validation Error
|
||||
- `429`: Rate Limited
|
||||
- `500-504`: Server Errors
|
||||
|
||||
### 5. API Response Parsing
|
||||
|
||||
All API responses follow the standard format:
|
||||
|
||||
```dart
|
||||
final response = await apiClient.get('/warehouses');
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
final warehouses = apiResponse.value;
|
||||
print('Found ${warehouses.length} warehouses');
|
||||
} else {
|
||||
print('Error: ${apiResponse.getErrorMessage()}');
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Initialize API Client
|
||||
|
||||
```dart
|
||||
import 'package:minhthu/core/core.dart';
|
||||
|
||||
final secureStorage = SecureStorage();
|
||||
final apiClient = ApiClient(
|
||||
secureStorage,
|
||||
onUnauthorized: () {
|
||||
// Navigate to login on 401 errors
|
||||
context.go('/login');
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Login Flow
|
||||
|
||||
```dart
|
||||
// 1. Login request
|
||||
final response = await apiClient.post(
|
||||
ApiEndpoints.login,
|
||||
data: {
|
||||
'username': 'user@example.com',
|
||||
'password': 'password123',
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Parse response
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => User.fromJson(json),
|
||||
);
|
||||
|
||||
// 3. Save tokens (typically done in LoginUseCase)
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
final user = apiResponse.value!;
|
||||
await secureStorage.saveAccessToken(user.accessToken);
|
||||
await secureStorage.saveRefreshToken(user.refreshToken);
|
||||
await secureStorage.saveUserId(user.userId);
|
||||
}
|
||||
```
|
||||
|
||||
### Get Warehouses (Authenticated)
|
||||
|
||||
```dart
|
||||
// Token is automatically added by the interceptor
|
||||
final response = await apiClient.get(ApiEndpoints.warehouses);
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
final warehouses = apiResponse.value!;
|
||||
// Use the data
|
||||
}
|
||||
```
|
||||
|
||||
### Get Products with Query Parameters
|
||||
|
||||
```dart
|
||||
final response = await apiClient.get(
|
||||
ApiEndpoints.products,
|
||||
queryParameters: ApiEndpoints.productQueryParams(
|
||||
warehouseId: 1,
|
||||
type: 'import',
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Save Scan Data
|
||||
|
||||
```dart
|
||||
final response = await apiClient.post(
|
||||
ApiEndpoints.scans,
|
||||
data: {
|
||||
'barcode': '1234567890',
|
||||
'field1': 'Value 1',
|
||||
'field2': 'Value 2',
|
||||
'field3': 'Value 3',
|
||||
'field4': 'Value 4',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Check Authentication Status
|
||||
|
||||
```dart
|
||||
final isAuthenticated = await apiClient.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
// Navigate to login
|
||||
}
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
```dart
|
||||
await apiClient.clearAuth(); // Clears all tokens
|
||||
```
|
||||
|
||||
## Integration with Repository Pattern
|
||||
|
||||
The API client is designed to work with your clean architecture:
|
||||
|
||||
```dart
|
||||
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
|
||||
final ApiClient apiClient;
|
||||
|
||||
WarehouseRemoteDataSourceImpl(this.apiClient);
|
||||
|
||||
@override
|
||||
Future<List<Warehouse>> getWarehouses() async {
|
||||
final response = await apiClient.get(ApiEndpoints.warehouses);
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
return apiResponse.value!;
|
||||
} else {
|
||||
throw ServerException(apiResponse.getErrorMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Timeouts (in `app_constants.dart`)
|
||||
|
||||
```dart
|
||||
static const int connectionTimeout = 30000; // 30 seconds
|
||||
static const int receiveTimeout = 30000; // 30 seconds
|
||||
static const int sendTimeout = 30000; // 30 seconds
|
||||
```
|
||||
|
||||
### Base URL (in `app_constants.dart`)
|
||||
|
||||
```dart
|
||||
static const String apiBaseUrl = 'https://api.example.com';
|
||||
```
|
||||
|
||||
Or update dynamically:
|
||||
|
||||
```dart
|
||||
apiClient.updateBaseUrl('https://dev-api.example.com');
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
1. **Token Encryption**: Tokens stored using platform-specific secure storage
|
||||
- Android: Encrypted SharedPreferences
|
||||
- iOS: Keychain with `first_unlock` accessibility
|
||||
|
||||
2. **Automatic Token Clearing**: 401 errors automatically clear all tokens
|
||||
|
||||
3. **Log Sanitization**: Authorization headers redacted in logs
|
||||
```
|
||||
Headers: {Authorization: ***REDACTED***}
|
||||
```
|
||||
|
||||
4. **Singleton Pattern**: SecureStorage uses singleton to prevent multiple instances
|
||||
|
||||
## Testing
|
||||
|
||||
To test the API connection:
|
||||
|
||||
```dart
|
||||
final isConnected = await apiClient.testConnection();
|
||||
if (!isConnected) {
|
||||
print('Cannot connect to API');
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Injection (GetIt)
|
||||
|
||||
Register with GetIt service locator:
|
||||
|
||||
```dart
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
// Register SecureStorage
|
||||
getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
|
||||
|
||||
// Register ApiClient
|
||||
getIt.registerLazySingleton<ApiClient>(
|
||||
() => ApiClient(
|
||||
getIt<SecureStorage>(),
|
||||
onUnauthorized: () {
|
||||
// Handle unauthorized
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
core/
|
||||
constants/
|
||||
app_constants.dart # Existing - timeouts and base URL
|
||||
api_endpoints.dart # NEW - API endpoint constants
|
||||
network/
|
||||
api_client.dart # UPDATED - Full implementation
|
||||
api_response.dart # NEW - API response wrapper
|
||||
api_client_example.dart # NEW - Usage examples
|
||||
README.md # NEW - Documentation
|
||||
storage/
|
||||
secure_storage.dart # NEW - Secure storage wrapper
|
||||
errors/
|
||||
exceptions.dart # Existing - Used by API client
|
||||
failures.dart # Existing - Used by repositories
|
||||
core.dart # UPDATED - Added new exports
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Update Existing Repositories**: Update your remote data sources to use the new API client
|
||||
2. **Configure Base URL**: Set the correct API base URL in `app_constants.dart`
|
||||
3. **Set Up Navigation**: Implement the `onUnauthorized` callback to navigate to login
|
||||
4. **Add API Endpoints**: Add any missing endpoints to `api_endpoints.dart`
|
||||
5. **Test Authentication Flow**: Test login, token injection, and 401 handling
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
Run the example:
|
||||
|
||||
```dart
|
||||
import 'package:minhthu/core/network/api_client_example.dart';
|
||||
|
||||
void main() async {
|
||||
await runExamples();
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token not being injected
|
||||
- Verify token is saved: `await secureStorage.getAccessToken()`
|
||||
- Check logs for: `REQUEST[...] Headers: {Authorization: ***REDACTED***}`
|
||||
|
||||
### 401 errors not clearing tokens
|
||||
- Verify `onUnauthorized` callback is set
|
||||
- Check logs for: `401 Unauthorized - Clearing tokens and triggering logout`
|
||||
|
||||
### Connection timeouts
|
||||
- Check network connectivity
|
||||
- Verify base URL is correct
|
||||
- Increase timeout values if needed
|
||||
|
||||
## Analysis Results
|
||||
|
||||
All files pass Flutter analysis with no issues:
|
||||
- ✅ `api_client.dart` - No issues found
|
||||
- ✅ `secure_storage.dart` - No issues found
|
||||
- ✅ `api_response.dart` - No issues found
|
||||
- ✅ `api_endpoints.dart` - No issues found
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed documentation, see:
|
||||
- `/lib/core/network/README.md` - Complete API client documentation
|
||||
- `/lib/core/network/api_client_example.dart` - Code examples
|
||||
|
||||
## Summary
|
||||
|
||||
The API client is production-ready with:
|
||||
- ✅ Automatic token management from secure storage
|
||||
- ✅ Request interceptor to inject Bearer tokens
|
||||
- ✅ Response interceptor for logging
|
||||
- ✅ Error interceptor to handle 401 errors
|
||||
- ✅ Automatic token clearing on unauthorized access
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Request/response logging with sensitive data redaction
|
||||
- ✅ Support for all HTTP methods (GET, POST, PUT, DELETE)
|
||||
- ✅ Configurable timeouts (30 seconds)
|
||||
- ✅ Environment-specific base URLs
|
||||
- ✅ Connection testing
|
||||
- ✅ Clean integration with repository pattern
|
||||
- ✅ Comprehensive documentation and examples
|
||||
- ✅ All files pass static analysis
|
||||
|
||||
The API client is ready to use and follows Flutter best practices and clean architecture principles!
|
||||
@@ -1,198 +0,0 @@
|
||||
# API Integration Complete
|
||||
|
||||
## ✅ API Configuration Updated
|
||||
|
||||
All API endpoints and authentication have been updated to match the actual backend API from `/lib/docs/api.sh`.
|
||||
|
||||
### 🔗 API Base URL
|
||||
```
|
||||
Base URL: https://dotnet.elidev.info:8157/ws
|
||||
App ID: Minhthu2016
|
||||
```
|
||||
|
||||
### 🔐 Authentication Updates
|
||||
|
||||
#### Headers Changed:
|
||||
- ❌ Old: `Authorization: Bearer {token}`
|
||||
- ✅ New: `AccessToken: {token}`
|
||||
- ✅ Added: `AppID: Minhthu2016`
|
||||
|
||||
#### Login Request Format:
|
||||
- ❌ Old fields: `username`, `password`
|
||||
- ✅ New fields: `EmailPhone`, `Password`
|
||||
|
||||
### 📍 API Endpoints Updated
|
||||
|
||||
| Feature | Endpoint | Method | Notes |
|
||||
|---------|----------|--------|-------|
|
||||
| Login | `/PortalAuth/Login` | POST | EmailPhone + Password |
|
||||
| Warehouses | `/portalWareHouse/search` | POST | Pagination params required |
|
||||
| Products | `/portalProduct/getAllProduct` | GET | Returns all products |
|
||||
|
||||
## 🛠️ Files Modified
|
||||
|
||||
### 1. **app_constants.dart**
|
||||
```dart
|
||||
static const String apiBaseUrl = 'https://dotnet.elidev.info:8157/ws';
|
||||
static const String appId = 'Minhthu2016';
|
||||
```
|
||||
|
||||
### 2. **api_endpoints.dart**
|
||||
```dart
|
||||
static const String login = '/PortalAuth/Login';
|
||||
static const String warehouses = '/portalWareHouse/search';
|
||||
static const String products = '/portalProduct/getAllProduct';
|
||||
```
|
||||
|
||||
### 3. **api_client.dart**
|
||||
```dart
|
||||
// Changed from Authorization: Bearer to AccessToken
|
||||
options.headers['AccessToken'] = token;
|
||||
options.headers['AppID'] = AppConstants.appId;
|
||||
```
|
||||
|
||||
### 4. **login_request_model.dart**
|
||||
```dart
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'EmailPhone': username, // Changed from 'username'
|
||||
'Password': password, // Changed from 'password'
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5. **warehouse_remote_datasource.dart**
|
||||
```dart
|
||||
// Changed from GET to POST with pagination
|
||||
final response = await apiClient.post(
|
||||
'/portalWareHouse/search',
|
||||
data: {
|
||||
'pageIndex': 0,
|
||||
'pageSize': 100,
|
||||
'Name': null,
|
||||
'Code': null,
|
||||
'sortExpression': null,
|
||||
'sortDirection': null,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 6. **products_remote_datasource.dart**
|
||||
```dart
|
||||
// Updated to use correct endpoint
|
||||
final response = await apiClient.get('/portalProduct/getAllProduct');
|
||||
```
|
||||
|
||||
## 🎯 Pre-filled Test Credentials
|
||||
|
||||
The login form is pre-filled with test credentials:
|
||||
- **Email**: `yesterday305@gmail.com`
|
||||
- **Password**: `123456`
|
||||
|
||||
## 🚀 Ready to Test
|
||||
|
||||
The app is now configured to connect to the actual backend API. You can:
|
||||
|
||||
1. **Run the app**:
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
2. **Test the flow**:
|
||||
- Login with pre-filled credentials
|
||||
- View warehouses list
|
||||
- Select a warehouse
|
||||
- Choose Import or Export
|
||||
- View products
|
||||
|
||||
## 📝 API Request Examples
|
||||
|
||||
### Login Request:
|
||||
```bash
|
||||
POST https://dotnet.elidev.info:8157/ws/PortalAuth/Login
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
AppID: Minhthu2016
|
||||
Body:
|
||||
{
|
||||
"EmailPhone": "yesterday305@gmail.com",
|
||||
"Password": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Warehouses Request:
|
||||
```bash
|
||||
POST https://dotnet.elidev.info:8157/ws/portalWareHouse/search
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
AppID: Minhthu2016
|
||||
AccessToken: {token_from_login}
|
||||
Body:
|
||||
{
|
||||
"pageIndex": 0,
|
||||
"pageSize": 100,
|
||||
"Name": null,
|
||||
"Code": null,
|
||||
"sortExpression": null,
|
||||
"sortDirection": null
|
||||
}
|
||||
```
|
||||
|
||||
### Get Products Request:
|
||||
```bash
|
||||
GET https://dotnet.elidev.info:8157/ws/portalProduct/getAllProduct
|
||||
Headers:
|
||||
AppID: Minhthu2016
|
||||
AccessToken: {token_from_login}
|
||||
```
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
1. **HTTPS Certificate**: The API uses a self-signed certificate. You may need to handle SSL certificate validation in production.
|
||||
|
||||
2. **CORS**: Make sure CORS is properly configured on the backend for mobile apps.
|
||||
|
||||
3. **Token Storage**: Access tokens are securely stored using `flutter_secure_storage`.
|
||||
|
||||
4. **Error Handling**: All API errors are properly handled and displayed to users.
|
||||
|
||||
5. **Logging**: API requests and responses are logged in debug mode for troubleshooting.
|
||||
|
||||
## 🔍 Testing Checklist
|
||||
|
||||
- [ ] Login with test credentials works
|
||||
- [ ] Access token is saved in secure storage
|
||||
- [ ] Warehouses list loads successfully
|
||||
- [ ] Warehouse selection works
|
||||
- [ ] Navigation to operations page works
|
||||
- [ ] Products list loads successfully
|
||||
- [ ] All UI states work (loading, error, success, empty)
|
||||
- [ ] Refresh functionality works
|
||||
- [ ] Logout clears the token
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### If login fails:
|
||||
1. Check internet connection
|
||||
2. Verify API is accessible at `https://dotnet.elidev.info:8157`
|
||||
3. Check credentials are correct
|
||||
4. Look at debug logs for detailed error messages
|
||||
|
||||
### If API calls fail after login:
|
||||
1. Verify access token is being saved
|
||||
2. Check that AccessToken and AppID headers are being sent
|
||||
3. Verify token hasn't expired
|
||||
4. Check API logs for detailed error information
|
||||
|
||||
## 📚 Related Files
|
||||
|
||||
- `/lib/docs/api.sh` - Original curl commands
|
||||
- `/lib/core/constants/app_constants.dart` - API configuration
|
||||
- `/lib/core/constants/api_endpoints.dart` - Endpoint definitions
|
||||
- `/lib/core/network/api_client.dart` - HTTP client configuration
|
||||
- `/lib/features/auth/data/models/login_request_model.dart` - Login request format
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Ready for testing with production API
|
||||
**Last Updated**: $(date)
|
||||
@@ -1,526 +0,0 @@
|
||||
# Complete App Setup Guide - Warehouse Management App
|
||||
|
||||
## Overview
|
||||
This guide provides a complete overview of the rewritten warehouse management app following clean architecture principles as specified in CLAUDE.md.
|
||||
|
||||
## App Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (UI, Widgets, State Management with Riverpod) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Domain Layer │
|
||||
│ (Business Logic, Use Cases, Entities, Interfaces) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Data Layer │
|
||||
│ (API Client, Models, Data Sources, Repositories) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Core Layer │
|
||||
│ (Network, Storage, Theme, Constants, Utilities) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Core infrastructure
|
||||
│ ├── constants/
|
||||
│ │ ├── api_endpoints.dart # API endpoint constants
|
||||
│ │ └── app_constants.dart # App-wide constants
|
||||
│ ├── di/
|
||||
│ │ └── providers.dart # Riverpod dependency injection
|
||||
│ ├── errors/
|
||||
│ │ ├── exceptions.dart # Exception classes
|
||||
│ │ └── failures.dart # Failure classes for Either
|
||||
│ ├── network/
|
||||
│ │ ├── api_client.dart # Dio HTTP client with interceptors
|
||||
│ │ └── api_response.dart # Generic API response wrapper
|
||||
│ ├── router/
|
||||
│ │ └── app_router.dart # GoRouter configuration
|
||||
│ ├── storage/
|
||||
│ │ └── secure_storage.dart # Secure token storage
|
||||
│ ├── theme/
|
||||
│ │ └── app_theme.dart # Material 3 theme
|
||||
│ └── widgets/
|
||||
│ ├── custom_button.dart # Reusable button widgets
|
||||
│ └── loading_indicator.dart # Loading indicators
|
||||
│
|
||||
├── features/ # Feature-first organization
|
||||
│ ├── auth/ # Authentication feature
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── datasources/
|
||||
│ │ │ │ └── auth_remote_datasource.dart
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── login_request_model.dart
|
||||
│ │ │ │ └── user_model.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── auth_repository_impl.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── entities/
|
||||
│ │ │ │ └── user_entity.dart
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── auth_repository.dart
|
||||
│ │ │ └── usecases/
|
||||
│ │ │ └── login_usecase.dart
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/
|
||||
│ │ │ └── login_page.dart
|
||||
│ │ ├── providers/
|
||||
│ │ │ └── auth_provider.dart
|
||||
│ │ └── widgets/
|
||||
│ │ └── login_form.dart
|
||||
│ │
|
||||
│ ├── warehouse/ # Warehouse feature
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── datasources/
|
||||
│ │ │ │ └── warehouse_remote_datasource.dart
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ └── warehouse_model.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── warehouse_repository_impl.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── entities/
|
||||
│ │ │ │ └── warehouse_entity.dart
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── warehouse_repository.dart
|
||||
│ │ │ └── usecases/
|
||||
│ │ │ └── get_warehouses_usecase.dart
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/
|
||||
│ │ │ └── warehouse_selection_page.dart
|
||||
│ │ ├── providers/
|
||||
│ │ │ └── warehouse_provider.dart
|
||||
│ │ └── widgets/
|
||||
│ │ └── warehouse_card.dart
|
||||
│ │
|
||||
│ ├── operation/ # Operation selection feature
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/
|
||||
│ │ │ └── operation_selection_page.dart
|
||||
│ │ └── widgets/
|
||||
│ │ └── operation_card.dart
|
||||
│ │
|
||||
│ └── products/ # Products feature
|
||||
│ ├── data/
|
||||
│ │ ├── datasources/
|
||||
│ │ │ └── products_remote_datasource.dart
|
||||
│ │ ├── models/
|
||||
│ │ │ └── product_model.dart
|
||||
│ │ └── repositories/
|
||||
│ │ └── products_repository_impl.dart
|
||||
│ ├── domain/
|
||||
│ │ ├── entities/
|
||||
│ │ │ └── product_entity.dart
|
||||
│ │ ├── repositories/
|
||||
│ │ │ └── products_repository.dart
|
||||
│ │ └── usecases/
|
||||
│ │ └── get_products_usecase.dart
|
||||
│ └── presentation/
|
||||
│ ├── pages/
|
||||
│ │ └── products_page.dart
|
||||
│ ├── providers/
|
||||
│ │ └── products_provider.dart
|
||||
│ └── widgets/
|
||||
│ └── product_list_item.dart
|
||||
│
|
||||
└── main.dart # App entry point
|
||||
```
|
||||
|
||||
## App Flow
|
||||
|
||||
```
|
||||
1. App Start
|
||||
↓
|
||||
2. Check Authentication (via SecureStorage)
|
||||
↓
|
||||
├── Not Authenticated → Login Screen
|
||||
│ ↓
|
||||
│ Enter credentials
|
||||
│ ↓
|
||||
│ API: POST /auth/login
|
||||
│ ↓
|
||||
│ Store access token
|
||||
│ ↓
|
||||
└── Authenticated → Warehouse Selection Screen
|
||||
↓
|
||||
API: GET /warehouses
|
||||
↓
|
||||
Select warehouse
|
||||
↓
|
||||
Operation Selection Screen
|
||||
↓
|
||||
Choose Import or Export
|
||||
↓
|
||||
Products List Screen
|
||||
↓
|
||||
API: GET /products?warehouseId={id}&type={type}
|
||||
↓
|
||||
Display products
|
||||
```
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Flutter SDK**: >=3.0.0 <4.0.0
|
||||
- **State Management**: Riverpod (flutter_riverpod ^2.4.9)
|
||||
- **Navigation**: GoRouter (go_router ^13.2.0)
|
||||
- **HTTP Client**: Dio (dio ^5.3.2)
|
||||
- **Secure Storage**: FlutterSecureStorage (flutter_secure_storage ^9.0.0)
|
||||
- **Functional Programming**: Dartz (dartz ^0.10.1)
|
||||
- **Value Equality**: Equatable (equatable ^2.0.5)
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd /Users/phuocnguyen/Projects/minhthu
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 2. Configure API Base URL
|
||||
|
||||
Edit `/Users/phuocnguyen/Projects/minhthu/lib/core/constants/app_constants.dart`:
|
||||
|
||||
```dart
|
||||
static const String apiBaseUrl = 'https://your-api-domain.com';
|
||||
```
|
||||
|
||||
### 3. Configure API Endpoints (if needed)
|
||||
|
||||
Edit `/Users/phuocnguyen/Projects/minhthu/lib/core/constants/api_endpoints.dart` to match your backend API paths.
|
||||
|
||||
### 4. Run the App
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### API Response Format
|
||||
|
||||
All APIs follow this response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"Value": <data>,
|
||||
"IsSuccess": true,
|
||||
"IsFailure": false,
|
||||
"Errors": [],
|
||||
"ErrorCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
### Available APIs
|
||||
|
||||
#### 1. Login
|
||||
```bash
|
||||
POST /auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "string",
|
||||
"password": "string"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"Value": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"accessToken": "string",
|
||||
"refreshToken": "string"
|
||||
},
|
||||
"IsSuccess": true,
|
||||
"IsFailure": false,
|
||||
"Errors": [],
|
||||
"ErrorCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Get Warehouses
|
||||
```bash
|
||||
GET /warehouses
|
||||
Authorization: Bearer {access_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"Value": [
|
||||
{
|
||||
"Id": 1,
|
||||
"Name": "Kho nguyên vật liệu",
|
||||
"Code": "001",
|
||||
"Description": null,
|
||||
"IsNGWareHouse": false,
|
||||
"TotalCount": 8
|
||||
}
|
||||
],
|
||||
"IsSuccess": true,
|
||||
"IsFailure": false,
|
||||
"Errors": [],
|
||||
"ErrorCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Get Products
|
||||
```bash
|
||||
GET /products?warehouseId={id}&type={import/export}
|
||||
Authorization: Bearer {access_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"Value": [
|
||||
{
|
||||
"Id": 11,
|
||||
"Name": "Thép 435",
|
||||
"Code": "SCM435",
|
||||
"FullName": "SCM435 | Thép 435",
|
||||
"Weight": 120.00,
|
||||
"Pieces": 1320,
|
||||
"ConversionRate": 11.00,
|
||||
... (43 total fields)
|
||||
}
|
||||
],
|
||||
"IsSuccess": true,
|
||||
"IsFailure": false,
|
||||
"Errors": [],
|
||||
"ErrorCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Using Auth Provider
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:minhthu/core/di/providers.dart';
|
||||
|
||||
class MyWidget extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch auth state
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||
|
||||
// Login
|
||||
onLoginPressed() async {
|
||||
await ref.read(authProvider.notifier).login(username, password);
|
||||
}
|
||||
|
||||
// Logout
|
||||
onLogoutPressed() async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Warehouse Provider
|
||||
|
||||
```dart
|
||||
class WarehousePage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch warehouses
|
||||
final warehouses = ref.watch(warehousesListProvider);
|
||||
final selectedWarehouse = ref.watch(selectedWarehouseProvider);
|
||||
final isLoading = ref.watch(warehouseProvider.select((s) => s.isLoading));
|
||||
|
||||
// Load warehouses
|
||||
useEffect(() {
|
||||
Future.microtask(() =>
|
||||
ref.read(warehouseProvider.notifier).loadWarehouses()
|
||||
);
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Select warehouse
|
||||
onWarehouseTap(warehouse) {
|
||||
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
|
||||
context.go('/operations', extra: warehouse);
|
||||
}
|
||||
|
||||
return ListView.builder(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Products Provider
|
||||
|
||||
```dart
|
||||
class ProductsPage extends ConsumerWidget {
|
||||
final int warehouseId;
|
||||
final String warehouseName;
|
||||
final String operationType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch products
|
||||
final products = ref.watch(productsListProvider);
|
||||
final isLoading = ref.watch(productsProvider.select((s) => s.isLoading));
|
||||
|
||||
// Load products
|
||||
useEffect(() {
|
||||
Future.microtask(() =>
|
||||
ref.read(productsProvider.notifier)
|
||||
.loadProducts(warehouseId, warehouseName, operationType)
|
||||
);
|
||||
return null;
|
||||
}, [warehouseId, operationType]);
|
||||
|
||||
return ListView.builder(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```dart
|
||||
// Navigate to login
|
||||
context.go('/login');
|
||||
|
||||
// Navigate to warehouses
|
||||
context.go('/warehouses');
|
||||
|
||||
// Navigate to operations
|
||||
context.go('/operations', extra: warehouseEntity);
|
||||
|
||||
// Navigate to products
|
||||
context.go('/products', extra: {
|
||||
'warehouse': warehouseEntity,
|
||||
'warehouseName': 'Kho nguyên vật liệu',
|
||||
'operationType': 'import', // or 'export'
|
||||
});
|
||||
|
||||
// Or use extension methods
|
||||
context.goToOperations(warehouse);
|
||||
context.goToProducts(warehouse: warehouse, operationType: 'import');
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Using Either for Error Handling
|
||||
|
||||
```dart
|
||||
final result = await ref.read(authProvider.notifier).login(username, password);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
// Handle error
|
||||
print('Error: ${failure.message}');
|
||||
},
|
||||
(user) {
|
||||
// Handle success
|
||||
print('Logged in as: ${user.username}');
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
- **ServerFailure**: API returned an error
|
||||
- **NetworkFailure**: Network connectivity issues
|
||||
- **AuthenticationFailure**: Authentication/authorization errors
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
### Run Analysis
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
```bash
|
||||
flutter test --coverage
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Token Storage**: Access tokens are stored securely using `flutter_secure_storage` with platform-specific encryption:
|
||||
- Android: EncryptedSharedPreferences
|
||||
- iOS: Keychain
|
||||
|
||||
2. **API Security**:
|
||||
- All authenticated requests include Bearer token
|
||||
- 401 errors automatically clear tokens and redirect to login
|
||||
- Sensitive data redacted in logs
|
||||
|
||||
3. **HTTPS**: All API calls should use HTTPS in production
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **Riverpod**: Minimal rebuilds with fine-grained reactivity
|
||||
2. **Lazy Loading**: Providers are created only when needed
|
||||
3. **Caching**: SecureStorage caches auth tokens
|
||||
4. **Error Boundaries**: Proper error handling prevents crashes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Login fails with 401
|
||||
- Check API base URL in `app_constants.dart`
|
||||
- Verify credentials
|
||||
- Check network connectivity
|
||||
|
||||
### Issue: White screen after login
|
||||
- Check if router redirect logic is working
|
||||
- Verify `SecureStorage.isAuthenticated()` returns true
|
||||
- Check console for errors
|
||||
|
||||
### Issue: Products not loading
|
||||
- Verify warehouse is selected
|
||||
- Check API endpoint configuration
|
||||
- Verify access token is valid
|
||||
|
||||
### Issue: Build errors
|
||||
```bash
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Documentation References
|
||||
|
||||
- **Core Architecture**: `/lib/core/di/README.md`
|
||||
- **Auth Feature**: `/lib/features/auth/README.md`
|
||||
- **Warehouse Feature**: `/lib/features/warehouse/README.md`
|
||||
- **Products Feature**: Inline documentation in code
|
||||
- **API Client**: `/lib/core/network/README.md`
|
||||
- **Router**: `/lib/core/router/README.md`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Core architecture set up
|
||||
2. ✅ Auth feature implemented
|
||||
3. ✅ Warehouse feature implemented
|
||||
4. ✅ Operation selection implemented
|
||||
5. ✅ Products feature implemented
|
||||
6. ✅ Routing configured
|
||||
7. ✅ Dependency injection set up
|
||||
8. ⏳ Configure production API URL
|
||||
9. ⏳ Test with real API
|
||||
10. ⏳ Add additional features as needed
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check inline documentation in code files
|
||||
2. Review README files in each module
|
||||
3. Check CLAUDE.md for specifications
|
||||
|
||||
## License
|
||||
|
||||
This project follows the specifications in CLAUDE.md and is built with clean architecture principles.
|
||||
@@ -1,384 +0,0 @@
|
||||
# Authentication Feature - Implementation Summary
|
||||
|
||||
Complete authentication feature following clean architecture for the warehouse management app.
|
||||
|
||||
## Created Files
|
||||
|
||||
### Data Layer (7 files)
|
||||
1. `/lib/features/auth/data/models/login_request_model.dart`
|
||||
- LoginRequest model with username and password
|
||||
- toJson() method for API requests
|
||||
|
||||
2. `/lib/features/auth/data/models/user_model.dart`
|
||||
- UserModel extending UserEntity
|
||||
- fromJson() and toJson() methods
|
||||
- Conversion between model and entity
|
||||
|
||||
3. `/lib/features/auth/data/datasources/auth_remote_datasource.dart`
|
||||
- Abstract AuthRemoteDataSource interface
|
||||
- AuthRemoteDataSourceImpl using ApiClient
|
||||
- login(), logout(), refreshToken() methods
|
||||
- Uses ApiResponse wrapper
|
||||
|
||||
4. `/lib/features/auth/data/repositories/auth_repository_impl.dart`
|
||||
- Implements AuthRepository interface
|
||||
- Coordinates remote data source and secure storage
|
||||
- Converts exceptions to failures
|
||||
- Returns Either<Failure, Success>
|
||||
|
||||
5. `/lib/features/auth/data/data.dart`
|
||||
- Barrel export file for data layer
|
||||
|
||||
### Domain Layer (4 files)
|
||||
6. `/lib/features/auth/domain/entities/user_entity.dart`
|
||||
- Pure domain entity (no dependencies)
|
||||
- UserEntity with userId, username, accessToken, refreshToken
|
||||
|
||||
7. `/lib/features/auth/domain/repositories/auth_repository.dart`
|
||||
- Abstract repository interface
|
||||
- Defines contract for authentication operations
|
||||
- Returns Either<Failure, Success>
|
||||
|
||||
8. `/lib/features/auth/domain/usecases/login_usecase.dart`
|
||||
- LoginUseCase with input validation
|
||||
- LogoutUseCase
|
||||
- CheckAuthStatusUseCase
|
||||
- GetCurrentUserUseCase
|
||||
- RefreshTokenUseCase
|
||||
|
||||
9. `/lib/features/auth/domain/domain.dart`
|
||||
- Barrel export file for domain layer
|
||||
|
||||
### Presentation Layer (5 files)
|
||||
10. `/lib/features/auth/presentation/providers/auth_provider.dart`
|
||||
- AuthState class (user, isAuthenticated, isLoading, error)
|
||||
- AuthNotifier using Riverpod StateNotifier
|
||||
- login(), logout(), checkAuthStatus() methods
|
||||
- State management logic
|
||||
|
||||
11. `/lib/features/auth/presentation/pages/login_page.dart`
|
||||
- LoginPage using ConsumerStatefulWidget
|
||||
- Material 3 design with app logo
|
||||
- Error display and loading states
|
||||
- Auto-navigation after successful login
|
||||
- Integration with auth provider
|
||||
|
||||
12. `/lib/features/auth/presentation/widgets/login_form.dart`
|
||||
- Reusable LoginForm widget
|
||||
- Form validation (username >= 3 chars, password >= 6 chars)
|
||||
- Password visibility toggle
|
||||
- TextField styling with Material 3
|
||||
|
||||
13. `/lib/features/auth/presentation/presentation.dart`
|
||||
- Barrel export file for presentation layer
|
||||
|
||||
### Dependency Injection (1 file)
|
||||
14. `/lib/features/auth/di/auth_dependency_injection.dart`
|
||||
- Complete Riverpod provider setup
|
||||
- Data layer providers (data sources, storage)
|
||||
- Domain layer providers (repository, use cases)
|
||||
- Presentation layer providers (state notifier)
|
||||
- Convenience providers for common use cases
|
||||
|
||||
### Main Exports (1 file)
|
||||
15. `/lib/features/auth/auth.dart`
|
||||
- Main barrel export for the entire feature
|
||||
- Public API for the auth module
|
||||
|
||||
### Documentation (3 files)
|
||||
16. `/lib/features/auth/README.md`
|
||||
- Comprehensive feature documentation
|
||||
- Architecture overview
|
||||
- Usage examples
|
||||
- API integration guide
|
||||
- Testing guidelines
|
||||
- Troubleshooting section
|
||||
|
||||
17. `/lib/features/auth/INTEGRATION_GUIDE.md`
|
||||
- Step-by-step integration guide
|
||||
- Code examples for main.dart and router
|
||||
- Testing checklist
|
||||
- Environment configuration
|
||||
- Common issues and solutions
|
||||
|
||||
18. `/AUTHENTICATION_FEATURE_SUMMARY.md`
|
||||
- This file - overview of all created files
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ LoginPage │ │ AuthProvider │ │ LoginForm │ │
|
||||
│ └────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ ↑
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Domain Layer │
|
||||
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
|
||||
│ │ UseCases │ │ Repository │ │ Entities │ │
|
||||
│ │ │ │ Interface │ │ │ │
|
||||
│ └──────────────┘ └────────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ ↑
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Data Layer │
|
||||
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
|
||||
│ │ Repository │ │ DataSources │ │ Models │ │
|
||||
│ │ Impl │ │ │ │ │ │
|
||||
│ └──────────────┘ └────────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ ↑
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ External Services │
|
||||
│ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ ApiClient │ │ SecureStorage │ │
|
||||
│ └──────────────┘ └────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### Core Functionality
|
||||
- ✅ User login with username/password
|
||||
- ✅ Secure token storage (encrypted)
|
||||
- ✅ Authentication state management with Riverpod
|
||||
- ✅ Form validation (username, password)
|
||||
- ✅ Error handling with user-friendly messages
|
||||
- ✅ Loading states during async operations
|
||||
- ✅ Auto-navigation after successful login
|
||||
- ✅ Check authentication status on app start
|
||||
- ✅ Logout functionality with token cleanup
|
||||
- ✅ Token refresh support (prepared for future use)
|
||||
|
||||
### Architecture Quality
|
||||
- ✅ Clean architecture (data/domain/presentation)
|
||||
- ✅ SOLID principles
|
||||
- ✅ Dependency injection with Riverpod
|
||||
- ✅ Repository pattern
|
||||
- ✅ Use case pattern
|
||||
- ✅ Either type for error handling (dartz)
|
||||
- ✅ Proper separation of concerns
|
||||
- ✅ Testable architecture
|
||||
- ✅ Feature-first organization
|
||||
- ✅ Barrel exports for clean imports
|
||||
|
||||
### UI/UX
|
||||
- ✅ Material 3 design system
|
||||
- ✅ Custom themed components
|
||||
- ✅ Loading indicators
|
||||
- ✅ Error messages with icons
|
||||
- ✅ Password visibility toggle
|
||||
- ✅ Form validation feedback
|
||||
- ✅ Disabled state during loading
|
||||
- ✅ Responsive layout
|
||||
- ✅ Accessibility support
|
||||
|
||||
### Security
|
||||
- ✅ Tokens stored in encrypted secure storage
|
||||
- ✅ Password field obscured
|
||||
- ✅ Auth token automatically added to API headers
|
||||
- ✅ Token cleared on logout
|
||||
- ✅ No sensitive data in logs
|
||||
- ✅ Input validation
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Login Flow
|
||||
```
|
||||
User Input (LoginPage)
|
||||
↓
|
||||
LoginForm Validation
|
||||
↓
|
||||
AuthNotifier.login()
|
||||
↓
|
||||
LoginUseCase (validation)
|
||||
↓
|
||||
AuthRepository.login()
|
||||
↓
|
||||
AuthRemoteDataSource.login() → API Call
|
||||
↓
|
||||
API Response → UserModel
|
||||
↓
|
||||
Save to SecureStorage
|
||||
↓
|
||||
Update AuthState (authenticated)
|
||||
↓
|
||||
Navigate to /warehouses
|
||||
```
|
||||
|
||||
### Logout Flow
|
||||
```
|
||||
User Action
|
||||
↓
|
||||
AuthNotifier.logout()
|
||||
↓
|
||||
LogoutUseCase
|
||||
↓
|
||||
AuthRepository.logout()
|
||||
↓
|
||||
API Logout (optional)
|
||||
↓
|
||||
Clear SecureStorage
|
||||
↓
|
||||
Reset AuthState
|
||||
↓
|
||||
Navigate to /login
|
||||
```
|
||||
|
||||
## Integration Checklist
|
||||
|
||||
### Prerequisites
|
||||
- [x] flutter_riverpod ^2.4.9
|
||||
- [x] dartz ^0.10.1
|
||||
- [x] flutter_secure_storage ^9.0.0
|
||||
- [x] dio ^5.3.2
|
||||
- [x] equatable ^2.0.5
|
||||
- [x] go_router ^12.1.3
|
||||
|
||||
### Integration Steps
|
||||
1. ⏳ Wrap app with ProviderScope in main.dart
|
||||
2. ⏳ Configure router with login and protected routes
|
||||
3. ⏳ Set API base URL in app_constants.dart
|
||||
4. ⏳ Add /warehouses route (or your target route)
|
||||
5. ⏳ Test login flow
|
||||
6. ⏳ Test logout flow
|
||||
7. ⏳ Test persistence (app restart)
|
||||
8. ⏳ Test error handling
|
||||
|
||||
### Testing TODO
|
||||
- [ ] Unit tests for use cases
|
||||
- [ ] Unit tests for repository
|
||||
- [ ] Unit tests for data sources
|
||||
- [ ] Widget tests for LoginPage
|
||||
- [ ] Widget tests for LoginForm
|
||||
- [ ] Integration tests for full flow
|
||||
|
||||
## API Integration
|
||||
|
||||
### Required Endpoints
|
||||
- `POST /api/v1/auth/login` - Login endpoint
|
||||
- `POST /api/v1/auth/logout` - Logout endpoint (optional)
|
||||
- `POST /api/v1/auth/refresh` - Token refresh endpoint
|
||||
|
||||
### Expected Response Format
|
||||
```json
|
||||
{
|
||||
"Value": {
|
||||
"userId": "string",
|
||||
"username": "string",
|
||||
"accessToken": "string",
|
||||
"refreshToken": "string"
|
||||
},
|
||||
"IsSuccess": true,
|
||||
"IsFailure": false,
|
||||
"Errors": [],
|
||||
"ErrorCodes": []
|
||||
}
|
||||
```
|
||||
|
||||
## File Size Summary
|
||||
|
||||
Total Files Created: 18
|
||||
- Dart Files: 15
|
||||
- Documentation: 3
|
||||
- Total Lines of Code: ~2,500
|
||||
|
||||
## Dependencies Used
|
||||
|
||||
### Core
|
||||
- `flutter_riverpod` - State management
|
||||
- `dartz` - Functional programming (Either)
|
||||
- `equatable` - Value equality
|
||||
|
||||
### Storage
|
||||
- `flutter_secure_storage` - Encrypted storage
|
||||
|
||||
### Network
|
||||
- `dio` - HTTP client (via ApiClient)
|
||||
|
||||
### Navigation
|
||||
- `go_router` - Routing
|
||||
|
||||
### Internal
|
||||
- `core/network/api_client.dart`
|
||||
- `core/storage/secure_storage.dart`
|
||||
- `core/errors/failures.dart`
|
||||
- `core/errors/exceptions.dart`
|
||||
- `core/widgets/custom_button.dart`
|
||||
- `core/widgets/loading_indicator.dart`
|
||||
- `core/constants/api_endpoints.dart`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate**
|
||||
- Configure API base URL
|
||||
- Integrate into main.dart
|
||||
- Configure router
|
||||
- Test basic login flow
|
||||
|
||||
2. **Short Term**
|
||||
- Create warehouse selection feature
|
||||
- Add token auto-refresh
|
||||
- Implement remember me
|
||||
- Add biometric authentication
|
||||
|
||||
3. **Long Term**
|
||||
- Add comprehensive tests
|
||||
- Implement password reset
|
||||
- Add multi-factor authentication
|
||||
- Performance optimization
|
||||
|
||||
## Code Quality Metrics
|
||||
|
||||
- Clean Architecture: ✅
|
||||
- SOLID Principles: ✅
|
||||
- Testability: ✅
|
||||
- Documentation: ✅
|
||||
- Type Safety: ✅
|
||||
- Error Handling: ✅
|
||||
- Separation of Concerns: ✅
|
||||
- Dependency Injection: ✅
|
||||
|
||||
## Files Location Reference
|
||||
|
||||
```
|
||||
lib/features/auth/
|
||||
├── data/
|
||||
│ ├── datasources/
|
||||
│ │ └── auth_remote_datasource.dart
|
||||
│ ├── models/
|
||||
│ │ ├── login_request_model.dart
|
||||
│ │ └── user_model.dart
|
||||
│ ├── repositories/
|
||||
│ │ └── auth_repository_impl.dart
|
||||
│ └── data.dart
|
||||
├── domain/
|
||||
│ ├── entities/
|
||||
│ │ └── user_entity.dart
|
||||
│ ├── repositories/
|
||||
│ │ └── auth_repository.dart
|
||||
│ ├── usecases/
|
||||
│ │ └── login_usecase.dart
|
||||
│ └── domain.dart
|
||||
├── presentation/
|
||||
│ ├── pages/
|
||||
│ │ └── login_page.dart
|
||||
│ ├── providers/
|
||||
│ │ └── auth_provider.dart
|
||||
│ ├── widgets/
|
||||
│ │ └── login_form.dart
|
||||
│ └── presentation.dart
|
||||
├── di/
|
||||
│ └── auth_dependency_injection.dart
|
||||
├── auth.dart
|
||||
├── README.md
|
||||
└── INTEGRATION_GUIDE.md
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The authentication feature is complete and ready for integration. It follows clean architecture principles, uses industry-standard patterns, and provides a solid foundation for the warehouse management app.
|
||||
|
||||
All code is documented, tested patterns are in place, and comprehensive guides are provided for integration and usage.
|
||||
@@ -1,257 +0,0 @@
|
||||
# API Client Quick Reference
|
||||
|
||||
## Import
|
||||
|
||||
```dart
|
||||
import 'package:minhthu/core/core.dart';
|
||||
```
|
||||
|
||||
## Initialization
|
||||
|
||||
```dart
|
||||
final secureStorage = SecureStorage();
|
||||
final apiClient = ApiClient(
|
||||
secureStorage,
|
||||
onUnauthorized: () => context.go('/login'),
|
||||
);
|
||||
```
|
||||
|
||||
## HTTP Methods
|
||||
|
||||
### GET Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.get(
|
||||
'/warehouses',
|
||||
queryParameters: {'limit': 10},
|
||||
);
|
||||
```
|
||||
|
||||
### POST Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.post(
|
||||
'/auth/login',
|
||||
data: {'username': 'user', 'password': 'pass'},
|
||||
);
|
||||
```
|
||||
|
||||
### PUT Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.put(
|
||||
'/products/123',
|
||||
data: {'name': 'Updated'},
|
||||
);
|
||||
```
|
||||
|
||||
### DELETE Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.delete('/products/123');
|
||||
```
|
||||
|
||||
## Parse API Response
|
||||
|
||||
```dart
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => User.fromJson(json), // or your model
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
final data = apiResponse.value;
|
||||
// Use data
|
||||
} else {
|
||||
final error = apiResponse.getErrorMessage();
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```dart
|
||||
try {
|
||||
final response = await apiClient.get('/products');
|
||||
} on NetworkException catch (e) {
|
||||
// Timeout, no internet
|
||||
print('Network error: ${e.message}');
|
||||
} on ServerException catch (e) {
|
||||
// HTTP errors (401, 404, 500, etc.)
|
||||
print('Server error: ${e.message}');
|
||||
print('Error code: ${e.code}');
|
||||
}
|
||||
```
|
||||
|
||||
## Token Management
|
||||
|
||||
### Save Token
|
||||
|
||||
```dart
|
||||
await secureStorage.saveAccessToken('your_token');
|
||||
await secureStorage.saveRefreshToken('refresh_token');
|
||||
```
|
||||
|
||||
### Get Token
|
||||
|
||||
```dart
|
||||
final token = await secureStorage.getAccessToken();
|
||||
```
|
||||
|
||||
### Check Authentication
|
||||
|
||||
```dart
|
||||
final isAuthenticated = await apiClient.isAuthenticated();
|
||||
```
|
||||
|
||||
### Clear Tokens (Logout)
|
||||
|
||||
```dart
|
||||
await apiClient.clearAuth();
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Use constants from `ApiEndpoints`:
|
||||
|
||||
```dart
|
||||
// Authentication
|
||||
ApiEndpoints.login // /auth/login
|
||||
ApiEndpoints.logout // /auth/logout
|
||||
|
||||
// Warehouses
|
||||
ApiEndpoints.warehouses // /warehouses
|
||||
ApiEndpoints.warehouseById(1) // /warehouses/1
|
||||
|
||||
// Products
|
||||
ApiEndpoints.products // /products
|
||||
ApiEndpoints.productById(123) // /products/123
|
||||
|
||||
// Query parameters helper
|
||||
ApiEndpoints.productQueryParams(
|
||||
warehouseId: 1,
|
||||
type: 'import',
|
||||
) // {warehouseId: 1, type: 'import'}
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
### Test Connection
|
||||
|
||||
```dart
|
||||
final isConnected = await apiClient.testConnection();
|
||||
```
|
||||
|
||||
### Update Base URL
|
||||
|
||||
```dart
|
||||
apiClient.updateBaseUrl('https://dev-api.example.com');
|
||||
```
|
||||
|
||||
### Get Current Token
|
||||
|
||||
```dart
|
||||
final token = await apiClient.getAccessToken();
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Login Flow
|
||||
|
||||
```dart
|
||||
// 1. Login
|
||||
final response = await apiClient.post(
|
||||
ApiEndpoints.login,
|
||||
data: {'username': username, 'password': password},
|
||||
);
|
||||
|
||||
// 2. Parse
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => User.fromJson(json),
|
||||
);
|
||||
|
||||
// 3. Save tokens
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
final user = apiResponse.value!;
|
||||
await secureStorage.saveAccessToken(user.accessToken);
|
||||
await secureStorage.saveUserId(user.userId);
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
```dart
|
||||
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
|
||||
final ApiClient apiClient;
|
||||
|
||||
WarehouseRemoteDataSourceImpl(this.apiClient);
|
||||
|
||||
@override
|
||||
Future<List<Warehouse>> getWarehouses() async {
|
||||
final response = await apiClient.get(ApiEndpoints.warehouses);
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
return apiResponse.value!;
|
||||
} else {
|
||||
throw ServerException(apiResponse.getErrorMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Set Base URL
|
||||
|
||||
In `lib/core/constants/app_constants.dart`:
|
||||
|
||||
```dart
|
||||
static const String apiBaseUrl = 'https://api.example.com';
|
||||
```
|
||||
|
||||
### Set Timeouts
|
||||
|
||||
In `lib/core/constants/app_constants.dart`:
|
||||
|
||||
```dart
|
||||
static const int connectionTimeout = 30000; // 30 seconds
|
||||
static const int receiveTimeout = 30000;
|
||||
static const int sendTimeout = 30000;
|
||||
```
|
||||
|
||||
## Files Location
|
||||
|
||||
- API Client: `/lib/core/network/api_client.dart`
|
||||
- API Response: `/lib/core/network/api_response.dart`
|
||||
- Secure Storage: `/lib/core/storage/secure_storage.dart`
|
||||
- API Endpoints: `/lib/core/constants/api_endpoints.dart`
|
||||
- Examples: `/lib/core/network/api_client_example.dart`
|
||||
- Documentation: `/lib/core/network/README.md`
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Automatic Token Injection**: Bearer token is automatically added to all requests
|
||||
2. **401 Handling**: 401 errors automatically clear tokens and trigger `onUnauthorized` callback
|
||||
3. **Logging**: All requests/responses are logged with sensitive data redacted
|
||||
4. **Singleton Storage**: SecureStorage is a singleton - use `SecureStorage()` everywhere
|
||||
5. **Error Codes**: ServerException includes error codes (e.g., '401', '404', '500')
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Token not injected?
|
||||
Check if token exists: `await secureStorage.getAccessToken()`
|
||||
|
||||
### 401 not clearing tokens?
|
||||
Verify `onUnauthorized` callback is set in ApiClient constructor
|
||||
|
||||
### Connection timeout?
|
||||
Check network, verify base URL, increase timeout in constants
|
||||
|
||||
### Logs not showing?
|
||||
Check Flutter DevTools console or developer.log output
|
||||
426
ROUTER_SETUP.md
426
ROUTER_SETUP.md
@@ -1,426 +0,0 @@
|
||||
# GoRouter Navigation Setup - Complete Guide
|
||||
|
||||
This document explains the complete navigation setup for the warehouse management app using GoRouter with authentication-based redirects.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
1. **`/lib/core/router/app_router.dart`** - Main router configuration
|
||||
2. **`/lib/core/router/README.md`** - Detailed router documentation
|
||||
3. **`/lib/features/warehouse/presentation/pages/warehouse_selection_page_example.dart`** - Integration examples
|
||||
|
||||
### Modified Files
|
||||
1. **`/lib/main.dart`** - Updated to use new router provider
|
||||
2. **`/lib/features/operation/presentation/pages/operation_selection_page.dart`** - Updated navigation
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Route Structure
|
||||
```
|
||||
/login → LoginPage
|
||||
/warehouses → WarehouseSelectionPage
|
||||
/operations → OperationSelectionPage (requires warehouse)
|
||||
/products → ProductsPage (requires warehouse + operationType)
|
||||
```
|
||||
|
||||
### Navigation Flow
|
||||
```
|
||||
┌─────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐
|
||||
│ Login │ --> │ Warehouses │ --> │ Operations │ --> │ Products │
|
||||
└─────────┘ └────────────┘ └────────────┘ └──────────┘
|
||||
│ │ │ │
|
||||
└─────────────────┴──────────────────┴──────────────────┘
|
||||
Protected Routes
|
||||
(Require Authentication via SecureStorage)
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Authentication-Based Redirects
|
||||
- **Unauthenticated users** → Redirected to `/login`
|
||||
- **Authenticated users on /login** → Redirected to `/warehouses`
|
||||
- Uses `SecureStorage.isAuthenticated()` to check access token
|
||||
|
||||
### 2. Type-Safe Navigation
|
||||
Extension methods provide type-safe navigation:
|
||||
```dart
|
||||
// Type-safe with auto-completion
|
||||
context.goToOperations(warehouse);
|
||||
context.goToProducts(warehouse: warehouse, operationType: 'import');
|
||||
|
||||
// vs. error-prone manual navigation
|
||||
context.go('/operations', extra: warehouse); // Less safe
|
||||
```
|
||||
|
||||
### 3. Parameter Validation
|
||||
Routes validate required parameters and redirect on error:
|
||||
```dart
|
||||
final warehouse = state.extra as WarehouseEntity?;
|
||||
if (warehouse == null) {
|
||||
// Show error and redirect to safe page
|
||||
return _ErrorScreen(message: 'Warehouse data is required');
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Reactive Navigation
|
||||
Router automatically reacts to authentication state changes:
|
||||
```dart
|
||||
// Login → Router detects auth change → Redirects to /warehouses
|
||||
await ref.read(authProvider.notifier).login(username, password);
|
||||
|
||||
// Logout → Router detects auth change → Redirects to /login
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
```
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
#### 1. Navigate to Login
|
||||
```dart
|
||||
context.goToLogin();
|
||||
```
|
||||
|
||||
#### 2. Navigate to Warehouses
|
||||
```dart
|
||||
context.goToWarehouses();
|
||||
```
|
||||
|
||||
#### 3. Navigate to Operations with Warehouse
|
||||
```dart
|
||||
// From warehouse selection page
|
||||
void onWarehouseSelected(WarehouseEntity warehouse) {
|
||||
context.goToOperations(warehouse);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Navigate to Products with Warehouse and Operation
|
||||
```dart
|
||||
// From operation selection page
|
||||
void onOperationSelected(WarehouseEntity warehouse, String operationType) {
|
||||
context.goToProducts(
|
||||
warehouse: warehouse,
|
||||
operationType: operationType, // 'import' or 'export'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Integration Example
|
||||
|
||||
#### Warehouse Selection Page
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:minhthu/core/router/app_router.dart';
|
||||
import 'package:minhthu/features/warehouse/domain/entities/warehouse_entity.dart';
|
||||
|
||||
class WarehouseSelectionPage extends ConsumerWidget {
|
||||
const WarehouseSelectionPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch warehouse state
|
||||
final state = ref.watch(warehouseProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Select Warehouse'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () async {
|
||||
// Logout - router will auto-redirect to login
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: state.warehouses.length,
|
||||
itemBuilder: (context, index) {
|
||||
final warehouse = state.warehouses[index];
|
||||
return ListTile(
|
||||
title: Text(warehouse.name),
|
||||
subtitle: Text(warehouse.code),
|
||||
onTap: () {
|
||||
// Type-safe navigation to operations
|
||||
context.goToOperations(warehouse);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Operation Selection Page
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:minhthu/core/router/app_router.dart';
|
||||
import 'package:minhthu/features/warehouse/domain/entities/warehouse_entity.dart';
|
||||
|
||||
class OperationSelectionPage extends StatelessWidget {
|
||||
final WarehouseEntity warehouse;
|
||||
|
||||
const OperationSelectionPage({required this.warehouse});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(warehouse.name)),
|
||||
body: Column(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Navigate to products with import operation
|
||||
context.goToProducts(
|
||||
warehouse: warehouse,
|
||||
operationType: 'import',
|
||||
);
|
||||
},
|
||||
child: const Text('Import Products'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Navigate to products with export operation
|
||||
context.goToProducts(
|
||||
warehouse: warehouse,
|
||||
operationType: 'export',
|
||||
);
|
||||
},
|
||||
child: const Text('Export Products'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Integration
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **App Starts**
|
||||
- Router checks `SecureStorage.isAuthenticated()`
|
||||
- If no token → Redirects to `/login`
|
||||
- If token exists → Allows navigation
|
||||
|
||||
2. **User Logs In**
|
||||
```dart
|
||||
// AuthNotifier saves token and updates state
|
||||
await loginUseCase(request); // Saves to SecureStorage
|
||||
state = AuthState.authenticated(user);
|
||||
|
||||
// GoRouterRefreshStream detects auth state change
|
||||
ref.listen(authProvider, (_, __) => notifyListeners());
|
||||
|
||||
// Router re-evaluates redirect logic
|
||||
// User is now authenticated → Redirects to /warehouses
|
||||
```
|
||||
|
||||
3. **User Logs Out**
|
||||
```dart
|
||||
// AuthNotifier clears token and resets state
|
||||
await secureStorage.clearAll();
|
||||
state = const AuthState.initial();
|
||||
|
||||
// Router detects auth state change
|
||||
// User is no longer authenticated → Redirects to /login
|
||||
```
|
||||
|
||||
### SecureStorage Methods Used
|
||||
```dart
|
||||
// Check authentication
|
||||
Future<bool> isAuthenticated() async {
|
||||
final token = await getAccessToken();
|
||||
return token != null && token.isNotEmpty;
|
||||
}
|
||||
|
||||
// Save tokens (during login)
|
||||
Future<void> saveAccessToken(String token);
|
||||
Future<void> saveRefreshToken(String token);
|
||||
|
||||
// Clear tokens (during logout)
|
||||
Future<void> clearAll();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. Missing Route Parameters
|
||||
If required parameters are missing, user sees error and gets redirected:
|
||||
```dart
|
||||
GoRoute(
|
||||
path: '/operations',
|
||||
builder: (context, state) {
|
||||
final warehouse = state.extra as WarehouseEntity?;
|
||||
|
||||
if (warehouse == null) {
|
||||
// Show error screen and redirect after frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go('/warehouses');
|
||||
});
|
||||
return const _ErrorScreen(
|
||||
message: 'Warehouse data is required',
|
||||
);
|
||||
}
|
||||
|
||||
return OperationSelectionPage(warehouse: warehouse);
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Page Not Found
|
||||
Custom 404 page with navigation back to login:
|
||||
```dart
|
||||
errorBuilder: (context, state) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64),
|
||||
Text('Page "${state.uri.path}" does not exist'),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
child: const Text('Go to Login'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Authentication Errors
|
||||
If `SecureStorage` throws an error, redirect to login for safety:
|
||||
```dart
|
||||
Future<String?> _handleRedirect(context, state) async {
|
||||
try {
|
||||
final isAuthenticated = await secureStorage.isAuthenticated();
|
||||
// ... redirect logic
|
||||
} catch (e) {
|
||||
debugPrint('Error in redirect: $e');
|
||||
return '/login'; // Safe fallback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extension Methods Reference
|
||||
|
||||
### Path-Based Navigation
|
||||
```dart
|
||||
context.goToLogin(); // Go to /login
|
||||
context.goToWarehouses(); // Go to /warehouses
|
||||
context.goToOperations(warehouse);
|
||||
context.goToProducts(warehouse: w, operationType: 'import');
|
||||
context.goBack(); // Pop current route
|
||||
```
|
||||
|
||||
### Named Route Navigation
|
||||
```dart
|
||||
context.goToLoginNamed();
|
||||
context.goToWarehousesNamed();
|
||||
context.goToOperationsNamed(warehouse);
|
||||
context.goToProductsNamed(warehouse: w, operationType: 'export');
|
||||
```
|
||||
|
||||
## Testing Authentication Flow
|
||||
|
||||
### Test Case 1: Fresh Install
|
||||
1. App starts → No token → Redirects to `/login`
|
||||
2. User logs in → Token saved → Redirects to `/warehouses`
|
||||
3. User selects warehouse → Navigates to `/operations`
|
||||
4. User selects operation → Navigates to `/products`
|
||||
|
||||
### Test Case 2: Logged In User
|
||||
1. App starts → Token exists → Shows `/warehouses`
|
||||
2. User navigates normally through app
|
||||
3. User logs out → Token cleared → Redirects to `/login`
|
||||
|
||||
### Test Case 3: Manual URL Entry
|
||||
1. User tries to access `/products` directly
|
||||
2. Router checks authentication
|
||||
3. If not authenticated → Redirects to `/login`
|
||||
4. If authenticated but missing params → Redirects to `/warehouses`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Stuck on login page after successful login
|
||||
**Solution**: Check if token is being saved to SecureStorage
|
||||
```dart
|
||||
// In LoginUseCase
|
||||
await secureStorage.saveAccessToken(user.accessToken);
|
||||
```
|
||||
|
||||
### Problem: Redirect loop between login and warehouses
|
||||
**Solution**: Verify `isAuthenticated()` logic
|
||||
```dart
|
||||
// Should return true only if token exists
|
||||
Future<bool> isAuthenticated() async {
|
||||
final token = await getAccessToken();
|
||||
return token != null && token.isNotEmpty;
|
||||
}
|
||||
```
|
||||
|
||||
### Problem: Navigation parameters are null
|
||||
**Solution**: Use extension methods with correct types
|
||||
```dart
|
||||
// Correct
|
||||
context.goToOperations(warehouse);
|
||||
|
||||
// Wrong - may lose type information
|
||||
context.go('/operations', extra: warehouse);
|
||||
```
|
||||
|
||||
### Problem: Router doesn't react to auth changes
|
||||
**Solution**: Verify GoRouterRefreshStream is listening
|
||||
```dart
|
||||
GoRouterRefreshStream(this.ref) {
|
||||
ref.listen(
|
||||
authProvider, // Must be the correct provider
|
||||
(_, __) => notifyListeners(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Implement Warehouse Provider**
|
||||
- Create warehouse state management
|
||||
- Load warehouses from API
|
||||
- Integrate with warehouse selection page
|
||||
|
||||
2. **Implement Products Provider**
|
||||
- Create products state management
|
||||
- Load products based on warehouse and operation
|
||||
- Integrate with products page
|
||||
|
||||
3. **Add Loading States**
|
||||
- Show loading indicators during navigation
|
||||
- Handle network errors gracefully
|
||||
|
||||
4. **Add Analytics**
|
||||
- Track navigation events
|
||||
- Monitor authentication flow
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Router Details**: `/lib/core/router/README.md`
|
||||
- **Auth Setup**: `/lib/features/auth/di/auth_dependency_injection.dart`
|
||||
- **SecureStorage**: `/lib/core/storage/secure_storage.dart`
|
||||
- **Examples**: `/lib/features/warehouse/presentation/pages/warehouse_selection_page_example.dart`
|
||||
|
||||
## Summary
|
||||
|
||||
The complete GoRouter setup provides:
|
||||
- Authentication-based navigation with auto-redirect
|
||||
- Type-safe parameter passing
|
||||
- Reactive updates on auth state changes
|
||||
- Proper error handling and validation
|
||||
- Easy-to-use extension methods
|
||||
- Integration with existing SecureStorage and Riverpod
|
||||
|
||||
The app flow is: **Login → Warehouses → Operations → Products**
|
||||
|
||||
All protected routes automatically redirect to login if user is not authenticated.
|
||||
2151
assets/fonts/NotoSans-Bold.ttf
Normal file
2151
assets/fonts/NotoSans-Bold.ttf
Normal file
File diff suppressed because one or more lines are too long
2151
assets/fonts/NotoSans-Regular.ttf
Normal file
2151
assets/fonts/NotoSans-Regular.ttf
Normal file
File diff suppressed because one or more lines are too long
@@ -8,6 +8,8 @@ PODS:
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- printing (1.0.0):
|
||||
- Flutter
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -17,6 +19,7 @@ DEPENDENCIES:
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- printing (from `.symlinks/plugins/printing/ios`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
@@ -28,15 +31,18 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
printing:
|
||||
:path: ".symlinks/plugins/printing/ios"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
printing: 233e1b73bd1f4a05615548e9b5a324c98588640b
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ class ApiEndpoints {
|
||||
/// 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 ====================
|
||||
|
||||
/// Get all warehouses
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../features/auth/data/repositories/auth_repository_impl.dart';
|
||||
import '../../features/auth/domain/repositories/auth_repository.dart';
|
||||
import '../../features/auth/domain/usecases/login_usecase.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/repositories/products_repository_impl.dart';
|
||||
import '../../features/products/domain/entities/product_stage_entity.dart';
|
||||
@@ -256,6 +257,12 @@ final warehouseErrorProvider = Provider<String?>((ref) {
|
||||
|
||||
// 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
|
||||
/// Handles API calls for products
|
||||
final productsRemoteDataSourceProvider =
|
||||
@@ -266,9 +273,14 @@ final productsRemoteDataSourceProvider =
|
||||
|
||||
/// Products repository provider
|
||||
/// Implements domain repository interface
|
||||
/// Coordinates between local and remote data sources
|
||||
final productsRepositoryProvider = Provider<ProductsRepository>((ref) {
|
||||
final remoteDataSource = ref.watch(productsRemoteDataSourceProvider);
|
||||
return ProductsRepositoryImpl(remoteDataSource);
|
||||
final localDataSource = ref.watch(productsLocalDataSourceProvider);
|
||||
return ProductsRepositoryImpl(
|
||||
remoteDataSource: remoteDataSource,
|
||||
localDataSource: localDataSource,
|
||||
);
|
||||
});
|
||||
|
||||
// Domain Layer
|
||||
|
||||
407
lib/core/services/print_service.dart
Normal file
407
lib/core/services/print_service.dart
Normal file
@@ -0,0 +1,407 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
/// Service for generating and printing warehouse export forms
|
||||
class PrintService {
|
||||
/// Generate and print a warehouse export form
|
||||
static Future<void> printWarehouseExport({
|
||||
required BuildContext context,
|
||||
required String warehouseName,
|
||||
required int productId,
|
||||
required String productCode,
|
||||
required String productName,
|
||||
String? stageName,
|
||||
required double passedKg,
|
||||
required int passedPcs,
|
||||
required double issuedKg,
|
||||
required int issuedPcs,
|
||||
String? responsibleName,
|
||||
String? receiverName,
|
||||
String? barcodeData,
|
||||
}) async {
|
||||
// Load Vietnamese-compatible fonts using PdfGoogleFonts
|
||||
// Noto Sans has excellent Vietnamese character support
|
||||
final fontRegular = await PdfGoogleFonts.notoSansRegular();
|
||||
final fontBold = await PdfGoogleFonts.notoSansBold();
|
||||
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Format current date
|
||||
final dt = DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now());
|
||||
|
||||
// Add page to PDF with theme for Vietnamese font support
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(12),
|
||||
theme: pw.ThemeData.withFont(
|
||||
base: fontRegular,
|
||||
bold: fontBold,
|
||||
),
|
||||
build: (pw.Context pdfContext) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.all(12),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
pw.Center(
|
||||
child: pw.Column(
|
||||
children: [
|
||||
pw.Text(
|
||||
'PHIẾU XUẤT KHO',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text(
|
||||
'Công ty TNHH Cơ Khí Chính Xác Minh Thư',
|
||||
style: const pw.TextStyle(fontSize: 16),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
warehouseName,
|
||||
style: const pw.TextStyle(fontSize: 14),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'Ngày: $dt',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 16),
|
||||
|
||||
// Product information box
|
||||
pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Column(
|
||||
children: [
|
||||
pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'ProductId',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
'$productId',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Mã sản phẩm',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
productCode,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Tên sản phẩm',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
productName,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Công đoạn',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
stageName ?? '-',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 12),
|
||||
|
||||
// Quantities box
|
||||
pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Số lượng:',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(
|
||||
color: PdfColors.black,
|
||||
width: 0.5,
|
||||
),
|
||||
children: [
|
||||
// Header
|
||||
pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(
|
||||
color: PdfColors.grey300,
|
||||
),
|
||||
children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
'Loại',
|
||||
style: pw.TextStyle(
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
'KG',
|
||||
style: pw.TextStyle(
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
'PCS',
|
||||
style: pw.TextStyle(
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Passed quantity row
|
||||
pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text('Hàng đạt'),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(passedKg.toStringAsFixed(2)),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text('$passedPcs'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Issued quantity row
|
||||
pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text('Hàng lỗi'),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(issuedKg.toStringAsFixed(2)),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text('$issuedPcs'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 12),
|
||||
|
||||
// Responsible person box
|
||||
pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Row(
|
||||
children: [
|
||||
pw.Text(
|
||||
'Nhân viên kho: ',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
responsibleName ?? '-',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 12),
|
||||
|
||||
|
||||
pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.black, width: 0.5),
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Row(
|
||||
children: [
|
||||
pw.Text(
|
||||
'Nhân viên tiếp nhận: ',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
receiverName ?? '-',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 12),
|
||||
|
||||
// Barcode section
|
||||
if (barcodeData != null && barcodeData.isNotEmpty)
|
||||
pw.Center(
|
||||
child: pw.BarcodeWidget(
|
||||
barcode: pw.Barcode.code128(),
|
||||
data: barcodeData,
|
||||
width: 200,
|
||||
height: 60,
|
||||
),
|
||||
),
|
||||
|
||||
pw.Spacer(),
|
||||
|
||||
// Footer signature section
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
children: [
|
||||
pw.Container(
|
||||
width: 150,
|
||||
child: pw.Column(
|
||||
children: [
|
||||
pw.Text(
|
||||
'Người nhận',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Container(
|
||||
height: 1,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Show print preview dialog
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||
name: 'warehouse_export_${productCode}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/core/services/sunmi_service.dart
Normal file
210
lib/core/services/sunmi_service.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sunmi_printer_plus/sunmi_printer_plus.dart';
|
||||
|
||||
/// Service for printing to Sunmi thermal printers
|
||||
class SunmiService {
|
||||
/// Print warehouse export form to Sunmi printer
|
||||
static Future<void> printWarehouseExport({
|
||||
required BuildContext context,
|
||||
required String warehouseName,
|
||||
required int productId,
|
||||
required String productCode,
|
||||
required String productName,
|
||||
String? stageName,
|
||||
required double passedKg,
|
||||
required int passedPcs,
|
||||
required double issuedKg,
|
||||
required int issuedPcs,
|
||||
String? responsibleName,
|
||||
String? receiverName,
|
||||
String? barcodeData,
|
||||
}) async {
|
||||
try {
|
||||
// Format current date
|
||||
final dt = DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now());
|
||||
|
||||
// Title - PHIẾU XUẤT KHO
|
||||
await SunmiPrinter.printText(
|
||||
'PHIEU XUAT KHO',
|
||||
style: SunmiTextStyle(
|
||||
align: SunmiPrintAlign.CENTER,
|
||||
bold: true,
|
||||
fontSize: 48,
|
||||
),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Company name
|
||||
await SunmiPrinter.printText(
|
||||
'Cong ty TNHH Co Khi Chinh Xac Minh Thu',
|
||||
style: SunmiTextStyle(
|
||||
align: SunmiPrintAlign.CENTER,
|
||||
fontSize: 32,
|
||||
),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Warehouse name
|
||||
await SunmiPrinter.printText(
|
||||
warehouseName,
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.CENTER),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Date
|
||||
await SunmiPrinter.printText(
|
||||
'Ngay: $dt',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.CENTER),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(2);
|
||||
|
||||
// Separator line
|
||||
await SunmiPrinter.line();
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Product information
|
||||
await SunmiPrinter.printText(
|
||||
'THONG TIN SAN PHAM',
|
||||
style: SunmiTextStyle(
|
||||
align: SunmiPrintAlign.LEFT,
|
||||
bold: true,
|
||||
),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// ProductId
|
||||
await SunmiPrinter.printText(
|
||||
'ProductId: $productId',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Product Code
|
||||
await SunmiPrinter.printText(
|
||||
'Ma san pham: $productCode',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Product Name
|
||||
await SunmiPrinter.printText(
|
||||
'Ten san pham: $productName',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Stage Name
|
||||
await SunmiPrinter.printText(
|
||||
'Cong doan: ${stageName ?? '-'}',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(2);
|
||||
|
||||
// Separator line
|
||||
await SunmiPrinter.line();
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Quantities
|
||||
await SunmiPrinter.printText(
|
||||
'SO LUONG',
|
||||
style: SunmiTextStyle(
|
||||
align: SunmiPrintAlign.LEFT,
|
||||
bold: true,
|
||||
),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Table header
|
||||
await SunmiPrinter.printText(
|
||||
'Loai KG PCS',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.line();
|
||||
|
||||
// Passed quantity (Hàng đạt)
|
||||
final passedLine =
|
||||
'Hang dat ${passedKg.toStringAsFixed(2).padLeft(7)} ${passedPcs.toString().padLeft(5)}';
|
||||
await SunmiPrinter.printText(
|
||||
passedLine,
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
|
||||
// Issued quantity (Hàng lỗi)
|
||||
final issuedLine =
|
||||
'Hang loi ${issuedKg.toStringAsFixed(2).padLeft(7)} ${issuedPcs.toString().padLeft(5)}';
|
||||
await SunmiPrinter.printText(
|
||||
issuedLine,
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(2);
|
||||
|
||||
// Separator line
|
||||
await SunmiPrinter.line();
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Responsible person
|
||||
await SunmiPrinter.printText(
|
||||
'Nhan vien kho: ${responsibleName ?? '-'}',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(1);
|
||||
|
||||
// Receiver
|
||||
await SunmiPrinter.printText(
|
||||
'Nhan vien tiep nhan: ${receiverName ?? '-'}',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.LEFT),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(2);
|
||||
|
||||
// Barcode
|
||||
if (barcodeData != null && barcodeData.isNotEmpty) {
|
||||
await SunmiPrinter.line();
|
||||
await SunmiPrinter.printBarCode(
|
||||
barcodeData,
|
||||
style: SunmiBarcodeStyle(
|
||||
type: SunmiBarcodeType.CODE128,
|
||||
textPos: SunmiBarcodeTextPos.TEXT_UNDER,
|
||||
height: 100,
|
||||
align: SunmiPrintAlign.CENTER,
|
||||
),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(2);
|
||||
}
|
||||
|
||||
// Footer
|
||||
await SunmiPrinter.line();
|
||||
await SunmiPrinter.printText(
|
||||
'Nguoi nhan',
|
||||
style: SunmiTextStyle(align: SunmiPrintAlign.CENTER),
|
||||
);
|
||||
await SunmiPrinter.lineWrap(4);
|
||||
|
||||
// Cut paper
|
||||
await SunmiPrinter.cutPaper();
|
||||
|
||||
// Show success message
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Đã in thành công!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Show error message
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Lỗi khi in: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ class SecureStorage {
|
||||
/// Key for storing username
|
||||
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 ====================
|
||||
|
||||
/// 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)
|
||||
Future<bool> isAuthenticated() async {
|
||||
final token = await getAccessToken();
|
||||
|
||||
86
lib/core/utils/text_utils.dart
Normal file
86
lib/core/utils/text_utils.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
/// Utility functions for text processing
|
||||
class TextUtils {
|
||||
/// Convert Vietnamese characters to English (non-accented) characters
|
||||
/// Example: "Tuấn" -> "tuan", "Hồ Chí Minh" -> "ho chi minh"
|
||||
static String removeVietnameseAccents(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
|
||||
// Convert to lowercase for consistent comparison
|
||||
String result = text.toLowerCase();
|
||||
|
||||
// Map of Vietnamese characters to their non-accented equivalents
|
||||
const vietnameseMap = {
|
||||
// a with accents
|
||||
'á': 'a', 'à': 'a', 'ả': 'a', 'ã': 'a', 'ạ': 'a',
|
||||
'ă': 'a', 'ắ': 'a', 'ằ': 'a', 'ẳ': 'a', 'ẵ': 'a', 'ặ': 'a',
|
||||
'â': 'a', 'ấ': 'a', 'ầ': 'a', 'ẩ': 'a', 'ẫ': 'a', 'ậ': 'a',
|
||||
|
||||
// e with accents
|
||||
'é': 'e', 'è': 'e', 'ẻ': 'e', 'ẽ': 'e', 'ẹ': 'e',
|
||||
'ê': 'e', 'ế': 'e', 'ề': 'e', 'ể': 'e', 'ễ': 'e', 'ệ': 'e',
|
||||
|
||||
// i with accents
|
||||
'í': 'i', 'ì': 'i', 'ỉ': 'i', 'ĩ': 'i', 'ị': 'i',
|
||||
|
||||
// o with accents
|
||||
'ó': 'o', 'ò': 'o', 'ỏ': 'o', 'õ': 'o', 'ọ': 'o',
|
||||
'ô': 'o', 'ố': 'o', 'ồ': 'o', 'ổ': 'o', 'ỗ': 'o', 'ộ': 'o',
|
||||
'ơ': 'o', 'ớ': 'o', 'ờ': 'o', 'ở': 'o', 'ỡ': 'o', 'ợ': 'o',
|
||||
|
||||
// u with accents
|
||||
'ú': 'u', 'ù': 'u', 'ủ': 'u', 'ũ': 'u', 'ụ': 'u',
|
||||
'ư': 'u', 'ứ': 'u', 'ừ': 'u', 'ử': 'u', 'ữ': 'u', 'ự': 'u',
|
||||
|
||||
// y with accents
|
||||
'ý': 'y', 'ỳ': 'y', 'ỷ': 'y', 'ỹ': 'y', 'ỵ': 'y',
|
||||
|
||||
// d with stroke
|
||||
'đ': 'd',
|
||||
};
|
||||
|
||||
// Replace each Vietnamese character with its non-accented equivalent
|
||||
vietnameseMap.forEach((vietnamese, english) {
|
||||
result = result.replaceAll(vietnamese, english);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Normalize text for search (lowercase + remove accents)
|
||||
static String normalizeForSearch(String text) {
|
||||
return removeVietnameseAccents(text.toLowerCase().trim());
|
||||
}
|
||||
|
||||
/// Check if a text contains a search term (Vietnamese-aware, case-insensitive)
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// containsVietnameseSearch("Nguyễn Văn Tuấn", "tuan") // returns true
|
||||
/// containsVietnameseSearch("tuan@example.com", "TUAN") // returns true
|
||||
/// ```
|
||||
static bool containsVietnameseSearch(String text, String searchTerm) {
|
||||
if (searchTerm.isEmpty) return true;
|
||||
if (text.isEmpty) return false;
|
||||
|
||||
final normalizedText = normalizeForSearch(text);
|
||||
final normalizedSearch = normalizeForSearch(searchTerm);
|
||||
|
||||
return normalizedText.contains(normalizedSearch);
|
||||
}
|
||||
|
||||
/// Check if any of the provided texts contains the search term
|
||||
static bool containsVietnameseSearchInAny(
|
||||
List<String> texts,
|
||||
String searchTerm,
|
||||
) {
|
||||
if (searchTerm.isEmpty) return true;
|
||||
|
||||
for (final text in texts) {
|
||||
if (containsVietnameseSearch(text, searchTerm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -128,3 +128,24 @@ curl --request GET \
|
||||
--header 'Sec-Fetch-Mode: cors' \
|
||||
--header 'Sec-Fetch-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
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.saveUserId(userModel.userId);
|
||||
await secureStorage.saveUsername(userModel.username);
|
||||
// Save email (username is the email from login)
|
||||
await secureStorage.saveEmail(request.username);
|
||||
|
||||
if (userModel.refreshToken != null) {
|
||||
await secureStorage.saveRefreshToken(userModel.refreshToken!);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:convert';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../models/product_model.dart';
|
||||
|
||||
/// Abstract interface for products local data source
|
||||
abstract class ProductsLocalDataSource {
|
||||
/// Get cached products for a specific warehouse and operation type
|
||||
///
|
||||
/// [warehouseId] - The ID of the warehouse
|
||||
/// [type] - The operation type ('import' or 'export')
|
||||
///
|
||||
/// Returns List<ProductModel> from cache or empty list if not found
|
||||
Future<List<ProductModel>> getCachedProducts(int warehouseId, String type);
|
||||
|
||||
/// Cache products for a specific warehouse and operation type
|
||||
///
|
||||
/// [warehouseId] - The ID of the warehouse
|
||||
/// [type] - The operation type ('import' or 'export')
|
||||
/// [products] - List of products to cache
|
||||
Future<void> cacheProducts(
|
||||
int warehouseId,
|
||||
String type,
|
||||
List<ProductModel> products,
|
||||
);
|
||||
|
||||
/// Clear all cached products
|
||||
Future<void> clearCache();
|
||||
|
||||
/// Clear cached products for a specific warehouse and operation type
|
||||
Future<void> clearCachedProducts(int warehouseId, String type);
|
||||
}
|
||||
|
||||
/// Implementation of ProductsLocalDataSource using Hive
|
||||
class ProductsLocalDataSourceImpl implements ProductsLocalDataSource {
|
||||
static const String _boxName = 'products_cache';
|
||||
Box<String>? _box;
|
||||
|
||||
/// Initialize the Hive box
|
||||
Future<void> init() async {
|
||||
if (_box == null || !_box!.isOpen) {
|
||||
_box = await Hive.openBox<String>(_boxName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate cache key for warehouse and operation type
|
||||
String _getCacheKey(int warehouseId, String type) {
|
||||
return 'products_${warehouseId}_$type';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProductModel>> getCachedProducts(
|
||||
int warehouseId,
|
||||
String type,
|
||||
) async {
|
||||
await init();
|
||||
|
||||
final key = _getCacheKey(warehouseId, type);
|
||||
final cachedData = _box?.get(key);
|
||||
|
||||
if (cachedData == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode JSON string to list
|
||||
final jsonList = jsonDecode(cachedData) as List;
|
||||
|
||||
// Convert JSON list to ProductModel list
|
||||
return jsonList
|
||||
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// If parsing fails, return empty list
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cacheProducts(
|
||||
int warehouseId,
|
||||
String type,
|
||||
List<ProductModel> products,
|
||||
) async {
|
||||
await init();
|
||||
|
||||
final key = _getCacheKey(warehouseId, type);
|
||||
|
||||
// Convert products to JSON list
|
||||
final jsonList = products.map((product) => product.toJson()).toList();
|
||||
|
||||
// Encode to JSON string and save
|
||||
final jsonString = jsonEncode(jsonList);
|
||||
await _box?.put(key, jsonString);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearCache() async {
|
||||
await init();
|
||||
await _box?.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearCachedProducts(int warehouseId, String type) async {
|
||||
await init();
|
||||
|
||||
final key = _getCacheKey(warehouseId, type);
|
||||
await _box?.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
|
||||
// The API returns a list of stages for the product
|
||||
final list = json as List;
|
||||
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
|
||||
return list
|
||||
|
||||
@@ -4,32 +4,69 @@ import '../../../../core/errors/failures.dart';
|
||||
import '../../domain/entities/product_entity.dart';
|
||||
import '../../domain/entities/product_stage_entity.dart';
|
||||
import '../../domain/repositories/products_repository.dart';
|
||||
import '../datasources/products_local_datasource.dart';
|
||||
import '../datasources/products_remote_datasource.dart';
|
||||
import '../models/create_product_warehouse_request.dart';
|
||||
import '../models/product_detail_request_model.dart';
|
||||
|
||||
/// Implementation of ProductsRepository
|
||||
/// 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 {
|
||||
final ProductsRemoteDataSource remoteDataSource;
|
||||
final ProductsLocalDataSource localDataSource;
|
||||
|
||||
ProductsRepositoryImpl(this.remoteDataSource);
|
||||
ProductsRepositoryImpl({
|
||||
required this.remoteDataSource,
|
||||
required this.localDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
||||
int warehouseId,
|
||||
String type,
|
||||
) async {
|
||||
String type, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
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);
|
||||
|
||||
// Cache the fetched products for future use
|
||||
await localDataSource.cacheProducts(warehouseId, type, products);
|
||||
|
||||
// Convert models to entities and return success
|
||||
return Right(products.map((model) => model.toEntity()).toList());
|
||||
} 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
|
||||
return Left(ServerFailure(e.message));
|
||||
} 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
|
||||
return Left(NetworkFailure(e.message));
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,12 +11,14 @@ abstract class ProductsRepository {
|
||||
///
|
||||
/// [warehouseId] - The ID of the warehouse
|
||||
/// [type] - The operation type ('import' or 'export')
|
||||
/// [forceRefresh] - If true, fetch from API even if cache exists
|
||||
///
|
||||
/// Returns Either<Failure, List<ProductEntity>>
|
||||
Future<Either<Failure, List<ProductEntity>>> getProducts(
|
||||
int warehouseId,
|
||||
String type,
|
||||
);
|
||||
String type, {
|
||||
bool forceRefresh = false,
|
||||
});
|
||||
|
||||
/// Get product stages for a product in a warehouse
|
||||
///
|
||||
|
||||
@@ -14,12 +14,18 @@ class GetProductsUseCase {
|
||||
///
|
||||
/// [warehouseId] - The ID of the warehouse to get products from
|
||||
/// [type] - The operation type ('import' or 'export')
|
||||
/// [forceRefresh] - If true, bypass cache and fetch from API
|
||||
///
|
||||
/// Returns Either<Failure, List<ProductEntity>>
|
||||
Future<Either<Failure, List<ProductEntity>>> call(
|
||||
int warehouseId,
|
||||
String type,
|
||||
) async {
|
||||
return await repository.getProducts(warehouseId, type);
|
||||
String type, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
return await repository.getProducts(
|
||||
warehouseId,
|
||||
type,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'package:dropdown_search/dropdown_search.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../../../core/di/providers.dart';
|
||||
import '../../../../core/services/sunmi_service.dart';
|
||||
import '../../../../core/storage/secure_storage.dart';
|
||||
import '../../../../core/utils/text_utils.dart';
|
||||
import '../../../users/domain/entities/user_entity.dart';
|
||||
import '../../data/models/create_product_warehouse_request.dart';
|
||||
import '../../domain/entities/product_stage_entity.dart';
|
||||
@@ -52,6 +57,9 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
// Load users from Hive (no API call)
|
||||
await ref.read(usersProvider.notifier).getUsers();
|
||||
|
||||
// Auto-select warehouse user based on stored email
|
||||
await _autoSelectWarehouseUser();
|
||||
|
||||
await ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail(
|
||||
widget.warehouseId,
|
||||
widget.productId,
|
||||
@@ -79,6 +87,41 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Auto-select warehouse user based on stored user ID from login
|
||||
Future<void> _autoSelectWarehouseUser() async {
|
||||
try {
|
||||
// Get stored current user ID from secure storage
|
||||
final secureStorage = SecureStorage();
|
||||
final currentUserId = await secureStorage.getCurrentUserId();
|
||||
|
||||
if (currentUserId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all warehouse users
|
||||
final warehouseUsers = ref.read(usersListProvider)
|
||||
.where((user) => user.isWareHouseUser)
|
||||
.toList();
|
||||
|
||||
// Find user with matching ID
|
||||
final matchingUsers = warehouseUsers
|
||||
.where((user) => user.id == currentUserId)
|
||||
.toList();
|
||||
|
||||
final matchingUser = matchingUsers.isNotEmpty ? matchingUsers.first : null;
|
||||
|
||||
// Set selected warehouse user only if a match is found
|
||||
if (matchingUser != null && mounted) {
|
||||
setState(() {
|
||||
_selectedWarehouseUser = matchingUser;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - user can still manually select
|
||||
debugPrint('Error auto-selecting warehouse user: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
// await ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
|
||||
// widget.warehouseId,
|
||||
@@ -97,6 +140,205 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _clearUserSelections() {
|
||||
setState(() {
|
||||
_selectedWarehouseUser = null;
|
||||
_selectedEmployee = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showBarcodeScanner() {
|
||||
final controller = MobileScannerController(
|
||||
formats: const [BarcodeFormat.code128],
|
||||
facing: CameraFacing.back,
|
||||
);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade900,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.qr_code_scanner,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Quét mã vạch',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () {
|
||||
controller.dispose();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Scanner
|
||||
Expanded(
|
||||
child: MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: (capture) {
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
if (barcodes.isNotEmpty) {
|
||||
final barcode = barcodes.first.rawValue;
|
||||
if (barcode != null) {
|
||||
controller.dispose();
|
||||
Navigator.pop(context);
|
||||
_handleScannedBarcode(barcode);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// Instructions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.grey.shade900,
|
||||
child: const Text(
|
||||
'Đặt mã vạch Code 128 vào khung để quét',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() => controller.dispose());
|
||||
}
|
||||
|
||||
Future<void> _handleScannedBarcode(String barcode) async {
|
||||
// Parse barcode to extract productId and optional stageId
|
||||
// Format 1: "123" (only productId)
|
||||
// Format 2: "123-456" (productId-stageId)
|
||||
|
||||
int? productId;
|
||||
int? stageId;
|
||||
|
||||
if (barcode.contains('-')) {
|
||||
// Format: productId-stageId
|
||||
final parts = barcode.split('-');
|
||||
if (parts.length == 2) {
|
||||
productId = int.tryParse(parts[0]);
|
||||
stageId = int.tryParse(parts[1]);
|
||||
}
|
||||
} else {
|
||||
// Format: productId only
|
||||
productId = int.tryParse(barcode);
|
||||
}
|
||||
|
||||
if (productId == null) {
|
||||
// Invalid barcode format
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Định dạng mã vạch không hợp lệ: "$barcode"'),
|
||||
backgroundColor: Colors.red,
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
textColor: Colors.white,
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Update the provider key and load product detail
|
||||
setState(() {
|
||||
_providerKey = '${widget.warehouseId}_$productId';
|
||||
});
|
||||
|
||||
// Clear current selections
|
||||
_clearControllers();
|
||||
|
||||
// Load product detail data from API
|
||||
await ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail(
|
||||
widget.warehouseId,
|
||||
productId,
|
||||
);
|
||||
|
||||
// Dismiss loading dialog
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
|
||||
// If stageId is provided, auto-select that stage
|
||||
if (stageId != null && mounted) {
|
||||
final stages = ref.read(productDetailProvider(_providerKey)).stages;
|
||||
final stageIndex = stages.indexWhere(
|
||||
(stage) => stage.productStageId == stageId,
|
||||
);
|
||||
if (stageIndex != -1) {
|
||||
ref.read(productDetailProvider(_providerKey).notifier).selectStage(stageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đã tải sản phẩm ID: $productId'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Dismiss loading dialog
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
|
||||
// Show error message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Lỗi khi tải sản phẩm: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -118,22 +360,13 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
operationTitle,
|
||||
style: textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
productName,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text('$operationTitle: $productName'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
onPressed: _showBarcodeScanner,
|
||||
tooltip: 'Quét mã vạch',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _onRefresh,
|
||||
@@ -149,6 +382,11 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
selectedIndex: selectedIndex,
|
||||
theme: theme,
|
||||
),
|
||||
bottomNavigationBar: _buildBottomActionBar(
|
||||
selectedStage: selectedStage,
|
||||
stages: stages,
|
||||
theme: theme,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +418,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Error',
|
||||
'Lỗi',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
@@ -277,7 +515,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
if (displayStages.isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
@@ -289,39 +527,6 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.stageId != null
|
||||
? 'Công đoạn'
|
||||
: 'Công đoạn (${displayStages.length})',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (widget.stageId != null) ...[
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'ID: ${widget.stageId}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
@@ -373,28 +578,95 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
spacing: 8,
|
||||
children: [
|
||||
// Stage header
|
||||
_buildStageHeader(stageToShow, theme),
|
||||
// _buildStageHeader(stageToShow, theme),
|
||||
//
|
||||
// _buildSectionCard(
|
||||
// theme: theme,
|
||||
// title: 'Thông tin công đoạn',
|
||||
// icon: Icons.info_outlined,
|
||||
// children: [
|
||||
// _buildInfoRow('Mã sản phẩm', '${stageToShow.productId}'),
|
||||
// if (stageToShow.productStageId != null)
|
||||
// _buildInfoRow('Mã công đoạn', '${stageToShow.productStageId}'),
|
||||
// if (stageToShow.actionTypeId != null)
|
||||
// _buildInfoRow('Mã loại thao tác', '${stageToShow.actionTypeId}'),
|
||||
// _buildInfoRow('Tên công đoạn', stageToShow.displayName),
|
||||
// ],
|
||||
// ),
|
||||
|
||||
// Add New Quantities section
|
||||
_buildSectionCard(
|
||||
theme: theme,
|
||||
title: 'Thông tin công đoạn',
|
||||
icon: Icons.info_outlined,
|
||||
title: 'Thêm số lượng mới',
|
||||
icon: Icons.add_circle_outline,
|
||||
children: [
|
||||
_buildInfoRow('Mã sản phẩm', '${stageToShow.productId}'),
|
||||
if (stageToShow.productStageId != null)
|
||||
_buildInfoRow('Mã công đoạn', '${stageToShow.productStageId}'),
|
||||
if (stageToShow.actionTypeId != null)
|
||||
_buildInfoRow('Mã loại thao tác', '${stageToShow.actionTypeId}'),
|
||||
_buildInfoRow('Tên công đoạn', stageToShow.displayName),
|
||||
|
||||
_buildTextField(
|
||||
label: 'Khối lượng đạt (kg)',
|
||||
controller: _passedWeightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
theme: theme,
|
||||
),
|
||||
_buildTextField(
|
||||
label: 'Số lượng đạt',
|
||||
controller: _passedQuantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
theme: theme,
|
||||
),
|
||||
_buildTextField(
|
||||
label: 'Khối lượng lỗi (kg)',
|
||||
controller: _issuedWeightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
theme: theme,
|
||||
),
|
||||
_buildTextField(
|
||||
label: 'Số lượng lỗi',
|
||||
controller: _issuedQuantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(theme: theme, title: "Nhân viên", icon: Icons.people, children: [
|
||||
// Warehouse User Dropdown (Required)
|
||||
_buildUserDropdown(
|
||||
label: 'Người dùng kho *',
|
||||
value: _selectedWarehouseUser,
|
||||
users: ref.watch(usersListProvider)
|
||||
.where((user) => user.isWareHouseUser)
|
||||
.toList(),
|
||||
onChanged: (user) {
|
||||
setState(() {
|
||||
_selectedWarehouseUser = user;
|
||||
});
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// All Employees Dropdown (Required)
|
||||
_buildUserDropdown(
|
||||
label: 'Nhân viên *',
|
||||
value: _selectedEmployee,
|
||||
users: ref.watch(usersListProvider)
|
||||
.where((user) => user.roleId == 2)
|
||||
.toList(),
|
||||
onChanged: (user) {
|
||||
setState(() {
|
||||
_selectedEmployee = user;
|
||||
});
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
]),
|
||||
|
||||
// Current Quantity information
|
||||
_buildSectionCard(
|
||||
theme: theme,
|
||||
@@ -413,96 +685,6 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Add New Quantities section
|
||||
_buildSectionCard(
|
||||
theme: theme,
|
||||
title: 'Thêm số lượng mới',
|
||||
icon: Icons.add_circle_outline,
|
||||
children: [
|
||||
_buildTextField(
|
||||
label: 'Số lượng đạt',
|
||||
controller: _passedQuantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
theme: theme,
|
||||
),
|
||||
_buildTextField(
|
||||
label: 'Khối lượng đạt (kg)',
|
||||
controller: _passedWeightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
theme: theme,
|
||||
),
|
||||
_buildTextField(
|
||||
label: 'Số lượng lỗi',
|
||||
controller: _issuedQuantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
theme: theme,
|
||||
),
|
||||
_buildTextField(
|
||||
label: 'Khối lượng lỗi (kg)',
|
||||
controller: _issuedWeightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
_buildSectionCard(theme: theme, title: "Nhân viên", icon: Icons.people, children: [
|
||||
// Warehouse User Dropdown
|
||||
_buildUserDropdown(
|
||||
label: 'Người dùng kho',
|
||||
value: _selectedWarehouseUser,
|
||||
users: ref.watch(usersListProvider)
|
||||
.where((user) => user.isWareHouseUser)
|
||||
.toList(),
|
||||
onChanged: (user) {
|
||||
setState(() {
|
||||
_selectedWarehouseUser = user;
|
||||
});
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
// All Employees Dropdown
|
||||
_buildUserDropdown(
|
||||
label: 'Nhân viên',
|
||||
value: _selectedEmployee,
|
||||
users: ref.watch(usersListProvider),
|
||||
onChanged: (user) {
|
||||
setState(() {
|
||||
_selectedEmployee = user;
|
||||
});
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
]),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _printQuantities(stageToShow),
|
||||
icon: const Icon(Icons.print),
|
||||
label: const Text('In'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _addNewQuantities(stageToShow),
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Lưu'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -513,14 +695,66 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _printQuantities(ProductStageEntity stage) {
|
||||
// TODO: Implement print functionality
|
||||
Future<void> _printQuantities(ProductStageEntity stage) async {
|
||||
// Validate that both users are selected
|
||||
if (_selectedEmployee == null || _selectedWarehouseUser == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tính năng in đang phát triển'),
|
||||
duration: Duration(seconds: 2),
|
||||
content: Text('Vui lòng chọn cả Nhân viên và Người dùng kho trước khi in'),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current quantity values (entered by user or use current values)
|
||||
final passedQuantity = int.tryParse(_passedQuantityController.text) ?? 0;
|
||||
final passedWeight = double.tryParse(_passedWeightController.text) ?? 0.0;
|
||||
final issuedQuantity = int.tryParse(_issuedQuantityController.text) ?? 0;
|
||||
final issuedWeight = double.tryParse(_issuedWeightController.text) ?? 0.0;
|
||||
|
||||
// Use entered values if available, otherwise use current stock values
|
||||
final finalPassedPcs = passedQuantity > 0 ? passedQuantity : stage.passedQuantity;
|
||||
final finalPassedKg = passedWeight > 0.0 ? passedWeight : stage.passedQuantityWeight;
|
||||
final finalIssuedPcs = issuedQuantity > 0 ? issuedQuantity : stage.issuedQuantity;
|
||||
final finalIssuedKg = issuedWeight > 0.0 ? issuedWeight : stage.issuedQuantityWeight;
|
||||
|
||||
// Get responsible user name
|
||||
final responsibleName = '${_selectedWarehouseUser!.name} ${_selectedWarehouseUser!.firstName}';
|
||||
final receiverName = '${_selectedEmployee!.name} ${_selectedEmployee!.firstName}';
|
||||
// Generate barcode data (using product code or product ID)
|
||||
final barcodeData = stage.productCode.isNotEmpty
|
||||
? stage.productCode
|
||||
: 'P${stage.productId}';
|
||||
|
||||
try {
|
||||
await SunmiService.printWarehouseExport(
|
||||
context: context,
|
||||
warehouseName: widget.warehouseName,
|
||||
productId: stage.productId,
|
||||
productCode: stage.productCode,
|
||||
productName: stage.productName,
|
||||
stageName: stage.displayName,
|
||||
passedKg: finalPassedKg,
|
||||
passedPcs: finalPassedPcs,
|
||||
issuedKg: finalIssuedKg,
|
||||
issuedPcs: finalIssuedPcs,
|
||||
responsibleName: responsibleName,
|
||||
receiverName: receiverName,
|
||||
barcodeData: barcodeData,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Lỗi khi in: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addNewQuantities(ProductStageEntity stage) async {
|
||||
@@ -546,13 +780,16 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
if (_selectedEmployee == null || _selectedWarehouseUser == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vui lòng chọn cả Nhân viên và Người dùng kho'),
|
||||
content: Text('Vui lòng chọn cả Nhân viên và Người dùng kho trước khi lưu'),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Show loading dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -614,7 +851,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
);
|
||||
}
|
||||
},
|
||||
(_) {
|
||||
(_) async {
|
||||
// Success - show success message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -625,14 +862,17 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
),
|
||||
);
|
||||
|
||||
// Clear the text fields after successful add
|
||||
_clearControllers();
|
||||
// Print before saving to API
|
||||
await _printQuantities(stage);
|
||||
|
||||
// Refresh the product detail to show updated quantities
|
||||
ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
|
||||
await ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
|
||||
widget.warehouseId,
|
||||
widget.productId,
|
||||
);
|
||||
|
||||
// Do NOT clear quantity/weight fields - keep them for reference
|
||||
// User can manually clear them if needed using the 'C' button
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -708,28 +948,11 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
@@ -865,10 +1088,91 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<UserEntity>(
|
||||
value: value,
|
||||
DropdownSearch<UserEntity>(
|
||||
items: (filter, infiniteScrollProps) => users,
|
||||
selectedItem: value,
|
||||
itemAsString: (UserEntity user) {
|
||||
return user.name.isNotEmpty
|
||||
? '${user.name} ${user.firstName}'
|
||||
: user.email;
|
||||
},
|
||||
compareFn: (item1, item2) => item1.id == item2.id,
|
||||
// Custom filter function for Vietnamese-aware search
|
||||
filterFn: (user, filter) {
|
||||
if (filter.isEmpty) return true;
|
||||
|
||||
// Search in name, firstName, and email
|
||||
final searchTexts = [
|
||||
user.name,
|
||||
user.firstName,
|
||||
user.email,
|
||||
'${user.name} ${user.firstName}', // Full name
|
||||
];
|
||||
|
||||
// Use Vietnamese-aware search
|
||||
return TextUtils.containsVietnameseSearchInAny(searchTexts, filter);
|
||||
},
|
||||
popupProps: PopupProps.menu(
|
||||
showSearchBox: true,
|
||||
searchFieldProps: TextFieldProps(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Tìm kiếm',
|
||||
hintText: 'Nhập tên hoặc email...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
menuProps: const MenuProps(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
elevation: 8,
|
||||
),
|
||||
itemBuilder: (context, item, isDisabled, isSelected) {
|
||||
return ListTile(
|
||||
selected: isSelected,
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
title: Text(
|
||||
item.name.isNotEmpty
|
||||
? '${item.name} ${item.firstName}'
|
||||
: item.email,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: item.email.isNotEmpty && item.name.isNotEmpty
|
||||
? Text(
|
||||
item.email,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
emptyBuilder: (context, searchEntry) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Không tìm thấy kết quả',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
decoratorProps: DropDownDecoratorProps(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: 'Chọn $label',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -892,18 +1196,8 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
hint: Text('Chọn $label'),
|
||||
items: users.map((user) {
|
||||
return DropdownMenuItem<UserEntity>(
|
||||
value: user,
|
||||
child: Text(
|
||||
user.name.isNotEmpty ? '${user.name} ${user.firstName}' : user.email,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
isExpanded: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -921,4 +1215,69 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget? _buildBottomActionBar({
|
||||
required ProductStageEntity? selectedStage,
|
||||
required List<ProductStageEntity> stages,
|
||||
required ThemeData theme,
|
||||
}) {
|
||||
// Determine which stage to show
|
||||
// When stageId is provided, use the filtered stage
|
||||
final displayStages = widget.stageId != null
|
||||
? stages.where((stage) => stage.productStageId == widget.stageId).toList()
|
||||
: stages;
|
||||
|
||||
final stageToShow = widget.stageId != null && displayStages.isNotEmpty
|
||||
? displayStages.first
|
||||
: selectedStage;
|
||||
|
||||
// Don't show action bar if there's no stage to work with
|
||||
if (stageToShow == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.shadow.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _printQuantities(stageToShow),
|
||||
icon: const Icon(Icons.print),
|
||||
label: const Text('In'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _addNewQuantities(stageToShow),
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Lưu'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -56,21 +56,23 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
|
||||
_isTabSwitching = true; // Mark that tab is switching
|
||||
});
|
||||
|
||||
// Load products for new operation type
|
||||
// Load products for new operation type from cache (forceRefresh: false)
|
||||
ref.read(productsProvider.notifier).loadProducts(
|
||||
widget.warehouseId,
|
||||
widget.warehouseName,
|
||||
_currentOperationType,
|
||||
forceRefresh: false, // Load from cache when switching tabs
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Load products when page is initialized
|
||||
// Load products from cache when page is initialized (forceRefresh: false)
|
||||
Future.microtask(() {
|
||||
ref.read(productsProvider.notifier).loadProducts(
|
||||
widget.warehouseId,
|
||||
widget.warehouseName,
|
||||
_currentOperationType,
|
||||
forceRefresh: false, // Load from cache on initial load
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,11 +52,13 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
||||
/// [warehouseId] - The ID of the warehouse
|
||||
/// [warehouseName] - The name of the warehouse (for display)
|
||||
/// [type] - The operation type ('import' or 'export')
|
||||
/// [forceRefresh] - If true, bypass cache and fetch from API
|
||||
Future<void> loadProducts(
|
||||
int warehouseId,
|
||||
String warehouseName,
|
||||
String type,
|
||||
) async {
|
||||
String type, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
// Set loading state
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
@@ -66,8 +68,12 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
||||
operationType: type,
|
||||
);
|
||||
|
||||
// Call the use case
|
||||
final result = await getProductsUseCase(warehouseId, type);
|
||||
// Call the use case with forceRefresh flag
|
||||
final result = await getProductsUseCase(
|
||||
warehouseId,
|
||||
type,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
|
||||
// Handle the result
|
||||
result.fold(
|
||||
@@ -95,13 +101,14 @@ class ProductsNotifier extends StateNotifier<ProductsState> {
|
||||
state = const ProductsState();
|
||||
}
|
||||
|
||||
/// Refresh products
|
||||
/// Refresh products - forces fetch from API
|
||||
Future<void> refreshProducts() async {
|
||||
if (state.warehouseId != null) {
|
||||
await loadProducts(
|
||||
state.warehouseId!,
|
||||
state.warehouseName ?? '',
|
||||
state.operationType,
|
||||
forceRefresh: true, // Always force refresh when explicitly requested
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import '../models/user_model.dart';
|
||||
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
|
||||
@@ -54,4 +57,41 @@ class UsersRemoteDataSourceImpl implements UsersRemoteDataSource {
|
||||
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()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.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_drawer.dart';
|
||||
|
||||
@@ -28,12 +29,34 @@ class _WarehouseSelectionPageState
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Load warehouses when page is first created
|
||||
Future.microtask(() {
|
||||
Future.microtask(() async {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// Watch warehouse state
|
||||
|
||||
@@ -33,7 +33,7 @@ class MyApp extends ConsumerWidget {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Warehouse Manager',
|
||||
title: 'MinhThu',
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.system,
|
||||
|
||||
88
pubspec.lock
88
pubspec.lock
@@ -49,6 +49,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -265,6 +289,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -448,6 +480,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -664,6 +704,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -712,6 +760,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
@@ -752,6 +816,14 @@ packages:
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -768,6 +840,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -949,6 +1029,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -35,6 +35,14 @@ dependencies:
|
||||
shimmer: ^3.0.0
|
||||
cached_network_image: ^3.3.1
|
||||
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:
|
||||
flutter_test:
|
||||
@@ -57,6 +65,7 @@ flutter:
|
||||
# Assets
|
||||
assets:
|
||||
- assets/app_icon.jpg
|
||||
- assets/fonts/
|
||||
|
||||
# Flutter Launcher Icons Configuration
|
||||
flutter_launcher_icons:
|
||||
|
||||
Reference in New Issue
Block a user