Compare commits

..

11 Commits

Author SHA1 Message Date
Phuoc Nguyen
2ff639fc42 sunmi 2025-11-04 18:10:54 +07:00
Phuoc Nguyen
1cfdd2c0c6 update. save => print 2025-11-04 09:29:35 +07:00
ff25363a19 sunmi 2025-11-04 08:12:13 +07:00
9df4b79a66 store current user id 2025-11-03 20:59:51 +07:00
2a6ec8f6b8 print => save 2025-11-02 23:24:50 +07:00
f47700ad2b asdasda 2025-11-02 22:08:28 +07:00
68cc5c0df3 s 2025-11-02 21:52:58 +07:00
2495330bf5 fix 2025-11-02 20:40:11 +07:00
Phuoc Nguyen
efcc6306b0 asdasdasd 2025-10-29 16:31:04 +07:00
Phuoc Nguyen
c12869b01f asdasdasd 2025-10-29 16:12:37 +07:00
Phuoc Nguyen
cb4df363ab fix UI 2025-10-29 15:52:24 +07:00
31 changed files with 6123 additions and 2484 deletions

View File

@@ -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!

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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',
);
}
}

View 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;
}
}
}

View File

@@ -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();

View 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;
}
}

View File

@@ -127,4 +127,25 @@ curl --request GET \
--header 'Sec-Fetch-Dest: empty' \
--header 'Sec-Fetch-Mode: cors' \
--header 'Sec-Fetch-Site: same-site' \
--header 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0'
--header '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
View 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>

View File

@@ -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!);

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
///

View File

@@ -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,
);
}
}

View File

@@ -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
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tính năng in đang phát triển'),
duration: Duration(seconds: 2),
),
);
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('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,45 +1088,116 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<UserEntity>(
value: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.outline,
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,
),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
menuProps: const MenuProps(
borderRadius: BorderRadius.all(Radius.circular(8)),
elevation: 8,
),
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
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),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
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),
),
),
),
],
),
),
),
);
}
}

View File

@@ -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
);
});
}

View File

@@ -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
);
}
}

View File

@@ -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()}');
}
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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: