This commit is contained in:
2025-10-28 00:09:46 +07:00
parent 9ebe7c2919
commit de49f564b1
110 changed files with 15392 additions and 3996 deletions

View File

@@ -0,0 +1,384 @@
# Authentication Feature Integration Guide
Quick guide to integrate the authentication feature into the warehouse management app.
## Prerequisites
Ensure these dependencies are in `pubspec.yaml`:
```yaml
dependencies:
flutter_riverpod: ^2.4.9
dartz: ^0.10.1
flutter_secure_storage: ^9.0.0
dio: ^5.3.2
equatable: ^2.0.5
go_router: ^12.1.3
```
## Step 1: Update Main App
### Update `lib/main.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/routing/app_router.dart';
import 'core/theme/app_theme.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Warehouse Manager',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
routerConfig: appRouter,
debugShowCheckedModeBanner: false,
);
}
}
```
## Step 2: Update Router Configuration
### Update `lib/core/routing/app_router.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/auth.dart';
import '../../features/auth/di/auth_dependency_injection.dart';
// Create a global key for navigator
final navigatorKey = GlobalKey<NavigatorState>();
// Router provider
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/login',
routes: [
// Login route
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
// Warehouses route (protected)
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => const WarehouseSelectionPage(), // TODO: Create this
),
// Add more routes as needed...
],
// Redirect logic for authentication
redirect: (context, state) {
// Get auth state from provider container
final container = ProviderScope.containerOf(context);
final authState = container.read(authProvider);
final isAuthenticated = authState.isAuthenticated;
final isLoggingIn = state.matchedLocation == '/login';
// If not authenticated and not on login page, redirect to login
if (!isAuthenticated && !isLoggingIn) {
return '/login';
}
// If authenticated and on login page, redirect to warehouses
if (isAuthenticated && isLoggingIn) {
return '/warehouses';
}
// No redirect needed
return null;
},
);
});
// Export the router instance
final appRouter = GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/login',
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => const Scaffold(
body: Center(child: Text('Warehouses Page - TODO')),
),
),
],
);
```
## Step 3: Configure API Base URL
### Update `lib/core/constants/app_constants.dart`
```dart
class AppConstants {
// API Configuration
static const String apiBaseUrl = 'https://your-api-url.com';
static const int connectionTimeout = 30000;
static const int receiveTimeout = 30000;
static const int sendTimeout = 30000;
// Other constants...
}
```
## Step 4: Create Protected Route Wrapper (Optional)
### Create `lib/core/widgets/protected_route.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/di/auth_dependency_injection.dart';
class ProtectedRoute extends ConsumerWidget {
final Widget child;
const ProtectedRoute({
super.key,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
// Show loading while checking auth
if (authState.isLoading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
// Redirect to login if not authenticated
if (!authState.isAuthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/login');
});
return const SizedBox.shrink();
}
// Show protected content
return child;
}
}
```
## Step 5: Add Logout Button (Optional)
### Example usage in any page:
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:minhthu/features/auth/di/auth_dependency_injection.dart';
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
// Show confirmation dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(authProvider.notifier).logout();
},
child: const Text('Logout'),
),
],
),
);
},
),
],
),
body: const Center(
child: Text('Settings'),
),
);
}
}
```
## Step 6: Handle API Client Setup
### Update `lib/core/di/core_providers.dart` (create if needed)
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../network/api_client.dart';
import '../storage/secure_storage.dart';
/// Provider for SecureStorage singleton
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
/// Provider for ApiClient
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
final apiClient = ApiClient(secureStorage);
// Set up unauthorized callback to handle 401 errors
apiClient.onUnauthorized = () {
// Navigate to login when unauthorized
// This can be enhanced with proper navigation context
};
return apiClient;
});
```
## Step 7: Test the Integration
### Manual Testing Checklist
1. **Login Flow**
- [ ] App starts on login page
- [ ] Form validation works
- [ ] Login with valid credentials succeeds
- [ ] Navigate to warehouses page after login
- [ ] Tokens saved in secure storage
2. **Error Handling**
- [ ] Invalid credentials show error message
- [ ] Network errors display properly
- [ ] Error messages are user-friendly
3. **Persistence**
- [ ] Close and reopen app stays logged in
- [ ] Tokens persisted in secure storage
- [ ] Auto-redirect to warehouses if authenticated
4. **Logout**
- [ ] Logout clears tokens
- [ ] Redirect to login page after logout
- [ ] Cannot access protected routes after logout
5. **Loading States**
- [ ] Loading indicator shows during login
- [ ] Form disabled during loading
- [ ] No double submissions
## Step 8: Environment Configuration (Optional)
### Create `lib/core/config/environment.dart`
```dart
enum Environment {
development,
staging,
production,
}
class EnvironmentConfig {
static Environment current = Environment.development;
static String get apiBaseUrl {
switch (current) {
case Environment.development:
return 'https://dev-api.example.com';
case Environment.staging:
return 'https://staging-api.example.com';
case Environment.production:
return 'https://api.example.com';
}
}
}
```
## Troubleshooting
### Issue: "Provider not found"
**Solution**: Ensure `ProviderScope` wraps your app in `main.dart`
### Issue: "Navigation doesn't work"
**Solution**: Verify router configuration and route names match
### Issue: "Secure storage error"
**Solution**:
- Add platform-specific configurations
- Check app permissions
- Clear app data and reinstall
### Issue: "API calls fail"
**Solution**:
- Verify API base URL in `app_constants.dart`
- Check network connectivity
- Verify API endpoint paths in `api_endpoints.dart`
## Next Steps
1. **Create Warehouse Feature** - Follow similar pattern
2. **Add Token Refresh** - Implement auto token refresh
3. **Add Remember Me** - Optional persistent login
4. **Add Biometric Auth** - Face ID / Touch ID
5. **Add Unit Tests** - Test use cases and repositories
6. **Add Widget Tests** - Test UI components
## Additional Resources
- [Riverpod Documentation](https://riverpod.dev/)
- [Go Router Documentation](https://pub.dev/packages/go_router)
- [Clean Architecture Guide](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Flutter Secure Storage](https://pub.dev/packages/flutter_secure_storage)
## Support
For issues or questions:
1. Check the main README in `lib/features/auth/README.md`
2. Review the CLAUDE.md for project guidelines
3. Check existing code examples in the codebase

View File

@@ -0,0 +1,252 @@
# Authentication Feature - Quick Reference
## Import
```dart
import 'package:minhthu/features/auth/auth.dart';
```
## Common Usage Patterns
### 1. Login
```dart
ref.read(authProvider.notifier).login(username, password);
```
### 2. Logout
```dart
ref.read(authProvider.notifier).logout();
```
### 3. Check if Authenticated
```dart
final isAuthenticated = ref.watch(isAuthenticatedProvider);
```
### 4. Get Current User
```dart
final user = ref.watch(currentUserProvider);
if (user != null) {
print('Logged in as: ${user.username}');
}
```
### 5. Watch Auth State
```dart
final authState = ref.watch(authProvider);
if (authState.isLoading) {
return LoadingIndicator();
}
if (authState.error != null) {
return ErrorView(message: authState.error!);
}
if (authState.isAuthenticated) {
return HomeView(user: authState.user!);
}
return LoginView();
```
### 6. Listen to Auth Changes
```dart
ref.listen(authProvider, (previous, next) {
if (next.isAuthenticated) {
context.go('/home');
} else if (next.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.error!)),
);
}
});
```
## Key Classes
### AuthState
```dart
class AuthState {
final UserEntity? user;
final bool isAuthenticated;
final bool isLoading;
final String? error;
}
```
### UserEntity
```dart
class UserEntity {
final String userId;
final String username;
final String accessToken;
final String? refreshToken;
}
```
### LoginRequestModel
```dart
final request = LoginRequestModel(
username: 'john.doe',
password: 'secure123',
);
```
## Providers
| Provider | Type | Description |
|----------|------|-------------|
| `authProvider` | `StateNotifier<AuthState>` | Main auth state |
| `isAuthenticatedProvider` | `bool` | Check auth status |
| `currentUserProvider` | `UserEntity?` | Get current user |
| `isAuthLoadingProvider` | `bool` | Check loading state |
| `authErrorProvider` | `String?` | Get error message |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/login` | POST | Login |
| `/api/v1/auth/logout` | POST | Logout |
| `/api/v1/auth/refresh` | POST | Refresh token |
## Error Types
- `ValidationFailure` - Invalid input
- `AuthenticationFailure` - Login failed
- `NetworkFailure` - Network error
- `ServerFailure` - Server error
## Protected Route Example
```dart
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
if (!authState.isAuthenticated) {
return LoginPage();
}
return Scaffold(
appBar: AppBar(
title: Text('Protected Page'),
actions: [
IconButton(
icon: Icon(Icons.logout),
onPressed: () => ref.read(authProvider.notifier).logout(),
),
],
),
body: Center(
child: Text('Hello, ${authState.user!.username}!'),
),
);
}
}
```
## Common Patterns
### Check Auth on App Start
```dart
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(authProvider.notifier).checkAuthStatus();
});
}
```
### Show Loading Overlay
```dart
if (ref.watch(isAuthLoadingProvider)) {
return Stack(
children: [
yourContent,
LoadingIndicator.overlay(),
],
);
}
```
### Conditional Navigation
```dart
ref.listen(authProvider, (previous, next) {
if (!previous!.isAuthenticated && next.isAuthenticated) {
context.go('/home');
} else if (previous.isAuthenticated && !next.isAuthenticated) {
context.go('/login');
}
});
```
## Testing Helpers
```dart
// Mock auth state
final mockAuthState = AuthState.authenticated(
UserEntity(
userId: '123',
username: 'test',
accessToken: 'token',
),
);
// Create test container
final container = ProviderContainer(
overrides: [
authProvider.overrideWith((ref) => MockAuthNotifier()),
],
);
```
## Files Reference
| Layer | File | Purpose |
|-------|------|---------|
| **Data** | `login_request_model.dart` | Request DTO |
| | `user_model.dart` | User DTO |
| | `auth_remote_datasource.dart` | API calls |
| | `auth_repository_impl.dart` | Repository impl |
| **Domain** | `user_entity.dart` | Domain entity |
| | `auth_repository.dart` | Repository interface |
| | `login_usecase.dart` | Business logic |
| **Presentation** | `login_page.dart` | Login UI |
| | `login_form.dart` | Form widget |
| | `auth_provider.dart` | State management |
| **DI** | `auth_dependency_injection.dart` | Providers setup |
## Troubleshooting Quick Fixes
| Issue | Solution |
|-------|----------|
| Provider not found | Add `ProviderScope` to main.dart |
| Navigation fails | Check router configuration |
| Tokens not saved | Verify secure storage setup |
| API calls fail | Check base URL in constants |
| State not updating | Use `ConsumerWidget` |
## Performance Tips
1. Use `ref.read()` for one-time operations
2. Use `ref.watch()` for reactive updates
3. Use `ref.listen()` for side effects
4. Avoid rebuilding entire tree - scope providers
5. Use `select()` for partial state watching
## Security Checklist
- [x] Tokens in secure storage
- [x] Password fields obscured
- [x] No logging of sensitive data
- [x] Token auto-added to headers
- [x] Token cleared on logout
- [x] Input validation
- [ ] HTTPS only (configure in production)
- [ ] Token expiration handling
- [ ] Rate limiting
- [ ] Biometric auth (optional)

380
lib/features/auth/README.md Normal file
View File

@@ -0,0 +1,380 @@
# Authentication Feature
Complete authentication implementation following clean architecture principles for the warehouse management app.
## Architecture Overview
```
auth/
├── data/ # Data layer
│ ├── datasources/ # API and local data sources
│ │ └── auth_remote_datasource.dart
│ ├── models/ # Data transfer objects
│ │ ├── login_request_model.dart
│ │ └── user_model.dart
│ ├── repositories/ # Repository implementations
│ │ └── auth_repository_impl.dart
│ └── data.dart # Barrel export
├── domain/ # Domain layer (business logic)
│ ├── entities/ # Business entities
│ │ └── user_entity.dart
│ ├── repositories/ # Repository interfaces
│ │ └── auth_repository.dart
│ ├── usecases/ # Use cases
│ │ └── login_usecase.dart
│ └── domain.dart # Barrel export
├── presentation/ # Presentation layer (UI)
│ ├── pages/ # Screen widgets
│ │ └── login_page.dart
│ ├── providers/ # State management
│ │ └── auth_provider.dart
│ ├── widgets/ # Reusable widgets
│ │ └── login_form.dart
│ └── presentation.dart # Barrel export
├── di/ # Dependency injection
│ └── auth_dependency_injection.dart
├── auth.dart # Main barrel export
└── README.md # This file
```
## Features
### Implemented
- ✅ User login with username/password
- ✅ Token storage in secure storage
- ✅ Authentication state management
- ✅ Form validation
- ✅ Error handling with user-friendly messages
- ✅ Loading states
- ✅ Auto-navigation after successful login
- ✅ Check authentication status on app start
- ✅ Logout functionality
- ✅ Token refresh (prepared for future use)
### Pending
- ⏳ Integration with actual API endpoints
- ⏳ Biometric authentication
- ⏳ Remember me functionality
- ⏳ Password recovery
## Data Flow
### Login Flow
```
1. User enters credentials in LoginPage
2. LoginForm validates input
3. AuthNotifier.login() is called
4. LoginUseCase validates and processes request
5. AuthRepository calls AuthRemoteDataSource
6. API response is converted to UserModel
7. Tokens saved to SecureStorage
8. AuthState updated to authenticated
9. Navigation to warehouses page
```
### Logout Flow
```
1. User triggers logout
2. AuthNotifier.logout() is called
3. LogoutUseCase calls AuthRepository
4. API logout call (optional, can fail)
5. SecureStorage cleared
6. AuthState reset to initial
7. Navigation to login page
```
## Usage
### Basic Import
```dart
import 'package:minhthu/features/auth/auth.dart';
```
### Using in UI
```dart
// In a ConsumerWidget or ConsumerStatefulWidget
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch auth state
final authState = ref.watch(authProvider);
// Check authentication
if (authState.isAuthenticated) {
return AuthenticatedView(user: authState.user!);
}
// Handle loading
if (authState.isLoading) {
return LoadingIndicator();
}
// Show error
if (authState.error != null) {
return ErrorView(message: authState.error!);
}
return LoginView();
}
}
```
### Perform Login
```dart
// In your widget
void handleLogin(String username, String password) {
ref.read(authProvider.notifier).login(username, password);
}
```
### Perform Logout
```dart
void handleLogout() {
ref.read(authProvider.notifier).logout();
}
```
### Check Auth Status
```dart
void checkIfAuthenticated() async {
await ref.read(authProvider.notifier).checkAuthStatus();
}
```
### Listen to Auth Changes
```dart
ref.listen(authProvider, (previous, next) {
if (next.isAuthenticated) {
// Navigate to home
context.go('/home');
} else if (next.error != null) {
// Show error snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.error!)),
);
}
});
```
## API Integration
### Expected API Response Format
```json
{
"Value": {
"userId": "string",
"username": "string",
"accessToken": "string",
"refreshToken": "string"
},
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
### Login Request
```json
POST /api/v1/auth/login
{
"username": "string",
"password": "string"
}
```
### Logout Request
```json
POST /api/v1/auth/logout
Authorization: Bearer {accessToken}
```
### Refresh Token Request
```json
POST /api/v1/auth/refresh
{
"refreshToken": "string"
}
```
## State Management
### AuthState
```dart
class AuthState {
final UserEntity? user; // Current user or null
final bool isAuthenticated; // Authentication status
final bool isLoading; // Loading indicator
final String? error; // Error message
}
```
### State Transitions
```
Initial State → Loading → Authenticated (success)
→ Error (failure)
```
## Testing
### Unit Tests (TODO)
```dart
// Test use cases
test('login with valid credentials returns user', () async {
// Arrange
final useCase = LoginUseCase(mockRepository);
final request = LoginRequestModel(
username: 'testuser',
password: 'password123',
);
// Act
final result = await useCase(request);
// Assert
expect(result.isRight(), true);
});
// Test repository
test('repository saves tokens on successful login', () async {
// Test implementation
});
```
### Widget Tests (TODO)
```dart
testWidgets('login page shows form fields', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(home: LoginPage()),
),
);
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('Username'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
});
```
## Error Handling
### Validation Errors
- Empty username/password
- Username too short (< 3 characters)
- Password too short (< 6 characters)
### Network Errors
- Connection timeout
- No internet connection
- Server unreachable
### Authentication Errors
- Invalid credentials
- Account locked
- Token expired
### Display Errors
All errors are displayed in a user-friendly format in the UI with appropriate styling.
## Security Considerations
### Implemented
- Tokens stored in secure storage (encrypted)
- Password field obscured
- Auth token added to API headers automatically
- Token cleared on logout
- No sensitive data logged
### Best Practices
- Never log passwords or tokens
- Use HTTPS for all API calls
- Implement token refresh before expiration
- Clear sensitive data on logout
- Validate all user input
## Dependencies
### Core Dependencies
- `flutter_riverpod` - State management
- `dartz` - Functional programming (Either type)
- `flutter_secure_storage` - Secure token storage
- `dio` - HTTP client (via ApiClient)
- `equatable` - Value equality
- `go_router` - Navigation
### Internal Dependencies
- `core/network/api_client.dart` - HTTP client wrapper
- `core/storage/secure_storage.dart` - Secure storage wrapper
- `core/errors/failures.dart` - Error types
- `core/errors/exceptions.dart` - Exception types
- `core/widgets/custom_button.dart` - Button widget
- `core/widgets/loading_indicator.dart` - Loading widget
## Troubleshooting
### Common Issues
**Issue: Login always fails**
- Check API endpoint configuration in `api_endpoints.dart`
- Verify API is running and accessible
- Check network connectivity
- Verify request/response format matches API
**Issue: Tokens not persisted**
- Verify secure storage is initialized
- Check device storage permissions
- Clear app data and try again
**Issue: Navigation doesn't work after login**
- Verify router configuration includes `/warehouses` route
- Check if listener in LoginPage is properly set up
- Ensure ProviderScope wraps the app
**Issue: State not updating in UI**
- Ensure using ConsumerWidget or ConsumerStatefulWidget
- Verify provider is being watched, not just read
- Check if state is properly copied in copyWith
## Future Enhancements
### Planned Features
1. **Biometric Authentication**
- Face ID / Touch ID support
- Fallback to password
2. **Token Auto-Refresh**
- Background token refresh
- Seamless reauthentication
3. **Multi-factor Authentication**
- OTP support
- SMS verification
4. **Remember Me**
- Optional persistent login
- Secure device storage
5. **Password Reset**
- Email-based reset flow
- Security questions
## Contributing
When modifying this feature:
1. Follow clean architecture principles
2. Maintain separation of concerns (data/domain/presentation)
3. Add tests for new functionality
4. Update this README with changes
5. Follow existing code style and patterns
## Related Files
- App Router: `lib/core/routing/app_router.dart`
- API Endpoints: `lib/core/constants/api_endpoints.dart`
- App Theme: `lib/core/theme/app_theme.dart`
- Main App: `lib/main.dart`

View File

@@ -0,0 +1,15 @@
/// Barrel file for auth feature exports
///
/// Main entry point for the authentication feature
// Dependency injection
export 'di/auth_dependency_injection.dart';
// Domain layer (public interface)
export 'domain/domain.dart';
// Presentation layer (UI components)
export 'presentation/presentation.dart';
// Data layer (usually not exported publicly, but included for completeness)
// export 'data/data.dart';

View File

@@ -0,0 +1,13 @@
/// Barrel file for auth data layer exports
///
/// Provides clean imports for data layer components
// Data sources
export 'datasources/auth_remote_datasource.dart';
// Models
export 'models/login_request_model.dart';
export 'models/user_model.dart';
// Repositories
export 'repositories/auth_repository_impl.dart';

View File

@@ -0,0 +1,147 @@
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/login_request_model.dart';
import '../models/user_model.dart';
/// Abstract interface for authentication remote data source
///
/// Defines the contract for authentication-related API operations
abstract class AuthRemoteDataSource {
/// Login with username and password
///
/// Throws [ServerException] if the login fails
/// Returns [UserModel] on successful login
Future<UserModel> login(LoginRequestModel request);
/// Logout current user
///
/// Throws [ServerException] if logout fails
Future<void> logout();
/// Refresh access token using refresh token
///
/// Throws [ServerException] if refresh fails
/// Returns new [UserModel] with updated tokens
Future<UserModel> refreshToken(String refreshToken);
}
/// Implementation of AuthRemoteDataSource using ApiClient
///
/// Handles all authentication-related API calls
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final ApiClient apiClient;
AuthRemoteDataSourceImpl(this.apiClient);
@override
Future<UserModel> login(LoginRequestModel request) async {
try {
// Make POST request to login endpoint
final response = await apiClient.post(
ApiEndpoints.login,
data: request.toJson(),
);
// Parse API response with ApiResponse wrapper
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => UserModel.fromJson(
json as Map<String, dynamic>,
username: request.username, // Pass username since API doesn't return it
),
);
// Check if login was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Extract error message from API response
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Login failed';
throw ServerException(
errorMessage,
code: apiResponse.errorCodes.isNotEmpty
? apiResponse.errorCodes.first
: null,
);
}
} on ServerException {
rethrow;
} catch (e) {
throw ServerException('Failed to login: ${e.toString()}');
}
}
@override
Future<void> logout() async {
try {
// Make POST request to logout endpoint
final response = await apiClient.post(ApiEndpoints.logout);
// Parse API response
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
null,
);
// Check if logout was successful
if (!apiResponse.isSuccess) {
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Logout failed';
throw ServerException(
errorMessage,
code: apiResponse.errorCodes.isNotEmpty
? apiResponse.errorCodes.first
: null,
);
}
} on ServerException {
rethrow;
} catch (e) {
throw ServerException('Failed to logout: ${e.toString()}');
}
}
@override
Future<UserModel> refreshToken(String refreshToken) async {
try {
// Make POST request to refresh token endpoint
final response = await apiClient.post(
ApiEndpoints.refreshToken,
data: {'refreshToken': refreshToken},
);
// Parse API response
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => UserModel.fromJson(json as Map<String, dynamic>),
);
// Check if refresh was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Token refresh failed';
throw ServerException(
errorMessage,
code: apiResponse.errorCodes.isNotEmpty
? apiResponse.errorCodes.first
: null,
);
}
} on ServerException {
rethrow;
} catch (e) {
throw ServerException('Failed to refresh token: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
/// Login request model for authentication
///
/// Contains the credentials required for user login
class LoginRequestModel extends Equatable {
/// Username for authentication
final String username;
/// Password for authentication
final String password;
const LoginRequestModel({
required this.username,
required this.password,
});
/// Convert to JSON for API request
Map<String, dynamic> toJson() {
return {
'EmailPhone': username,
'Password': password,
};
}
/// Create a copy with modified fields
LoginRequestModel copyWith({
String? username,
String? password,
}) {
return LoginRequestModel(
username: username ?? this.username,
password: password ?? this.password,
);
}
@override
List<Object?> get props => [username, password];
@override
String toString() => 'LoginRequestModel(username: $username)';
}

View File

@@ -0,0 +1,81 @@
import '../../domain/entities/user_entity.dart';
/// User model that extends UserEntity for data layer
///
/// Handles JSON serialization/deserialization for API responses
class UserModel extends UserEntity {
const UserModel({
required super.userId,
required super.username,
required super.accessToken,
super.refreshToken,
});
/// Create UserModel from JSON response
///
/// Expected JSON format from API:
/// ```json
/// {
/// "AccessToken": "string"
/// }
/// ```
factory UserModel.fromJson(Map<String, dynamic> json, {String? username}) {
return UserModel(
userId: username ?? 'user', // Use username as userId or default
username: username ?? 'user',
accessToken: json['AccessToken'] as String,
refreshToken: null, // API doesn't provide refresh token
);
}
/// Convert UserModel to JSON
Map<String, dynamic> toJson() {
return {
'userId': userId,
'username': username,
'accessToken': accessToken,
'refreshToken': refreshToken,
};
}
/// Create UserModel from UserEntity
factory UserModel.fromEntity(UserEntity entity) {
return UserModel(
userId: entity.userId,
username: entity.username,
accessToken: entity.accessToken,
refreshToken: entity.refreshToken,
);
}
/// Convert to UserEntity
UserEntity toEntity() {
return UserEntity(
userId: userId,
username: username,
accessToken: accessToken,
refreshToken: refreshToken,
);
}
/// Create a copy with modified fields
@override
UserModel copyWith({
String? userId,
String? username,
String? accessToken,
String? refreshToken,
}) {
return UserModel(
userId: userId ?? this.userId,
username: username ?? this.username,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
);
}
@override
String toString() {
return 'UserModel(userId: $userId, username: $username, hasRefreshToken: ${refreshToken != null})';
}
}

View File

@@ -0,0 +1,134 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/storage/secure_storage.dart';
import '../../domain/entities/user_entity.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
import '../models/login_request_model.dart';
/// Implementation of AuthRepository
///
/// Coordinates between remote data source and local storage
/// Handles error conversion from exceptions to failures
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final SecureStorage secureStorage;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.secureStorage,
});
@override
Future<Either<Failure, UserEntity>> login(LoginRequestModel request) async {
try {
// Call remote data source to login
final userModel = await remoteDataSource.login(request);
// Save tokens to secure storage
await secureStorage.saveAccessToken(userModel.accessToken);
await secureStorage.saveUserId(userModel.userId);
await secureStorage.saveUsername(userModel.username);
if (userModel.refreshToken != null) {
await secureStorage.saveRefreshToken(userModel.refreshToken!);
}
// Return user entity
return Right(userModel.toEntity());
} on ServerException catch (e) {
return Left(AuthenticationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Login failed: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> logout() async {
try {
// Call remote data source to logout (optional - can fail silently)
try {
await remoteDataSource.logout();
} catch (e) {
// Ignore remote logout errors, still clear local data
}
// Clear all local authentication data
await secureStorage.clearAll();
return const Right(null);
} catch (e) {
return Left(UnknownFailure('Logout failed: ${e.toString()}'));
}
}
@override
Future<Either<Failure, UserEntity>> refreshToken(String refreshToken) async {
try {
// Call remote data source to refresh token
final userModel = await remoteDataSource.refreshToken(refreshToken);
// Update tokens in secure storage
await secureStorage.saveAccessToken(userModel.accessToken);
if (userModel.refreshToken != null) {
await secureStorage.saveRefreshToken(userModel.refreshToken!);
}
return Right(userModel.toEntity());
} on ServerException catch (e) {
return Left(AuthenticationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Token refresh failed: ${e.toString()}'));
}
}
@override
Future<bool> isAuthenticated() async {
try {
return await secureStorage.isAuthenticated();
} catch (e) {
return false;
}
}
@override
Future<Either<Failure, UserEntity>> getCurrentUser() async {
try {
final userId = await secureStorage.getUserId();
final username = await secureStorage.getUsername();
final accessToken = await secureStorage.getAccessToken();
final refreshToken = await secureStorage.getRefreshToken();
if (userId == null || username == null || accessToken == null) {
return const Left(AuthenticationFailure('No user data found'));
}
final user = UserEntity(
userId: userId,
username: username,
accessToken: accessToken,
refreshToken: refreshToken,
);
return Right(user);
} catch (e) {
return Left(CacheFailure('Failed to get user data: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> clearAuthData() async {
try {
await secureStorage.clearAll();
return const Right(null);
} catch (e) {
return Left(CacheFailure('Failed to clear auth data: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/storage/secure_storage.dart';
import '../data/datasources/auth_remote_datasource.dart';
import '../data/repositories/auth_repository_impl.dart';
import '../domain/repositories/auth_repository.dart';
import '../domain/usecases/login_usecase.dart';
import '../presentation/providers/auth_provider.dart';
/// Dependency injection setup for authentication feature
///
/// This file contains all Riverpod providers for the auth feature
/// following clean architecture principles
// ==================== Data Layer ====================
/// Provider for AuthRemoteDataSource
///
/// Depends on ApiClient from core
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
// TODO: Replace with actual ApiClient provider when available
final apiClient = ApiClient(SecureStorage());
return AuthRemoteDataSourceImpl(apiClient);
});
/// Provider for SecureStorage
///
/// Singleton instance
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
// ==================== Domain Layer ====================
/// Provider for AuthRepository
///
/// Depends on AuthRemoteDataSource and SecureStorage
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final remoteDataSource = ref.watch(authRemoteDataSourceProvider);
final secureStorage = ref.watch(secureStorageProvider);
return AuthRepositoryImpl(
remoteDataSource: remoteDataSource,
secureStorage: secureStorage,
);
});
/// Provider for LoginUseCase
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LoginUseCase(repository);
});
/// Provider for LogoutUseCase
final logoutUseCaseProvider = Provider<LogoutUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return LogoutUseCase(repository);
});
/// Provider for CheckAuthStatusUseCase
final checkAuthStatusUseCaseProvider = Provider<CheckAuthStatusUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return CheckAuthStatusUseCase(repository);
});
/// Provider for GetCurrentUserUseCase
final getCurrentUserUseCaseProvider = Provider<GetCurrentUserUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return GetCurrentUserUseCase(repository);
});
/// Provider for RefreshTokenUseCase
final refreshTokenUseCaseProvider = Provider<RefreshTokenUseCase>((ref) {
final repository = ref.watch(authRepositoryProvider);
return RefreshTokenUseCase(repository);
});
// ==================== Presentation Layer ====================
/// Provider for AuthNotifier (State Management)
///
/// This is the main provider that UI will interact with
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final loginUseCase = ref.watch(loginUseCaseProvider);
final logoutUseCase = ref.watch(logoutUseCaseProvider);
final checkAuthStatusUseCase = ref.watch(checkAuthStatusUseCaseProvider);
final getCurrentUserUseCase = ref.watch(getCurrentUserUseCaseProvider);
return AuthNotifier(
loginUseCase: loginUseCase,
logoutUseCase: logoutUseCase,
checkAuthStatusUseCase: checkAuthStatusUseCase,
getCurrentUserUseCase: getCurrentUserUseCase,
);
});
// ==================== Convenience Providers ====================
/// Provider to check if user is authenticated
///
/// Returns boolean indicating authentication status
final isAuthenticatedProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isAuthenticated;
});
/// Provider to get current user
///
/// Returns UserEntity if authenticated, null otherwise
final currentUserProvider = Provider((ref) {
final authState = ref.watch(authProvider);
return authState.user;
});
/// Provider to check if auth operation is loading
final isAuthLoadingProvider = Provider<bool>((ref) {
final authState = ref.watch(authProvider);
return authState.isLoading;
});
/// Provider to get auth error message
final authErrorProvider = Provider<String?>((ref) {
final authState = ref.watch(authProvider);
return authState.error;
});

View File

@@ -0,0 +1,12 @@
/// Barrel file for auth domain layer exports
///
/// Provides clean imports for domain layer components
// Entities
export 'entities/user_entity.dart';
// Repositories
export 'repositories/auth_repository.dart';
// Use cases
export 'usecases/login_usecase.dart';

View File

@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
/// User entity representing authenticated user in the domain layer
///
/// This is a pure domain model with no external dependencies
class UserEntity extends Equatable {
/// Unique user identifier
final String userId;
/// Username
final String username;
/// Access token for API authentication
final String accessToken;
/// Refresh token for renewing access token
final String? refreshToken;
const UserEntity({
required this.userId,
required this.username,
required this.accessToken,
this.refreshToken,
});
/// Create a copy with modified fields
UserEntity copyWith({
String? userId,
String? username,
String? accessToken,
String? refreshToken,
}) {
return UserEntity(
userId: userId ?? this.userId,
username: username ?? this.username,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
);
}
@override
List<Object?> get props => [userId, username, accessToken, refreshToken];
@override
String toString() {
return 'UserEntity(userId: $userId, username: $username, hasRefreshToken: ${refreshToken != null})';
}
}

View File

@@ -0,0 +1,46 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../data/models/login_request_model.dart';
import '../entities/user_entity.dart';
/// Abstract repository interface for authentication operations
///
/// This defines the contract that the data layer must implement.
/// Returns Either<Failure, Success> for proper error handling.
abstract class AuthRepository {
/// Login with username and password
///
/// Returns [Right(UserEntity)] on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, UserEntity>> login(LoginRequestModel request);
/// Logout current user
///
/// Returns [Right(void)] on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, void>> logout();
/// Refresh access token
///
/// Returns [Right(UserEntity)] with new tokens on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, UserEntity>> refreshToken(String refreshToken);
/// Check if user is authenticated
///
/// Returns true if valid access token exists
Future<bool> isAuthenticated();
/// Get current user from local storage
///
/// Returns [Right(UserEntity)] if user data exists
/// Returns [Left(Failure)] if no user data found
Future<Either<Failure, UserEntity>> getCurrentUser();
/// Clear authentication data (logout locally)
///
/// Returns [Right(void)] on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, void>> clearAuthData();
}

View File

@@ -0,0 +1,126 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../data/models/login_request_model.dart';
import '../entities/user_entity.dart';
import '../repositories/auth_repository.dart';
/// Use case for user login
///
/// Encapsulates the business logic for authentication
/// Validates input, calls repository, and handles the response
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
/// Execute login operation
///
/// [request] - Login credentials (username and password)
///
/// Returns [Right(UserEntity)] on successful login
/// Returns [Left(Failure)] on error:
/// - [ValidationFailure] if credentials are invalid
/// - [AuthenticationFailure] if login fails
/// - [NetworkFailure] if network error occurs
Future<Either<Failure, UserEntity>> call(LoginRequestModel request) async {
// Validate input
final validationError = _validateInput(request);
if (validationError != null) {
return Left(validationError);
}
// Call repository to perform login
return await repository.login(request);
}
/// Validate login request input
///
/// Returns [ValidationFailure] if validation fails, null otherwise
ValidationFailure? _validateInput(LoginRequestModel request) {
// Validate username
if (request.username.trim().isEmpty) {
return const ValidationFailure('Username is required');
}
if (request.username.length < 3) {
return const ValidationFailure('Username must be at least 3 characters');
}
// Validate password
if (request.password.isEmpty) {
return const ValidationFailure('Password is required');
}
if (request.password.length < 6) {
return const ValidationFailure('Password must be at least 6 characters');
}
return null;
}
}
/// Use case for user logout
class LogoutUseCase {
final AuthRepository repository;
LogoutUseCase(this.repository);
/// Execute logout operation
///
/// Returns [Right(void)] on successful logout
/// Returns [Left(Failure)] on error
Future<Either<Failure, void>> call() async {
return await repository.logout();
}
}
/// Use case for checking authentication status
class CheckAuthStatusUseCase {
final AuthRepository repository;
CheckAuthStatusUseCase(this.repository);
/// Check if user is authenticated
///
/// Returns true if user has valid access token
Future<bool> call() async {
return await repository.isAuthenticated();
}
}
/// Use case for getting current user
class GetCurrentUserUseCase {
final AuthRepository repository;
GetCurrentUserUseCase(this.repository);
/// Get current authenticated user
///
/// Returns [Right(UserEntity)] if user is authenticated
/// Returns [Left(Failure)] if no user found or error occurs
Future<Either<Failure, UserEntity>> call() async {
return await repository.getCurrentUser();
}
}
/// Use case for refreshing access token
class RefreshTokenUseCase {
final AuthRepository repository;
RefreshTokenUseCase(this.repository);
/// Refresh access token using refresh token
///
/// [refreshToken] - The refresh token
///
/// Returns [Right(UserEntity)] with new tokens on success
/// Returns [Left(Failure)] on error
Future<Either<Failure, UserEntity>> call(String refreshToken) async {
if (refreshToken.isEmpty) {
return const Left(ValidationFailure('Refresh token is required'));
}
return await repository.refreshToken(refreshToken);
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../di/auth_dependency_injection.dart';
import '../widgets/login_form.dart';
/// Login page for user authentication
///
/// Displays login form and handles authentication flow
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
@override
void initState() {
super.initState();
// Check authentication status on page load
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAuthStatus();
});
}
/// Check if user is already authenticated
Future<void> _checkAuthStatus() async {
ref.read(authProvider.notifier).checkAuthStatus();
}
/// Handle login button press
void _handleLogin(String username, String password) {
ref.read(authProvider.notifier).login(username, password);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final authState = ref.watch(authProvider);
final isLoading = authState.isLoading;
final error = authState.error;
// Listen for authentication state changes
ref.listen(authProvider, (previous, next) {
if (next.isAuthenticated) {
// Navigate to warehouses page on successful login
context.go('/warehouses');
}
});
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo/icon
Icon(
Icons.warehouse_outlined,
size: 80,
color: theme.colorScheme.primary,
),
const SizedBox(height: 24),
// App title
Text(
'Warehouse Manager',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Subtitle
Text(
'Login to continue',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Error message (show before form)
if (error != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
error,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
),
if (error != null) const SizedBox(height: 16),
// Login form (includes button)
LoginForm(
onSubmit: _handleLogin,
isLoading: isLoading,
),
const SizedBox(height: 24),
// Additional info or version
Text(
'Version 1.0.0',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,12 @@
/// Barrel file for auth presentation layer exports
///
/// Provides clean imports for presentation layer components
// Pages
export 'pages/login_page.dart';
// Providers
export 'providers/auth_provider.dart';
// Widgets
export 'widgets/login_form.dart';

View File

@@ -0,0 +1,190 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/login_request_model.dart';
import '../../domain/entities/user_entity.dart';
import '../../domain/usecases/login_usecase.dart';
/// Authentication state
///
/// Represents the current authentication status and user data
class AuthState extends Equatable {
/// Current authenticated user (null if not authenticated)
final UserEntity? user;
/// Whether user is authenticated
final bool isAuthenticated;
/// Whether an authentication operation is in progress
final bool isLoading;
/// Error message if authentication fails
final String? error;
const AuthState({
this.user,
this.isAuthenticated = false,
this.isLoading = false,
this.error,
});
/// Initial state (not authenticated, not loading)
const AuthState.initial()
: user = null,
isAuthenticated = false,
isLoading = false,
error = null;
/// Loading state
const AuthState.loading()
: user = null,
isAuthenticated = false,
isLoading = true,
error = null;
/// Authenticated state with user data
const AuthState.authenticated(UserEntity user)
: user = user,
isAuthenticated = true,
isLoading = false,
error = null;
/// Error state
const AuthState.error(String message)
: user = null,
isAuthenticated = false,
isLoading = false,
error = message;
/// Create a copy with modified fields
AuthState copyWith({
UserEntity? user,
bool? isAuthenticated,
bool? isLoading,
String? error,
}) {
return AuthState(
user: user ?? this.user,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
@override
List<Object?> get props => [user, isAuthenticated, isLoading, error];
@override
String toString() {
return 'AuthState(isAuthenticated: $isAuthenticated, isLoading: $isLoading, error: $error, user: $user)';
}
}
/// Auth state notifier that manages authentication state
///
/// Handles login, logout, and authentication status checks
class AuthNotifier extends StateNotifier<AuthState> {
final LoginUseCase loginUseCase;
final LogoutUseCase logoutUseCase;
final CheckAuthStatusUseCase checkAuthStatusUseCase;
final GetCurrentUserUseCase getCurrentUserUseCase;
AuthNotifier({
required this.loginUseCase,
required this.logoutUseCase,
required this.checkAuthStatusUseCase,
required this.getCurrentUserUseCase,
}) : super(const AuthState.initial());
/// Login with username and password
///
/// Updates state to loading, then either authenticated or error
Future<void> login(String username, String password) async {
// Set loading state
state = const AuthState.loading();
// Create login request
final request = LoginRequestModel(
username: username,
password: password,
);
// Call login use case
final result = await loginUseCase(request);
// Handle result
result.fold(
(failure) {
// Login failed - set error state
state = AuthState.error(failure.message);
},
(user) {
// Login successful - set authenticated state
state = AuthState.authenticated(user);
},
);
}
/// Logout current user
///
/// Clears authentication data and returns to initial state
Future<void> logout() async {
// Set loading state
state = state.copyWith(isLoading: true, error: null);
// Call logout use case
final result = await logoutUseCase();
// Handle result
result.fold(
(failure) {
// Logout failed - but still reset to initial state
// (local data should be cleared even if API call fails)
state = const AuthState.initial();
},
(_) {
// Logout successful - reset to initial state
state = const AuthState.initial();
},
);
}
/// Check authentication status on app start
///
/// Loads user data from storage if authenticated
Future<void> checkAuthStatus() async {
// Check if user is authenticated
final isAuthenticated = await checkAuthStatusUseCase();
if (isAuthenticated) {
// Try to load user data
final result = await getCurrentUserUseCase();
result.fold(
(failure) {
// Failed to load user data - reset to initial state
state = const AuthState.initial();
},
(user) {
// User data loaded - set authenticated state
state = AuthState.authenticated(user);
},
);
} else {
// Not authenticated - initial state
state = const AuthState.initial();
}
}
/// Clear error message
void clearError() {
if (state.error != null) {
state = state.copyWith(error: null);
}
}
/// Reset to initial state
void reset() {
state = const AuthState.initial();
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
/// Reusable login form widget with validation
///
/// Handles username and password input with proper validation
class LoginForm extends StatefulWidget {
/// Callback when login button is pressed
final void Function(String username, String password) onSubmit;
/// Whether the form is in loading state
final bool isLoading;
const LoginForm({
super.key,
required this.onSubmit,
this.isLoading = false,
});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController(text: "yesterday305@gmail.com");
final _passwordController = TextEditingController(text: '123456');
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _handleSubmit() {
// Validate form
if (_formKey.currentState?.validate() ?? false) {
// Call submit callback
widget.onSubmit(
_usernameController.text.trim(),
_passwordController.text,
);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Username field
TextFormField(
controller: _usernameController,
enabled: !widget.isLoading,
decoration: InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
if (value.trim().length < 3) {
return 'Username must be at least 3 characters';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
enabled: !widget.isLoading,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSubmit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 24),
// Login button
FilledButton.icon(
onPressed: widget.isLoading ? null : _handleSubmit,
icon: widget.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login),
label: Text(widget.isLoading ? 'Logging in...' : 'Login'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
);
}
}
/// Simple text field widget for login forms
class LoginTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final IconData? prefixIcon;
final bool obscureText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final String? Function(String?)? validator;
final void Function(String)? onFieldSubmitted;
final bool enabled;
final Widget? suffixIcon;
const LoginTextField({
super.key,
required this.controller,
required this.label,
required this.hint,
this.prefixIcon,
this.obscureText = false,
this.keyboardType,
this.textInputAction,
this.validator,
this.onFieldSubmitted,
this.enabled = true,
this.suffixIcon,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
enabled: enabled,
obscureText: obscureText,
keyboardType: keyboardType,
textInputAction: textInputAction,
onFieldSubmitted: onFieldSubmitted,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: enabled
? Theme.of(context).colorScheme.surface
: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
),
validator: validator,
);
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../warehouse/domain/entities/warehouse_entity.dart';
import '../../../../core/constants/app_constants.dart';
import '../widgets/operation_card.dart';
/// Operation Selection Page
/// Allows users to choose between import and export operations for a selected warehouse
class OperationSelectionPage extends ConsumerWidget {
final WarehouseEntity warehouse;
const OperationSelectionPage({
super.key,
required this.warehouse,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Select Operation'),
elevation: 0,
),
body: SafeArea(
child: Column(
children: [
// Warehouse information header
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppConstants.defaultPadding),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Warehouse',
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
warehouse.name,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.qr_code,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Code: ${warehouse.code}',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 16),
Icon(
Icons.inventory_2_outlined,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Items: ${warehouse.totalCount}',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
// Operation cards
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppConstants.largePadding,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Import Products Card
OperationCard(
title: 'Import Products',
icon: Icons.arrow_downward_rounded,
backgroundColor: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
iconColor: colorScheme.tertiary,
onTap: () => _navigateToProducts(
context,
warehouse,
'import',
),
),
const SizedBox(height: AppConstants.defaultPadding),
// Export Products Card
OperationCard(
title: 'Export Products',
icon: Icons.arrow_upward_rounded,
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
iconColor: colorScheme.primary,
onTap: () => _navigateToProducts(
context,
warehouse,
'export',
),
),
],
),
),
),
],
),
),
);
}
/// Navigate to products page with warehouse and operation type
void _navigateToProducts(
BuildContext context,
WarehouseEntity warehouse,
String operationType,
) {
context.goNamed(
'products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': operationType,
},
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_constants.dart';
/// Reusable operation card widget
/// Large, tappable card with icon and text for operation selection
class OperationCard extends StatelessWidget {
final String title;
final IconData icon;
final VoidCallback onTap;
final Color? backgroundColor;
final Color? iconColor;
const OperationCard({
super.key,
required this.title,
required this.icon,
required this.onTap,
this.backgroundColor,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(
horizontal: AppConstants.defaultPadding,
vertical: AppConstants.smallPadding,
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: Container(
padding: const EdgeInsets.all(AppConstants.largePadding),
height: 180,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon container with background
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: backgroundColor ??
colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 48,
color: iconColor ?? colorScheme.primary,
),
),
const SizedBox(height: AppConstants.defaultPadding),
// Title
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,62 @@
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/product_model.dart';
/// Abstract interface for products remote data source
abstract class ProductsRemoteDataSource {
/// Fetch products from the API
///
/// [warehouseId] - The ID of the warehouse
/// [type] - The operation type ('import' or 'export')
///
/// Returns List<ProductModel>
/// Throws [ServerException] if the API call fails
Future<List<ProductModel>> getProducts(int warehouseId, String type);
}
/// Implementation of ProductsRemoteDataSource using ApiClient
class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
final ApiClient apiClient;
ProductsRemoteDataSourceImpl(this.apiClient);
@override
Future<List<ProductModel>> getProducts(int warehouseId, String type) async {
try {
// Make API call to get all products
final response = await apiClient.get('/portalProduct/getAllProduct');
// Parse the API response using ApiResponse wrapper
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => (json as List)
.map((e) => ProductModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
// Check if the API call was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Throw exception with error message from API
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get products',
);
}
} 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 products: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,203 @@
import '../../domain/entities/product_entity.dart';
/// Product model - data transfer object
/// Extends ProductEntity and adds serialization capabilities
class ProductModel extends ProductEntity {
const ProductModel({
required super.id,
required super.name,
required super.code,
required super.fullName,
super.description,
super.lotCode,
super.lotNumber,
super.logo,
super.barcode,
required super.quantity,
required super.totalQuantity,
required super.passedQuantity,
super.passedQuantityWeight,
required super.issuedQuantity,
super.issuedQuantityWeight,
required super.piecesInStock,
required super.weightInStock,
required super.weight,
required super.pieces,
required super.conversionRate,
super.percent,
super.price,
required super.isActive,
required super.isConfirm,
super.productStatusId,
required super.productTypeId,
super.orderId,
super.parentId,
super.receiverStageId,
super.order,
super.startDate,
super.endDate,
super.productions,
super.customerProducts,
super.productStages,
super.childrenProducts,
super.productStageWareHouses,
super.productStageDetailWareHouses,
super.productExportExcelSheetDataModels,
super.materialLabels,
super.materials,
super.images,
super.attachmentFiles,
});
/// Create ProductModel from JSON
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
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'],
);
}
/// Convert ProductModel to JSON
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,
};
}
/// Convert ProductModel to ProductEntity
ProductEntity toEntity() => this;
/// Create ProductModel from ProductEntity
factory ProductModel.fromEntity(ProductEntity entity) {
return ProductModel(
id: entity.id,
name: entity.name,
code: entity.code,
fullName: entity.fullName,
description: entity.description,
lotCode: entity.lotCode,
lotNumber: entity.lotNumber,
logo: entity.logo,
barcode: entity.barcode,
quantity: entity.quantity,
totalQuantity: entity.totalQuantity,
passedQuantity: entity.passedQuantity,
passedQuantityWeight: entity.passedQuantityWeight,
issuedQuantity: entity.issuedQuantity,
issuedQuantityWeight: entity.issuedQuantityWeight,
piecesInStock: entity.piecesInStock,
weightInStock: entity.weightInStock,
weight: entity.weight,
pieces: entity.pieces,
conversionRate: entity.conversionRate,
percent: entity.percent,
price: entity.price,
isActive: entity.isActive,
isConfirm: entity.isConfirm,
productStatusId: entity.productStatusId,
productTypeId: entity.productTypeId,
orderId: entity.orderId,
parentId: entity.parentId,
receiverStageId: entity.receiverStageId,
order: entity.order,
startDate: entity.startDate,
endDate: entity.endDate,
productions: entity.productions,
customerProducts: entity.customerProducts,
productStages: entity.productStages,
childrenProducts: entity.childrenProducts,
productStageWareHouses: entity.productStageWareHouses,
productStageDetailWareHouses: entity.productStageDetailWareHouses,
productExportExcelSheetDataModels:
entity.productExportExcelSheetDataModels,
materialLabels: entity.materialLabels,
materials: entity.materials,
images: entity.images,
attachmentFiles: entity.attachmentFiles,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/product_entity.dart';
import '../../domain/repositories/products_repository.dart';
import '../datasources/products_remote_datasource.dart';
/// Implementation of ProductsRepository
/// Handles data operations and error conversion
class ProductsRepositoryImpl implements ProductsRepository {
final ProductsRemoteDataSource remoteDataSource;
ProductsRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, List<ProductEntity>>> getProducts(
int warehouseId,
String type,
) async {
try {
// Fetch products from remote data source
final products = await remoteDataSource.getProducts(warehouseId, type);
// Convert models to entities and return success
return Right(products.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Convert ServerException to ServerFailure
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
// Convert NetworkException to NetworkFailure
return Left(NetworkFailure(e.message));
} catch (e) {
// Handle any other exceptions
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,154 @@
import 'package:equatable/equatable.dart';
/// Product entity - pure domain model
/// Represents a product in the warehouse management system
class ProductEntity extends Equatable {
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;
const ProductEntity({
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,
});
@override
List<Object?> get props => [
id,
name,
code,
fullName,
description,
lotCode,
lotNumber,
logo,
barcode,
quantity,
totalQuantity,
passedQuantity,
passedQuantityWeight,
issuedQuantity,
issuedQuantityWeight,
piecesInStock,
weightInStock,
weight,
pieces,
conversionRate,
percent,
price,
isActive,
isConfirm,
productStatusId,
productTypeId,
orderId,
parentId,
receiverStageId,
order,
startDate,
endDate,
productions,
customerProducts,
productStages,
childrenProducts,
productStageWareHouses,
productStageDetailWareHouses,
productExportExcelSheetDataModels,
materialLabels,
materials,
images,
attachmentFiles,
];
}

View File

@@ -0,0 +1,18 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_entity.dart';
/// Abstract repository interface for products
/// Defines the contract for product data operations
abstract class ProductsRepository {
/// Get products for a specific warehouse and operation type
///
/// [warehouseId] - The ID of the warehouse
/// [type] - The operation type ('import' or 'export')
///
/// Returns Either<Failure, List<ProductEntity>>
Future<Either<Failure, List<ProductEntity>>> getProducts(
int warehouseId,
String type,
);
}

View File

@@ -0,0 +1,25 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_entity.dart';
import '../repositories/products_repository.dart';
/// Use case for getting products
/// Encapsulates the business logic for fetching products
class GetProductsUseCase {
final ProductsRepository repository;
GetProductsUseCase(this.repository);
/// Execute the use case
///
/// [warehouseId] - The ID of the warehouse to get products from
/// [type] - The operation type ('import' or 'export')
///
/// Returns Either<Failure, List<ProductEntity>>
Future<Either<Failure, List<ProductEntity>>> call(
int warehouseId,
String type,
) async {
return await repository.getProducts(warehouseId, type);
}
}

View File

@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../widgets/product_list_item.dart';
/// Products list page
/// Displays products for a specific warehouse and operation type
class ProductsPage extends ConsumerStatefulWidget {
final int warehouseId;
final String warehouseName;
final String operationType;
const ProductsPage({
super.key,
required this.warehouseId,
required this.warehouseName,
required this.operationType,
});
@override
ConsumerState<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends ConsumerState<ProductsPage> {
@override
void initState() {
super.initState();
// Load products when page is initialized
Future.microtask(() {
ref.read(productsProvider.notifier).loadProducts(
widget.warehouseId,
widget.warehouseName,
widget.operationType,
);
});
}
Future<void> _onRefresh() async {
await ref.read(productsProvider.notifier).refreshProducts();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
// Watch the products state
final productsState = ref.watch(productsProvider);
final products = productsState.products;
final isLoading = productsState.isLoading;
final error = productsState.error;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Products (${_getOperationTypeDisplay()})',
style: textTheme.titleMedium,
),
Text(
widget.warehouseName,
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _onRefresh,
tooltip: 'Refresh',
),
],
),
body: _buildBody(
isLoading: isLoading,
error: error,
products: products,
theme: theme,
),
);
}
/// Build the body based on the current state
Widget _buildBody({
required bool isLoading,
required String? error,
required List products,
required ThemeData theme,
}) {
return Column(
children: [
// Info header
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: Row(
children: [
Icon(
widget.operationType == 'import'
? Icons.arrow_downward
: Icons.arrow_upward,
color: widget.operationType == 'import'
? Colors.green
: Colors.orange,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getOperationTypeDisplay(),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'Warehouse: ${widget.warehouseName}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
// Content area
Expanded(
child: _buildContent(
isLoading: isLoading,
error: error,
products: products,
theme: theme,
),
),
],
);
}
/// Build content based on state
Widget _buildContent({
required bool isLoading,
required String? error,
required List products,
required ThemeData theme,
}) {
// Loading state
if (isLoading && products.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading products...'),
],
),
);
}
// Error state
if (error != null && products.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
// Empty state
if (products.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No Products',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'No products found for this warehouse and operation type.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
],
),
),
);
}
// Success state - show products list
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ProductListItem(
product: product,
onTap: () {
// Handle product tap if needed
// For now, just show a snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Selected: ${product.fullName}'),
duration: const Duration(seconds: 1),
),
);
},
);
},
),
);
}
/// Get display text for operation type
String _getOperationTypeDisplay() {
return widget.operationType == 'import'
? 'Import Products'
: 'Export Products';
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/product_entity.dart';
import '../../domain/usecases/get_products_usecase.dart';
/// Products state class
/// Holds the current state of the products feature
class ProductsState {
final List<ProductEntity> products;
final String operationType;
final int? warehouseId;
final String? warehouseName;
final bool isLoading;
final String? error;
const ProductsState({
this.products = const [],
this.operationType = 'import',
this.warehouseId,
this.warehouseName,
this.isLoading = false,
this.error,
});
ProductsState copyWith({
List<ProductEntity>? products,
String? operationType,
int? warehouseId,
String? warehouseName,
bool? isLoading,
String? error,
}) {
return ProductsState(
products: products ?? this.products,
operationType: operationType ?? this.operationType,
warehouseId: warehouseId ?? this.warehouseId,
warehouseName: warehouseName ?? this.warehouseName,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Products notifier
/// Manages the products state and business logic
class ProductsNotifier extends StateNotifier<ProductsState> {
final GetProductsUseCase getProductsUseCase;
ProductsNotifier(this.getProductsUseCase) : super(const ProductsState());
/// Load products for a specific warehouse and operation type
///
/// [warehouseId] - The ID of the warehouse
/// [warehouseName] - The name of the warehouse (for display)
/// [type] - The operation type ('import' or 'export')
Future<void> loadProducts(
int warehouseId,
String warehouseName,
String type,
) async {
// Set loading state
state = state.copyWith(
isLoading: true,
error: null,
warehouseId: warehouseId,
warehouseName: warehouseName,
operationType: type,
);
// Call the use case
final result = await getProductsUseCase(warehouseId, type);
// Handle the result
result.fold(
(failure) {
// Handle failure
state = state.copyWith(
isLoading: false,
error: failure.message,
products: [],
);
},
(products) {
// Handle success
state = state.copyWith(
isLoading: false,
error: null,
products: products,
);
},
);
}
/// Clear products list
void clearProducts() {
state = const ProductsState();
}
/// Refresh products
Future<void> refreshProducts() async {
if (state.warehouseId != null) {
await loadProducts(
state.warehouseId!,
state.warehouseName ?? '',
state.operationType,
);
}
}
}

View File

@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import '../../domain/entities/product_entity.dart';
/// Reusable product list item widget
/// Displays key product information in a card layout
class ProductListItem extends StatelessWidget {
final ProductEntity product;
final VoidCallback? onTap;
const ProductListItem({
super.key,
required this.product,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product name and code
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.fullName,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Code: ${product.code}',
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
// Active status indicator
if (product.isActive)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Active',
style: textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Weight and pieces information
Row(
children: [
Expanded(
child: _InfoItem(
label: 'Weight',
value: '${product.weight.toStringAsFixed(2)} kg',
icon: Icons.fitness_center,
),
),
const SizedBox(width: 16),
Expanded(
child: _InfoItem(
label: 'Pieces',
value: product.pieces.toString(),
icon: Icons.inventory_2,
),
),
],
),
const SizedBox(height: 12),
// In stock information
Row(
children: [
Expanded(
child: _InfoItem(
label: 'In Stock (Pieces)',
value: product.piecesInStock.toString(),
icon: Icons.warehouse,
color: product.piecesInStock > 0
? Colors.green
: Colors.orange,
),
),
const SizedBox(width: 16),
Expanded(
child: _InfoItem(
label: 'In Stock (Weight)',
value: '${product.weightInStock.toStringAsFixed(2)} kg',
icon: Icons.scale,
color: product.weightInStock > 0
? Colors.green
: Colors.orange,
),
),
],
),
const SizedBox(height: 12),
// Conversion rate
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Conversion Rate',
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
product.conversionRate.toStringAsFixed(2),
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
// Barcode if available
if (product.barcode != null && product.barcode!.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.qr_code,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Barcode: ${product.barcode}',
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
],
),
),
),
);
}
}
/// Helper widget for displaying info items
class _InfoItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color? color;
const _InfoItem({
required this.label,
required this.value,
required this.icon,
this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final effectiveColor = color ?? theme.colorScheme.primary;
return Row(
children: [
Icon(
icon,
size: 20,
color: effectiveColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
value,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: effectiveColor,
),
),
],
),
),
],
);
}
}

View File

@@ -1,6 +0,0 @@
// Data layer exports
export 'datasources/scanner_local_datasource.dart';
export 'datasources/scanner_remote_datasource.dart';
export 'models/save_request_model.dart';
export 'models/scan_item.dart';
export 'repositories/scanner_repository_impl.dart';

View File

@@ -1,229 +0,0 @@
import 'package:hive_ce/hive.dart';
import '../../../../core/errors/exceptions.dart';
import '../models/scan_item.dart';
/// Abstract local data source for scanner operations
abstract class ScannerLocalDataSource {
/// Save scan to local storage
Future<void> saveScan(ScanItem scan);
/// Get all scans from local storage
Future<List<ScanItem>> getAllScans();
/// Get scan by barcode from local storage
Future<ScanItem?> getScanByBarcode(String barcode);
/// Update scan in local storage
Future<void> updateScan(ScanItem scan);
/// Delete scan from local storage
Future<void> deleteScan(String barcode);
/// Clear all scans from local storage
Future<void> clearAllScans();
}
/// Implementation of ScannerLocalDataSource using Hive
class ScannerLocalDataSourceImpl implements ScannerLocalDataSource {
static const String _boxName = 'scans';
Box<ScanItem>? _box;
/// Initialize Hive box
Future<Box<ScanItem>> _getBox() async {
if (_box == null || !_box!.isOpen) {
try {
_box = await Hive.openBox<ScanItem>(_boxName);
} catch (e) {
throw CacheException('Failed to open Hive box: ${e.toString()}');
}
}
return _box!;
}
@override
Future<void> saveScan(ScanItem scan) async {
try {
final box = await _getBox();
// Use barcode as key to avoid duplicates
await box.put(scan.barcode, scan);
// Optional: Log the save operation
// print('Scan saved locally: ${scan.barcode}');
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to save scan locally: ${e.toString()}');
}
}
@override
Future<List<ScanItem>> getAllScans() async {
try {
final box = await _getBox();
// Get all values from the box
final scans = box.values.toList();
// Sort by timestamp (most recent first)
scans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return scans;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scans from local storage: ${e.toString()}');
}
}
@override
Future<ScanItem?> getScanByBarcode(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final box = await _getBox();
// Get scan by barcode key
return box.get(barcode);
} on ValidationException {
rethrow;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scan by barcode: ${e.toString()}');
}
}
@override
Future<void> updateScan(ScanItem scan) async {
try {
final box = await _getBox();
// Check if scan exists
if (!box.containsKey(scan.barcode)) {
throw CacheException('Scan with barcode ${scan.barcode} not found');
}
// Update the scan
await box.put(scan.barcode, scan);
// Optional: Log the update operation
// print('Scan updated locally: ${scan.barcode}');
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to update scan locally: ${e.toString()}');
}
}
@override
Future<void> deleteScan(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final box = await _getBox();
// Check if scan exists
if (!box.containsKey(barcode)) {
throw CacheException('Scan with barcode $barcode not found');
}
// Delete the scan
await box.delete(barcode);
// Optional: Log the delete operation
// print('Scan deleted locally: $barcode');
} on ValidationException {
rethrow;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to delete scan locally: ${e.toString()}');
}
}
@override
Future<void> clearAllScans() async {
try {
final box = await _getBox();
// Clear all scans
await box.clear();
// Optional: Log the clear operation
// print('All scans cleared from local storage');
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to clear all scans: ${e.toString()}');
}
}
/// Get scans count (utility method)
Future<int> getScansCount() async {
try {
final box = await _getBox();
return box.length;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scans count: ${e.toString()}');
}
}
/// Check if scan exists (utility method)
Future<bool> scanExists(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return false;
}
final box = await _getBox();
return box.containsKey(barcode);
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to check if scan exists: ${e.toString()}');
}
}
/// Get scans within date range (utility method)
Future<List<ScanItem>> getScansByDateRange({
required DateTime startDate,
required DateTime endDate,
}) async {
try {
final allScans = await getAllScans();
// Filter by date range
final filteredScans = allScans.where((scan) {
return scan.timestamp.isAfter(startDate) &&
scan.timestamp.isBefore(endDate);
}).toList();
return filteredScans;
} on CacheException {
rethrow;
} catch (e) {
throw CacheException('Failed to get scans by date range: ${e.toString()}');
}
}
/// Close the Hive box (call this when app is closing)
Future<void> dispose() async {
if (_box != null && _box!.isOpen) {
await _box!.close();
_box = null;
}
}
}

View File

@@ -1,148 +0,0 @@
import '../../../../core/network/api_client.dart';
import '../../../../core/errors/exceptions.dart';
import '../models/save_request_model.dart';
/// Abstract remote data source for scanner operations
abstract class ScannerRemoteDataSource {
/// Save scan data to remote server
Future<void> saveScan(SaveRequestModel request);
/// Get scan data from remote server (optional for future use)
Future<Map<String, dynamic>?> getScanData(String barcode);
}
/// Implementation of ScannerRemoteDataSource using HTTP API
class ScannerRemoteDataSourceImpl implements ScannerRemoteDataSource {
final ApiClient apiClient;
ScannerRemoteDataSourceImpl({required this.apiClient});
@override
Future<void> saveScan(SaveRequestModel request) async {
try {
// Validate request before sending
if (!request.isValid) {
throw ValidationException('Invalid request data: ${request.validationErrors.join(', ')}');
}
final response = await apiClient.post(
'/api/scans',
data: request.toJson(),
);
// Check if the response indicates success
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to save scan: $errorMessage');
}
// Log successful save (in production, use proper logging)
// print('Scan saved successfully: ${request.barcode}');
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
// Handle any unexpected errors
throw ServerException('Unexpected error occurred while saving scan: ${e.toString()}');
}
}
@override
Future<Map<String, dynamic>?> getScanData(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final response = await apiClient.get(
'/api/scans/$barcode',
);
if (response.statusCode == 404) {
// Scan not found is not an error, just return null
return null;
}
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to get scan data: $errorMessage');
}
return response.data as Map<String, dynamic>?;
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw ServerException('Unexpected error occurred while getting scan data: ${e.toString()}');
}
}
/// Update scan data on remote server (optional for future use)
Future<void> updateScan(String barcode, SaveRequestModel request) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
if (!request.isValid) {
throw ValidationException('Invalid request data: ${request.validationErrors.join(', ')}');
}
final response = await apiClient.put(
'/api/scans/$barcode',
data: request.toJson(),
);
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to update scan: $errorMessage');
}
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw ServerException('Unexpected error occurred while updating scan: ${e.toString()}');
}
}
/// Delete scan data from remote server (optional for future use)
Future<void> deleteScan(String barcode) async {
try {
if (barcode.trim().isEmpty) {
throw const ValidationException('Barcode cannot be empty');
}
final response = await apiClient.delete('/api/scans/$barcode');
if (response.statusCode == null ||
(response.statusCode! < 200 || response.statusCode! >= 300)) {
final errorMessage = response.data?['message'] ?? 'Unknown server error';
throw ServerException('Failed to delete scan: $errorMessage');
}
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw ServerException('Unexpected error occurred while deleting scan: ${e.toString()}');
}
}
}

View File

@@ -1,134 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/scan_entity.dart';
part 'save_request_model.g.dart';
/// API request model for saving scan data to the server
@JsonSerializable()
class SaveRequestModel {
final String barcode;
final String field1;
final String field2;
final String field3;
final String field4;
SaveRequestModel({
required this.barcode,
required this.field1,
required this.field2,
required this.field3,
required this.field4,
});
/// Create from domain entity
factory SaveRequestModel.fromEntity(ScanEntity entity) {
return SaveRequestModel(
barcode: entity.barcode,
field1: entity.field1,
field2: entity.field2,
field3: entity.field3,
field4: entity.field4,
);
}
/// Create from parameters
factory SaveRequestModel.fromParams({
required String barcode,
required String field1,
required String field2,
required String field3,
required String field4,
}) {
return SaveRequestModel(
barcode: barcode,
field1: field1,
field2: field2,
field3: field3,
field4: field4,
);
}
/// Create from JSON
factory SaveRequestModel.fromJson(Map<String, dynamic> json) =>
_$SaveRequestModelFromJson(json);
/// Convert to JSON for API requests
Map<String, dynamic> toJson() => _$SaveRequestModelToJson(this);
/// Create a copy with updated fields
SaveRequestModel copyWith({
String? barcode,
String? field1,
String? field2,
String? field3,
String? field4,
}) {
return SaveRequestModel(
barcode: barcode ?? this.barcode,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
);
}
/// Validate the request data
bool get isValid {
return barcode.trim().isNotEmpty &&
field1.trim().isNotEmpty &&
field2.trim().isNotEmpty &&
field3.trim().isNotEmpty &&
field4.trim().isNotEmpty;
}
/// Get validation errors
List<String> get validationErrors {
final errors = <String>[];
if (barcode.trim().isEmpty) {
errors.add('Barcode is required');
}
if (field1.trim().isEmpty) {
errors.add('Field 1 is required');
}
if (field2.trim().isEmpty) {
errors.add('Field 2 is required');
}
if (field3.trim().isEmpty) {
errors.add('Field 3 is required');
}
if (field4.trim().isEmpty) {
errors.add('Field 4 is required');
}
return errors;
}
@override
String toString() {
return 'SaveRequestModel{barcode: $barcode, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SaveRequestModel &&
runtimeType == other.runtimeType &&
barcode == other.barcode &&
field1 == other.field1 &&
field2 == other.field2 &&
field3 == other.field3 &&
field4 == other.field4;
@override
int get hashCode =>
barcode.hashCode ^
field1.hashCode ^
field2.hashCode ^
field3.hashCode ^
field4.hashCode;
}

View File

@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'save_request_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SaveRequestModel _$SaveRequestModelFromJson(Map<String, dynamic> json) =>
SaveRequestModel(
barcode: json['barcode'] as String,
field1: json['field1'] as String,
field2: json['field2'] as String,
field3: json['field3'] as String,
field4: json['field4'] as String,
);
Map<String, dynamic> _$SaveRequestModelToJson(SaveRequestModel instance) =>
<String, dynamic>{
'barcode': instance.barcode,
'field1': instance.field1,
'field2': instance.field2,
'field3': instance.field3,
'field4': instance.field4,
};

View File

@@ -1,131 +0,0 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/scan_entity.dart';
part 'scan_item.g.dart';
/// Data model for ScanEntity with Hive annotations for local storage
/// This is the data layer representation that can be persisted
@HiveType(typeId: 0)
class ScanItem extends HiveObject {
@HiveField(0)
final String barcode;
@HiveField(1)
final DateTime timestamp;
@HiveField(2)
final String field1;
@HiveField(3)
final String field2;
@HiveField(4)
final String field3;
@HiveField(5)
final String field4;
ScanItem({
required this.barcode,
required this.timestamp,
this.field1 = '',
this.field2 = '',
this.field3 = '',
this.field4 = '',
});
/// Convert from domain entity to data model
factory ScanItem.fromEntity(ScanEntity entity) {
return ScanItem(
barcode: entity.barcode,
timestamp: entity.timestamp,
field1: entity.field1,
field2: entity.field2,
field3: entity.field3,
field4: entity.field4,
);
}
/// Convert to domain entity
ScanEntity toEntity() {
return ScanEntity(
barcode: barcode,
timestamp: timestamp,
field1: field1,
field2: field2,
field3: field3,
field4: field4,
);
}
/// Create from JSON (useful for API responses)
factory ScanItem.fromJson(Map<String, dynamic> json) {
return ScanItem(
barcode: json['barcode'] ?? '',
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'])
: DateTime.now(),
field1: json['field1'] ?? '',
field2: json['field2'] ?? '',
field3: json['field3'] ?? '',
field4: json['field4'] ?? '',
);
}
/// Convert to JSON (useful for API requests)
Map<String, dynamic> toJson() {
return {
'barcode': barcode,
'timestamp': timestamp.toIso8601String(),
'field1': field1,
'field2': field2,
'field3': field3,
'field4': field4,
};
}
/// Create a copy with updated fields
ScanItem copyWith({
String? barcode,
DateTime? timestamp,
String? field1,
String? field2,
String? field3,
String? field4,
}) {
return ScanItem(
barcode: barcode ?? this.barcode,
timestamp: timestamp ?? this.timestamp,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
);
}
@override
String toString() {
return 'ScanItem{barcode: $barcode, timestamp: $timestamp, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScanItem &&
runtimeType == other.runtimeType &&
barcode == other.barcode &&
timestamp == other.timestamp &&
field1 == other.field1 &&
field2 == other.field2 &&
field3 == other.field3 &&
field4 == other.field4;
@override
int get hashCode =>
barcode.hashCode ^
timestamp.hashCode ^
field1.hashCode ^
field2.hashCode ^
field3.hashCode ^
field4.hashCode;
}

View File

@@ -1,56 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scan_item.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ScanItemAdapter extends TypeAdapter<ScanItem> {
@override
final int typeId = 0;
@override
ScanItem read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ScanItem(
barcode: fields[0] as String,
timestamp: fields[1] as DateTime,
field1: fields[2] as String,
field2: fields[3] as String,
field3: fields[4] as String,
field4: fields[5] as String,
);
}
@override
void write(BinaryWriter writer, ScanItem obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.barcode)
..writeByte(1)
..write(obj.timestamp)
..writeByte(2)
..write(obj.field1)
..writeByte(3)
..write(obj.field2)
..writeByte(4)
..write(obj.field3)
..writeByte(5)
..write(obj.field4);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScanItemAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,265 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
import '../../domain/entities/scan_entity.dart';
import '../../domain/repositories/scanner_repository.dart';
import '../datasources/scanner_local_datasource.dart';
import '../datasources/scanner_remote_datasource.dart';
import '../models/save_request_model.dart';
import '../models/scan_item.dart';
/// Implementation of ScannerRepository
/// This class handles the coordination between remote and local data sources
class ScannerRepositoryImpl implements ScannerRepository {
final ScannerRemoteDataSource remoteDataSource;
final ScannerLocalDataSource localDataSource;
ScannerRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, void>> saveScan({
required String barcode,
required String field1,
required String field2,
required String field3,
required String field4,
}) async {
try {
// Create the request model
final request = SaveRequestModel.fromParams(
barcode: barcode,
field1: field1,
field2: field2,
field3: field3,
field4: field4,
);
// Validate the request
if (!request.isValid) {
return Left(ValidationFailure(request.validationErrors.join(', ')));
}
// Save to remote server
await remoteDataSource.saveScan(request);
// If remote save succeeds, we return success
// Local save will be handled separately by the use case if needed
return const Right(null);
} on ValidationException catch (e) {
return Left(ValidationFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to save scan: ${e.toString()}'));
}
}
@override
Future<Either<Failure, List<ScanEntity>>> getScanHistory() async {
try {
// Get scans from local storage
final scanItems = await localDataSource.getAllScans();
// Convert to domain entities
final entities = scanItems.map((item) => item.toEntity()).toList();
return Right(entities);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> saveScanLocally(ScanEntity scan) async {
try {
// Convert entity to data model
final scanItem = ScanItem.fromEntity(scan);
// Save to local storage
await localDataSource.saveScan(scanItem);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to save scan locally: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> deleteScanLocally(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return const Left(ValidationFailure('Barcode cannot be empty'));
}
// Delete from local storage
await localDataSource.deleteScan(barcode);
return const Right(null);
} on ValidationException catch (e) {
return Left(ValidationFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to delete scan: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> clearScanHistory() async {
try {
// Clear all scans from local storage
await localDataSource.clearAllScans();
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to clear scan history: ${e.toString()}'));
}
}
@override
Future<Either<Failure, ScanEntity?>> getScanByBarcode(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return const Left(ValidationFailure('Barcode cannot be empty'));
}
// Get scan from local storage
final scanItem = await localDataSource.getScanByBarcode(barcode);
if (scanItem == null) {
return const Right(null);
}
// Convert to domain entity
final entity = scanItem.toEntity();
return Right(entity);
} on ValidationException catch (e) {
return Left(ValidationFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to get scan by barcode: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> updateScanLocally(ScanEntity scan) async {
try {
// Convert entity to data model
final scanItem = ScanItem.fromEntity(scan);
// Update in local storage
await localDataSource.updateScan(scanItem);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnknownFailure('Failed to update scan: ${e.toString()}'));
}
}
/// Additional utility methods for repository
/// Get scans count
Future<Either<Failure, int>> getScansCount() async {
try {
if (localDataSource is ScannerLocalDataSourceImpl) {
final impl = localDataSource as ScannerLocalDataSourceImpl;
final count = await impl.getScansCount();
return Right(count);
}
// Fallback: get all scans and count them
final result = await getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) => Right(scans.length),
);
} catch (e) {
return Left(UnknownFailure('Failed to get scans count: ${e.toString()}'));
}
}
/// Check if scan exists locally
Future<Either<Failure, bool>> scanExistsLocally(String barcode) async {
try {
if (barcode.trim().isEmpty) {
return const Left(ValidationFailure('Barcode cannot be empty'));
}
if (localDataSource is ScannerLocalDataSourceImpl) {
final impl = localDataSource as ScannerLocalDataSourceImpl;
final exists = await impl.scanExists(barcode);
return Right(exists);
}
// Fallback: get scan by barcode
final result = await getScanByBarcode(barcode);
return result.fold(
(failure) => Left(failure),
(scan) => Right(scan != null),
);
} catch (e) {
return Left(UnknownFailure('Failed to check if scan exists: ${e.toString()}'));
}
}
/// Get scans by date range
Future<Either<Failure, List<ScanEntity>>> getScansByDateRange({
required DateTime startDate,
required DateTime endDate,
}) async {
try {
if (localDataSource is ScannerLocalDataSourceImpl) {
final impl = localDataSource as ScannerLocalDataSourceImpl;
final scanItems = await impl.getScansByDateRange(
startDate: startDate,
endDate: endDate,
);
// Convert to domain entities
final entities = scanItems.map((item) => item.toEntity()).toList();
return Right(entities);
}
// Fallback: get all scans and filter
final result = await getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
final filteredScans = scans
.where((scan) =>
scan.timestamp.isAfter(startDate) &&
scan.timestamp.isBefore(endDate))
.toList();
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scans by date range: ${e.toString()}'));
}
}
}

View File

@@ -1,5 +0,0 @@
// Domain layer exports
export 'entities/scan_entity.dart';
export 'repositories/scanner_repository.dart';
export 'usecases/get_scan_history_usecase.dart';
export 'usecases/save_scan_usecase.dart';

View File

@@ -1,71 +0,0 @@
import 'package:equatable/equatable.dart';
/// Domain entity representing a scan item
/// This is the business logic representation without any external dependencies
class ScanEntity extends Equatable {
final String barcode;
final DateTime timestamp;
final String field1;
final String field2;
final String field3;
final String field4;
const ScanEntity({
required this.barcode,
required this.timestamp,
this.field1 = '',
this.field2 = '',
this.field3 = '',
this.field4 = '',
});
/// Create a copy with updated fields
ScanEntity copyWith({
String? barcode,
DateTime? timestamp,
String? field1,
String? field2,
String? field3,
String? field4,
}) {
return ScanEntity(
barcode: barcode ?? this.barcode,
timestamp: timestamp ?? this.timestamp,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
);
}
/// Check if the entity has any form data
bool get hasFormData {
return field1.isNotEmpty ||
field2.isNotEmpty ||
field3.isNotEmpty ||
field4.isNotEmpty;
}
/// Check if all form fields are filled
bool get isFormComplete {
return field1.isNotEmpty &&
field2.isNotEmpty &&
field3.isNotEmpty &&
field4.isNotEmpty;
}
@override
List<Object> get props => [
barcode,
timestamp,
field1,
field2,
field3,
field4,
];
@override
String toString() {
return 'ScanEntity{barcode: $barcode, timestamp: $timestamp, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
}

View File

@@ -1,34 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/scan_entity.dart';
/// Abstract repository interface for scanner operations
/// This defines the contract that the data layer must implement
abstract class ScannerRepository {
/// Save scan data to remote server
Future<Either<Failure, void>> saveScan({
required String barcode,
required String field1,
required String field2,
required String field3,
required String field4,
});
/// Get scan history from local storage
Future<Either<Failure, List<ScanEntity>>> getScanHistory();
/// Save scan to local storage
Future<Either<Failure, void>> saveScanLocally(ScanEntity scan);
/// Delete a scan from local storage
Future<Either<Failure, void>> deleteScanLocally(String barcode);
/// Clear all scan history from local storage
Future<Either<Failure, void>> clearScanHistory();
/// Get a specific scan by barcode from local storage
Future<Either<Failure, ScanEntity?>> getScanByBarcode(String barcode);
/// Update a scan in local storage
Future<Either<Failure, void>> updateScanLocally(ScanEntity scan);
}

View File

@@ -1,113 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/scan_entity.dart';
import '../repositories/scanner_repository.dart';
/// Use case for retrieving scan history
/// Handles the business logic for fetching scan history from local storage
class GetScanHistoryUseCase {
final ScannerRepository repository;
GetScanHistoryUseCase(this.repository);
/// Execute the get scan history operation
///
/// Returns a list of scan entities sorted by timestamp (most recent first)
Future<Either<Failure, List<ScanEntity>>> call() async {
try {
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Sort scans by timestamp (most recent first)
final sortedScans = List<ScanEntity>.from(scans);
sortedScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(sortedScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
/// Get scan history filtered by date range
Future<Either<Failure, List<ScanEntity>>> getHistoryInDateRange({
required DateTime startDate,
required DateTime endDate,
}) async {
try {
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Filter scans by date range
final filteredScans = scans
.where((scan) =>
scan.timestamp.isAfter(startDate) &&
scan.timestamp.isBefore(endDate))
.toList();
// Sort by timestamp (most recent first)
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
/// Get scans that have form data (non-empty fields)
Future<Either<Failure, List<ScanEntity>>> getScansWithFormData() async {
try {
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Filter scans that have form data
final filteredScans = scans.where((scan) => scan.hasFormData).toList();
// Sort by timestamp (most recent first)
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to get scan history: ${e.toString()}'));
}
}
/// Search scans by barcode pattern
Future<Either<Failure, List<ScanEntity>>> searchByBarcode(String pattern) async {
try {
if (pattern.trim().isEmpty) {
return const Right([]);
}
final result = await repository.getScanHistory();
return result.fold(
(failure) => Left(failure),
(scans) {
// Filter scans by barcode pattern (case-insensitive)
final filteredScans = scans
.where((scan) =>
scan.barcode.toLowerCase().contains(pattern.toLowerCase()))
.toList();
// Sort by timestamp (most recent first)
filteredScans.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return Right(filteredScans);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to search scans: ${e.toString()}'));
}
}
}

View File

@@ -1,109 +0,0 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/scan_entity.dart';
import '../repositories/scanner_repository.dart';
/// Use case for saving scan data
/// Handles the business logic for saving scan information to both remote and local storage
class SaveScanUseCase {
final ScannerRepository repository;
SaveScanUseCase(this.repository);
/// Execute the save scan operation
///
/// First saves to remote server, then saves locally only if remote save succeeds
/// This ensures data consistency and allows for offline-first behavior
Future<Either<Failure, void>> call(SaveScanParams params) async {
// Validate input parameters
final validationResult = _validateParams(params);
if (validationResult != null) {
return Left(ValidationFailure(validationResult));
}
try {
// Save to remote server first
final remoteResult = await repository.saveScan(
barcode: params.barcode,
field1: params.field1,
field2: params.field2,
field3: params.field3,
field4: params.field4,
);
return remoteResult.fold(
(failure) => Left(failure),
(_) async {
// If remote save succeeds, save to local storage
final scanEntity = ScanEntity(
barcode: params.barcode,
timestamp: DateTime.now(),
field1: params.field1,
field2: params.field2,
field3: params.field3,
field4: params.field4,
);
final localResult = await repository.saveScanLocally(scanEntity);
return localResult.fold(
(failure) {
// Log the local save failure but don't fail the entire operation
// since remote save succeeded
return const Right(null);
},
(_) => const Right(null),
);
},
);
} catch (e) {
return Left(UnknownFailure('Failed to save scan: ${e.toString()}'));
}
}
/// Validate the input parameters
String? _validateParams(SaveScanParams params) {
if (params.barcode.trim().isEmpty) {
return 'Barcode cannot be empty';
}
if (params.field1.trim().isEmpty) {
return 'Field 1 cannot be empty';
}
if (params.field2.trim().isEmpty) {
return 'Field 2 cannot be empty';
}
if (params.field3.trim().isEmpty) {
return 'Field 3 cannot be empty';
}
if (params.field4.trim().isEmpty) {
return 'Field 4 cannot be empty';
}
return null;
}
}
/// Parameters for the SaveScanUseCase
class SaveScanParams {
final String barcode;
final String field1;
final String field2;
final String field3;
final String field4;
SaveScanParams({
required this.barcode,
required this.field1,
required this.field2,
required this.field3,
required this.field4,
});
@override
String toString() {
return 'SaveScanParams{barcode: $barcode, field1: $field1, field2: $field2, field3: $field3, field4: $field4}';
}
}

View File

@@ -1,334 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/models/scan_item.dart';
import '../providers/form_provider.dart';
import '../providers/scanner_provider.dart';
/// Detail page for editing scan data with 4 text fields and Save/Print buttons
class DetailPage extends ConsumerStatefulWidget {
final String barcode;
const DetailPage({
required this.barcode,
super.key,
});
@override
ConsumerState<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends ConsumerState<DetailPage> {
late final TextEditingController _field1Controller;
late final TextEditingController _field2Controller;
late final TextEditingController _field3Controller;
late final TextEditingController _field4Controller;
@override
void initState() {
super.initState();
_field1Controller = TextEditingController();
_field2Controller = TextEditingController();
_field3Controller = TextEditingController();
_field4Controller = TextEditingController();
// Initialize controllers with existing data if available
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadExistingData();
});
}
@override
void dispose() {
_field1Controller.dispose();
_field2Controller.dispose();
_field3Controller.dispose();
_field4Controller.dispose();
super.dispose();
}
/// Load existing data from history if available
void _loadExistingData() {
final history = ref.read(scanHistoryProvider);
final existingScan = history.firstWhere(
(item) => item.barcode == widget.barcode,
orElse: () => ScanItem(barcode: widget.barcode, timestamp: DateTime.now()),
);
_field1Controller.text = existingScan.field1;
_field2Controller.text = existingScan.field2;
_field3Controller.text = existingScan.field3;
_field4Controller.text = existingScan.field4;
// Update form provider with existing data
final formNotifier = ref.read(formProviderFamily(widget.barcode).notifier);
formNotifier.populateWithScanItem(existingScan);
}
@override
Widget build(BuildContext context) {
final formState = ref.watch(formProviderFamily(widget.barcode));
final formNotifier = ref.read(formProviderFamily(widget.barcode).notifier);
// Listen to form state changes for navigation
ref.listen<FormDetailState>(
formProviderFamily(widget.barcode),
(previous, next) {
if (next.isSaveSuccess && (previous?.isSaveSuccess != true)) {
_showSuccessAndNavigateBack(context);
}
},
);
return Scaffold(
appBar: AppBar(
title: const Text('Edit Details'),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: Column(
children: [
// Barcode Header
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Barcode',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
widget.barcode,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
],
),
),
// Form Fields
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Field 1
_buildTextField(
controller: _field1Controller,
label: 'Field 1',
onChanged: formNotifier.updateField1,
),
const SizedBox(height: 16),
// Field 2
_buildTextField(
controller: _field2Controller,
label: 'Field 2',
onChanged: formNotifier.updateField2,
),
const SizedBox(height: 16),
// Field 3
_buildTextField(
controller: _field3Controller,
label: 'Field 3',
onChanged: formNotifier.updateField3,
),
const SizedBox(height: 16),
// Field 4
_buildTextField(
controller: _field4Controller,
label: 'Field 4',
onChanged: formNotifier.updateField4,
),
const SizedBox(height: 24),
// Error Message
if (formState.error != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.error,
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
formState.error!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
const SizedBox(height: 16),
],
],
),
),
),
// Action Buttons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: SafeArea(
child: Row(
children: [
// Save Button
Expanded(
child: ElevatedButton(
onPressed: formState.isLoading ? null : () => _saveData(formNotifier),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
minimumSize: const Size.fromHeight(48),
),
child: formState.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Save'),
),
),
const SizedBox(width: 16),
// Print Button
Expanded(
child: OutlinedButton(
onPressed: formState.isLoading ? null : () => _printData(formNotifier),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('Print'),
),
),
],
),
),
),
],
),
);
}
/// Build text field widget
Widget _buildTextField({
required TextEditingController controller,
required String label,
required void Function(String) onChanged,
}) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
),
textCapitalization: TextCapitalization.sentences,
onChanged: onChanged,
);
}
/// Save form data
Future<void> _saveData(FormNotifier formNotifier) async {
// Clear any previous errors
formNotifier.clearError();
// Attempt to save
await formNotifier.saveData();
}
/// Print form data
Future<void> _printData(FormNotifier formNotifier) async {
try {
await formNotifier.printData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Print dialog opened'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Print failed: ${e.toString()}'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
/// Show success message and navigate back
void _showSuccessAndNavigateBack(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Data saved successfully!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
// Navigate back after a short delay
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) {
context.pop();
}
});
}
}

View File

@@ -1,193 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/scanner_provider.dart';
import '../widgets/barcode_scanner_widget.dart';
import '../widgets/scan_result_display.dart';
import '../widgets/scan_history_list.dart';
/// Home page with barcode scanner, result display, and history list
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scannerState = ref.watch(scannerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Barcode Scanner'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
ref.read(scannerProvider.notifier).refreshHistory();
},
tooltip: 'Refresh History',
),
],
),
body: Column(
children: [
// Barcode Scanner Section (Top Half)
Expanded(
flex: 1,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const BarcodeScannerWidget(),
),
),
// Scan Result Display
ScanResultDisplay(
barcode: scannerState.currentBarcode,
onTap: scannerState.currentBarcode != null
? () => _navigateToDetail(context, scannerState.currentBarcode!)
: null,
),
// Divider
const Divider(height: 1),
// History Section (Bottom Half)
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// History Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Scan History',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (scannerState.history.isNotEmpty)
Text(
'${scannerState.history.length} items',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
// History List
Expanded(
child: _buildHistorySection(context, ref, scannerState),
),
],
),
),
),
],
),
);
}
/// Build history section based on current state
Widget _buildHistorySection(
BuildContext context,
WidgetRef ref,
ScannerState scannerState,
) {
if (scannerState.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (scannerState.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading history',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
scannerState.error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
ref.read(scannerProvider.notifier).refreshHistory();
},
child: const Text('Retry'),
),
],
),
);
}
if (scannerState.history.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.qr_code_scanner,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No scans yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Start scanning barcodes to see your history here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
return ScanHistoryList(
history: scannerState.history,
onItemTap: (scanItem) => _navigateToDetail(context, scanItem.barcode),
);
}
/// Navigate to detail page with barcode
void _navigateToDetail(BuildContext context, String barcode) {
context.push('/detail/$barcode');
}
}

View File

@@ -1,127 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce/hive.dart';
import '../../../../core/network/api_client.dart';
import '../../data/datasources/scanner_local_datasource.dart';
import '../../data/datasources/scanner_remote_datasource.dart';
import '../../data/models/scan_item.dart';
import '../../data/repositories/scanner_repository_impl.dart';
import '../../domain/repositories/scanner_repository.dart';
import '../../domain/usecases/get_scan_history_usecase.dart';
import '../../domain/usecases/save_scan_usecase.dart';
/// Network layer providers
final dioProvider = Provider<Dio>((ref) {
final dio = Dio();
dio.options.baseUrl = 'https://api.example.com'; // Replace with actual API URL
dio.options.connectTimeout = const Duration(seconds: 30);
dio.options.receiveTimeout = const Duration(seconds: 30);
dio.options.headers['Content-Type'] = 'application/json';
dio.options.headers['Accept'] = 'application/json';
// Add interceptors for logging, authentication, etc.
dio.interceptors.add(
LogInterceptor(
requestBody: true,
responseBody: true,
logPrint: (obj) {
// Log to console in debug mode using debugPrint
// This will only log in debug mode
},
),
);
return dio;
});
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
/// Local storage providers
final hiveBoxProvider = Provider<Box<ScanItem>>((ref) {
return Hive.box<ScanItem>('scans');
});
/// Settings box provider
final settingsBoxProvider = Provider<Box>((ref) {
return Hive.box('settings');
});
/// Data source providers
final scannerRemoteDataSourceProvider = Provider<ScannerRemoteDataSource>((ref) {
return ScannerRemoteDataSourceImpl(apiClient: ref.watch(apiClientProvider));
});
final scannerLocalDataSourceProvider = Provider<ScannerLocalDataSource>((ref) {
return ScannerLocalDataSourceImpl();
});
/// Repository providers
final scannerRepositoryProvider = Provider<ScannerRepository>((ref) {
return ScannerRepositoryImpl(
remoteDataSource: ref.watch(scannerRemoteDataSourceProvider),
localDataSource: ref.watch(scannerLocalDataSourceProvider),
);
});
/// Use case providers
final saveScanUseCaseProvider = Provider<SaveScanUseCase>((ref) {
return SaveScanUseCase(ref.watch(scannerRepositoryProvider));
});
final getScanHistoryUseCaseProvider = Provider<GetScanHistoryUseCase>((ref) {
return GetScanHistoryUseCase(ref.watch(scannerRepositoryProvider));
});
/// Additional utility providers
final currentTimestampProvider = Provider<DateTime>((ref) {
return DateTime.now();
});
/// Provider for checking network connectivity
final networkStatusProvider = Provider<bool>((ref) {
// This would typically use connectivity_plus package
// For now, returning true as a placeholder
return true;
});
/// Provider for app configuration
final appConfigProvider = Provider<Map<String, dynamic>>((ref) {
return {
'apiBaseUrl': 'https://api.example.com',
'apiTimeout': 30000,
'maxHistoryItems': 100,
'enableLogging': !const bool.fromEnvironment('dart.vm.product'),
};
});
/// Provider for error handling configuration
final errorHandlingConfigProvider = Provider<Map<String, String>>((ref) {
return {
'networkError': 'Network connection failed. Please check your internet connection.',
'serverError': 'Server error occurred. Please try again later.',
'validationError': 'Please check your input and try again.',
'unknownError': 'An unexpected error occurred. Please try again.',
};
});
/// Provider for checking if required dependencies are initialized
final dependenciesInitializedProvider = Provider<bool>((ref) {
try {
// Check if all critical dependencies are available
ref.read(scannerRepositoryProvider);
ref.read(saveScanUseCaseProvider);
ref.read(getScanHistoryUseCaseProvider);
return true;
} catch (e) {
return false;
}
});
/// Helper provider for getting localized error messages
final errorMessageProvider = Provider.family<String, String>((ref, errorKey) {
final config = ref.watch(errorHandlingConfigProvider);
return config[errorKey] ?? config['unknownError']!;
});

View File

@@ -1,253 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/scan_item.dart';
import '../../domain/usecases/save_scan_usecase.dart';
import 'dependency_injection.dart';
import 'scanner_provider.dart';
/// State for the form functionality
class FormDetailState {
final String barcode;
final String field1;
final String field2;
final String field3;
final String field4;
final bool isLoading;
final bool isSaveSuccess;
final String? error;
const FormDetailState({
required this.barcode,
this.field1 = '',
this.field2 = '',
this.field3 = '',
this.field4 = '',
this.isLoading = false,
this.isSaveSuccess = false,
this.error,
});
FormDetailState copyWith({
String? barcode,
String? field1,
String? field2,
String? field3,
String? field4,
bool? isLoading,
bool? isSaveSuccess,
String? error,
}) {
return FormDetailState(
barcode: barcode ?? this.barcode,
field1: field1 ?? this.field1,
field2: field2 ?? this.field2,
field3: field3 ?? this.field3,
field4: field4 ?? this.field4,
isLoading: isLoading ?? this.isLoading,
isSaveSuccess: isSaveSuccess ?? this.isSaveSuccess,
error: error,
);
}
/// Check if all required fields are filled
bool get isValid {
return barcode.trim().isNotEmpty &&
field1.trim().isNotEmpty &&
field2.trim().isNotEmpty &&
field3.trim().isNotEmpty &&
field4.trim().isNotEmpty;
}
/// Get validation error messages
List<String> get validationErrors {
final errors = <String>[];
if (barcode.trim().isEmpty) {
errors.add('Barcode is required');
}
if (field1.trim().isEmpty) {
errors.add('Field 1 is required');
}
if (field2.trim().isEmpty) {
errors.add('Field 2 is required');
}
if (field3.trim().isEmpty) {
errors.add('Field 3 is required');
}
if (field4.trim().isEmpty) {
errors.add('Field 4 is required');
}
return errors;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FormDetailState &&
runtimeType == other.runtimeType &&
barcode == other.barcode &&
field1 == other.field1 &&
field2 == other.field2 &&
field3 == other.field3 &&
field4 == other.field4 &&
isLoading == other.isLoading &&
isSaveSuccess == other.isSaveSuccess &&
error == other.error;
@override
int get hashCode =>
barcode.hashCode ^
field1.hashCode ^
field2.hashCode ^
field3.hashCode ^
field4.hashCode ^
isLoading.hashCode ^
isSaveSuccess.hashCode ^
error.hashCode;
}
/// Form state notifier
class FormNotifier extends StateNotifier<FormDetailState> {
final SaveScanUseCase _saveScanUseCase;
final Ref _ref;
FormNotifier(
this._saveScanUseCase,
this._ref,
String barcode,
) : super(FormDetailState(barcode: barcode));
/// Update field 1
void updateField1(String value) {
state = state.copyWith(field1: value, error: null);
}
/// Update field 2
void updateField2(String value) {
state = state.copyWith(field2: value, error: null);
}
/// Update field 3
void updateField3(String value) {
state = state.copyWith(field3: value, error: null);
}
/// Update field 4
void updateField4(String value) {
state = state.copyWith(field4: value, error: null);
}
/// Update barcode
void updateBarcode(String value) {
state = state.copyWith(barcode: value, error: null);
}
/// Clear all fields
void clearFields() {
state = FormDetailState(barcode: state.barcode);
}
/// Populate form with existing scan data
void populateWithScanItem(ScanItem scanItem) {
state = state.copyWith(
barcode: scanItem.barcode,
field1: scanItem.field1,
field2: scanItem.field2,
field3: scanItem.field3,
field4: scanItem.field4,
error: null,
);
}
/// Save form data to server and local storage
Future<void> saveData() async {
if (!state.isValid) {
final errors = state.validationErrors;
state = state.copyWith(error: errors.join(', '));
return;
}
state = state.copyWith(isLoading: true, error: null, isSaveSuccess: false);
final params = SaveScanParams(
barcode: state.barcode,
field1: state.field1,
field2: state.field2,
field3: state.field3,
field4: state.field4,
);
final result = await _saveScanUseCase.call(params);
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
isSaveSuccess: false,
),
(_) {
state = state.copyWith(
isLoading: false,
isSaveSuccess: true,
error: null,
);
// Update the scanner history with saved data
final savedScanItem = ScanItem(
barcode: state.barcode,
timestamp: DateTime.now(),
field1: state.field1,
field2: state.field2,
field3: state.field3,
field4: state.field4,
);
_ref.read(scannerProvider.notifier).updateScanItem(savedScanItem);
},
);
}
/// Print form data
Future<void> printData() async {
try {
} catch (e) {
state = state.copyWith(error: 'Failed to print: ${e.toString()}');
}
}
/// Clear error message
void clearError() {
state = state.copyWith(error: null);
}
/// Reset save success state
void resetSaveSuccess() {
state = state.copyWith(isSaveSuccess: false);
}
}
/// Provider factory for form state (requires barcode parameter)
final formProviderFamily = StateNotifierProvider.family<FormNotifier, FormDetailState, String>(
(ref, barcode) => FormNotifier(
ref.watch(saveScanUseCaseProvider),
ref,
barcode,
),
);
/// Convenience provider for accessing form state with a specific barcode
/// This should be used with Provider.of or ref.watch(formProvider(barcode))
Provider<FormNotifier> formProvider(String barcode) {
return Provider<FormNotifier>((ref) {
return ref.watch(formProviderFamily(barcode).notifier);
});
}
/// Convenience provider for accessing form state
Provider<FormDetailState> formStateProvider(String barcode) {
return Provider<FormDetailState>((ref) {
return ref.watch(formProviderFamily(barcode));
});
}

View File

@@ -1,163 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/scan_item.dart';
import '../../domain/usecases/get_scan_history_usecase.dart';
import 'dependency_injection.dart';
/// State for the scanner functionality
class ScannerState {
final String? currentBarcode;
final List<ScanItem> history;
final bool isLoading;
final String? error;
const ScannerState({
this.currentBarcode,
this.history = const [],
this.isLoading = false,
this.error,
});
ScannerState copyWith({
String? currentBarcode,
List<ScanItem>? history,
bool? isLoading,
String? error,
}) {
return ScannerState(
currentBarcode: currentBarcode ?? this.currentBarcode,
history: history ?? this.history,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScannerState &&
runtimeType == other.runtimeType &&
currentBarcode == other.currentBarcode &&
history == other.history &&
isLoading == other.isLoading &&
error == other.error;
@override
int get hashCode =>
currentBarcode.hashCode ^
history.hashCode ^
isLoading.hashCode ^
error.hashCode;
}
/// Scanner state notifier
class ScannerNotifier extends StateNotifier<ScannerState> {
final GetScanHistoryUseCase _getScanHistoryUseCase;
ScannerNotifier(this._getScanHistoryUseCase) : super(const ScannerState()) {
_loadHistory();
}
/// Load scan history from local storage
Future<void> _loadHistory() async {
state = state.copyWith(isLoading: true, error: null);
final result = await _getScanHistoryUseCase();
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
),
(history) => state = state.copyWith(
isLoading: false,
history: history.map((entity) => ScanItem.fromEntity(entity)).toList(),
),
);
}
/// Update current scanned barcode
void updateBarcode(String barcode) {
if (barcode.trim().isEmpty) return;
state = state.copyWith(currentBarcode: barcode);
// Add to history if not already present
final existingIndex = state.history.indexWhere((item) => item.barcode == barcode);
if (existingIndex == -1) {
final newScanItem = ScanItem(
barcode: barcode,
timestamp: DateTime.now(),
);
final updatedHistory = [newScanItem, ...state.history];
state = state.copyWith(history: updatedHistory);
} else {
// Move existing item to top
final existingItem = state.history[existingIndex];
final updatedHistory = List<ScanItem>.from(state.history);
updatedHistory.removeAt(existingIndex);
updatedHistory.insert(0, existingItem.copyWith(timestamp: DateTime.now()));
state = state.copyWith(history: updatedHistory);
}
}
/// Clear current barcode
void clearBarcode() {
state = state.copyWith(currentBarcode: null);
}
/// Refresh history from storage
Future<void> refreshHistory() async {
await _loadHistory();
}
/// Add or update scan item in history
void updateScanItem(ScanItem scanItem) {
final existingIndex = state.history.indexWhere(
(item) => item.barcode == scanItem.barcode,
);
List<ScanItem> updatedHistory;
if (existingIndex != -1) {
// Update existing item
updatedHistory = List<ScanItem>.from(state.history);
updatedHistory[existingIndex] = scanItem;
} else {
// Add new item at the beginning
updatedHistory = [scanItem, ...state.history];
}
state = state.copyWith(history: updatedHistory);
}
/// Clear error message
void clearError() {
state = state.copyWith(error: null);
}
}
/// Provider for scanner state
final scannerProvider = StateNotifierProvider<ScannerNotifier, ScannerState>(
(ref) => ScannerNotifier(
ref.watch(getScanHistoryUseCaseProvider),
),
);
/// Provider for current barcode (for easy access)
final currentBarcodeProvider = Provider<String?>((ref) {
return ref.watch(scannerProvider).currentBarcode;
});
/// Provider for scan history (for easy access)
final scanHistoryProvider = Provider<List<ScanItem>>((ref) {
return ref.watch(scannerProvider).history;
});
/// Provider for scanner loading state
final scannerLoadingProvider = Provider<bool>((ref) {
return ref.watch(scannerProvider).isLoading;
});
/// Provider for scanner error state
final scannerErrorProvider = Provider<String?>((ref) {
return ref.watch(scannerProvider).error;
});

View File

@@ -1,344 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../providers/scanner_provider.dart';
/// Widget that provides barcode scanning functionality using device camera
class BarcodeScannerWidget extends ConsumerStatefulWidget {
const BarcodeScannerWidget({super.key});
@override
ConsumerState<BarcodeScannerWidget> createState() => _BarcodeScannerWidgetState();
}
class _BarcodeScannerWidgetState extends ConsumerState<BarcodeScannerWidget>
with WidgetsBindingObserver {
late MobileScannerController _controller;
bool _isStarted = false;
String? _lastScannedCode;
DateTime? _lastScanTime;
bool _isTorchOn = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controller = MobileScannerController(
formats: [
BarcodeFormat.code128,
],
facing: CameraFacing.back,
torchEnabled: false,
);
_startScanner();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.paused:
_stopScanner();
break;
case AppLifecycleState.resumed:
_startScanner();
break;
case AppLifecycleState.detached:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break;
}
}
Future<void> _startScanner() async {
if (!_isStarted && mounted) {
try {
await _controller.start();
setState(() {
_isStarted = true;
});
} catch (e) {
debugPrint('Failed to start scanner: $e');
}
}
}
Future<void> _stopScanner() async {
if (_isStarted) {
try {
await _controller.stop();
setState(() {
_isStarted = false;
});
} catch (e) {
debugPrint('Failed to stop scanner: $e');
}
}
}
void _onBarcodeDetected(BarcodeCapture capture) {
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final barcode = barcodes.first;
final code = barcode.rawValue;
if (code != null && code.isNotEmpty) {
// Prevent duplicate scans within 2 seconds
final now = DateTime.now();
if (_lastScannedCode == code &&
_lastScanTime != null &&
now.difference(_lastScanTime!).inSeconds < 2) {
return;
}
_lastScannedCode = code;
_lastScanTime = now;
// Update scanner provider with new barcode
ref.read(scannerProvider.notifier).updateBarcode(code);
// Provide haptic feedback
_provideHapticFeedback();
}
}
}
void _provideHapticFeedback() {
// Haptic feedback is handled by the system
// You can add custom vibration here if needed
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(0),
),
child: Stack(
children: [
// Camera View
ClipRRect(
borderRadius: BorderRadius.circular(0),
child: MobileScanner(
controller: _controller,
onDetect: _onBarcodeDetected,
),
),
// Overlay with scanner frame
_buildScannerOverlay(context),
// Control buttons
_buildControlButtons(context),
],
),
);
}
/// Build scanner overlay with frame and guidance
Widget _buildScannerOverlay(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Stack(
children: [
// Dark overlay with cutout
Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: Container(
width: 250,
height: 150,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
color: Colors.transparent,
),
),
),
),
),
// Instructions
Positioned(
bottom: 60,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Text(
'Position barcode within the frame',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
/// Build control buttons (torch, camera switch)
Widget _buildControlButtons(BuildContext context) {
return Positioned(
top: 16,
right: 16,
child: Column(
children: [
// Torch Toggle
Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
_isTorchOn ? Icons.flash_on : Icons.flash_off,
color: Colors.white,
),
onPressed: _toggleTorch,
),
),
const SizedBox(height: 12),
// Camera Switch
Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(
Icons.cameraswitch,
color: Colors.white,
),
onPressed: _switchCamera,
),
),
],
),
);
}
/// Build error widget when camera fails
Widget _buildErrorWidget(MobileScannerException error) {
return Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt_outlined,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Camera Error',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_getErrorMessage(error),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _restartScanner,
child: const Text('Retry'),
),
],
),
),
);
}
/// Build placeholder while camera is loading
Widget _buildPlaceholderWidget() {
return Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
);
}
/// Get user-friendly error message
String _getErrorMessage(MobileScannerException error) {
switch (error.errorCode) {
case MobileScannerErrorCode.permissionDenied:
return 'Camera permission is required to scan barcodes. Please enable camera access in settings.';
case MobileScannerErrorCode.unsupported:
return 'Your device does not support barcode scanning.';
default:
return 'Unable to access camera. Please check your device settings and try again.';
}
}
/// Toggle torch/flashlight
void _toggleTorch() async {
try {
await _controller.toggleTorch();
setState(() {
_isTorchOn = !_isTorchOn;
});
} catch (e) {
debugPrint('Failed to toggle torch: $e');
}
}
/// Switch between front and back camera
void _switchCamera() async {
try {
await _controller.switchCamera();
} catch (e) {
debugPrint('Failed to switch camera: $e');
}
}
/// Restart scanner after error
void _restartScanner() async {
try {
await _controller.stop();
await _controller.start();
setState(() {
_isStarted = true;
});
} catch (e) {
debugPrint('Failed to restart scanner: $e');
}
}
}

View File

@@ -1,236 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../data/models/scan_item.dart';
/// Widget to display a scrollable list of scan history items
class ScanHistoryList extends StatelessWidget {
final List<ScanItem> history;
final Function(ScanItem)? onItemTap;
final Function(ScanItem)? onItemLongPress;
final bool showTimestamp;
const ScanHistoryList({
required this.history,
this.onItemTap,
this.onItemLongPress,
this.showTimestamp = true,
super.key,
});
@override
Widget build(BuildContext context) {
if (history.isEmpty) {
return _buildEmptyState(context);
}
return ListView.builder(
itemCount: history.length,
padding: const EdgeInsets.only(top: 8),
itemBuilder: (context, index) {
final scanItem = history[index];
return _buildHistoryItem(context, scanItem, index);
},
);
}
/// Build individual history item
Widget _buildHistoryItem(BuildContext context, ScanItem scanItem, int index) {
final hasData = scanItem.field1.isNotEmpty ||
scanItem.field2.isNotEmpty ||
scanItem.field3.isNotEmpty ||
scanItem.field4.isNotEmpty;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
elevation: 1,
child: InkWell(
onTap: onItemTap != null ? () => onItemTap!(scanItem) : null,
onLongPress: onItemLongPress != null ? () => onItemLongPress!(scanItem) : null,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: [
// Icon indicating scan status
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: hasData
? Colors.green.withOpacity(0.1)
: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
hasData ? Icons.check_circle : Icons.qr_code,
size: 20,
color: hasData
? Colors.green
: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
// Barcode and details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barcode
Text(
scanItem.barcode,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Status and timestamp
Row(
children: [
// Status indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: hasData
? Colors.green.withOpacity(0.2)
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
hasData ? 'Saved' : 'Scanned',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: hasData
? Colors.green.shade700
: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
if (showTimestamp) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
_formatTimestamp(scanItem.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
// Data preview (if available)
if (hasData) ...[
const SizedBox(height: 4),
Text(
_buildDataPreview(scanItem),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Chevron icon
if (onItemTap != null)
Icon(
Icons.chevron_right,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
/// Build empty state when no history is available
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'No scan history',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Scanned barcodes will appear here',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
/// Format timestamp for display
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays > 0) {
return DateFormat('MMM dd, yyyy').format(timestamp);
} else if (difference.inHours > 0) {
return '${difference.inHours}h ago';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}m ago';
} else {
return 'Just now';
}
}
/// Build preview of saved data
String _buildDataPreview(ScanItem scanItem) {
final fields = [
scanItem.field1,
scanItem.field2,
scanItem.field3,
scanItem.field4,
].where((field) => field.isNotEmpty).toList();
if (fields.isEmpty) {
return 'No data saved';
}
return fields.join('');
}
}

View File

@@ -1,240 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Widget to display the most recent scan result with tap to edit functionality
class ScanResultDisplay extends StatelessWidget {
final String? barcode;
final VoidCallback? onTap;
final VoidCallback? onCopy;
const ScanResultDisplay({
required this.barcode,
this.onTap,
this.onCopy,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: barcode != null ? _buildScannedResult(context) : _buildEmptyState(context),
);
}
/// Build widget when barcode is scanned
Widget _buildScannedResult(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
width: 1,
),
),
child: Row(
children: [
// Barcode icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.qr_code,
size: 24,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
// Barcode text and label
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Last Scanned',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
barcode!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (onTap != null) ...[
const SizedBox(height: 4),
Text(
'Tap to edit',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
// Action buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Copy button
IconButton(
icon: Icon(
Icons.copy,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onPressed: () => _copyToClipboard(context),
tooltip: 'Copy to clipboard',
visualDensity: VisualDensity.compact,
),
// Edit button (if tap is enabled)
if (onTap != null)
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
);
}
/// Build empty state when no barcode is scanned
Widget _buildEmptyState(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Placeholder icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.qr_code_scanner,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
// Placeholder text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No barcode scanned',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
'Point camera at barcode to scan',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
// Scan animation (optional visual feedback)
_buildScanAnimation(context),
],
),
);
}
/// Build scanning animation indicator
Widget _buildScanAnimation(BuildContext context) {
return TweenAnimationBuilder<double>(
duration: const Duration(seconds: 2),
tween: Tween(begin: 0.0, end: 1.0),
builder: (context, value, child) {
return Opacity(
opacity: (1.0 - value).clamp(0.3, 1.0),
child: Container(
width: 4,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
);
},
onEnd: () {
// Restart animation (this creates a continuous effect)
},
);
}
/// Copy barcode to clipboard
void _copyToClipboard(BuildContext context) {
if (barcode != null) {
Clipboard.setData(ClipboardData(text: barcode!));
// Show feedback
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied "$barcode" to clipboard'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'OK',
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
// Call custom onCopy callback if provided
onCopy?.call();
}
}

View File

@@ -0,0 +1,398 @@
# Warehouse Feature - Architecture Diagram
## Clean Architecture Layers
```
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ (UI, State Management, User Interactions) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │
│ │ WarehouseCard │ │ WarehouseSelectionPage │ │
│ │ - Shows warehouse │ │ - Displays warehouse list │ │
│ │ information │ │ - Handles user selection │ │
│ └─────────────────────┘ │ - Pull to refresh │ │
│ │ - Loading/Error/Empty states │ │
│ └──────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ WarehouseNotifier │ │
│ │ (StateNotifier) │ │
│ │ - loadWarehouses() │ │
│ │ - selectWarehouse() │ │
│ │ - refresh() │ │
│ └──────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ WarehouseState │ │
│ │ - warehouses: List │ │
│ │ - selectedWarehouse: Warehouse? │ │
│ │ - isLoading: bool │ │
│ │ - error: String? │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓ uses
┌─────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ (Business Logic, Entities, Use Cases) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ GetWarehousesUseCase │ │
│ │ - Encapsulates business logic for fetching warehouses │ │
│ │ - Single responsibility │ │
│ │ - Returns Either<Failure, List<WarehouseEntity>> │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ uses │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WarehouseRepository (Interface) │ │
│ │ + getWarehouses(): Either<Failure, List<Warehouse>> │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WarehouseEntity │ │
│ │ - id: int │ │
│ │ - name: String │ │
│ │ - code: String │ │
│ │ - description: String? │ │
│ │ - isNGWareHouse: bool │ │
│ │ - totalCount: int │ │
│ │ + hasItems: bool │ │
│ │ + isNGType: bool │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓ implements
┌─────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ (API Calls, Data Sources, Models) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WarehouseRepositoryImpl │ │
│ │ - Implements WarehouseRepository interface │ │
│ │ - Coordinates data sources │ │
│ │ - Converts exceptions to failures │ │
│ │ - Maps models to entities │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ uses │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WarehouseRemoteDataSource (Interface) │ │
│ │ + getWarehouses(): Future<List<WarehouseModel>> │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WarehouseRemoteDataSourceImpl │ │
│ │ - Makes API calls using ApiClient │ │
│ │ - Parses ApiResponse wrapper │ │
│ │ - Throws ServerException or NetworkException │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ uses │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ WarehouseModel │ │
│ │ - Extends WarehouseEntity │ │
│ │ - Adds JSON serialization (fromJson, toJson) │ │
│ │ - Maps API fields to entity fields │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ uses │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ApiClient (Core) │ │
│ │ - Dio HTTP client wrapper │ │
│ │ - Adds authentication headers │ │
│ │ - Handles 401 errors │ │
│ │ - Logging and error handling │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Data Flow
### 1. Loading Warehouses Flow
```
User Action (Pull to Refresh / Page Load)
WarehouseSelectionPage
↓ calls
ref.read(warehouseProvider.notifier).loadWarehouses()
WarehouseNotifier.loadWarehouses()
↓ sets state
state = state.setLoading() → UI shows loading indicator
↓ calls
GetWarehousesUseCase.call()
↓ calls
WarehouseRepository.getWarehouses()
↓ calls
WarehouseRemoteDataSource.getWarehouses()
↓ makes HTTP request
ApiClient.get('/warehouses')
↓ API Response
{
"Value": [...],
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
↓ parse
List<WarehouseModel> from JSON
↓ convert
List<WarehouseEntity>
↓ wrap
Right(warehouses) or Left(failure)
↓ update state
state = state.setSuccess(warehouses)
UI rebuilds with warehouse list
```
### 2. Error Handling Flow
```
API Error / Network Error
ApiClient throws DioException
_handleDioError() converts to custom exception
ServerException or NetworkException
WarehouseRemoteDataSource catches and rethrows
WarehouseRepositoryImpl catches exception
Converts to Failure:
- ServerException → ServerFailure
- NetworkException → NetworkFailure
Returns Left(failure)
GetWarehousesUseCase returns Left(failure)
WarehouseNotifier receives Left(failure)
state = state.setError(failure.message)
UI shows error state with retry button
```
### 3. Warehouse Selection Flow
```
User taps on WarehouseCard
onTap callback triggered
_onWarehouseSelected(warehouse)
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse)
state = state.setSelectedWarehouse(warehouse)
Navigation: context.push('/operations', extra: warehouse)
OperationSelectionPage receives warehouse
```
## Dependency Graph
```
┌─────────────────────────────────────────────────┐
│ Riverpod Providers │
├─────────────────────────────────────────────────┤
│ │
│ secureStorageProvider │
│ ↓ │
│ apiClientProvider │
│ ↓ │
│ warehouseRemoteDataSourceProvider │
│ ↓ │
│ warehouseRepositoryProvider │
│ ↓ │
│ getWarehousesUseCaseProvider │
│ ↓ │
│ warehouseProvider (StateNotifierProvider) │
│ ↓ │
│ WarehouseSelectionPage watches this provider │
│ │
└─────────────────────────────────────────────────┘
```
## File Dependencies
```
warehouse_selection_page.dart
↓ imports
- warehouse_entity.dart
- warehouse_card.dart
- warehouse_provider.dart (via DI setup)
warehouse_card.dart
↓ imports
- warehouse_entity.dart
warehouse_provider.dart
↓ imports
- warehouse_entity.dart
- get_warehouses_usecase.dart
get_warehouses_usecase.dart
↓ imports
- warehouse_entity.dart
- warehouse_repository.dart (interface)
warehouse_repository_impl.dart
↓ imports
- warehouse_entity.dart
- warehouse_repository.dart (interface)
- warehouse_remote_datasource.dart
warehouse_remote_datasource.dart
↓ imports
- warehouse_model.dart
- api_client.dart
- api_response.dart
warehouse_model.dart
↓ imports
- warehouse_entity.dart
```
## State Transitions
```
┌──────────────┐
│ Initial │
│ isLoading: F │
│ error: null │
│ warehouses:[]│
└──────────────┘
loadWarehouses()
┌──────────────┐
│ Loading │
│ isLoading: T │────────────────┐
│ error: null │ │
│ warehouses:[]│ │
└──────────────┘ │
↓ │
Success Failure
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Success │ │ Error │
│ isLoading: F │ │ isLoading: F │
│ error: null │ │ error: "..." │
│ warehouses:[…]│ │ warehouses:[]│
└──────────────┘ └──────────────┘
↓ ↓
Selection Retry
↓ ↓
┌──────────────┐ (back to Loading)
│ Selected │
│ selected: W │
└──────────────┘
```
## API Response Parsing
```
Raw API Response (JSON)
{
"Value": [
{
"Id": 1,
"Name": "Warehouse A",
"Code": "001",
...
}
],
"IsSuccess": true,
...
}
ApiResponse.fromJson() parses wrapper
ApiResponse<List<WarehouseModel>> {
value: [WarehouseModel, WarehouseModel, ...],
isSuccess: true,
isFailure: false,
errors: [],
errorCodes: []
}
Check isSuccess
if (isSuccess && value != null)
return value!
else
throw ServerException(errors.first)
List<WarehouseModel>
map((model) => model.toEntity())
List<WarehouseEntity>
```
## Separation of Concerns
### Domain Layer
- **No dependencies** on Flutter, Dio, or other frameworks
- Contains **pure business logic**
- Defines **contracts** (repository interfaces)
- **Independent** and **testable**
### Data Layer
- **Implements** domain contracts
- Handles **external dependencies** (API, database)
- **Converts** between models and entities
- **Transforms** exceptions to failures
### Presentation Layer
- **Depends** only on domain layer
- Handles **UI rendering** and **user interactions**
- Manages **local state** with Riverpod
- **Observes** changes and **reacts** to state updates
## Testing Strategy
```
Unit Tests
├── Domain Layer
│ ├── Test entities (equality, methods)
│ ├── Test use cases (mock repository)
│ └── Verify business logic
├── Data Layer
│ ├── Test models (JSON serialization)
│ ├── Test data sources (mock ApiClient)
│ └── Test repository (mock data source)
└── Presentation Layer
├── Test notifier (mock use case)
└── Test state transitions
Widget Tests
├── Test UI rendering
├── Test user interactions
└── Test state-based UI changes
Integration Tests
├── Test complete flow
└── Test with real dependencies
```
## Benefits of This Architecture
1. **Testability**: Each layer can be tested independently with mocks
2. **Maintainability**: Changes in one layer don't affect others
3. **Scalability**: Easy to add new features following the same pattern
4. **Reusability**: Domain entities and use cases can be reused
5. **Separation**: Clear boundaries between UI, business logic, and data
6. **Flexibility**: Easy to swap implementations (e.g., change API client)
---
**Last Updated:** 2025-10-27
**Version:** 1.0.0

View File

@@ -0,0 +1,649 @@
# Warehouse Feature
Complete implementation of the warehouse feature following **Clean Architecture** principles.
## Architecture Overview
This feature follows a three-layer clean architecture pattern:
```
Presentation Layer (UI)
↓ (uses)
Domain Layer (Business Logic)
↓ (uses)
Data Layer (API & Data Sources)
```
### Key Principles
- **Separation of Concerns**: Each layer has a single responsibility
- **Dependency Inversion**: Outer layers depend on inner layers, not vice versa
- **Testability**: Each layer can be tested independently
- **Maintainability**: Changes in one layer don't affect others
## Project Structure
```
lib/features/warehouse/
├── data/
│ ├── datasources/
│ │ └── warehouse_remote_datasource.dart # API calls using ApiClient
│ ├── models/
│ │ └── warehouse_model.dart # Data transfer objects with JSON serialization
│ └── repositories/
│ └── warehouse_repository_impl.dart # Repository implementation
├── domain/
│ ├── entities/
│ │ └── warehouse_entity.dart # Pure business models
│ ├── repositories/
│ │ └── warehouse_repository.dart # Repository interface/contract
│ └── usecases/
│ └── get_warehouses_usecase.dart # Business logic use cases
├── presentation/
│ ├── pages/
│ │ └── warehouse_selection_page.dart # Main warehouse selection screen
│ ├── providers/
│ │ └── warehouse_provider.dart # Riverpod state management
│ └── widgets/
│ └── warehouse_card.dart # Reusable warehouse card widget
├── warehouse_exports.dart # Barrel file for clean imports
├── warehouse_provider_setup_example.dart # Provider setup guide
└── README.md # This file
```
## Layer Details
### 1. Domain Layer (Core Business Logic)
The innermost layer that contains business entities, repository interfaces, and use cases. **No dependencies on external frameworks or packages** (except dartz for Either).
#### Entities
`domain/entities/warehouse_entity.dart`
- Pure Dart class representing a warehouse
- No JSON serialization logic
- Contains business rules and validations
- Extends Equatable for value comparison
```dart
class WarehouseEntity extends Equatable {
final int id;
final String name;
final String code;
final String? description;
final bool isNGWareHouse;
final int totalCount;
bool get hasItems => totalCount > 0;
bool get isNGType => isNGWareHouse;
}
```
#### Repository Interface
`domain/repositories/warehouse_repository.dart`
- Abstract interface defining data operations
- Returns `Either<Failure, T>` for error handling
- Implementation is provided by the data layer
```dart
abstract class WarehouseRepository {
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses();
}
```
#### Use Cases
`domain/usecases/get_warehouses_usecase.dart`
- Single responsibility: fetch warehouses
- Encapsulates business logic
- Depends only on repository interface
```dart
class GetWarehousesUseCase {
final WarehouseRepository repository;
Future<Either<Failure, List<WarehouseEntity>>> call() async {
return await repository.getWarehouses();
}
}
```
### 2. Data Layer (External Data Management)
Handles all data operations including API calls, JSON serialization, and error handling.
#### Models
`data/models/warehouse_model.dart`
- Extends domain entity
- Adds JSON serialization (`fromJson`, `toJson`)
- Maps API response format to domain entities
- Matches API field naming (PascalCase)
```dart
class WarehouseModel extends WarehouseEntity {
factory WarehouseModel.fromJson(Map<String, dynamic> json) {
return WarehouseModel(
id: json['Id'] ?? 0,
name: json['Name'] ?? '',
code: json['Code'] ?? '',
description: json['Description'],
isNGWareHouse: json['IsNGWareHouse'] ?? false,
totalCount: json['TotalCount'] ?? 0,
);
}
}
```
#### Data Sources
`data/datasources/warehouse_remote_datasource.dart`
- Interface + implementation pattern
- Makes API calls using `ApiClient`
- Parses `ApiResponse` wrapper
- Throws typed exceptions (`ServerException`, `NetworkException`)
```dart
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
Future<List<WarehouseModel>> getWarehouses() async {
final response = await apiClient.get('/warehouses');
final apiResponse = ApiResponse.fromJson(
response.data,
(json) => (json as List).map((e) => WarehouseModel.fromJson(e)).toList(),
);
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
throw ServerException(apiResponse.errors.first);
}
}
}
```
#### Repository Implementation
`data/repositories/warehouse_repository_impl.dart`
- Implements domain repository interface
- Coordinates data sources
- Converts exceptions to failures
- Maps models to entities
```dart
class WarehouseRepositoryImpl implements WarehouseRepository {
@override
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses() async {
try {
final warehouses = await remoteDataSource.getWarehouses();
final entities = warehouses.map((model) => model.toEntity()).toList();
return Right(entities);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure(e.message));
}
}
}
```
### 3. Presentation Layer (UI & State Management)
Handles UI rendering, user interactions, and state management using Riverpod.
#### State Management
`presentation/providers/warehouse_provider.dart`
- `WarehouseState`: Immutable state class
- `warehouses`: List of warehouses
- `selectedWarehouse`: Currently selected warehouse
- `isLoading`: Loading indicator
- `error`: Error message
- `WarehouseNotifier`: StateNotifier managing state
- `loadWarehouses()`: Fetch warehouses from API
- `selectWarehouse()`: Select a warehouse
- `refresh()`: Reload warehouses
- `clearError()`: Clear error state
```dart
class WarehouseState {
final List<WarehouseEntity> warehouses;
final WarehouseEntity? selectedWarehouse;
final bool isLoading;
final String? error;
}
class WarehouseNotifier extends StateNotifier<WarehouseState> {
Future<void> loadWarehouses() async {
state = state.setLoading();
final result = await getWarehousesUseCase();
result.fold(
(failure) => state = state.setError(failure.message),
(warehouses) => state = state.setSuccess(warehouses),
);
}
}
```
#### Pages
`presentation/pages/warehouse_selection_page.dart`
- ConsumerStatefulWidget using Riverpod
- Loads warehouses on initialization
- Displays different UI states:
- Loading: CircularProgressIndicator
- Error: Error message with retry button
- Empty: No warehouses message
- Success: List of warehouse cards
- Pull-to-refresh support
- Navigation to operations page on selection
#### Widgets
`presentation/widgets/warehouse_card.dart`
- Reusable warehouse card component
- Displays:
- Warehouse name (title)
- Code (with QR icon)
- Total items count (with inventory icon)
- Description (if available)
- NG warehouse badge (if applicable)
- Material Design 3 styling
- Tap to select functionality
## API Integration
### Endpoint
```
GET /warehouses
```
### Request
```bash
curl -X GET https://api.example.com/warehouses \
-H "Authorization: Bearer {access_token}"
```
### Response Format
```json
{
"Value": [
{
"Id": 1,
"Name": "Kho nguyên vật liệu",
"Code": "001",
"Description": "Kho chứa nguyên vật liệu",
"IsNGWareHouse": false,
"TotalCount": 8
},
{
"Id": 2,
"Name": "Kho bán thành phẩm công đoạn",
"Code": "002",
"Description": null,
"IsNGWareHouse": false,
"TotalCount": 12
}
],
"IsSuccess": true,
"IsFailure": false,
"Errors": [],
"ErrorCodes": []
}
```
## Setup & Integration
### 1. Install Dependencies
Ensure your `pubspec.yaml` includes:
```yaml
dependencies:
flutter_riverpod: ^2.4.9
dio: ^5.3.2
dartz: ^0.10.1
equatable: ^2.0.5
flutter_secure_storage: ^9.0.0
```
### 2. Set Up Providers
Create or update your provider configuration file (e.g., `lib/core/di/providers.dart`):
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/warehouse/warehouse_exports.dart';
// Core providers (if not already set up)
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
return ApiClient(secureStorage);
});
// Warehouse data layer providers
final warehouseRemoteDataSourceProvider = Provider<WarehouseRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return WarehouseRemoteDataSourceImpl(apiClient);
});
final warehouseRepositoryProvider = Provider((ref) {
final remoteDataSource = ref.watch(warehouseRemoteDataSourceProvider);
return WarehouseRepositoryImpl(remoteDataSource);
});
// Warehouse domain layer providers
final getWarehousesUseCaseProvider = Provider((ref) {
final repository = ref.watch(warehouseRepositoryProvider);
return GetWarehousesUseCase(repository);
});
// Warehouse presentation layer providers
final warehouseProvider = StateNotifierProvider<WarehouseNotifier, WarehouseState>((ref) {
final getWarehousesUseCase = ref.watch(getWarehousesUseCaseProvider);
return WarehouseNotifier(getWarehousesUseCase);
});
```
### 3. Update WarehouseSelectionPage
Replace the TODO comments in `warehouse_selection_page.dart`:
```dart
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(warehouseProvider);
// Rest of the implementation...
}
```
### 4. Add to Router
Using go_router:
```dart
GoRoute(
path: '/warehouses',
name: 'warehouses',
builder: (context, state) => const WarehouseSelectionPage(),
),
GoRoute(
path: '/operations',
name: 'operations',
builder: (context, state) {
final warehouse = state.extra as WarehouseEntity;
return OperationSelectionPage(warehouse: warehouse);
},
),
```
### 5. Navigate to Warehouse Page
```dart
// From login page after successful authentication
context.go('/warehouses');
// Or using Navigator
Navigator.of(context).pushNamed('/warehouses');
```
## Usage Examples
### Loading Warehouses
```dart
// In a widget
ElevatedButton(
onPressed: () {
ref.read(warehouseProvider.notifier).loadWarehouses();
},
child: const Text('Load Warehouses'),
)
```
### Watching State
```dart
// In a ConsumerWidget
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(warehouseProvider);
if (state.isLoading) {
return const CircularProgressIndicator();
}
if (state.error != null) {
return Text('Error: ${state.error}');
}
return ListView.builder(
itemCount: state.warehouses.length,
itemBuilder: (context, index) {
final warehouse = state.warehouses[index];
return ListTile(title: Text(warehouse.name));
},
);
}
```
### Selecting a Warehouse
```dart
// Select warehouse and navigate
void onWarehouseTap(WarehouseEntity warehouse) {
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
context.push('/operations', extra: warehouse);
}
```
### Pull to Refresh
```dart
RefreshIndicator(
onRefresh: () => ref.read(warehouseProvider.notifier).refresh(),
child: ListView(...),
)
```
### Accessing Selected Warehouse
```dart
// In another page
final state = ref.watch(warehouseProvider);
final selectedWarehouse = state.selectedWarehouse;
if (selectedWarehouse != null) {
Text('Current: ${selectedWarehouse.name}');
}
```
## Error Handling
The feature uses dartz's `Either` type for functional error handling:
```dart
// In use case or repository
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses() async {
try {
final warehouses = await remoteDataSource.getWarehouses();
return Right(warehouses); // Success
} on ServerException catch (e) {
return Left(ServerFailure(e.message)); // Failure
}
}
// In presentation layer
result.fold(
(failure) => print('Error: ${failure.message}'),
(warehouses) => print('Success: ${warehouses.length} items'),
);
```
### Failure Types
- `ServerFailure`: API errors, HTTP errors
- `NetworkFailure`: Connection issues, timeouts
- `CacheFailure`: Local storage errors (if implemented)
## Testing
### Unit Tests
**Test Use Case:**
```dart
test('should get warehouses from repository', () async {
// Arrange
when(mockRepository.getWarehouses())
.thenAnswer((_) async => Right(mockWarehouses));
// Act
final result = await useCase();
// Assert
expect(result, Right(mockWarehouses));
verify(mockRepository.getWarehouses());
});
```
**Test Repository:**
```dart
test('should return warehouses when remote call is successful', () async {
// Arrange
when(mockRemoteDataSource.getWarehouses())
.thenAnswer((_) async => mockWarehouseModels);
// Act
final result = await repository.getWarehouses();
// Assert
expect(result.isRight(), true);
});
```
**Test Notifier:**
```dart
test('should emit loading then success when warehouses are loaded', () async {
// Arrange
when(mockUseCase()).thenAnswer((_) async => Right(mockWarehouses));
// Act
await notifier.loadWarehouses();
// Assert
expect(notifier.state.isLoading, false);
expect(notifier.state.warehouses, mockWarehouses);
});
```
### Widget Tests
```dart
testWidgets('should display warehouse list when loaded', (tester) async {
// Arrange
final container = ProviderContainer(
overrides: [
warehouseProvider.overrideWith((ref) => MockWarehouseNotifier()),
],
);
// Act
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const WarehouseSelectionPage(),
),
);
// Assert
expect(find.byType(WarehouseCard), findsWidgets);
});
```
## Best Practices
1. **Always use Either for error handling** - Don't throw exceptions across layers
2. **Keep domain layer pure** - No Flutter/external dependencies
3. **Use value objects** - Entities should be immutable
4. **Single responsibility** - Each class has one reason to change
5. **Dependency inversion** - Depend on abstractions, not concretions
6. **Test each layer independently** - Use mocks and test doubles
## Common Issues
### Provider Not Found
**Error:** `ProviderNotFoundException`
**Solution:** Make sure you've set up all providers in your provider configuration file and wrapped your app with `ProviderScope`.
### Null Safety Issues
**Error:** `Null check operator used on a null value`
**Solution:** Always check for null before accessing optional fields:
```dart
if (warehouse.description != null) {
Text(warehouse.description!);
}
```
### API Response Format Mismatch
**Error:** `ServerException: Invalid response format`
**Solution:** Verify that the API response matches the expected format in `ApiResponse.fromJson` and `WarehouseModel.fromJson`.
## Future Enhancements
- [ ] Add caching with Hive for offline support
- [ ] Implement warehouse search functionality
- [ ] Add warehouse filtering (by type, name, etc.)
- [ ] Add pagination for large warehouse lists
- [ ] Implement warehouse CRUD operations
- [ ] Add warehouse analytics and statistics
## Related Features
- **Authentication**: `/lib/features/auth/` - User login and token management
- **Operations**: `/lib/features/operation/` - Import/Export selection
- **Products**: `/lib/features/products/` - Product listing per warehouse
## References
- [Clean Architecture by Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Flutter Riverpod Documentation](https://riverpod.dev/)
- [Dartz Package for Functional Programming](https://pub.dev/packages/dartz)
- [Material Design 3](https://m3.material.io/)
---
**Last Updated:** 2025-10-27
**Version:** 1.0.0
**Author:** Claude Code

View File

@@ -0,0 +1,76 @@
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/warehouse_model.dart';
/// Abstract interface for warehouse remote data source
abstract class WarehouseRemoteDataSource {
/// Get all warehouses from the API
///
/// Returns [List<WarehouseModel>] on success
/// Throws [ServerException] on API errors
/// Throws [NetworkException] on network errors
Future<List<WarehouseModel>> getWarehouses();
}
/// Implementation of warehouse remote data source
/// Uses ApiClient to make HTTP requests to the backend
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
final ApiClient apiClient;
WarehouseRemoteDataSourceImpl(this.apiClient);
@override
Future<List<WarehouseModel>> getWarehouses() async {
try {
// Make POST request to /portalWareHouse/search endpoint
final response = await apiClient.post(
'/portalWareHouse/search',
data: {
'pageIndex': 0,
'pageSize': 100,
'Name': null,
'Code': null,
'sortExpression': null,
'sortDirection': null,
},
);
// Parse the API response wrapper
final apiResponse = ApiResponse.fromJson(
response.data,
(json) {
// Handle the list of warehouses
if (json is List) {
return json.map((e) => WarehouseModel.fromJson(e)).toList();
}
throw const ServerException('Invalid response format: expected List');
},
);
// Check if API call was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Extract error message from API response
final errorMessage = apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get warehouses';
throw ServerException(
errorMessage,
code: apiResponse.firstErrorCode,
);
}
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
// Wrap any unexpected errors
throw ServerException(
'Unexpected error while fetching warehouses: ${e.toString()}',
);
}
}
}

View File

@@ -0,0 +1,100 @@
import '../../domain/entities/warehouse_entity.dart';
/// Warehouse data model
/// Extends domain entity and adds JSON serialization
/// Matches the API response format from backend
class WarehouseModel extends WarehouseEntity {
const WarehouseModel({
required super.id,
required super.name,
required super.code,
super.description,
required super.isNGWareHouse,
required super.totalCount,
});
/// Create a WarehouseModel from JSON
///
/// JSON format from API:
/// ```json
/// {
/// "Id": 1,
/// "Name": "Kho nguyên vật liệu",
/// "Code": "001",
/// "Description": "Kho chứa nguyên vật liệu",
/// "IsNGWareHouse": false,
/// "TotalCount": 8
/// }
/// ```
factory WarehouseModel.fromJson(Map<String, dynamic> json) {
return WarehouseModel(
id: json['Id'] ?? 0,
name: json['Name'] ?? '',
code: json['Code'] ?? '',
description: json['Description'],
isNGWareHouse: json['IsNGWareHouse'] ?? false,
totalCount: json['TotalCount'] ?? 0,
);
}
/// Convert model to JSON
Map<String, dynamic> toJson() {
return {
'Id': id,
'Name': name,
'Code': code,
'Description': description,
'IsNGWareHouse': isNGWareHouse,
'TotalCount': totalCount,
};
}
/// Create from domain entity
factory WarehouseModel.fromEntity(WarehouseEntity entity) {
return WarehouseModel(
id: entity.id,
name: entity.name,
code: entity.code,
description: entity.description,
isNGWareHouse: entity.isNGWareHouse,
totalCount: entity.totalCount,
);
}
/// Convert to domain entity
WarehouseEntity toEntity() {
return WarehouseEntity(
id: id,
name: name,
code: code,
description: description,
isNGWareHouse: isNGWareHouse,
totalCount: totalCount,
);
}
/// Create a copy with modified fields
@override
WarehouseModel copyWith({
int? id,
String? name,
String? code,
String? description,
bool? isNGWareHouse,
int? totalCount,
}) {
return WarehouseModel(
id: id ?? this.id,
name: name ?? this.name,
code: code ?? this.code,
description: description ?? this.description,
isNGWareHouse: isNGWareHouse ?? this.isNGWareHouse,
totalCount: totalCount ?? this.totalCount,
);
}
@override
String toString() {
return 'WarehouseModel(id: $id, name: $name, code: $code, totalCount: $totalCount)';
}
}

View File

@@ -0,0 +1,39 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/warehouse_entity.dart';
import '../../domain/repositories/warehouse_repository.dart';
import '../datasources/warehouse_remote_datasource.dart';
/// Implementation of WarehouseRepository
/// Coordinates between data sources and domain layer
/// Converts exceptions to failures for proper error handling
class WarehouseRepositoryImpl implements WarehouseRepository {
final WarehouseRemoteDataSource remoteDataSource;
WarehouseRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses() async {
try {
// Fetch warehouses from remote data source
final warehouses = await remoteDataSource.getWarehouses();
// Convert models to entities
final entities = warehouses
.map((model) => model.toEntity())
.toList();
return Right(entities);
} on ServerException catch (e) {
// Convert server exceptions to server failures
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
// Convert network exceptions to network failures
return Left(NetworkFailure(e.message));
} catch (e) {
// Handle any unexpected errors
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,61 @@
import 'package:equatable/equatable.dart';
/// Warehouse domain entity
/// Pure business model with no dependencies on data layer
class WarehouseEntity extends Equatable {
final int id;
final String name;
final String code;
final String? description;
final bool isNGWareHouse;
final int totalCount;
const WarehouseEntity({
required this.id,
required this.name,
required this.code,
this.description,
required this.isNGWareHouse,
required this.totalCount,
});
@override
List<Object?> get props => [
id,
name,
code,
description,
isNGWareHouse,
totalCount,
];
@override
String toString() {
return 'WarehouseEntity(id: $id, name: $name, code: $code, totalCount: $totalCount)';
}
/// Check if warehouse has items
bool get hasItems => totalCount > 0;
/// Check if this is an NG (Not Good/Defect) warehouse
bool get isNGType => isNGWareHouse;
/// Create a copy with modified fields
WarehouseEntity copyWith({
int? id,
String? name,
String? code,
String? description,
bool? isNGWareHouse,
int? totalCount,
}) {
return WarehouseEntity(
id: id ?? this.id,
name: name ?? this.name,
code: code ?? this.code,
description: description ?? this.description,
isNGWareHouse: isNGWareHouse ?? this.isNGWareHouse,
totalCount: totalCount ?? this.totalCount,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/warehouse_entity.dart';
/// Abstract repository interface for warehouse operations
/// Defines the contract for warehouse data operations
/// Implementations should handle data sources and convert exceptions to failures
abstract class WarehouseRepository {
/// Get all warehouses from the remote data source
///
/// Returns [Either<Failure, List<WarehouseEntity>>]
/// - Right: List of warehouses on success
/// - Left: Failure object with error details
Future<Either<Failure, List<WarehouseEntity>>> getWarehouses();
}

View File

@@ -0,0 +1,32 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/warehouse_entity.dart';
import '../repositories/warehouse_repository.dart';
/// Use case for getting all warehouses
/// Encapsulates the business logic for fetching warehouses
///
/// Usage:
/// ```dart
/// final useCase = GetWarehousesUseCase(repository);
/// final result = await useCase();
///
/// result.fold(
/// (failure) => print('Error: ${failure.message}'),
/// (warehouses) => print('Loaded ${warehouses.length} warehouses'),
/// );
/// ```
class GetWarehousesUseCase {
final WarehouseRepository repository;
GetWarehousesUseCase(this.repository);
/// Execute the use case
///
/// Returns [Either<Failure, List<WarehouseEntity>>]
/// - Right: List of warehouses on success
/// - Left: Failure object with error details
Future<Either<Failure, List<WarehouseEntity>>> call() async {
return await repository.getWarehouses();
}
}

View File

@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/providers.dart';
import '../widgets/warehouse_card.dart';
/// Warehouse selection page
/// Displays a list of warehouses and allows user to select one
///
/// Features:
/// - Loads warehouses on init
/// - Pull to refresh
/// - Loading, error, empty, and success states
/// - Navigate to operations page on warehouse selection
class WarehouseSelectionPage extends ConsumerStatefulWidget {
const WarehouseSelectionPage({super.key});
@override
ConsumerState<WarehouseSelectionPage> createState() =>
_WarehouseSelectionPageState();
}
class _WarehouseSelectionPageState
extends ConsumerState<WarehouseSelectionPage> {
@override
void initState() {
super.initState();
// Load warehouses when page is first created
Future.microtask(() {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
@override
Widget build(BuildContext context) {
// Watch warehouse state
final warehouseState = ref.watch(warehouseProvider);
final warehouses = warehouseState.warehouses;
final isLoading = warehouseState.isLoading;
final error = warehouseState.error;
return Scaffold(
appBar: AppBar(
title: const Text('Select Warehouse'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
ref.read(warehouseProvider.notifier).loadWarehouses();
},
tooltip: 'Refresh',
),
],
),
body: _buildBody(
isLoading: isLoading,
error: error,
warehouses: warehouses,
),
);
}
/// Build body based on state
Widget _buildBody({
required bool isLoading,
required String? error,
required List warehouses,
}) {
// Loading state
if (isLoading && warehouses.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading warehouses...'),
],
),
);
}
// Error state
if (error != null && warehouses.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error Loading Warehouses',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
ref.read(warehouseProvider.notifier).loadWarehouses();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
// Empty state
if (warehouses.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No Warehouses Available',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'There are no warehouses to display.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () {
ref.read(warehouseProvider.notifier).loadWarehouses();
},
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
],
),
),
);
}
// Success state - show warehouse list
return RefreshIndicator(
onRefresh: () async {
await ref.read(warehouseProvider.notifier).loadWarehouses();
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: warehouses.length,
itemBuilder: (context, index) {
final warehouse = warehouses[index];
return WarehouseCard(
warehouse: warehouse,
onTap: () {
// Select warehouse and navigate to operations
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Navigate to operations page
context.go('/operations', extra: warehouse);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/warehouse_entity.dart';
import '../widgets/warehouse_card.dart';
import '../../../../core/router/app_router.dart';
/// EXAMPLE: Warehouse selection page with proper navigation integration
///
/// This is a complete example showing how to integrate the warehouse selection
/// page with the new router. Use this as a reference when implementing the
/// actual warehouse provider and state management.
///
/// Key Features:
/// - Uses type-safe navigation with extension methods
/// - Proper error handling
/// - Loading states
/// - Pull to refresh
/// - Integration with router
class WarehouseSelectionPageExample extends ConsumerStatefulWidget {
const WarehouseSelectionPageExample({super.key});
@override
ConsumerState<WarehouseSelectionPageExample> createState() =>
_WarehouseSelectionPageExampleState();
}
class _WarehouseSelectionPageExampleState
extends ConsumerState<WarehouseSelectionPageExample> {
@override
void initState() {
super.initState();
// Load warehouses when page is first created
WidgetsBinding.instance.addPostFrameCallback((_) {
// TODO: Replace with actual provider
// ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
@override
Widget build(BuildContext context) {
// TODO: Replace with actual provider
// final state = ref.watch(warehouseProvider);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Select Warehouse'),
backgroundColor: colorScheme.primaryContainer,
foregroundColor: colorScheme.onPrimaryContainer,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _handleLogout(context),
tooltip: 'Logout',
),
],
),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
// For demonstration, showing example warehouse list
// In actual implementation, use state from provider:
// if (state.isLoading) return _buildLoadingState();
// if (state.error != null) return _buildErrorState(context, state.error!);
// if (!state.hasWarehouses) return _buildEmptyState(context);
// return _buildWarehouseList(state.warehouses);
// Example warehouses for demonstration
final exampleWarehouses = _getExampleWarehouses();
return _buildWarehouseList(exampleWarehouses);
}
/// Build loading state UI
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Loading warehouses...',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
/// Build error state UI
Widget _buildErrorState(BuildContext context, String error) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error Loading Warehouses',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
// TODO: Replace with actual provider
// ref.read(warehouseProvider.notifier).loadWarehouses();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
/// Build empty state UI
Widget _buildEmptyState(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No Warehouses Available',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'There are no warehouses to display.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () {
// TODO: Replace with actual provider
// ref.read(warehouseProvider.notifier).loadWarehouses();
},
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
],
),
),
);
}
/// Build warehouse list UI
Widget _buildWarehouseList(List<WarehouseEntity> warehouses) {
return RefreshIndicator(
onRefresh: () async {
// TODO: Replace with actual provider
// await ref.read(warehouseProvider.notifier).refresh();
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: warehouses.length,
itemBuilder: (context, index) {
final warehouse = warehouses[index];
return WarehouseCard(
warehouse: warehouse,
onTap: () => _onWarehouseSelected(context, warehouse),
);
},
),
);
}
/// Handle warehouse selection
///
/// This is the key integration point with the router!
/// Uses the type-safe extension method to navigate to operations page
void _onWarehouseSelected(BuildContext context, WarehouseEntity warehouse) {
// TODO: Optionally save selected warehouse to provider
// ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Navigate to operations page using type-safe extension method
context.goToOperations(warehouse);
}
/// Handle logout
void _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (confirmed == true && context.mounted) {
// TODO: Call logout from auth provider
// await ref.read(authProvider.notifier).logout();
// Router will automatically redirect to login page
// due to authentication state change
}
}
/// Get example warehouses for demonstration
List<WarehouseEntity> _getExampleWarehouses() {
return [
WarehouseEntity(
id: 1,
name: 'Kho nguyên vật liệu',
code: '001',
description: 'Warehouse for raw materials',
isNGWareHouse: false,
totalCount: 8,
),
WarehouseEntity(
id: 2,
name: 'Kho bán thành phẩm công đoạn',
code: '002',
description: 'Semi-finished goods warehouse',
isNGWareHouse: false,
totalCount: 8,
),
WarehouseEntity(
id: 3,
name: 'Kho thành phẩm',
code: '003',
description: 'Finished goods warehouse',
isNGWareHouse: false,
totalCount: 8,
),
WarehouseEntity(
id: 4,
name: 'Kho tiêu hao',
code: '004',
description: 'Để chứa phụ tùng',
isNGWareHouse: false,
totalCount: 8,
),
WarehouseEntity(
id: 5,
name: 'Kho NG',
code: '005',
description: 'Non-conforming products warehouse',
isNGWareHouse: true,
totalCount: 3,
),
];
}
}
/// EXAMPLE: Alternative approach using named routes
///
/// This shows how to use named routes instead of path-based navigation
class WarehouseSelectionWithNamedRoutesExample extends ConsumerWidget {
const WarehouseSelectionWithNamedRoutesExample({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Example warehouse for demonstration
final warehouse = WarehouseEntity(
id: 1,
name: 'Example Warehouse',
code: '001',
isNGWareHouse: false,
totalCount: 10,
);
return Scaffold(
appBar: AppBar(title: const Text('Named Routes Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
// Using named route navigation
context.goToOperationsNamed(warehouse);
},
child: const Text('Go to Operations (Named)'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Using path-based navigation
context.goToOperations(warehouse);
},
child: const Text('Go to Operations (Path)'),
),
],
),
),
);
}
}
/// EXAMPLE: Navigation from operation to products
///
/// Shows how to navigate from operation selection to products page
class OperationNavigationExample extends StatelessWidget {
final WarehouseEntity warehouse;
const OperationNavigationExample({
super.key,
required this.warehouse,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Operation Navigation Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () {
// Navigate to products with import operation
context.goToProducts(
warehouse: warehouse,
operationType: 'import',
);
},
icon: const Icon(Icons.arrow_downward),
label: const Text('Import Products'),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
// Navigate to products with export operation
context.goToProducts(
warehouse: warehouse,
operationType: 'export',
);
},
icon: const Icon(Icons.arrow_upward),
label: const Text('Export Products'),
),
const SizedBox(height: 32),
OutlinedButton(
onPressed: () {
// Navigate back
context.goBack();
},
child: const Text('Go Back'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/warehouse_entity.dart';
import '../../domain/usecases/get_warehouses_usecase.dart';
/// Warehouse state that holds the current state of warehouse feature
class WarehouseState {
final List<WarehouseEntity> warehouses;
final WarehouseEntity? selectedWarehouse;
final bool isLoading;
final String? error;
const WarehouseState({
this.warehouses = const [],
this.selectedWarehouse,
this.isLoading = false,
this.error,
});
/// Create initial state
factory WarehouseState.initial() {
return const WarehouseState();
}
/// Create loading state
WarehouseState setLoading() {
return WarehouseState(
warehouses: warehouses,
selectedWarehouse: selectedWarehouse,
isLoading: true,
error: null,
);
}
/// Create success state
WarehouseState setSuccess(List<WarehouseEntity> warehouses) {
return WarehouseState(
warehouses: warehouses,
selectedWarehouse: selectedWarehouse,
isLoading: false,
error: null,
);
}
/// Create error state
WarehouseState setError(String error) {
return WarehouseState(
warehouses: warehouses,
selectedWarehouse: selectedWarehouse,
isLoading: false,
error: error,
);
}
/// Create state with selected warehouse
WarehouseState setSelectedWarehouse(WarehouseEntity? warehouse) {
return WarehouseState(
warehouses: warehouses,
selectedWarehouse: warehouse,
isLoading: isLoading,
error: error,
);
}
/// Create a copy with modified fields
WarehouseState copyWith({
List<WarehouseEntity>? warehouses,
WarehouseEntity? selectedWarehouse,
bool? isLoading,
String? error,
}) {
return WarehouseState(
warehouses: warehouses ?? this.warehouses,
selectedWarehouse: selectedWarehouse ?? this.selectedWarehouse,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
/// Check if warehouses are loaded
bool get hasWarehouses => warehouses.isNotEmpty;
/// Check if a warehouse is selected
bool get hasSelection => selectedWarehouse != null;
@override
String toString() {
return 'WarehouseState(warehouses: ${warehouses.length}, '
'selectedWarehouse: ${selectedWarehouse?.name}, '
'isLoading: $isLoading, error: $error)';
}
}
/// State notifier for warehouse operations
/// Manages the warehouse state and handles business logic
class WarehouseNotifier extends StateNotifier<WarehouseState> {
final GetWarehousesUseCase getWarehousesUseCase;
WarehouseNotifier(this.getWarehousesUseCase)
: super(WarehouseState.initial());
/// Load all warehouses from the API
Future<void> loadWarehouses() async {
// Set loading state
state = state.setLoading();
// Execute the use case
final result = await getWarehousesUseCase();
// Handle the result
result.fold(
(failure) {
// Set error state on failure
state = state.setError(failure.message);
},
(warehouses) {
// Set success state with warehouses
state = state.setSuccess(warehouses);
},
);
}
/// Select a warehouse
void selectWarehouse(WarehouseEntity warehouse) {
state = state.setSelectedWarehouse(warehouse);
}
/// Clear selected warehouse
void clearSelection() {
state = state.setSelectedWarehouse(null);
}
/// Refresh warehouses (reload from API)
Future<void> refresh() async {
await loadWarehouses();
}
/// Clear error message
void clearError() {
state = state.copyWith(error: null);
}
/// Reset state to initial
void reset() {
state = WarehouseState.initial();
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import '../../domain/entities/warehouse_entity.dart';
/// Reusable warehouse card widget
/// Displays warehouse information in a card format
class WarehouseCard extends StatelessWidget {
final WarehouseEntity warehouse;
final VoidCallback onTap;
const WarehouseCard({
super.key,
required this.warehouse,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Warehouse name
Text(
warehouse.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
// Warehouse code
Row(
children: [
Icon(
Icons.qr_code,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Code: ${warehouse.code}',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 4),
// Items count
Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Items: ${warehouse.totalCount}',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
// Description (if available)
if (warehouse.description != null &&
warehouse.description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
warehouse.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
// NG Warehouse badge (if applicable)
if (warehouse.isNGWareHouse) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'NG Warehouse',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
),
);
}
}

View File

@@ -0,0 +1,14 @@
// Domain Layer Exports
export 'domain/entities/warehouse_entity.dart';
export 'domain/repositories/warehouse_repository.dart';
export 'domain/usecases/get_warehouses_usecase.dart';
// Data Layer Exports
export 'data/models/warehouse_model.dart';
export 'data/datasources/warehouse_remote_datasource.dart';
export 'data/repositories/warehouse_repository_impl.dart';
// Presentation Layer Exports
export 'presentation/providers/warehouse_provider.dart';
export 'presentation/pages/warehouse_selection_page.dart';
export 'presentation/widgets/warehouse_card.dart';

View File

@@ -0,0 +1,153 @@
/// EXAMPLE FILE: How to set up the warehouse provider
///
/// This file demonstrates how to wire up all the warehouse feature dependencies
/// using Riverpod providers. Copy this setup to your actual provider configuration file.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
import 'data/datasources/warehouse_remote_datasource.dart';
import 'data/repositories/warehouse_repository_impl.dart';
import 'domain/usecases/get_warehouses_usecase.dart';
import 'presentation/providers/warehouse_provider.dart';
// ==============================================================================
// STEP 1: Provide core dependencies (ApiClient, SecureStorage)
// ==============================================================================
// These should already be set up in your app. If not, add them to your
// core providers file (e.g., lib/core/di/providers.dart or lib/core/providers.dart)
// Provider for SecureStorage
final secureStorageProvider = Provider<SecureStorage>((ref) {
return SecureStorage();
});
// Provider for ApiClient
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
return ApiClient(secureStorage);
});
// ==============================================================================
// STEP 2: Data Layer Providers
// ==============================================================================
// Provider for WarehouseRemoteDataSource
final warehouseRemoteDataSourceProvider = Provider<WarehouseRemoteDataSource>((ref) {
final apiClient = ref.watch(apiClientProvider);
return WarehouseRemoteDataSourceImpl(apiClient);
});
// Provider for WarehouseRepository
final warehouseRepositoryProvider = Provider((ref) {
final remoteDataSource = ref.watch(warehouseRemoteDataSourceProvider);
return WarehouseRepositoryImpl(remoteDataSource);
});
// ==============================================================================
// STEP 3: Domain Layer Providers (Use Cases)
// ==============================================================================
// Provider for GetWarehousesUseCase
final getWarehousesUseCaseProvider = Provider((ref) {
final repository = ref.watch(warehouseRepositoryProvider);
return GetWarehousesUseCase(repository);
});
// ==============================================================================
// STEP 4: Presentation Layer Providers (State Management)
// ==============================================================================
// Provider for WarehouseNotifier (StateNotifier)
final warehouseProvider = StateNotifierProvider<WarehouseNotifier, WarehouseState>((ref) {
final getWarehousesUseCase = ref.watch(getWarehousesUseCaseProvider);
return WarehouseNotifier(getWarehousesUseCase);
});
// ==============================================================================
// USAGE IN WIDGETS
// ==============================================================================
/*
// Example 1: In WarehouseSelectionPage
class WarehouseSelectionPage extends ConsumerStatefulWidget {
const WarehouseSelectionPage({super.key});
@override
ConsumerState<WarehouseSelectionPage> createState() =>
_WarehouseSelectionPageState();
}
class _WarehouseSelectionPageState extends ConsumerState<WarehouseSelectionPage> {
@override
void initState() {
super.initState();
// Load warehouses when page is created
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(warehouseProvider.notifier).loadWarehouses();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(warehouseProvider);
return Scaffold(
appBar: AppBar(title: const Text('Select Warehouse')),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: state.error != null
? Center(child: Text('Error: ${state.error}'))
: ListView.builder(
itemCount: state.warehouses.length,
itemBuilder: (context, index) {
final warehouse = state.warehouses[index];
return ListTile(
title: Text(warehouse.name),
subtitle: Text('Code: ${warehouse.code}'),
onTap: () {
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Navigate to next screen
context.push('/operations', extra: warehouse);
},
);
},
),
);
}
}
// Example 2: Accessing selected warehouse in another page
class OperationSelectionPage extends ConsumerWidget {
const OperationSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(warehouseProvider);
final selectedWarehouse = state.selectedWarehouse;
return Scaffold(
appBar: AppBar(
title: Text('Warehouse: ${selectedWarehouse?.code ?? 'None'}'),
),
body: Center(
child: Text('Selected: ${selectedWarehouse?.name ?? 'No selection'}'),
),
);
}
}
// Example 3: Manually loading warehouses on button press
ElevatedButton(
onPressed: () {
ref.read(warehouseProvider.notifier).loadWarehouses();
},
child: const Text('Load Warehouses'),
);
// Example 4: Refresh warehouses
RefreshIndicator(
onRefresh: () => ref.read(warehouseProvider.notifier).refresh(),
child: ListView(...),
);
*/