From c1dabb486d57e41e0c8d91c0acb923f1c551b22a Mon Sep 17 00:00:00 2001 From: Nabil Derouiche Date: Mon, 20 Apr 2026 20:15:58 +0100 Subject: [PATCH] feat(frontend): init Flutter project with login screen and JWT auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Projet Flutter créé manuellement (pubspec.yaml, structure lib/) - Écran de connexion complet avec validation, gestion erreurs, spinner - ApiClient Dio avec intercepteur JWT automatique - AuthProvider (Provider) + AuthService (shared_preferences) - Routing GoRouter avec redirection auth/non-auth - DashboardScreen placeholder - Rapport PFE : ajout Chapitre 5 Frontend (architecture, JWT, login) Co-Authored-By: Claude Sonnet 4.6 --- Livrables/rapport-projet.md | 116 +++++++++ .../android/app/src/main/AndroidManifest.xml | 27 ++ frontend/lib/main.dart | 56 ++++ frontend/lib/providers/auth_provider.dart | 52 ++++ frontend/lib/screens/dashboard_screen.dart | 31 +++ frontend/lib/screens/login_screen.dart | 240 ++++++++++++++++++ frontend/lib/services/api_client.dart | 33 +++ frontend/lib/services/auth_service.dart | 35 +++ frontend/pubspec.yaml | 30 +++ 9 files changed, 620 insertions(+) create mode 100644 frontend/android/app/src/main/AndroidManifest.xml create mode 100644 frontend/lib/main.dart create mode 100644 frontend/lib/providers/auth_provider.dart create mode 100644 frontend/lib/screens/dashboard_screen.dart create mode 100644 frontend/lib/screens/login_screen.dart create mode 100644 frontend/lib/services/api_client.dart create mode 100644 frontend/lib/services/auth_service.dart create mode 100644 frontend/pubspec.yaml diff --git a/Livrables/rapport-projet.md b/Livrables/rapport-projet.md index b9b9b69..3a7d68c 100644 --- a/Livrables/rapport-projet.md +++ b/Livrables/rapport-projet.md @@ -362,3 +362,119 @@ docker logs rayhan-backend -f # Accéder à MySQL docker exec -it rayhan-mysql mysql -u root -prayhan_erp_2024 rayhan_erp_db ``` + +--- + +# Chapitre 5 — Réalisation Frontend (Application Mobile Flutter) + +## 5.1 Choix Technologique + +L'interface utilisateur de l'ERP Rayhan est développée avec **Flutter**, le framework de Google permettant de créer des applications mobiles multiplateformes (Android et iOS) à partir d'une seule base de code Dart. + +### Justification du choix Flutter + +| Critère | Flutter | React Native | Natif Android/iOS | +|---------|---------|--------------|-------------------| +| Code partagé Android/iOS | 100% | ~85% | 0% | +| Performance | Excellente | Bonne | Excellente | +| Courbe d'apprentissage | Moyenne | Moyenne | Élevée | +| Écosystème packages | Riche | Riche | Natif | +| Rendu UI | Moteur propre (Skia) | Bridge natif | Natif | + +Flutter a été retenu pour sa capacité à produire une application unique couvrant les deux plateformes mobiles majeures, tout en offrant des performances proches du natif grâce à son moteur de rendu indépendant. + +## 5.2 Architecture du Projet Flutter + +L'architecture adoptée suit le pattern **Provider + Services** : + +``` +frontend/ +├── lib/ +│ ├── main.dart # Point d'entrée, routing, thème +│ ├── screens/ # Écrans de l'application +│ │ ├── login_screen.dart # Écran de connexion +│ │ └── dashboard_screen.dart # Tableau de bord KPI +│ ├── providers/ # Gestion d'état (Provider) +│ │ └── auth_provider.dart # État authentification +│ ├── services/ # Appels API +│ │ ├── api_client.dart # Client Dio + intercepteur JWT +│ │ └── auth_service.dart # Service authentification +│ ├── models/ # Modèles de données Dart +│ └── widgets/ # Composants réutilisables +├── pubspec.yaml # Dépendances Flutter +└── android/ # Configuration Android +``` + +### Dépendances principales + +| Package | Version | Rôle | +|---------|---------|------| +| `dio` | ^5.4.0 | Client HTTP avec intercepteurs | +| `provider` | ^6.1.1 | Gestion d'état réactive | +| `shared_preferences` | ^2.2.2 | Stockage local du token JWT | +| `go_router` | ^13.2.0 | Navigation déclarative | +| `fl_chart` | ^0.67.0 | Graphiques pour le dashboard | +| `intl` | ^0.19.0 | Formatage dates et nombres | + +## 5.3 Authentification et Sécurité + +### Flux d'authentification + +Le flux d'authentification suit le protocole JWT standard : + +1. L'utilisateur saisit son identifiant et mot de passe +2. L'application envoie une requête `POST /api/auth/signin` +3. Le serveur retourne un token JWT signé (validité 24h) +4. Le token est stocké localement via `shared_preferences` +5. Chaque requête API suivante inclut automatiquement `Authorization: Bearer ` +6. À la déconnexion, le token est supprimé du stockage local + +### Intercepteur JWT automatique + +La classe `ApiClient` centralise toutes les communications HTTP et injecte automatiquement le token JWT dans les en-têtes via un intercepteur `Dio` : + +```dart +class _AuthInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('jwt_token'); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } +} +``` + +Cette approche centralise la gestion du token et évite sa répétition dans chaque appel API. + +### Redirection automatique (GoRouter) + +Le routeur détecte l'état d'authentification et redirige automatiquement : +- Un utilisateur non connecté est redirigé vers `/login` +- Un utilisateur déjà connecté accédant à `/login` est redirigé vers `/dashboard` + +## 5.4 Écran de Connexion + +L'écran de connexion (`LoginScreen`) présente une interface épurée et professionnelle : + +**Composants UI :** +- Logo de l'application avec icône usine (représentant l'industrie plasturgie) +- Titre "RAYHAN ERP" et sous-titre +- Formulaire de connexion avec validation : + - Champ identifiant avec icône + - Champ mot de passe avec bouton afficher/masquer + - Message d'erreur contextuel en cas d'échec + - Bouton de connexion avec indicateur de chargement +- Footer © SUARL Rayhan + +**Gestion des états :** +- État initial : formulaire vide +- État chargement : spinner animé, bouton désactivé +- État erreur : bandeau rouge avec message explicite +- État succès : redirection automatique vers le dashboard + +--- + +*[Section à compléter avec captures d'écran lors de la finalisation du rapport]* diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1774d71 --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart new file mode 100644 index 0000000..40b7635 --- /dev/null +++ b/frontend/lib/main.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:go_router/go_router.dart'; + +import 'providers/auth_provider.dart'; +import 'screens/login_screen.dart'; +import 'screens/dashboard_screen.dart'; + +void main() { + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ], + child: const RayhanApp(), + ), + ); +} + +class RayhanApp extends StatelessWidget { + const RayhanApp({super.key}); + + @override + Widget build(BuildContext context) { + final authProvider = context.watch(); + + final router = GoRouter( + initialLocation: '/login', + redirect: (context, state) { + final loggedIn = authProvider.isAuthenticated; + final onLogin = state.matchedLocation == '/login'; + if (!loggedIn && !onLogin) return '/login'; + if (loggedIn && onLogin) return '/dashboard'; + return null; + }, + routes: [ + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()), + ], + ); + + return MaterialApp.router( + title: 'Rayhan ERP', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1565C0), + brightness: Brightness.light, + ), + useMaterial3: true, + fontFamily: 'Roboto', + ), + routerConfig: router, + ); + } +} diff --git a/frontend/lib/providers/auth_provider.dart b/frontend/lib/providers/auth_provider.dart new file mode 100644 index 0000000..a2c6f1b --- /dev/null +++ b/frontend/lib/providers/auth_provider.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../services/auth_service.dart'; + +class AuthProvider extends ChangeNotifier { + bool _isAuthenticated = false; + String? _role; + String? _errorMessage; + bool _isLoading = false; + + bool get isAuthenticated => _isAuthenticated; + String? get role => _role; + String? get errorMessage => _errorMessage; + bool get isLoading => _isLoading; + + Future login(String username, String password) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final data = await AuthService.login(username, password); + final token = data['token'] as String; + final roles = data['roles'] as List; + final role = roles.isNotEmpty ? roles.first as String : 'ROLE_USER'; + + await AuthService.saveToken(token, role); + _isAuthenticated = true; + _role = role; + return true; + } catch (e) { + _errorMessage = 'Identifiants incorrects. Vérifiez votre nom d\'utilisateur et mot de passe.'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future logout() async { + await AuthService.logout(); + _isAuthenticated = false; + _role = null; + notifyListeners(); + } + + Future checkAuth() async { + final token = await AuthService.getToken(); + _isAuthenticated = token != null; + _role = await AuthService.getRole(); + notifyListeners(); + } +} diff --git a/frontend/lib/screens/dashboard_screen.dart b/frontend/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..2e18414 --- /dev/null +++ b/frontend/lib/screens/dashboard_screen.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:go_router/go_router.dart'; +import '../providers/auth_provider.dart'; + +class DashboardScreen extends StatelessWidget { + const DashboardScreen({super.key}); + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Tableau de bord'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await auth.logout(); + if (context.mounted) context.go('/login'); + }, + ), + ], + ), + body: const Center( + child: Text('Dashboard — en cours de développement'), + ), + ); + } +} diff --git a/frontend/lib/screens/login_screen.dart b/frontend/lib/screens/login_screen.dart new file mode 100644 index 0000000..5a09201 --- /dev/null +++ b/frontend/lib/screens/login_screen.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:go_router/go_router.dart'; +import '../providers/auth_provider.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + final auth = context.read(); + final success = await auth.login( + _usernameController.text.trim(), + _passwordController.text, + ); + + if (success && mounted) { + context.go('/dashboard'); + } + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo / En-tête + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: const Icon(Icons.factory, size: 48, color: Colors.white), + ), + const SizedBox(height: 24), + Text( + 'RAYHAN ERP', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + letterSpacing: 2, + ), + ), + const SizedBox(height: 6), + Text( + 'Système de Gestion Intégré', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 40), + + // Carte de connexion + Container( + constraints: const BoxConstraints(maxWidth: 420), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 24, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(32), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Connexion', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Entrez vos identifiants pour accéder au système', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 28), + + // Champ nom d'utilisateur + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + labelText: 'Nom d\'utilisateur', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: const Color(0xFFF8F9FA), + ), + textInputAction: TextInputAction.next, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Champ obligatoire' : null, + ), + const SizedBox(height: 16), + + // Champ mot de passe + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Mot de passe', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: const Color(0xFFF8F9FA), + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (v) => + (v == null || v.isEmpty) ? 'Champ obligatoire' : null, + ), + const SizedBox(height: 12), + + // Message d'erreur + if (auth.errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red[200]!), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red[700], size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + auth.errorMessage!, + style: TextStyle(color: Colors.red[700], fontSize: 13), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Bouton connexion + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: auth.isLoading ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 2, + ), + child: auth.isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Se connecter', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 32), + Text( + '© 2026 SUARL Rayhan — Tataouine, Tunisie', + style: TextStyle(color: Colors.grey[500], fontSize: 12), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/services/api_client.dart b/frontend/lib/services/api_client.dart new file mode 100644 index 0000000..3100b68 --- /dev/null +++ b/frontend/lib/services/api_client.dart @@ -0,0 +1,33 @@ +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ApiClient { + static const String baseUrl = 'http://192.168.100.33:8090/api'; + + static final Dio _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + headers: {'Content-Type': 'application/json'}, + )) + ..interceptors.add(_AuthInterceptor()); + + static Dio get instance => _dio; +} + +class _AuthInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString('jwt_token'); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + handler.next(err); + } +} diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart new file mode 100644 index 0000000..fc7155a --- /dev/null +++ b/frontend/lib/services/auth_service.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'api_client.dart'; + +class AuthService { + static Future> login(String username, String password) async { + final response = await ApiClient.instance.post('/auth/signin', data: { + 'username': username, + 'password': password, + }); + return response.data as Map; + } + + static Future saveToken(String token, String role) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('jwt_token', token); + await prefs.setString('user_role', role); + } + + static Future getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('jwt_token'); + } + + static Future getRole() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_role'); + } + + static Future logout() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('jwt_token'); + await prefs.remove('user_role'); + } +} diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml new file mode 100644 index 0000000..5b294c7 --- /dev/null +++ b/frontend/pubspec.yaml @@ -0,0 +1,30 @@ +name: rayhan_erp +description: ERP Sur Mesure — SUARL Rayhan +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + dio: ^5.4.0 + provider: ^6.1.1 + shared_preferences: ^2.2.2 + go_router: ^13.2.0 + intl: ^0.19.0 + fl_chart: ^0.67.0 + cached_network_image: ^3.3.1 + flutter_svg: ^2.0.9 + shimmer: ^3.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + assets: + - assets/images/