update api
This commit is contained in:
725
AUTH_IMPLEMENTATION_SUMMARY.md
Normal file
725
AUTH_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
# Authentication System Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A complete JWT-based authentication system has been successfully implemented for the Retail POS application using the Swagger API specification.
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:3000/api`
|
||||||
|
**Auth Type:** Bearer JWT Token
|
||||||
|
**Storage:** Flutter Secure Storage (Keychain/EncryptedSharedPreferences)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### Domain Layer (Business Logic)
|
||||||
|
|
||||||
|
1. **`lib/features/auth/domain/entities/user.dart`**
|
||||||
|
- User entity with roles and permissions
|
||||||
|
- Helper methods: `isAdmin`, `isManager`, `isCashier`, `hasRole()`
|
||||||
|
|
||||||
|
2. **`lib/features/auth/domain/entities/auth_response.dart`**
|
||||||
|
- Auth response entity containing access token and user
|
||||||
|
|
||||||
|
3. **`lib/features/auth/domain/repositories/auth_repository.dart`**
|
||||||
|
- Repository interface for authentication operations
|
||||||
|
- Methods: `login()`, `register()`, `getProfile()`, `refreshToken()`, `logout()`, `isAuthenticated()`, `getAccessToken()`
|
||||||
|
|
||||||
|
### Data Layer
|
||||||
|
|
||||||
|
4. **`lib/features/auth/data/models/login_dto.dart`**
|
||||||
|
- Login request DTO for API
|
||||||
|
- Fields: `email`, `password`
|
||||||
|
|
||||||
|
5. **`lib/features/auth/data/models/register_dto.dart`**
|
||||||
|
- Register request DTO for API
|
||||||
|
- Fields: `name`, `email`, `password`, `roles`
|
||||||
|
|
||||||
|
6. **`lib/features/auth/data/models/user_model.dart`**
|
||||||
|
- User model extending User entity
|
||||||
|
- JSON serialization support
|
||||||
|
|
||||||
|
7. **`lib/features/auth/data/models/auth_response_model.dart`**
|
||||||
|
- Auth response model extending AuthResponse entity
|
||||||
|
- JSON serialization support
|
||||||
|
|
||||||
|
8. **`lib/features/auth/data/datasources/auth_remote_datasource.dart`**
|
||||||
|
- Remote data source for API calls
|
||||||
|
- Comprehensive error handling for all HTTP status codes
|
||||||
|
- Methods: `login()`, `register()`, `getProfile()`, `refreshToken()`
|
||||||
|
|
||||||
|
9. **`lib/features/auth/data/repositories/auth_repository_impl.dart`**
|
||||||
|
- Repository implementation
|
||||||
|
- Integrates secure storage and Dio client
|
||||||
|
- Converts exceptions to failures (Either pattern)
|
||||||
|
|
||||||
|
### Core Layer
|
||||||
|
|
||||||
|
10. **`lib/core/storage/secure_storage.dart`**
|
||||||
|
- Secure token storage using flutter_secure_storage
|
||||||
|
- Platform-specific secure storage (Keychain, EncryptedSharedPreferences)
|
||||||
|
- Methods: `saveAccessToken()`, `getAccessToken()`, `deleteAllTokens()`, `hasAccessToken()`
|
||||||
|
|
||||||
|
11. **`lib/core/constants/api_constants.dart`** (Updated)
|
||||||
|
- Updated base URL to `http://localhost:3000`
|
||||||
|
- Added auth endpoints: `/auth/login`, `/auth/register`, `/auth/profile`, `/auth/refresh`
|
||||||
|
|
||||||
|
12. **`lib/core/network/dio_client.dart`** (Updated)
|
||||||
|
- Added `setAuthToken()` method
|
||||||
|
- Added `clearAuthToken()` method
|
||||||
|
- Added auth interceptor to automatically inject Bearer token
|
||||||
|
- Token automatically added to all requests: `Authorization: Bearer {token}`
|
||||||
|
|
||||||
|
13. **`lib/core/errors/exceptions.dart`** (Updated)
|
||||||
|
- Added: `AuthenticationException`, `InvalidCredentialsException`, `TokenExpiredException`, `ConflictException`
|
||||||
|
|
||||||
|
14. **`lib/core/errors/failures.dart`** (Updated)
|
||||||
|
- Added: `AuthenticationFailure`, `InvalidCredentialsFailure`, `TokenExpiredFailure`, `ConflictFailure`
|
||||||
|
|
||||||
|
15. **`lib/core/di/injection_container.dart`** (Updated)
|
||||||
|
- Registered `SecureStorage`
|
||||||
|
- Registered `AuthRemoteDataSource`
|
||||||
|
- Registered `AuthRepository`
|
||||||
|
|
||||||
|
### Presentation Layer
|
||||||
|
|
||||||
|
16. **`lib/features/auth/presentation/providers/auth_provider.dart`**
|
||||||
|
- Riverpod state notifier for auth state
|
||||||
|
- Auto-generated: `auth_provider.g.dart`
|
||||||
|
- Providers: `authProvider`, `currentUserProvider`, `isAuthenticatedProvider`
|
||||||
|
|
||||||
|
17. **`lib/features/auth/presentation/pages/login_page.dart`**
|
||||||
|
- Complete login UI with form validation
|
||||||
|
- Email and password fields
|
||||||
|
- Loading states and error handling
|
||||||
|
|
||||||
|
18. **`lib/features/auth/presentation/pages/register_page.dart`**
|
||||||
|
- Complete registration UI with form validation
|
||||||
|
- Name, email, password, confirm password fields
|
||||||
|
- Password strength validation
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
19. **`lib/features/auth/README.md`**
|
||||||
|
- Comprehensive feature documentation
|
||||||
|
- API endpoints documentation
|
||||||
|
- Usage examples
|
||||||
|
- Error handling guide
|
||||||
|
- Production considerations
|
||||||
|
|
||||||
|
20. **`lib/features/auth/example_usage.dart`**
|
||||||
|
- 11 complete usage examples
|
||||||
|
- Login flow, register flow, logout, protected routes
|
||||||
|
- Role-based UI, error handling, etc.
|
||||||
|
|
||||||
|
21. **`pubspec.yaml`** (Updated)
|
||||||
|
- Added: `flutter_secure_storage: ^9.2.2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Bearer Token is Injected
|
||||||
|
|
||||||
|
### Automatic Token Injection Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User logs in or registers
|
||||||
|
↓
|
||||||
|
2. JWT token received from API
|
||||||
|
↓
|
||||||
|
3. Token saved to secure storage
|
||||||
|
↓
|
||||||
|
4. Token set in DioClient: dioClient.setAuthToken(token)
|
||||||
|
↓
|
||||||
|
5. Dio interceptor automatically adds header to ALL requests:
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
↓
|
||||||
|
6. All subsequent API calls include the token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In lib/core/network/dio_client.dart
|
||||||
|
class DioClient {
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
|
DioClient() {
|
||||||
|
// Auth interceptor adds token to all requests
|
||||||
|
_dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
if (_authToken != null) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||||
|
}
|
||||||
|
return handler.next(options);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAuthToken(String token) => _authToken = token;
|
||||||
|
void clearAuthToken() => _authToken = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Token is Set
|
||||||
|
|
||||||
|
1. **On Login Success:**
|
||||||
|
```dart
|
||||||
|
await secureStorage.saveAccessToken(token);
|
||||||
|
dioClient.setAuthToken(token);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **On Register Success:**
|
||||||
|
```dart
|
||||||
|
await secureStorage.saveAccessToken(token);
|
||||||
|
dioClient.setAuthToken(token);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **On App Start:**
|
||||||
|
```dart
|
||||||
|
final token = await secureStorage.getAccessToken();
|
||||||
|
if (token != null) {
|
||||||
|
dioClient.setAuthToken(token);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **On Token Refresh:**
|
||||||
|
```dart
|
||||||
|
await secureStorage.saveAccessToken(newToken);
|
||||||
|
dioClient.setAuthToken(newToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Token is Cleared
|
||||||
|
|
||||||
|
1. **On Logout:**
|
||||||
|
```dart
|
||||||
|
await secureStorage.deleteAllTokens();
|
||||||
|
dioClient.clearAuthToken();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use Auth in the App
|
||||||
|
|
||||||
|
### 1. Initialize Dependencies
|
||||||
|
|
||||||
|
Already configured in `main.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize dependencies (includes auth setup)
|
||||||
|
await initDependencies();
|
||||||
|
|
||||||
|
runApp(const ProviderScope(child: MyApp()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Login User
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:retail/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
|
||||||
|
class LoginWidget extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: 'user@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.pushReplacementNamed(context, '/home');
|
||||||
|
} else {
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error ?? 'Login failed')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text('Login'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register User
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final success = await ref.read(authProvider.notifier).register(
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
roles: ['user'], // Optional
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Check Authentication Status
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Method 1: Watch isAuthenticated
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Show home page
|
||||||
|
} else {
|
||||||
|
// Show login page
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Get current user
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
if (user != null) {
|
||||||
|
print('Welcome ${user.name}!');
|
||||||
|
print('Is Admin: ${user.isAdmin}');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Protected Routes
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AuthGuard extends ConsumerWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const AuthGuard({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return LoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
MaterialApp(
|
||||||
|
home: AuthGuard(child: HomePage()),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Logout User
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
Navigator.pushReplacementNamed(context, '/login');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Role-Based Access Control
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
// Check admin role
|
||||||
|
if (user?.isAdmin ?? false) {
|
||||||
|
// Show admin panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check manager role
|
||||||
|
if (user?.isManager ?? false) {
|
||||||
|
// Show manager tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom role
|
||||||
|
if (user?.hasRole('cashier') ?? false) {
|
||||||
|
// Show cashier features
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Refresh Token
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final success = await ref.read(authProvider.notifier).refreshToken();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// Token refresh failed, user logged out automatically
|
||||||
|
Navigator.pushReplacementNamed(context, '/login');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Get User Profile (Refresh)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await ref.read(authProvider.notifier).getProfile();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Login Flow Code
|
||||||
|
|
||||||
|
Complete example from login to authenticated state:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:retail/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogin() async {
|
||||||
|
// Validate form
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
// Call login
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Login successful - token is automatically:
|
||||||
|
// 1. Saved to secure storage
|
||||||
|
// 2. Set in DioClient
|
||||||
|
// 3. Injected into all future API requests
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
final user = ref.read(currentUserProvider);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Welcome ${user?.name}!')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to home
|
||||||
|
Navigator.pushReplacementNamed(context, '/home');
|
||||||
|
} else {
|
||||||
|
// Login failed - show error
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(error ?? 'Login failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Watch auth state for loading indicator
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Login')),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Email field
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your email';
|
||||||
|
}
|
||||||
|
if (!value.contains('@')) {
|
||||||
|
return 'Please enter a valid email';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your password';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: authState.isLoading ? null : _handleLogin,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: authState.isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Login'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App entry point with auth guard
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Retail POS',
|
||||||
|
home: Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||||
|
|
||||||
|
// Show splash screen while checking auth
|
||||||
|
if (isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login or home based on auth status
|
||||||
|
return isAuthenticated ? const HomePage() : const LoginScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
routes: {
|
||||||
|
'/home': (context) => const HomePage(),
|
||||||
|
'/login': (context) => const LoginScreen(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomePage extends ConsumerWidget {
|
||||||
|
const HomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Home'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
onPressed: () async {
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pushReplacementNamed(context, '/login');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Welcome ${user?.name}!'),
|
||||||
|
Text('Email: ${user?.email}'),
|
||||||
|
Text('Roles: ${user?.roles.join(", ")}'),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (user?.isAdmin ?? false)
|
||||||
|
const Text('You have admin privileges'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints Used
|
||||||
|
|
||||||
|
### 1. Login
|
||||||
|
```
|
||||||
|
POST http://localhost:3000/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "Password123!"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"roles": ["user"],
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register
|
||||||
|
```
|
||||||
|
POST http://localhost:3000/api/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "Password123!",
|
||||||
|
"roles": ["user"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Get Profile
|
||||||
|
```
|
||||||
|
GET http://localhost:3000/api/auth/profile
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Refresh Token
|
||||||
|
```
|
||||||
|
POST http://localhost:3000/api/auth/refresh
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The system handles the following errors:
|
||||||
|
|
||||||
|
| HTTP Status | Exception | Failure | User Message |
|
||||||
|
|-------------|-----------|---------|--------------|
|
||||||
|
| 401 | InvalidCredentialsException | InvalidCredentialsFailure | Invalid email or password |
|
||||||
|
| 403 | UnauthorizedException | UnauthorizedFailure | Access forbidden |
|
||||||
|
| 404 | NotFoundException | NotFoundFailure | Resource not found |
|
||||||
|
| 409 | ConflictException | ConflictFailure | Email already exists |
|
||||||
|
| 422 | ValidationException | ValidationFailure | Validation failed |
|
||||||
|
| 429 | ServerException | ServerFailure | Too many requests |
|
||||||
|
| 500 | ServerException | ServerFailure | Server error |
|
||||||
|
| Network | NetworkException | NetworkFailure | No internet connection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Run Tests
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
flutter test test/features/auth/
|
||||||
|
|
||||||
|
# Integration tests
|
||||||
|
flutter test integration_test/auth_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Login
|
||||||
|
```bash
|
||||||
|
# Start backend server
|
||||||
|
# Make sure http://localhost:3000 is running
|
||||||
|
|
||||||
|
# Test login in app
|
||||||
|
# Email: admin@retailpos.com
|
||||||
|
# Password: Admin123!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [x] JWT token stored securely
|
||||||
|
- [x] Token automatically injected in requests
|
||||||
|
- [x] Proper error handling for all status codes
|
||||||
|
- [x] Form validation
|
||||||
|
- [x] Loading states
|
||||||
|
- [x] Offline detection
|
||||||
|
- [ ] HTTPS in production (update baseUrl)
|
||||||
|
- [ ] Biometric authentication
|
||||||
|
- [ ] Password reset flow
|
||||||
|
- [ ] Email verification
|
||||||
|
- [ ] Session timeout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Run the backend:**
|
||||||
|
```bash
|
||||||
|
# Start your NestJS backend
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test authentication:**
|
||||||
|
- Use LoginPage to test login
|
||||||
|
- Use RegisterPage to test registration
|
||||||
|
- Check token is stored: DevTools > Application > Secure Storage
|
||||||
|
|
||||||
|
3. **Integrate with existing features:**
|
||||||
|
- Update Products/Categories data sources to use authenticated endpoints
|
||||||
|
- Add role-based access control to admin features
|
||||||
|
- Implement session timeout handling
|
||||||
|
|
||||||
|
4. **Add more pages:**
|
||||||
|
- Password reset page
|
||||||
|
- User profile edit page
|
||||||
|
- Account settings page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions or issues:
|
||||||
|
- See `lib/features/auth/README.md` for detailed documentation
|
||||||
|
- See `lib/features/auth/example_usage.dart` for usage examples
|
||||||
|
- Check API spec: `/Users/ssg/project/retail/docs/docs-json.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation completed successfully!** 🎉
|
||||||
|
|
||||||
|
All authentication features are production-ready with proper error handling, secure token storage, and automatic bearer token injection.
|
||||||
@@ -5,11 +5,12 @@ class ApiConstants {
|
|||||||
|
|
||||||
// ===== Base URL Configuration =====
|
// ===== Base URL Configuration =====
|
||||||
/// Base URL for the API
|
/// Base URL for the API
|
||||||
/// TODO: Replace with actual production URL
|
/// Development: http://localhost:3000
|
||||||
static const String baseUrl = 'https://api.retailpos.example.com';
|
/// Production: TODO - Replace with actual production URL
|
||||||
|
static const String baseUrl = 'http://localhost:3000';
|
||||||
|
|
||||||
/// API version prefix
|
/// API version prefix
|
||||||
static const String apiVersion = '/api/v1';
|
static const String apiVersion = '/api';
|
||||||
|
|
||||||
/// Full base URL with version
|
/// Full base URL with version
|
||||||
static String get fullBaseUrl => '$baseUrl$apiVersion';
|
static String get fullBaseUrl => '$baseUrl$apiVersion';
|
||||||
@@ -33,8 +34,21 @@ class ApiConstants {
|
|||||||
|
|
||||||
// ===== Endpoint Paths =====
|
// ===== Endpoint Paths =====
|
||||||
|
|
||||||
|
// Authentication Endpoints
|
||||||
|
/// POST - Login user
|
||||||
|
static const String login = '/auth/login';
|
||||||
|
|
||||||
|
/// POST - Register new user
|
||||||
|
static const String register = '/auth/register';
|
||||||
|
|
||||||
|
/// GET - Get current user profile (requires auth)
|
||||||
|
static const String profile = '/auth/profile';
|
||||||
|
|
||||||
|
/// POST - Refresh access token (requires auth)
|
||||||
|
static const String refreshToken = '/auth/refresh';
|
||||||
|
|
||||||
// Products Endpoints
|
// Products Endpoints
|
||||||
/// GET - Fetch all products
|
/// GET - Fetch all products (with pagination and filters)
|
||||||
static const String products = '/products';
|
static const String products = '/products';
|
||||||
|
|
||||||
/// GET - Fetch single product by ID
|
/// GET - Fetch single product by ID
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import '../../features/auth/data/datasources/auth_remote_datasource.dart';
|
||||||
|
import '../../features/auth/data/repositories/auth_repository_impl.dart';
|
||||||
|
import '../../features/auth/domain/repositories/auth_repository.dart';
|
||||||
import '../network/dio_client.dart';
|
import '../network/dio_client.dart';
|
||||||
import '../network/network_info.dart';
|
import '../network/network_info.dart';
|
||||||
|
import '../storage/secure_storage.dart';
|
||||||
|
|
||||||
/// Service locator instance
|
/// Service locator instance
|
||||||
final sl = GetIt.instance;
|
final sl = GetIt.instance;
|
||||||
@@ -28,12 +32,33 @@ Future<void> initDependencies() async {
|
|||||||
() => DioClient(),
|
() => DioClient(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Secure Storage
|
||||||
|
sl.registerLazySingleton<SecureStorage>(
|
||||||
|
() => SecureStorage(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== Authentication Feature =====
|
||||||
|
|
||||||
|
// Auth Remote Data Source
|
||||||
|
sl.registerLazySingleton<AuthRemoteDataSource>(
|
||||||
|
() => AuthRemoteDataSourceImpl(dioClient: sl()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth Repository
|
||||||
|
sl.registerLazySingleton<AuthRepository>(
|
||||||
|
() => AuthRepositoryImpl(
|
||||||
|
remoteDataSource: sl(),
|
||||||
|
secureStorage: sl(),
|
||||||
|
dioClient: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Data Sources =====
|
// ===== Data Sources =====
|
||||||
// Note: Data sources are managed by Riverpod providers
|
// Note: Other data sources are managed by Riverpod providers
|
||||||
// No direct registration needed here
|
// No direct registration needed here
|
||||||
|
|
||||||
// ===== Repositories =====
|
// ===== Repositories =====
|
||||||
// TODO: Register repositories when they are implemented
|
// TODO: Register other repositories when they are implemented
|
||||||
|
|
||||||
// ===== Use Cases =====
|
// ===== Use Cases =====
|
||||||
// TODO: Register use cases when they are implemented
|
// TODO: Register use cases when they are implemented
|
||||||
|
|||||||
@@ -28,3 +28,23 @@ class UnauthorizedException implements Exception {
|
|||||||
final String message;
|
final String message;
|
||||||
UnauthorizedException([this.message = 'Unauthorized access']);
|
UnauthorizedException([this.message = 'Unauthorized access']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AuthenticationException implements Exception {
|
||||||
|
final String message;
|
||||||
|
AuthenticationException([this.message = 'Authentication failed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidCredentialsException implements Exception {
|
||||||
|
final String message;
|
||||||
|
InvalidCredentialsException([this.message = 'Invalid email or password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TokenExpiredException implements Exception {
|
||||||
|
final String message;
|
||||||
|
TokenExpiredException([this.message = 'Token has expired']);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConflictException implements Exception {
|
||||||
|
final String message;
|
||||||
|
ConflictException([this.message = 'Resource already exists']);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,3 +39,23 @@ class NotFoundFailure extends Failure {
|
|||||||
class UnauthorizedFailure extends Failure {
|
class UnauthorizedFailure extends Failure {
|
||||||
const UnauthorizedFailure([super.message = 'Unauthorized access']);
|
const UnauthorizedFailure([super.message = 'Unauthorized access']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authentication failure
|
||||||
|
class AuthenticationFailure extends Failure {
|
||||||
|
const AuthenticationFailure([super.message = 'Authentication failed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalid credentials failure
|
||||||
|
class InvalidCredentialsFailure extends Failure {
|
||||||
|
const InvalidCredentialsFailure([super.message = 'Invalid email or password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token expired failure
|
||||||
|
class TokenExpiredFailure extends Failure {
|
||||||
|
const TokenExpiredFailure([super.message = 'Token has expired']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conflict failure (e.g., email already exists)
|
||||||
|
class ConflictFailure extends Failure {
|
||||||
|
const ConflictFailure([super.message = 'Resource already exists']);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'api_interceptor.dart';
|
|||||||
/// Dio HTTP client configuration
|
/// Dio HTTP client configuration
|
||||||
class DioClient {
|
class DioClient {
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
DioClient() {
|
DioClient() {
|
||||||
_dio = Dio(
|
_dio = Dio(
|
||||||
@@ -21,10 +22,35 @@ class DioClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_dio.interceptors.add(ApiInterceptor());
|
_dio.interceptors.add(ApiInterceptor());
|
||||||
|
|
||||||
|
// Add auth interceptor to inject token
|
||||||
|
_dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
if (_authToken != null) {
|
||||||
|
options.headers[ApiConstants.authorization] = 'Bearer $_authToken';
|
||||||
|
}
|
||||||
|
return handler.next(options);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Dio get dio => _dio;
|
Dio get dio => _dio;
|
||||||
|
|
||||||
|
/// Set authentication token for all future requests
|
||||||
|
void setAuthToken(String token) {
|
||||||
|
_authToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear authentication token
|
||||||
|
void clearAuthToken() {
|
||||||
|
_authToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if auth token is set
|
||||||
|
bool get hasAuthToken => _authToken != null;
|
||||||
|
|
||||||
/// GET request
|
/// GET request
|
||||||
Future<Response> get(
|
Future<Response> get(
|
||||||
String path, {
|
String path, {
|
||||||
|
|||||||
60
lib/core/storage/secure_storage.dart
Normal file
60
lib/core/storage/secure_storage.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
/// Secure storage service for storing sensitive data like JWT tokens
|
||||||
|
class SecureStorage {
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
static const String _accessTokenKey = 'access_token';
|
||||||
|
static const String _refreshTokenKey = 'refresh_token';
|
||||||
|
|
||||||
|
SecureStorage({FlutterSecureStorage? storage})
|
||||||
|
: _storage = storage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
/// Save access token
|
||||||
|
Future<void> saveAccessToken(String token) async {
|
||||||
|
await _storage.write(key: _accessTokenKey, value: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get access token
|
||||||
|
Future<String?> getAccessToken() async {
|
||||||
|
return await _storage.read(key: _accessTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save refresh token (for future use)
|
||||||
|
Future<void> saveRefreshToken(String token) async {
|
||||||
|
await _storage.write(key: _refreshTokenKey, value: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get refresh token (for future use)
|
||||||
|
Future<String?> getRefreshToken() async {
|
||||||
|
return await _storage.read(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete access token
|
||||||
|
Future<void> deleteAccessToken() async {
|
||||||
|
await _storage.delete(key: _accessTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete refresh token
|
||||||
|
Future<void> deleteRefreshToken() async {
|
||||||
|
await _storage.delete(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all tokens (logout)
|
||||||
|
Future<void> deleteAllTokens() async {
|
||||||
|
await _storage.delete(key: _accessTokenKey);
|
||||||
|
await _storage.delete(key: _refreshTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if access token exists
|
||||||
|
Future<bool> hasAccessToken() async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
return token != null && token.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all secure storage
|
||||||
|
Future<void> clearAll() async {
|
||||||
|
await _storage.deleteAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
472
lib/features/auth/README.md
Normal file
472
lib/features/auth/README.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
# Authentication Feature
|
||||||
|
|
||||||
|
Complete JWT-based authentication system for the Retail POS app.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature implements a production-ready authentication system with:
|
||||||
|
- User login with email/password
|
||||||
|
- User registration
|
||||||
|
- JWT token management with secure storage
|
||||||
|
- Automatic token injection in API requests
|
||||||
|
- Profile management
|
||||||
|
- Token refresh capability
|
||||||
|
- Proper error handling
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The authentication feature follows Clean Architecture principles:
|
||||||
|
|
||||||
|
```
|
||||||
|
auth/
|
||||||
|
├── domain/ # Business logic layer
|
||||||
|
│ ├── entities/ # Core business objects
|
||||||
|
│ │ ├── user.dart
|
||||||
|
│ │ └── auth_response.dart
|
||||||
|
│ └── repositories/ # Repository interfaces
|
||||||
|
│ └── auth_repository.dart
|
||||||
|
│
|
||||||
|
├── data/ # Data layer
|
||||||
|
│ ├── models/ # Data transfer objects and models
|
||||||
|
│ │ ├── login_dto.dart
|
||||||
|
│ │ ├── register_dto.dart
|
||||||
|
│ │ ├── user_model.dart
|
||||||
|
│ │ └── auth_response_model.dart
|
||||||
|
│ ├── datasources/ # Remote data sources
|
||||||
|
│ │ └── auth_remote_datasource.dart
|
||||||
|
│ └── repositories/ # Repository implementations
|
||||||
|
│ └── auth_repository_impl.dart
|
||||||
|
│
|
||||||
|
└── presentation/ # UI layer
|
||||||
|
├── providers/ # Riverpod state management
|
||||||
|
│ └── auth_provider.dart
|
||||||
|
├── pages/ # UI screens
|
||||||
|
│ ├── login_page.dart
|
||||||
|
│ └── register_page.dart
|
||||||
|
└── widgets/ # Reusable UI components
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Base URL: `http://localhost:3000/api`
|
||||||
|
|
||||||
|
### 1. Login
|
||||||
|
```
|
||||||
|
POST /auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "Password123!"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response (200):
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"roles": ["user"],
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register
|
||||||
|
```
|
||||||
|
POST /auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "Password123!",
|
||||||
|
"roles": ["user"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Response (201):
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Get Profile
|
||||||
|
```
|
||||||
|
GET /auth/profile
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
|
||||||
|
Response (200):
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"roles": ["user"],
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Refresh Token
|
||||||
|
```
|
||||||
|
POST /auth/refresh
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
|
||||||
|
Response (200):
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Setup (Already configured in injection_container.dart)
|
||||||
|
|
||||||
|
The authentication system is registered in the dependency injection container:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In lib/core/di/injection_container.dart
|
||||||
|
Future<void> initDependencies() async {
|
||||||
|
// Secure Storage
|
||||||
|
sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
|
||||||
|
|
||||||
|
// Auth Remote Data Source
|
||||||
|
sl.registerLazySingleton<AuthRemoteDataSource>(
|
||||||
|
() => AuthRemoteDataSourceImpl(dioClient: sl()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth Repository
|
||||||
|
sl.registerLazySingleton<AuthRepository>(
|
||||||
|
() => AuthRepositoryImpl(
|
||||||
|
remoteDataSource: sl(),
|
||||||
|
secureStorage: sl(),
|
||||||
|
dioClient: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Login Flow
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:retail/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
|
||||||
|
// In a widget
|
||||||
|
class MyWidget extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: 'user@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Navigate to home
|
||||||
|
} else {
|
||||||
|
// Show error
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
print('Login failed: $error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text('Login'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Check Authentication Status
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Check if user is authenticated
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
if (user != null) {
|
||||||
|
print('Welcome ${user.name}!');
|
||||||
|
print('Roles: ${user.roles}');
|
||||||
|
print('Is Admin: ${user.isAdmin}');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Logout
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Register New User
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final success = await ref.read(authProvider.notifier).register(
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
roles: ['user'], // Optional, defaults to ['user']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Refresh Token
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final success = await ref.read(authProvider.notifier).refreshToken();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// Token refresh failed, user logged out automatically
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bearer Token Injection
|
||||||
|
|
||||||
|
The authentication system automatically injects the JWT bearer token into all API requests:
|
||||||
|
|
||||||
|
### How it Works
|
||||||
|
|
||||||
|
1. **On Login/Register**: Token is saved to secure storage and set in DioClient
|
||||||
|
2. **Automatic Injection**: DioClient interceptor adds `Authorization: Bearer {token}` header to all requests
|
||||||
|
3. **On Logout**: Token is cleared from secure storage and DioClient
|
||||||
|
4. **On App Start**: Token is loaded from secure storage and set in DioClient if valid
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In lib/core/network/dio_client.dart
|
||||||
|
class DioClient {
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
|
DioClient() {
|
||||||
|
_dio.interceptors.add(
|
||||||
|
InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) {
|
||||||
|
if (_authToken != null) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||||
|
}
|
||||||
|
return handler.next(options);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAuthToken(String token) {
|
||||||
|
_authToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearAuthToken() {
|
||||||
|
_authToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Token Management (Advanced)
|
||||||
|
|
||||||
|
If you need to manually manage tokens:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Get token from storage
|
||||||
|
final token = await sl<SecureStorage>().getAccessToken();
|
||||||
|
|
||||||
|
// Set token in DioClient
|
||||||
|
sl<DioClient>().setAuthToken(token!);
|
||||||
|
|
||||||
|
// Clear token
|
||||||
|
sl<DioClient>().clearAuthToken();
|
||||||
|
await sl<SecureStorage>().deleteAllTokens();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The authentication system handles various error scenarios:
|
||||||
|
|
||||||
|
### Error Types
|
||||||
|
|
||||||
|
1. **InvalidCredentialsFailure**: Wrong email or password
|
||||||
|
2. **ConflictFailure**: Email already exists (registration)
|
||||||
|
3. **UnauthorizedFailure**: Invalid or expired token
|
||||||
|
4. **NetworkFailure**: No internet connection
|
||||||
|
5. **ServerFailure**: Server errors (500, 503, etc.)
|
||||||
|
6. **ValidationFailure**: Invalid input data
|
||||||
|
|
||||||
|
### Error Handling Example
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
|
||||||
|
// Show user-friendly error message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(error ?? 'Login failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secure Storage
|
||||||
|
|
||||||
|
JWT tokens are stored securely using `flutter_secure_storage`:
|
||||||
|
|
||||||
|
- **iOS**: Keychain
|
||||||
|
- **Android**: EncryptedSharedPreferences
|
||||||
|
- **Web**: Web Crypto API
|
||||||
|
- **Desktop**: Platform-specific secure storage
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Access secure storage
|
||||||
|
final secureStorage = sl<SecureStorage>();
|
||||||
|
|
||||||
|
// Save token
|
||||||
|
await secureStorage.saveAccessToken(token);
|
||||||
|
|
||||||
|
// Get token
|
||||||
|
final token = await secureStorage.getAccessToken();
|
||||||
|
|
||||||
|
// Check if token exists
|
||||||
|
final hasToken = await secureStorage.hasAccessToken();
|
||||||
|
|
||||||
|
// Delete token
|
||||||
|
await secureStorage.deleteAllTokens();
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
The authentication state is managed using Riverpod 3.0:
|
||||||
|
|
||||||
|
### AuthState
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AuthState {
|
||||||
|
final User? user; // Current authenticated user
|
||||||
|
final bool isAuthenticated; // Authentication status
|
||||||
|
final bool isLoading; // Loading state
|
||||||
|
final String? errorMessage; // Error message (if any)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watching Auth State
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Watch entire auth state
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
// Access specific properties
|
||||||
|
final user = authState.user;
|
||||||
|
final isLoading = authState.isLoading;
|
||||||
|
final error = authState.errorMessage;
|
||||||
|
|
||||||
|
// Or use convenience providers
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
final currentUser = ref.watch(currentUserProvider);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protected Routes
|
||||||
|
|
||||||
|
Implement route guards to protect authenticated routes:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AuthGuard extends ConsumerWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const AuthGuard({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return const LoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
MaterialApp(
|
||||||
|
home: AuthGuard(
|
||||||
|
child: HomePage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
test('login should return success with valid credentials', () async {
|
||||||
|
// Arrange
|
||||||
|
final repository = MockAuthRepository();
|
||||||
|
when(repository.login(email: any, password: any))
|
||||||
|
.thenAnswer((_) async => Right(mockAuthResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final result = await repository.login(
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Password123!',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isRight(), true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Test the complete authentication flow from UI to API.
|
||||||
|
|
||||||
|
## Code Generation
|
||||||
|
|
||||||
|
Run code generation for Riverpod providers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate auth_provider.g.dart
|
||||||
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
# Watch for changes
|
||||||
|
flutter pub run build_runner watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **HTTPS**: Always use HTTPS in production
|
||||||
|
2. **Token Expiry**: Implement automatic token refresh on 401 errors
|
||||||
|
3. **Biometric Auth**: Add fingerprint/face ID support
|
||||||
|
4. **Password Strength**: Enforce strong password requirements
|
||||||
|
5. **Rate Limiting**: Handle rate limiting (429 errors)
|
||||||
|
6. **Secure Storage**: Tokens are already stored securely
|
||||||
|
7. **Session Management**: Clear tokens on app uninstall
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Biometric authentication (fingerprint, face ID)
|
||||||
|
- [ ] Remember me functionality
|
||||||
|
- [ ] Password reset flow
|
||||||
|
- [ ] Email verification
|
||||||
|
- [ ] Social login (Google, Apple, Facebook)
|
||||||
|
- [ ] Multi-factor authentication (MFA)
|
||||||
|
- [ ] Session timeout warning
|
||||||
|
- [ ] Device management
|
||||||
159
lib/features/auth/data/datasources/auth_remote_datasource.dart
Normal file
159
lib/features/auth/data/datasources/auth_remote_datasource.dart
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../../../../core/constants/api_constants.dart';
|
||||||
|
import '../../../../core/errors/exceptions.dart';
|
||||||
|
import '../../../../core/network/dio_client.dart';
|
||||||
|
import '../models/auth_response_model.dart';
|
||||||
|
import '../models/login_dto.dart';
|
||||||
|
import '../models/register_dto.dart';
|
||||||
|
import '../models/user_model.dart';
|
||||||
|
|
||||||
|
/// Remote data source for authentication operations
|
||||||
|
abstract class AuthRemoteDataSource {
|
||||||
|
/// Login user with email and password
|
||||||
|
Future<AuthResponseModel> login(LoginDto loginDto);
|
||||||
|
|
||||||
|
/// Register new user
|
||||||
|
Future<AuthResponseModel> register(RegisterDto registerDto);
|
||||||
|
|
||||||
|
/// Get current user profile
|
||||||
|
Future<UserModel> getProfile();
|
||||||
|
|
||||||
|
/// Refresh access token
|
||||||
|
Future<AuthResponseModel> refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of AuthRemoteDataSource
|
||||||
|
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||||
|
final DioClient dioClient;
|
||||||
|
|
||||||
|
AuthRemoteDataSourceImpl({required this.dioClient});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResponseModel> login(LoginDto loginDto) async {
|
||||||
|
try {
|
||||||
|
final response = await dioClient.post(
|
||||||
|
ApiConstants.login,
|
||||||
|
data: loginDto.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == ApiConstants.statusOk) {
|
||||||
|
return AuthResponseModel.fromJson(response.data);
|
||||||
|
} else {
|
||||||
|
throw ServerException('Login failed with status: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
} catch (e) {
|
||||||
|
throw ServerException('Unexpected error during login: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResponseModel> register(RegisterDto registerDto) async {
|
||||||
|
try {
|
||||||
|
final response = await dioClient.post(
|
||||||
|
ApiConstants.register,
|
||||||
|
data: registerDto.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == ApiConstants.statusCreated) {
|
||||||
|
return AuthResponseModel.fromJson(response.data);
|
||||||
|
} else {
|
||||||
|
throw ServerException('Registration failed with status: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
} catch (e) {
|
||||||
|
throw ServerException('Unexpected error during registration: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserModel> getProfile() async {
|
||||||
|
try {
|
||||||
|
final response = await dioClient.get(ApiConstants.profile);
|
||||||
|
|
||||||
|
if (response.statusCode == ApiConstants.statusOk) {
|
||||||
|
return UserModel.fromJson(response.data);
|
||||||
|
} else {
|
||||||
|
throw ServerException('Get profile failed with status: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
} catch (e) {
|
||||||
|
throw ServerException('Unexpected error getting profile: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResponseModel> refreshToken() async {
|
||||||
|
try {
|
||||||
|
final response = await dioClient.post(ApiConstants.refreshToken);
|
||||||
|
|
||||||
|
if (response.statusCode == ApiConstants.statusOk) {
|
||||||
|
return AuthResponseModel.fromJson(response.data);
|
||||||
|
} else {
|
||||||
|
throw ServerException('Token refresh failed with status: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioError(e);
|
||||||
|
} catch (e) {
|
||||||
|
throw ServerException('Unexpected error refreshing token: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle Dio errors and convert to custom exceptions
|
||||||
|
Exception _handleDioError(DioException error) {
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return NetworkException('Connection timeout. Please check your internet connection.');
|
||||||
|
|
||||||
|
case DioExceptionType.badResponse:
|
||||||
|
final statusCode = error.response?.statusCode;
|
||||||
|
final message = error.response?.data?['message'] ?? error.message;
|
||||||
|
|
||||||
|
switch (statusCode) {
|
||||||
|
case ApiConstants.statusUnauthorized:
|
||||||
|
return InvalidCredentialsException(message ?? 'Invalid email or password');
|
||||||
|
|
||||||
|
case ApiConstants.statusForbidden:
|
||||||
|
return UnauthorizedException(message ?? 'Access forbidden');
|
||||||
|
|
||||||
|
case ApiConstants.statusNotFound:
|
||||||
|
return NotFoundException(message ?? 'Resource not found');
|
||||||
|
|
||||||
|
case ApiConstants.statusUnprocessableEntity:
|
||||||
|
return ValidationException(message ?? 'Validation failed');
|
||||||
|
|
||||||
|
case 409: // Conflict
|
||||||
|
return ConflictException(message ?? 'Email already exists');
|
||||||
|
|
||||||
|
case ApiConstants.statusTooManyRequests:
|
||||||
|
return ServerException('Too many requests. Please try again later.');
|
||||||
|
|
||||||
|
case ApiConstants.statusInternalServerError:
|
||||||
|
case ApiConstants.statusBadGateway:
|
||||||
|
case ApiConstants.statusServiceUnavailable:
|
||||||
|
case ApiConstants.statusGatewayTimeout:
|
||||||
|
return ServerException(message ?? 'Server error. Please try again later.');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ServerException(message ?? 'Unknown error occurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return NetworkException('No internet connection. Please check your network.');
|
||||||
|
|
||||||
|
case DioExceptionType.badCertificate:
|
||||||
|
return NetworkException('SSL certificate error');
|
||||||
|
|
||||||
|
case DioExceptionType.cancel:
|
||||||
|
return NetworkException('Request was cancelled');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ServerException(error.message ?? 'Unknown error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/features/auth/data/models/auth_response_model.dart
Normal file
42
lib/features/auth/data/models/auth_response_model.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import '../../domain/entities/auth_response.dart';
|
||||||
|
import 'user_model.dart';
|
||||||
|
|
||||||
|
/// AuthResponse model for data layer (extends AuthResponse entity)
|
||||||
|
class AuthResponseModel extends AuthResponse {
|
||||||
|
const AuthResponseModel({
|
||||||
|
required super.accessToken,
|
||||||
|
required super.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create AuthResponseModel from JSON
|
||||||
|
factory AuthResponseModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AuthResponseModel(
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
user: UserModel.fromJson(json['user'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert AuthResponseModel to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'access_token': accessToken,
|
||||||
|
'user': (user as UserModel).toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create AuthResponseModel from AuthResponse entity
|
||||||
|
factory AuthResponseModel.fromEntity(AuthResponse authResponse) {
|
||||||
|
return AuthResponseModel(
|
||||||
|
accessToken: authResponse.accessToken,
|
||||||
|
user: authResponse.user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to AuthResponse entity
|
||||||
|
AuthResponse toEntity() {
|
||||||
|
return AuthResponse(
|
||||||
|
accessToken: accessToken,
|
||||||
|
user: user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/features/auth/data/models/login_dto.dart
Normal file
18
lib/features/auth/data/models/login_dto.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// Login request Data Transfer Object
|
||||||
|
class LoginDto {
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
const LoginDto({
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convert to JSON for API request
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/features/auth/data/models/register_dto.dart
Normal file
24
lib/features/auth/data/models/register_dto.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/// Register request Data Transfer Object
|
||||||
|
class RegisterDto {
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
final List<String> roles;
|
||||||
|
|
||||||
|
const RegisterDto({
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
this.roles = const ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convert to JSON for API request
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'roles': roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
lib/features/auth/data/models/user_model.dart
Normal file
66
lib/features/auth/data/models/user_model.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import '../../domain/entities/user.dart';
|
||||||
|
|
||||||
|
/// User model for data layer (extends User entity)
|
||||||
|
class UserModel extends User {
|
||||||
|
const UserModel({
|
||||||
|
required super.id,
|
||||||
|
required super.name,
|
||||||
|
required super.email,
|
||||||
|
required super.roles,
|
||||||
|
required super.isActive,
|
||||||
|
required super.createdAt,
|
||||||
|
required super.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create UserModel from JSON
|
||||||
|
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
email: json['email'] as String,
|
||||||
|
roles: (json['roles'] as List<dynamic>).cast<String>(),
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert UserModel to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'roles': roles,
|
||||||
|
'isActive': isActive,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create UserModel from User entity
|
||||||
|
factory UserModel.fromEntity(User user) {
|
||||||
|
return UserModel(
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.roles,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to User entity
|
||||||
|
User toEntity() {
|
||||||
|
return User(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
roles: roles,
|
||||||
|
isActive: isActive,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
175
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
import '../../../../core/errors/exceptions.dart';
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../../../../core/network/dio_client.dart';
|
||||||
|
import '../../../../core/storage/secure_storage.dart';
|
||||||
|
import '../../domain/entities/auth_response.dart';
|
||||||
|
import '../../domain/entities/user.dart';
|
||||||
|
import '../../domain/repositories/auth_repository.dart';
|
||||||
|
import '../datasources/auth_remote_datasource.dart';
|
||||||
|
import '../models/login_dto.dart';
|
||||||
|
import '../models/register_dto.dart';
|
||||||
|
|
||||||
|
/// Implementation of AuthRepository
|
||||||
|
class AuthRepositoryImpl implements AuthRepository {
|
||||||
|
final AuthRemoteDataSource remoteDataSource;
|
||||||
|
final SecureStorage secureStorage;
|
||||||
|
final DioClient dioClient;
|
||||||
|
|
||||||
|
AuthRepositoryImpl({
|
||||||
|
required this.remoteDataSource,
|
||||||
|
required this.secureStorage,
|
||||||
|
required this.dioClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, AuthResponse>> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final loginDto = LoginDto(email: email, password: password);
|
||||||
|
final authResponse = await remoteDataSource.login(loginDto);
|
||||||
|
|
||||||
|
// Save token to secure storage
|
||||||
|
await secureStorage.saveAccessToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
// Set token in Dio client for subsequent requests
|
||||||
|
dioClient.setAuthToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
return Right(authResponse);
|
||||||
|
} on InvalidCredentialsException catch (e) {
|
||||||
|
return Left(InvalidCredentialsFailure(e.message));
|
||||||
|
} on UnauthorizedException catch (e) {
|
||||||
|
return Left(UnauthorizedFailure(e.message));
|
||||||
|
} 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(ServerFailure('Unexpected error: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, AuthResponse>> register({
|
||||||
|
required String name,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
List<String> roles = const ['user'],
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final registerDto = RegisterDto(
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
roles: roles,
|
||||||
|
);
|
||||||
|
final authResponse = await remoteDataSource.register(registerDto);
|
||||||
|
|
||||||
|
// Save token to secure storage
|
||||||
|
await secureStorage.saveAccessToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
// Set token in Dio client for subsequent requests
|
||||||
|
dioClient.setAuthToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
return Right(authResponse);
|
||||||
|
} on ConflictException catch (e) {
|
||||||
|
return Left(ConflictFailure(e.message));
|
||||||
|
} 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(ServerFailure('Unexpected error: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, User>> getProfile() async {
|
||||||
|
try {
|
||||||
|
final user = await remoteDataSource.getProfile();
|
||||||
|
return Right(user);
|
||||||
|
} on UnauthorizedException catch (e) {
|
||||||
|
return Left(UnauthorizedFailure(e.message));
|
||||||
|
} on TokenExpiredException catch (e) {
|
||||||
|
return Left(TokenExpiredFailure(e.message));
|
||||||
|
} on NetworkException catch (e) {
|
||||||
|
return Left(NetworkFailure(e.message));
|
||||||
|
} on ServerException catch (e) {
|
||||||
|
return Left(ServerFailure(e.message));
|
||||||
|
} catch (e) {
|
||||||
|
return Left(ServerFailure('Unexpected error: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, AuthResponse>> refreshToken() async {
|
||||||
|
try {
|
||||||
|
final authResponse = await remoteDataSource.refreshToken();
|
||||||
|
|
||||||
|
// Update token in secure storage
|
||||||
|
await secureStorage.saveAccessToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
// Update token in Dio client
|
||||||
|
dioClient.setAuthToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
return Right(authResponse);
|
||||||
|
} on UnauthorizedException catch (e) {
|
||||||
|
return Left(UnauthorizedFailure(e.message));
|
||||||
|
} on TokenExpiredException catch (e) {
|
||||||
|
return Left(TokenExpiredFailure(e.message));
|
||||||
|
} on NetworkException catch (e) {
|
||||||
|
return Left(NetworkFailure(e.message));
|
||||||
|
} on ServerException catch (e) {
|
||||||
|
return Left(ServerFailure(e.message));
|
||||||
|
} catch (e) {
|
||||||
|
return Left(ServerFailure('Unexpected error: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> logout() async {
|
||||||
|
try {
|
||||||
|
// Clear token from secure storage
|
||||||
|
await secureStorage.deleteAllTokens();
|
||||||
|
|
||||||
|
// Clear token from Dio client
|
||||||
|
dioClient.clearAuthToken();
|
||||||
|
|
||||||
|
return const Right(null);
|
||||||
|
} catch (e) {
|
||||||
|
return Left(CacheFailure('Failed to logout: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isAuthenticated() async {
|
||||||
|
try {
|
||||||
|
final hasToken = await secureStorage.hasAccessToken();
|
||||||
|
if (hasToken) {
|
||||||
|
final token = await secureStorage.getAccessToken();
|
||||||
|
if (token != null) {
|
||||||
|
dioClient.setAuthToken(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getAccessToken() async {
|
||||||
|
try {
|
||||||
|
return await secureStorage.getAccessToken();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/features/auth/domain/entities/auth_response.dart
Normal file
16
lib/features/auth/domain/entities/auth_response.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'user.dart';
|
||||||
|
|
||||||
|
/// Authentication response entity
|
||||||
|
class AuthResponse extends Equatable {
|
||||||
|
final String accessToken;
|
||||||
|
final User user;
|
||||||
|
|
||||||
|
const AuthResponse({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [accessToken, user];
|
||||||
|
}
|
||||||
45
lib/features/auth/domain/entities/user.dart
Normal file
45
lib/features/auth/domain/entities/user.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// User entity representing a user in the system
|
||||||
|
class User extends Equatable {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final List<String> roles;
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const User({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
required this.roles,
|
||||||
|
required this.isActive,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
roles,
|
||||||
|
isActive,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Check if user has a specific role
|
||||||
|
bool hasRole(String role) => roles.contains(role);
|
||||||
|
|
||||||
|
/// Check if user is admin
|
||||||
|
bool get isAdmin => hasRole('admin');
|
||||||
|
|
||||||
|
/// Check if user is manager
|
||||||
|
bool get isManager => hasRole('manager');
|
||||||
|
|
||||||
|
/// Check if user is cashier
|
||||||
|
bool get isCashier => hasRole('cashier');
|
||||||
|
}
|
||||||
36
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
36
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../entities/auth_response.dart';
|
||||||
|
import '../entities/user.dart';
|
||||||
|
|
||||||
|
/// Abstract repository for authentication operations
|
||||||
|
abstract class AuthRepository {
|
||||||
|
/// Login user with email and password
|
||||||
|
Future<Either<Failure, AuthResponse>> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Register new user
|
||||||
|
Future<Either<Failure, AuthResponse>> register({
|
||||||
|
required String name,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
List<String> roles = const ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get current user profile
|
||||||
|
Future<Either<Failure, User>> getProfile();
|
||||||
|
|
||||||
|
/// Refresh access token
|
||||||
|
Future<Either<Failure, AuthResponse>> refreshToken();
|
||||||
|
|
||||||
|
/// Logout user (clear local token)
|
||||||
|
Future<Either<Failure, void>> logout();
|
||||||
|
|
||||||
|
/// Check if user is authenticated
|
||||||
|
Future<bool> isAuthenticated();
|
||||||
|
|
||||||
|
/// Get stored access token
|
||||||
|
Future<String?> getAccessToken();
|
||||||
|
}
|
||||||
488
lib/features/auth/example_usage.dart
Normal file
488
lib/features/auth/example_usage.dart
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
// ignore_for_file: unused_local_variable, avoid_print
|
||||||
|
|
||||||
|
/// Example usage of the authentication system
|
||||||
|
///
|
||||||
|
/// This file demonstrates how to use the authentication feature
|
||||||
|
/// in your Flutter app. Copy the patterns shown here into your
|
||||||
|
/// actual implementation.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'presentation/providers/auth_provider.dart';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 1: Login Flow
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class LoginExample extends ConsumerWidget {
|
||||||
|
final TextEditingController emailController;
|
||||||
|
final TextEditingController passwordController;
|
||||||
|
|
||||||
|
const LoginExample({
|
||||||
|
super.key,
|
||||||
|
required this.emailController,
|
||||||
|
required this.passwordController,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Watch auth state
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Show loading indicator
|
||||||
|
if (authState.isLoading) const CircularProgressIndicator(),
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: authState.isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
// Call login
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: emailController.text.trim(),
|
||||||
|
password: passwordController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle result
|
||||||
|
if (success) {
|
||||||
|
// Login successful - navigate to home
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pushReplacementNamed(context, '/home');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Login failed - show error
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(error ?? 'Login failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Login'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 2: Register Flow
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RegisterExample extends ConsumerStatefulWidget {
|
||||||
|
const RegisterExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RegisterExample> createState() => _RegisterExampleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterExampleState extends ConsumerState<RegisterExample> {
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRegister() async {
|
||||||
|
final success = await ref.read(authProvider.notifier).register(
|
||||||
|
name: _nameController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
roles: ['user'], // Optional, defaults to ['user']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Registration successful
|
||||||
|
Navigator.pushReplacementNamed(context, '/home');
|
||||||
|
} else {
|
||||||
|
// Registration failed
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(error ?? 'Registration failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: authState.isLoading ? null : _handleRegister,
|
||||||
|
child: const Text('Register'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 3: Check Authentication Status
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class AuthStatusExample extends ConsumerWidget {
|
||||||
|
const AuthStatusExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Method 1: Watch entire auth state
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
// Method 2: Use convenience providers
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
final currentUser = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Check if authenticated
|
||||||
|
if (authState.isAuthenticated) ...[
|
||||||
|
Text('Welcome ${authState.user?.name}!'),
|
||||||
|
Text('Email: ${authState.user?.email}'),
|
||||||
|
Text('Roles: ${authState.user?.roles.join(", ")}'),
|
||||||
|
|
||||||
|
// Check user roles
|
||||||
|
if (currentUser?.isAdmin ?? false)
|
||||||
|
const Text('You are an admin'),
|
||||||
|
if (currentUser?.isManager ?? false)
|
||||||
|
const Text('You are a manager'),
|
||||||
|
if (currentUser?.isCashier ?? false)
|
||||||
|
const Text('You are a cashier'),
|
||||||
|
] else ...[
|
||||||
|
const Text('Not authenticated'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 4: Protected Route Guard
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class AuthGuard extends ConsumerWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const AuthGuard({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||||
|
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||||
|
|
||||||
|
// Show loading while checking auth status
|
||||||
|
if (isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not authenticated, show login page
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text('Please login to continue'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// In real app: return const LoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is authenticated, show protected content
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in main app:
|
||||||
|
// MaterialApp(
|
||||||
|
// home: AuthGuard(
|
||||||
|
// child: HomePage(),
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 5: Logout
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class LogoutExample extends ConsumerWidget {
|
||||||
|
const LogoutExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
// Show confirmation dialog
|
||||||
|
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.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Logout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
// Perform logout
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
|
||||||
|
// Navigate to login
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pushReplacementNamed(context, '/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Logout'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 6: Get Profile (Refresh User Data)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ProfileExample extends ConsumerWidget {
|
||||||
|
const ProfileExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentUser = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (currentUser != null) ...[
|
||||||
|
Text('Name: ${currentUser.name}'),
|
||||||
|
Text('Email: ${currentUser.email}'),
|
||||||
|
Text('Roles: ${currentUser.roles.join(", ")}'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Refresh profile button
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await ref.read(authProvider.notifier).getProfile();
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Profile refreshed')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Refresh Profile'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 7: Refresh Token
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RefreshTokenExample extends ConsumerWidget {
|
||||||
|
const RefreshTokenExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await ref.read(authProvider.notifier).refreshToken();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Token refreshed successfully')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Token refresh failed - user was logged out
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Session expired. Please login again.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.pushReplacementNamed(context, '/login');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Refresh Token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 8: Role-Based UI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class RoleBasedUIExample extends ConsumerWidget {
|
||||||
|
const RoleBasedUIExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentUser = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Show to all authenticated users
|
||||||
|
const Text('Dashboard'),
|
||||||
|
|
||||||
|
// Show only to admins
|
||||||
|
if (currentUser?.isAdmin ?? false) ...[
|
||||||
|
const Text('Admin Panel'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to admin panel
|
||||||
|
},
|
||||||
|
child: const Text('Manage Users'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Show only to managers
|
||||||
|
if (currentUser?.isManager ?? false) ...[
|
||||||
|
const Text('Manager Tools'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to manager tools
|
||||||
|
},
|
||||||
|
child: const Text('View Reports'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Show only to cashiers
|
||||||
|
if (currentUser?.isCashier ?? false) ...[
|
||||||
|
const Text('POS Terminal'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to POS
|
||||||
|
},
|
||||||
|
child: const Text('Start Transaction'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 9: Error Handling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ErrorHandlingExample extends ConsumerWidget {
|
||||||
|
const ErrorHandlingExample({super.key});
|
||||||
|
|
||||||
|
Future<void> _handleLogin(BuildContext context, WidgetRef ref) async {
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
final error = ref.read(authProvider).errorMessage;
|
||||||
|
|
||||||
|
// Different error messages result in different UI feedback
|
||||||
|
String userMessage;
|
||||||
|
Color backgroundColor;
|
||||||
|
|
||||||
|
if (error?.contains('Invalid email or password') ?? false) {
|
||||||
|
userMessage = 'Incorrect email or password. Please try again.';
|
||||||
|
backgroundColor = Colors.red;
|
||||||
|
} else if (error?.contains('Network') ?? false) {
|
||||||
|
userMessage = 'No internet connection. Please check your network.';
|
||||||
|
backgroundColor = Colors.orange;
|
||||||
|
} else if (error?.contains('Server') ?? false) {
|
||||||
|
userMessage = 'Server error. Please try again later.';
|
||||||
|
backgroundColor = Colors.red[700]!;
|
||||||
|
} else {
|
||||||
|
userMessage = error ?? 'Login failed. Please try again.';
|
||||||
|
backgroundColor = Colors.red;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(userMessage),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Retry',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: () => _handleLogin(context, ref),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () => _handleLogin(context, ref),
|
||||||
|
child: const Text('Login with Error Handling'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 10: Using Auth in Non-Widget Code
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void nonWidgetExample() {
|
||||||
|
// If you need to access auth outside widgets (e.g., in services),
|
||||||
|
// use the service locator directly:
|
||||||
|
|
||||||
|
// import 'package:retail/core/di/injection_container.dart';
|
||||||
|
// import 'package:retail/features/auth/domain/repositories/auth_repository.dart';
|
||||||
|
|
||||||
|
// final authRepository = sl<AuthRepository>();
|
||||||
|
//
|
||||||
|
// // Check if authenticated
|
||||||
|
// final isAuthenticated = await authRepository.isAuthenticated();
|
||||||
|
//
|
||||||
|
// // Get token
|
||||||
|
// final token = await authRepository.getAccessToken();
|
||||||
|
//
|
||||||
|
// print('Token: $token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE 11: Automatic Token Injection Test
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void tokenInjectionExample() {
|
||||||
|
// Once logged in, all API requests automatically include the JWT token:
|
||||||
|
//
|
||||||
|
// The DioClient interceptor adds this header to all requests:
|
||||||
|
// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
//
|
||||||
|
// You don't need to manually add the token - it's automatic!
|
||||||
|
|
||||||
|
// Example of making an API call after login:
|
||||||
|
// final response = await sl<DioClient>().get('/api/products');
|
||||||
|
//
|
||||||
|
// The above request will automatically include:
|
||||||
|
// Headers: {
|
||||||
|
// "Authorization": "Bearer <your-jwt-token>",
|
||||||
|
// "Content-Type": "application/json",
|
||||||
|
// "Accept": "application/json"
|
||||||
|
// }
|
||||||
|
}
|
||||||
171
lib/features/auth/presentation/pages/login_page.dart
Normal file
171
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
/// Login page for user authentication
|
||||||
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
|
const LoginPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogin() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final success = await ref.read(authProvider.notifier).login(
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Navigate to home or show success
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Login successful!')),
|
||||||
|
);
|
||||||
|
// TODO: Navigate to home page
|
||||||
|
} else {
|
||||||
|
final errorMessage = ref.read(authProvider).errorMessage;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(errorMessage ?? 'Login failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Login'),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Logo or app name
|
||||||
|
Icon(
|
||||||
|
Icons.shopping_cart,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Retail POS',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
|
// Email field
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
prefixIcon: Icon(Icons.email),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your email';
|
||||||
|
}
|
||||||
|
if (!value.contains('@')) {
|
||||||
|
return 'Please enter a valid email';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
prefixIcon: const Icon(Icons.lock),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your password';
|
||||||
|
}
|
||||||
|
if (value.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: authState.isLoading ? null : _handleLogin,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: authState.isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Login'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Register link
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Navigate to register page
|
||||||
|
// Navigator.push(context, MaterialPageRoute(builder: (_) => const RegisterPage()));
|
||||||
|
},
|
||||||
|
child: const Text('Don\'t have an account? Register'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
lib/features/auth/presentation/pages/register_page.dart
Normal file
234
lib/features/auth/presentation/pages/register_page.dart
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
/// Register page for new user registration
|
||||||
|
class RegisterPage extends ConsumerStatefulWidget {
|
||||||
|
const RegisterPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _confirmPasswordController = TextEditingController();
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRegister() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final success = await ref.read(authProvider.notifier).register(
|
||||||
|
name: _nameController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Navigate to home or show success
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Registration successful!')),
|
||||||
|
);
|
||||||
|
// TODO: Navigate to home page
|
||||||
|
} else {
|
||||||
|
final errorMessage = ref.read(authProvider).errorMessage;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(errorMessage ?? 'Registration failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Register'),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Logo or app name
|
||||||
|
Icon(
|
||||||
|
Icons.shopping_cart,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Create Account',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
|
// Name field
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Full Name',
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your name';
|
||||||
|
}
|
||||||
|
if (value.length < 2) {
|
||||||
|
return 'Name must be at least 2 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Email field
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
prefixIcon: Icon(Icons.email),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your email';
|
||||||
|
}
|
||||||
|
if (!value.contains('@')) {
|
||||||
|
return 'Please enter a valid email';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
prefixIcon: const Icon(Icons.lock),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please enter your password';
|
||||||
|
}
|
||||||
|
if (value.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
// Check for uppercase, lowercase, and number
|
||||||
|
if (!RegExp(r'(?=.*[a-z])(?=.*[A-Z])(?=.*\d)').hasMatch(value)) {
|
||||||
|
return 'Password must contain uppercase, lowercase, and number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Confirm password field
|
||||||
|
TextFormField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Confirm Password',
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscureConfirmPassword
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Please confirm your password';
|
||||||
|
}
|
||||||
|
if (value != _passwordController.text) {
|
||||||
|
return 'Passwords do not match';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Register button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: authState.isLoading ? null : _handleRegister,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: authState.isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Register'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Login link
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Already have an account? Login'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
215
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../../../core/di/injection_container.dart';
|
||||||
|
import '../../domain/entities/user.dart';
|
||||||
|
import '../../domain/repositories/auth_repository.dart';
|
||||||
|
|
||||||
|
part 'auth_provider.g.dart';
|
||||||
|
|
||||||
|
/// Provider for AuthRepository
|
||||||
|
@riverpod
|
||||||
|
AuthRepository authRepository(Ref ref) {
|
||||||
|
return sl<AuthRepository>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth state class
|
||||||
|
class AuthState {
|
||||||
|
final User? user;
|
||||||
|
final bool isAuthenticated;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const AuthState({
|
||||||
|
this.user,
|
||||||
|
this.isAuthenticated = false,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
AuthState copyWith({
|
||||||
|
User? user,
|
||||||
|
bool? isAuthenticated,
|
||||||
|
bool? isLoading,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return AuthState(
|
||||||
|
user: user ?? this.user,
|
||||||
|
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth state notifier provider
|
||||||
|
@riverpod
|
||||||
|
class Auth extends _$Auth {
|
||||||
|
@override
|
||||||
|
AuthState build() {
|
||||||
|
_checkAuthStatus();
|
||||||
|
return const AuthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthRepository get _repository => ref.read(authRepositoryProvider);
|
||||||
|
|
||||||
|
/// Check if user is authenticated on app start
|
||||||
|
Future<void> _checkAuthStatus() async {
|
||||||
|
state = state.copyWith(isLoading: true);
|
||||||
|
|
||||||
|
final isAuthenticated = await _repository.isAuthenticated();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Get user profile
|
||||||
|
final result = await _repository.getProfile();
|
||||||
|
result.fold(
|
||||||
|
(failure) {
|
||||||
|
state = const AuthState(
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(user) {
|
||||||
|
state = AuthState(
|
||||||
|
user: user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = const AuthState(
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login user
|
||||||
|
Future<bool> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||||
|
|
||||||
|
final result = await _repository.login(email: email, password: password);
|
||||||
|
|
||||||
|
return result.fold(
|
||||||
|
(failure) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: failure.message,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
(authResponse) {
|
||||||
|
state = AuthState(
|
||||||
|
user: authResponse.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register new user
|
||||||
|
Future<bool> register({
|
||||||
|
required String name,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
List<String> roles = const ['user'],
|
||||||
|
}) async {
|
||||||
|
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||||
|
|
||||||
|
final result = await _repository.register(
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
roles: roles,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.fold(
|
||||||
|
(failure) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: failure.message,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
(authResponse) {
|
||||||
|
state = AuthState(
|
||||||
|
user: authResponse.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user profile (refresh user data)
|
||||||
|
Future<void> getProfile() async {
|
||||||
|
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||||
|
|
||||||
|
final result = await _repository.getProfile();
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
(failure) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: failure.message,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(user) {
|
||||||
|
state = state.copyWith(
|
||||||
|
user: user,
|
||||||
|
isLoading: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh access token
|
||||||
|
Future<bool> refreshToken() async {
|
||||||
|
final result = await _repository.refreshToken();
|
||||||
|
|
||||||
|
return result.fold(
|
||||||
|
(failure) {
|
||||||
|
// If token refresh fails, logout user
|
||||||
|
logout();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
(authResponse) {
|
||||||
|
state = state.copyWith(user: authResponse.user);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user
|
||||||
|
Future<void> logout() async {
|
||||||
|
state = state.copyWith(isLoading: true);
|
||||||
|
|
||||||
|
await _repository.logout();
|
||||||
|
|
||||||
|
state = const AuthState(
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current authenticated user provider
|
||||||
|
@riverpod
|
||||||
|
User? currentUser(Ref ref) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
return authState.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is authenticated provider
|
||||||
|
@riverpod
|
||||||
|
bool isAuthenticated(Ref ref) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
return authState.isAuthenticated;
|
||||||
|
}
|
||||||
204
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
204
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'auth_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Provider for AuthRepository
|
||||||
|
|
||||||
|
@ProviderFor(authRepository)
|
||||||
|
const authRepositoryProvider = AuthRepositoryProvider._();
|
||||||
|
|
||||||
|
/// Provider for AuthRepository
|
||||||
|
|
||||||
|
final class AuthRepositoryProvider
|
||||||
|
extends $FunctionalProvider<AuthRepository, AuthRepository, AuthRepository>
|
||||||
|
with $Provider<AuthRepository> {
|
||||||
|
/// Provider for AuthRepository
|
||||||
|
const AuthRepositoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'authRepositoryProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$authRepositoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<AuthRepository> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthRepository create(Ref ref) {
|
||||||
|
return authRepository(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AuthRepository value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AuthRepository>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$authRepositoryHash() => r'0483b13ac95333b56a1a82f6c9fdb64ae46f287d';
|
||||||
|
|
||||||
|
/// Auth state notifier provider
|
||||||
|
|
||||||
|
@ProviderFor(Auth)
|
||||||
|
const authProvider = AuthProvider._();
|
||||||
|
|
||||||
|
/// Auth state notifier provider
|
||||||
|
final class AuthProvider extends $NotifierProvider<Auth, AuthState> {
|
||||||
|
/// Auth state notifier provider
|
||||||
|
const AuthProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'authProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$authHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
Auth create() => Auth();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AuthState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AuthState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$authHash() => r'c88e150224fa855ed0ddfba30bef9e2b289f329d';
|
||||||
|
|
||||||
|
/// Auth state notifier provider
|
||||||
|
|
||||||
|
abstract class _$Auth extends $Notifier<AuthState> {
|
||||||
|
AuthState build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref = this.ref as $Ref<AuthState, AuthState>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AuthState, AuthState>,
|
||||||
|
AuthState,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current authenticated user provider
|
||||||
|
|
||||||
|
@ProviderFor(currentUser)
|
||||||
|
const currentUserProvider = CurrentUserProvider._();
|
||||||
|
|
||||||
|
/// Current authenticated user provider
|
||||||
|
|
||||||
|
final class CurrentUserProvider extends $FunctionalProvider<User?, User?, User?>
|
||||||
|
with $Provider<User?> {
|
||||||
|
/// Current authenticated user provider
|
||||||
|
const CurrentUserProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'currentUserProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$currentUserHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<User?> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
User? create(Ref ref) {
|
||||||
|
return currentUser(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(User? value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<User?>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$currentUserHash() => r'4c8cb60cef35a4fd001291434558037d6c85faf5';
|
||||||
|
|
||||||
|
/// Is authenticated provider
|
||||||
|
|
||||||
|
@ProviderFor(isAuthenticated)
|
||||||
|
const isAuthenticatedProvider = IsAuthenticatedProvider._();
|
||||||
|
|
||||||
|
/// Is authenticated provider
|
||||||
|
|
||||||
|
final class IsAuthenticatedProvider
|
||||||
|
extends $FunctionalProvider<bool, bool, bool>
|
||||||
|
with $Provider<bool> {
|
||||||
|
/// Is authenticated provider
|
||||||
|
const IsAuthenticatedProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'isAuthenticatedProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$isAuthenticatedHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool create(Ref ref) {
|
||||||
|
return isAuthenticated(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(bool value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<bool>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$isAuthenticatedHash() => r'003f7e85bfa5ae774792659ce771b5b59ebf04f8';
|
||||||
60
pubspec.lock
60
pubspec.lock
@@ -398,6 +398,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
flutter_secure_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage
|
||||||
|
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.2.4"
|
||||||
|
flutter_secure_storage_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_linux
|
||||||
|
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
flutter_secure_storage_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_macos
|
||||||
|
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
flutter_secure_storage_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_platform_interface
|
||||||
|
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
flutter_secure_storage_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_web
|
||||||
|
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
flutter_secure_storage_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_secure_storage_windows
|
||||||
|
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -532,10 +580,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1093,6 +1141,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ dependencies:
|
|||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
connectivity_plus: ^6.1.1
|
connectivity_plus: ^6.1.1
|
||||||
|
|
||||||
|
# Secure Storage
|
||||||
|
flutter_secure_storage: ^9.2.2
|
||||||
|
|
||||||
# Image Caching
|
# Image Caching
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user