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