29 KiB
29 KiB
Flutter Warehouse Management App Guidelines
App Overview
Warehouse management app for importing and exporting products with authentication.
App Flow
- Login Screen: User authentication → Store access token
- Select Warehouse Screen: Choose warehouse from list
- Operation Selection Screen: Choose Import or Export products
- Product List Screen: Display products based on operation type
Project Structure
lib/
core/
constants/
api_endpoints.dart
theme/
app_theme.dart
widgets/
custom_button.dart
loading_indicator.dart
network/
api_client.dart
api_response.dart
storage/
secure_storage.dart
features/
auth/
data/
datasources/
auth_remote_datasource.dart
models/
login_request_model.dart
login_response_model.dart
repositories/
auth_repository_impl.dart
domain/
entities/
user_entity.dart
repositories/
auth_repository.dart
usecases/
login_usecase.dart
presentation/
providers/
auth_provider.dart
pages/
login_page.dart
widgets/
login_form.dart
warehouse/
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/
providers/
warehouse_provider.dart
pages/
warehouse_selection_page.dart
widgets/
warehouse_card.dart
operation/
presentation/
pages/
operation_selection_page.dart
widgets/
operation_card.dart
products/
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/
providers/
products_provider.dart
pages/
products_page.dart
widgets/
product_list_item.dart
main.dart
API Response Format
All API responses follow this structure:
class ApiResponse<T> {
final T? value;
final bool isSuccess;
final bool isFailure;
final List<String> errors;
final List<String> errorCodes;
ApiResponse({
this.value,
required this.isSuccess,
required this.isFailure,
this.errors = const [],
this.errorCodes = const [],
});
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic)? fromJsonT,
) {
return ApiResponse(
value: json['Value'] != null && fromJsonT != null
? fromJsonT(json['Value'])
: json['Value'],
isSuccess: json['IsSuccess'] ?? false,
isFailure: json['IsFailure'] ?? false,
errors: List<String>.from(json['Errors'] ?? []),
errorCodes: List<String>.from(json['ErrorCodes'] ?? []),
);
}
}
Data Models
User Model
class User {
final String userId;
final String username;
final String accessToken;
final String? refreshToken;
User({
required this.userId,
required this.username,
required this.accessToken,
this.refreshToken,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
userId: json['userId'],
username: json['username'],
accessToken: json['accessToken'],
refreshToken: json['refreshToken'],
);
}
}
Warehouse Model
class Warehouse {
final int id;
final String name;
final String code;
final String? description;
final bool isNGWareHouse;
final int totalCount;
Warehouse({
required this.id,
required this.name,
required this.code,
this.description,
required this.isNGWareHouse,
required this.totalCount,
});
factory Warehouse.fromJson(Map<String, dynamic> json) {
return Warehouse(
id: json['Id'] ?? 0,
name: json['Name'] ?? '',
code: json['Code'] ?? '',
description: json['Description'],
isNGWareHouse: json['IsNGWareHouse'] ?? false,
totalCount: json['TotalCount'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'Id': id,
'Name': name,
'Code': code,
'Description': description,
'IsNGWareHouse': isNGWareHouse,
'TotalCount': totalCount,
};
}
}
Product Model
class Product {
final int id;
final String name;
final String code;
final String fullName;
final String? description;
final String? lotCode;
final String? lotNumber;
final String? logo;
final String? barcode;
// Quantity fields
final int quantity;
final int totalQuantity;
final int passedQuantity;
final double? passedQuantityWeight;
final int issuedQuantity;
final double? issuedQuantityWeight;
final int piecesInStock;
final double weightInStock;
// Weight and pieces
final double weight;
final int pieces;
final double conversionRate;
final double? percent;
// Price and status
final double? price;
final bool isActive;
final bool isConfirm;
final int? productStatusId;
final int productTypeId;
// Relations
final int? orderId;
final int? parentId;
final int? receiverStageId;
final dynamic order;
// Dates
final String? startDate;
final String? endDate;
// Lists
final List<dynamic> productions;
final List<dynamic> customerProducts;
final List<dynamic> productStages;
final dynamic childrenProducts;
final dynamic productStageWareHouses;
final dynamic productStageDetailWareHouses;
final dynamic productExportExcelSheetDataModels;
final dynamic materialLabels;
final dynamic materials;
final dynamic images;
final dynamic attachmentFiles;
Product({
required this.id,
required this.name,
required this.code,
required this.fullName,
this.description,
this.lotCode,
this.lotNumber,
this.logo,
this.barcode,
required this.quantity,
required this.totalQuantity,
required this.passedQuantity,
this.passedQuantityWeight,
required this.issuedQuantity,
this.issuedQuantityWeight,
required this.piecesInStock,
required this.weightInStock,
required this.weight,
required this.pieces,
required this.conversionRate,
this.percent,
this.price,
required this.isActive,
required this.isConfirm,
this.productStatusId,
required this.productTypeId,
this.orderId,
this.parentId,
this.receiverStageId,
this.order,
this.startDate,
this.endDate,
this.productions = const [],
this.customerProducts = const [],
this.productStages = const [],
this.childrenProducts,
this.productStageWareHouses,
this.productStageDetailWareHouses,
this.productExportExcelSheetDataModels,
this.materialLabels,
this.materials,
this.images,
this.attachmentFiles,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['Id'] ?? 0,
name: json['Name'] ?? '',
code: json['Code'] ?? '',
fullName: json['FullName'] ?? '',
description: json['Description'],
lotCode: json['LotCode'],
lotNumber: json['LotNumber'],
logo: json['Logo'],
barcode: json['Barcode'],
quantity: json['Quantity'] ?? 0,
totalQuantity: json['TotalQuantity'] ?? 0,
passedQuantity: json['PassedQuantity'] ?? 0,
passedQuantityWeight: json['PassedQuantityWeight']?.toDouble(),
issuedQuantity: json['IssuedQuantity'] ?? 0,
issuedQuantityWeight: json['IssuedQuantityWeight']?.toDouble(),
piecesInStock: json['PiecesInStock'] ?? 0,
weightInStock: (json['WeightInStock'] ?? 0).toDouble(),
weight: (json['Weight'] ?? 0).toDouble(),
pieces: json['Pieces'] ?? 0,
conversionRate: (json['ConversionRate'] ?? 0).toDouble(),
percent: json['Percent']?.toDouble(),
price: json['Price']?.toDouble(),
isActive: json['IsActive'] ?? true,
isConfirm: json['IsConfirm'] ?? false,
productStatusId: json['ProductStatusId'],
productTypeId: json['ProductTypeId'] ?? 0,
orderId: json['OrderId'],
parentId: json['ParentId'],
receiverStageId: json['ReceiverStageId'],
order: json['Order'],
startDate: json['StartDate'],
endDate: json['EndDate'],
productions: json['Productions'] ?? [],
customerProducts: json['CustomerProducts'] ?? [],
productStages: json['ProductStages'] ?? [],
childrenProducts: json['ChildrenProducts'],
productStageWareHouses: json['ProductStageWareHouses'],
productStageDetailWareHouses: json['ProductStageDetailWareHouses'],
productExportExcelSheetDataModels: json['ProductExportExcelSheetDataModels'],
materialLabels: json['MaterialLabels'],
materials: json['Materials'],
images: json['Images'],
attachmentFiles: json['AttachmentFiles'],
);
}
Map<String, dynamic> toJson() {
return {
'Id': id,
'Name': name,
'Code': code,
'FullName': fullName,
'Description': description,
'LotCode': lotCode,
'LotNumber': lotNumber,
'Logo': logo,
'Barcode': barcode,
'Quantity': quantity,
'TotalQuantity': totalQuantity,
'PassedQuantity': passedQuantity,
'PassedQuantityWeight': passedQuantityWeight,
'IssuedQuantity': issuedQuantity,
'IssuedQuantityWeight': issuedQuantityWeight,
'PiecesInStock': piecesInStock,
'WeightInStock': weightInStock,
'Weight': weight,
'Pieces': pieces,
'ConversionRate': conversionRate,
'Percent': percent,
'Price': price,
'IsActive': isActive,
'IsConfirm': isConfirm,
'ProductStatusId': productStatusId,
'ProductTypeId': productTypeId,
'OrderId': orderId,
'ParentId': parentId,
'ReceiverStageId': receiverStageId,
'Order': order,
'StartDate': startDate,
'EndDate': endDate,
'Productions': productions,
'CustomerProducts': customerProducts,
'ProductStages': productStages,
'ChildrenProducts': childrenProducts,
'ProductStageWareHouses': productStageWareHouses,
'ProductStageDetailWareHouses': productStageDetailWareHouses,
'ProductExportExcelSheetDataModels': productExportExcelSheetDataModels,
'MaterialLabels': materialLabels,
'Materials': materials,
'Images': images,
'AttachmentFiles': attachmentFiles,
};
}
}
Login Request Model
class LoginRequest {
final String username;
final String password;
LoginRequest({
required this.username,
required this.password,
});
Map<String, dynamic> toJson() => {
'username': username,
'password': password,
};
}
Screen Layouts
Login Screen
┌─────────────────────────┐
│ │
│ [App Logo] │
│ │
│ Warehouse Manager │
│ │
├─────────────────────────┤
│ │
│ Username: [__________] │
│ │
│ Password: [__________] │
│ │
│ [Login Button] │
│ │
└─────────────────────────┘
Select Warehouse Screen
┌──────────────────────────────────┐
│ Select Warehouse │
├──────────────────────────────────┤
│ │
│ ┌──────────────────────────────┐ │
│ │ Kho nguyên vật liệu │ │
│ │ Code: 001 │ │
│ │ Items: 8 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ Kho bán thành phẩm công đoạn│ │
│ │ Code: 002 │ │
│ │ Items: 8 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ Kho thành phẩm │ │
│ │ Code: 003 │ │
│ │ Items: 8 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ Kho tiêu hao │ │
│ │ Code: 004 │ │
│ │ Items: 8 • Để chứa phụ tùng │ │
│ └──────────────────────────────┘ │
│ │
└──────────────────────────────────┘
Operation Selection Screen
┌─────────────────────────┐
│ Select Operation │
│ Warehouse: 001 │
├─────────────────────────┤
│ │
│ │
│ ┌─────────────────┐ │
│ │ │ │
│ │ Import Products│ │
│ │ │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ │ │
│ │ Export Products│ │
│ │ │ │
│ └─────────────────┘ │
│ │
│ │
└─────────────────────────┘
Products List Screen
┌───────────────────────────────────┐
│ Products (Import) │
│ Warehouse: Kho nguyên vật liệu │
├───────────────────────────────────┤
│ │
│ • SCM435 | Thép 435 │
│ Code: SCM435 │
│ Weight: 120.00 | Pieces: 1320 │
│ In Stock: 0 pcs (0.00 kg) │
│ Conversion Rate: 11.00 │
├───────────────────────────────────┤
│ • SCM440 | Thép 440 │
│ Code: SCM440 │
│ Weight: 85.50 | Pieces: 950 │
│ In Stock: 150 pcs (12.75 kg) │
│ Conversion Rate: 11.20 │
├───────────────────────────────────┤
│ • SS304 | Thép không gỉ │
│ Code: SS304 │
│ Weight: 200.00 | Pieces: 2000 │
│ In Stock: 500 pcs (50.00 kg) │
│ Conversion Rate: 10.00 │
├───────────────────────────────────┤
│ │
└───────────────────────────────────┘
State Management (Riverpod)
Auth State
class AuthState {
final User? user;
final bool isAuthenticated;
final bool isLoading;
final String? error;
AuthState({
this.user,
this.isAuthenticated = false,
this.isLoading = false,
this.error,
});
AuthState copyWith({
User? user,
bool? isAuthenticated,
bool? isLoading,
String? error,
}) {
return AuthState(
user: user ?? this.user,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
Warehouse State
class WarehouseState {
final List<Warehouse> warehouses;
final Warehouse? selectedWarehouse;
final bool isLoading;
final String? error;
WarehouseState({
this.warehouses = const [],
this.selectedWarehouse,
this.isLoading = false,
this.error,
});
WarehouseState copyWith({
List<Warehouse>? warehouses,
Warehouse? selectedWarehouse,
bool? isLoading,
String? error,
}) {
return WarehouseState(
warehouses: warehouses ?? this.warehouses,
selectedWarehouse: selectedWarehouse ?? this.selectedWarehouse,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
Products State
class ProductsState {
final List<Product> products;
final String operationType; // 'import' or 'export'
final bool isLoading;
final String? error;
ProductsState({
this.products = const [],
this.operationType = 'import',
this.isLoading = false,
this.error,
});
ProductsState copyWith({
List<Product>? products,
String? operationType,
bool? isLoading,
String? error,
}) {
return ProductsState(
products: products ?? this.products,
operationType: operationType ?? this.operationType,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
Secure Storage
Token Management
class SecureStorage {
static const _storage = FlutterSecureStorage();
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
Future<void> saveAccessToken(String token) async {
await _storage.write(key: _accessTokenKey, value: token);
}
Future<String?> getAccessToken() async {
return await _storage.read(key: _accessTokenKey);
}
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: _refreshTokenKey, value: token);
}
Future<String?> getRefreshToken() async {
return await _storage.read(key: _refreshTokenKey);
}
Future<void> clearAll() async {
await _storage.deleteAll();
}
}
API Integration
Available APIs (CURL format)
# Login
curl -X POST https://api.example.com/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "user", "password": "pass"}'
# Get Warehouses
curl -X GET https://api.example.com/warehouses \
-H "Authorization: Bearer {access_token}"
# Get Products
curl -X GET https://api.example.com/products?warehouseId={id}&type={import/export} \
-H "Authorization: Bearer {access_token}"
Auth Remote Data Source
abstract class AuthRemoteDataSource {
Future<User> login(LoginRequest request);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final ApiClient apiClient;
AuthRemoteDataSourceImpl(this.apiClient);
@override
Future<User> login(LoginRequest request) async {
final response = await apiClient.post(
'/auth/login',
data: request.toJson(),
);
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => User.fromJson(json),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Login failed'
);
}
}
}
Warehouse Remote Data Source
abstract class WarehouseRemoteDataSource {
Future<List<Warehouse>> getWarehouses();
}
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
final ApiClient apiClient;
WarehouseRemoteDataSourceImpl(this.apiClient);
@override
Future<List<Warehouse>> getWarehouses() async {
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) {
return apiResponse.value!;
} else {
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get warehouses'
);
}
}
}
Products Remote Data Source
abstract class ProductsRemoteDataSource {
Future<List<Product>> getProducts(int warehouseId, String type);
}
class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
final ApiClient apiClient;
ProductsRemoteDataSourceImpl(this.apiClient);
@override
Future<List<Product>> getProducts(int warehouseId, String type) async {
final response = await apiClient.get(
'/products',
queryParameters: {
'warehouseId': warehouseId,
'type': type,
},
);
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => Product.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get products'
);
}
}
}
Use Cases
Login Use Case
class LoginUseCase {
final AuthRepository repository;
final SecureStorage secureStorage;
LoginUseCase(this.repository, this.secureStorage);
Future<Either<Failure, User>> call(LoginRequest request) async {
final result = await repository.login(request);
return result.fold(
(failure) => Left(failure),
(user) async {
// Save tokens to secure storage
await secureStorage.saveAccessToken(user.accessToken);
if (user.refreshToken != null) {
await secureStorage.saveRefreshToken(user.refreshToken!);
}
return Right(user);
},
);
}
}
Get Warehouses Use Case
class GetWarehousesUseCase {
final WarehouseRepository repository;
GetWarehousesUseCase(this.repository);
Future<Either<Failure, List<Warehouse>>> call() async {
return await repository.getWarehouses();
}
}
Get Products Use Case
class GetProductsUseCase {
final ProductsRepository repository;
GetProductsUseCase(this.repository);
Future<Either<Failure, List<Product>>> call(
int warehouseId,
String type,
) async {
return await repository.getProducts(warehouseId, type);
}
}
Repository Pattern
Auth Repository
abstract class AuthRepository {
Future<Either<Failure, User>> login(LoginRequest request);
}
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
AuthRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, User>> login(LoginRequest request) async {
try {
final user = await remoteDataSource.login(request);
return Right(user);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
Warehouse Repository
abstract class WarehouseRepository {
Future<Either<Failure, List<Warehouse>>> getWarehouses();
}
class WarehouseRepositoryImpl implements WarehouseRepository {
final WarehouseRemoteDataSource remoteDataSource;
WarehouseRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, List<Warehouse>>> getWarehouses() async {
try {
final warehouses = await remoteDataSource.getWarehouses();
return Right(warehouses);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
Products Repository
abstract class ProductsRepository {
Future<Either<Failure, List<Product>>> getProducts(
int warehouseId,
String type,
);
}
class ProductsRepositoryImpl implements ProductsRepository {
final ProductsRemoteDataSource remoteDataSource;
ProductsRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, List<Product>>> getProducts(
int warehouseId,
String type,
) async {
try {
final products = await remoteDataSource.getProducts(warehouseId, type);
return Right(products);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
Navigation Flow
Router Configuration (go_router)
final router = GoRouter(
initialLocation: '/login',
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => LoginPage(),
),
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => WarehouseSelectionPage(),
),
GoRoute(
path: '/operations',
name: 'operations',
builder: (context, state) {
final warehouse = state.extra as Warehouse;
return OperationSelectionPage(warehouse: warehouse);
},
),
GoRoute(
path: '/products',
name: 'products',
builder: (context, state) {
final params = state.extra as Map<String, dynamic>;
return ProductsPage(
warehouse: params['warehouse'],
operationType: params['type'],
);
},
),
],
redirect: (context, state) async {
final secureStorage = SecureStorage();
final token = await secureStorage.getAccessToken();
final isAuthenticated = token != null;
final isLoggingIn = state.matchedLocation == '/login';
if (!isAuthenticated && !isLoggingIn) {
return '/login';
}
if (isAuthenticated && isLoggingIn) {
return '/warehouses';
}
return null;
},
);
API Client with Interceptor
Dio Configuration
class ApiClient {
late final Dio _dio;
final SecureStorage _secureStorage;
ApiClient(this._secureStorage) {
_dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: Duration(seconds: 30),
receiveTimeout: Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
},
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Add token to headers
final token = await _secureStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (error, handler) async {
// Handle 401 unauthorized
if (error.response?.statusCode == 401) {
// Clear tokens and redirect to login
await _secureStorage.clearAll();
// Navigate to login
}
return handler.next(error);
},
),
);
}
Future<Response> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
return await _dio.get(path, queryParameters: queryParameters);
}
Future<Response> post(
String path, {
dynamic data,
}) async {
return await _dio.post(path, data: data);
}
}
Dependencies
dependencies:
flutter_riverpod: ^2.4.9
go_router: ^12.1.3
dio: ^5.3.2
dartz: ^0.10.1
get_it: ^7.6.4
flutter_secure_storage: ^9.0.0
dev_dependencies:
flutter_test: ^3.0.0
mockito: ^5.4.2
build_runner: ^2.4.7
Error Handling
abstract class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
const ServerFailure(String message) : super(message);
}
class NetworkFailure extends Failure {
const NetworkFailure(String message) : super(message);
}
class AuthenticationFailure extends Failure {
const AuthenticationFailure(String message) : super(message);
}
class ServerException implements Exception {
final String message;
const ServerException(this.message);
}
Key Points
- Store access token in flutter_secure_storage after successful login
- All API responses use "Value" key for data
- API responses follow IsSuccess/IsFailure pattern
- Add Authorization header with Bearer token to all authenticated requests
- Handle 401 errors by clearing tokens and redirecting to login
- Use clean architecture with use cases and repository pattern
- Navigation flow: Login → Warehouses → Operations → Products
- Only login, get warehouses, and get products APIs are available currently
- Other features (import/export operations) will use placeholder/mock data until APIs are ready