diff --git a/.gitignore b/.gitignore index 3820a95c65c3e5983cc66d481e2e68706a750090..347de1b1f5abf50913e9d95006b66f4e76d2577f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + + +.env +.env.local \ No newline at end of file diff --git a/Code.gs b/Code.gs new file mode 100644 index 0000000000000000000000000000000000000000..cf481f28623403fcbf276df37f1fc8bfecf9dd3b --- /dev/null +++ b/Code.gs @@ -0,0 +1,126 @@ +/** + * Handle POST requests + * Expects JSON: { "students": [ { ...student1 }, { ...student2 } ] } + */ +function doPost(e) { + var lock = LockService.getScriptLock(); + lock.tryLock(30000); // Wait up to 30s for other processes to finish + + try { + var doc = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = doc.getActiveSheet(); + + // Parse the JSON body + var rawData = e.postData.contents; + var jsonPayload = JSON.parse(rawData); + var studentsList = jsonPayload.students; + + if (!studentsList || !Array.isArray(studentsList)) { + throw new Error("Invalid format: 'students' array missing."); + } + + var results = []; + + // Process each student + studentsList.forEach(function(data) { + var studentId = data.no_etudiant; + var leoId = data.no_leo; + var prenom = data.prenom; + var nom = data.nom; + + // Timestamp from the scanner app + var scanTime = new Date(data.timestamp); + + // 1. ROBUST SEARCH: Find if student exists + var rowIndex = findRowIndex(sheet, studentId); + var status = ""; + + if (rowIndex == -1) { + // --- SCENARIO A: STUDENT NOT FOUND -> NEW ROW (ENTRY) --- + var newRow = [ + formatDate(scanTime), // A: Timestamp (Date Only) + studentId, // B: ID + leoId, // C: Leo + prenom, // D: First Name + nom, // E: Last Name + formatTime(scanTime),// F: Entry Time + "" // G: Exit Time (Empty) + ]; + sheet.appendRow(newRow); + status = "Entrée (Nouveau)"; + } else { + // --- SCENARIO B: STUDENT FOUND -> ADD TIME TO EXISTING ROW --- + + // Get the entire row data to find the next empty column + var lastCol = Math.max(sheet.getLastColumn(), 10); // Check at least 10 cols + var rowValues = sheet.getRange(rowIndex, 1, 1, lastCol).getValues()[0]; + + var targetCol = -1; + + // Start checking from Index 6 (Column G) for the first empty cell + for (var i = 6; i < rowValues.length; i++) { + if (rowValues[i] === "") { + targetCol = i + 1; // Found empty spot + break; + } + } + + // If row is full, append to the very end + if (targetCol === -1) { + targetCol = sheet.getLastColumn() + 1; + } + + // Write the timestamp in the found column + sheet.getRange(rowIndex, targetCol).setValue(formatTime(scanTime)); + status = "Sortie/Update (Col " + targetCol + ")"; + } + + results.push({ + "id": studentId, + "name": prenom + " " + nom, + "status": status + }); + }); + + return ContentService.createTextOutput(JSON.stringify({ + "status": "success", + "processed": results + })).setMimeType(ContentService.MimeType.JSON); + + } catch (error) { + return ContentService.createTextOutput(JSON.stringify({ + "status": "error", + "message": error.toString() + })).setMimeType(ContentService.MimeType.JSON); + } finally { + lock.releaseLock(); + } +} + +// --- HELPER FUNCTIONS --- + +// Improved Finder: Converts both to String, trims spaces, and standardizes case +function findRowIndex(sheet, idToFind) { + var data = sheet.getDataRange().getValues(); + // Clean the ID we are looking for: String -> Trim -> UpperCase + var searchId = String(idToFind).trim().toUpperCase(); + + // Loop through rows (skip header row 0) + for (var i = 1; i < data.length; i++) { + // Clean the ID in the sheet (Column B is index 1) + var sheetId = String(data[i][1]).trim().toUpperCase(); + + if (sheetId === searchId) { + return i + 1; // Return the actual row number (1-based) + } + } + return -1; +} + +function formatTime(date) { + return Utilities.formatDate(date, Session.getScriptTimeZone(), "HH:mm:ss"); +} + +function formatDate(date) { + return Utilities.formatDate(date, Session.getScriptTimeZone(), "dd/MM/yyyy"); +} \ No newline at end of file diff --git a/README.md b/README.md index 74152bba8972f08519d13dd327a5759a1a5281d9..49288df4f4735e91095649c040d2d36d1a75e01f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,99 @@ -# checkmein +# 📱 Exam Attendance Manager -Checkme in app. +Une application Flutter cross-platform pour permettre aux professeurs d'émarger les étudiants durant un examen, synchronisée en temps réel avec un Google Sheet. -## Getting Started +## 🏗 Architecture du Projet -This project is a starting point for a Flutter application. +Le projet repose sur une architecture robuste conçue pour gérer la connectivité instable (mode hors-ligne) et la réactivité de l'interface. -A few resources to get you started if this is your first Flutter project: + * **Service Singleton (`AttendanceService`)** : Cœur de l'application, injecté via `GetIt`. Il gère la logique métier et la communication avec l'API. + * **Système de Queue** : Les émargements ne sont pas envoyés directement. Ils sont ajoutés dans une queue (FIFO). Un worker en arrière-plan dépile cette queue et synchronise avec le Google Sheet dès que possible. + * **Programmation Réactive (RxDart)** : L'état de la queue est exposé via un `Observable` (Stream). L'interface utilisateur écoute ce stream pour afficher un indicateur visuel de synchronisation en temps réel. + * **Backend Google Apps Script** : Un script hébergé sur le Google Sheet agit comme une API REST pour recevoir les données JSON. -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +----- -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## 📂 Structure des Fichiers (`lib/`) + +L'intégralité du code Flutter réside dans le dossier `lib` pour assurer la portabilité. + +```text +lib/ +├── main.dart # Point d'entrée + UI principale (Scan NFC & QR) +├── locator.dart # Configuration de l'injection de dépendance (GetIt) +├── models/ +│ └── student.dart # Modèle de données (JSON serialization) +├── services/ +│ └── attendance_service.dart # Service Singleton + Queue + Worker +└── widgets/ + └── sync_status_indicator.dart # Widget réactif écoutant l'état de la queue +``` + +----- + +## 🚀 Installation et Configuration + +### 1\. Côté Backend (Google Sheet) + +Avant de lancer l'application, il faut préparer le fichier de destination. + +1. Créez un nouveau Google Sheet. +2. Allez dans **Extensions \> Apps Script**. +3. Collez le code `doPost(e)` fourni précédemment. +4. **Déployez** en tant qu'Application Web : + * *Execute as*: **Me** (Moi). + * *Who has access*: **Anyone** (Tout le monde) -\> *Indispensable pour que l'app mobile puisse écrire sans authentification complexe.* +5. Copiez l'URL du script générée (ex: `https://script.google.com/.../exec`). +6. Générez un QR Code contenant cette URL. + +### 2\. Côté Application (Flutter) + +Ajoutez les dépendances suivantes dans votre `pubspec.yaml` : + +```yaml +dependencies: + flutter: + sdk: flutter + get_it: ^7.6.0 # Injection de dépendance (Singleton) + rxdart: ^0.27.7 # Reactive Programming + http: ^1.1.0 # Requêtes réseau vers Google Sheet + # nfc_manager: ^3.3.0 # Pour le scan réel (optionnel pour test) +``` + +Puis lancez : + +```bash +flutter pub get +flutter run +``` + +----- + +## 📖 Guide d'Utilisation + +L'application suit le workflow défini dans le cahier des charges : + +1. **Configuration (Professeur)** : + + * Au premier lancement, scannez le **QR Code** généré à l'étape Backend via l'application. + * L'application configure automatiquement l'URL de l'API cible. + +2. **Émargement (Examen)** : + + * Le professeur scanne la carte étudiante (NFC) ou un QR étudiant. + * L'étudiant est ajouté à la **Queue locale** via `push_student_queue`. + * L'indicateur visuel passe à l'orange ("Données en attente"). + +3. **Synchronisation** : + + * Le service tente d'envoyer les données en arrière-plan. + * **Premier Scan** : Enregistre l'heure d'arrivée ("Entrée"). + * **Deuxième Scan** : Met à jour la ligne existante avec l'heure de départ ("Sortie") pour calculer le temps total. + * Une fois la queue vide, l'indicateur repasse au vert. + +----- + +## ⚠️ Notes Importantes + + * **Sécurité des données** : L'indicateur visuel est crucial. Ne fermez pas l'application tant que l'icône de synchronisation n'est pas verte (queue vide). + * **Format des données** : Le modèle `Student` envoie les champs : `prenom`, `nom`, `no_etudiant`, `no_leo`, `entree`, `sortie`. \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fa0b357c4f4a29c3de7c5abfa47ba9ea8e52dd92 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/components/sync_status_indicator.dart b/lib/components/sync_status_indicator.dart new file mode 100644 index 0000000000000000000000000000000000000000..08f24ef4091b78a2901adab22ccb54dba068fe06 --- /dev/null +++ b/lib/components/sync_status_indicator.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../utils/locator.dart'; +import '../services/attendance_service.dart'; + +class SyncStatusIndicator extends StatelessWidget { + const SyncStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final service = getIt(); + + // Le composant écoute l'observable + return StreamBuilder( + stream: service.queueLengthStream, + initialData: 0, + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + final isSynced = count == 0; + + return Card( + color: isSynced ? Colors.green[100] : Colors.orange[100], + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + Icon( + isSynced ? Icons.check_circle : Icons.sync_problem, + color: isSynced ? Colors.green : Colors.orange, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isSynced + ? "Toutes les données sont synchronisées." + : "Attention: $count étudiant(s) en attente de synchro. Ne fermez pas l'appli !", + style: TextStyle( + color: isSynced ? Colors.green[900] : Colors.orange[900], + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 75ab52022cf34b1065cc40369f9048f4c0c417a7..40e25b326b5b1dfd508209566dad5a0d53e133e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; -import './views/home.dart'; -import 'services/spreadsheet_service.dart'; -import 'package:get_it/get_it.dart'; - -final getIt = GetIt.instance; - -void main() { - // Register your services here - getIt.registerSingleton(SomeService()); +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'utils/locator.dart'; +import 'views/home.dart'; +Future main() async { + // Ensure widgets are bound before async calls + WidgetsFlutterBinding.ensureInitialized(); + // Load the .env file + await dotenv.load(fileName: ".env"); + setupLocator(); // Initialisation du Singleton runApp(const MyApp()); } @@ -18,15 +18,9 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Check me in', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - initialRoute: '/', - routes: { - '/': (context) => const Home(), - // Other routes here - }, + title: 'Exam Émargement', + theme: ThemeData(primarySwatch: Colors.blue), + home: const HomePage(), ); } } diff --git a/lib/models/student.dart b/lib/models/student.dart new file mode 100644 index 0000000000000000000000000000000000000000..209208fcb5dd3885a30b78671da63d8701fdedc0 --- /dev/null +++ b/lib/models/student.dart @@ -0,0 +1,41 @@ +class Student { + final String firstName; + final String lastName; + final String studentId; // Numéro étudiant + final String leoId; // Numéro léocarte + final DateTime? entryTime; // Heure d'arrivée + final DateTime? exitTime; // Heure de sortie + + Student({ + required this.firstName, + required this.lastName, + required this.studentId, + required this.leoId, + this.entryTime, + this.exitTime, + }); + + // Méthode utilitaire pour copier l'objet avec une heure de sortie mise à jour + Student copyWithExitTime(DateTime time) { + return Student( + firstName: firstName, + lastName: lastName, + studentId: studentId, + leoId: leoId, + entryTime: entryTime, + exitTime: time, + ); + } + + // Conversion en Map pour l'envoi vers Google Sheets + Map toJson() { + return { + 'prenom': firstName, + 'nom': lastName, + 'no_etudiant': studentId, + 'no_leo': leoId, + 'entree': entryTime?.toIso8601String(), + 'sortie': exitTime?.toIso8601String(), + }; + } +} diff --git a/lib/services/attendance_service.dart b/lib/services/attendance_service.dart new file mode 100644 index 0000000000000000000000000000000000000000..d5e6584b9104fb9134ed44b3bedb173affab497c --- /dev/null +++ b/lib/services/attendance_service.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:rxdart/rxdart.dart'; +import '../models/student.dart'; + +class AttendanceService { + String? _sheetApiUrl; + + // Queue to store students locally before sync + final Queue _uploadQueue = Queue(); + + // Observable for UI + final BehaviorSubject _queueLengthSubject = BehaviorSubject.seeded( + 0, + ); + Stream get queueLengthStream => _queueLengthSubject.stream; + + // Timer for batch flushing + Timer? _flushTimer; + + AttendanceService() { + // Load from Env var immediately + _sheetApiUrl = dotenv.env['GOOGLE_SHEET_URL']; + + if (_sheetApiUrl != null) { + print("Service initialized with Env URL: $_sheetApiUrl"); + } + _startBatchTimer(); + } + + void setSheetUrl(String url) { + _sheetApiUrl = url; + print("URL Configured: $_sheetApiUrl"); + } + + /// Adds a student to the queue with the current timestamp (Scan Time) + void pushStudentQueue(Student student) { + _uploadQueue.add(student); + _updateQueueLength(); + print("Student added to queue. Total: ${_uploadQueue.length}"); + } + + void _updateQueueLength() { + _queueLengthSubject.add(_uploadQueue.length); + } + + /// Starts the 10-second periodic flush + void _startBatchTimer() { + _flushTimer?.cancel(); + _flushTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (_uploadQueue.isNotEmpty && + _sheetApiUrl != null && + _sheetApiUrl!.isNotEmpty) { + _flushQueue(); + } + }); + } + + /// Sends all students in the queue to Google Sheets in one batch + Future _flushQueue() async { + // 1. Snapshot current batch to avoid concurrency issues if new students are added during upload + final int batchSize = _uploadQueue.length; + final List batch = []; + + for (int i = 0; i < batchSize; i++) { + batch.add(_uploadQueue.removeFirst()); + } + + // Update UI immediately (optimistic) or wait for error? + // Let's keep them in a temp buffer in case of failure. + + try { + print("Flushing batch of ${batch.length} students..."); + + // 2. Prepare JSON Payload + // Note: We send .toIso8601String() for the timestamp so Google Script can parse it + final Map payload = { + "students": batch + .map( + (s) => { + "no_etudiant": s.studentId, + "no_leo": s.leoId, + "prenom": s.firstName, + "nom": s.lastName, + "timestamp": s.entryTime + ?.toIso8601String(), // CRITICAL: Send scan time + }, + ) + .toList(), + }; + + // 3. Send POST Request + var response = await http.post( + Uri.parse(_sheetApiUrl!), + body: jsonEncode(payload), + ); + + // Handle Google Redirect (302) + if (response.statusCode == 302) { + final String? redirectUrl = response.headers['location']; + if (redirectUrl != null) { + response = await http.get(Uri.parse(redirectUrl)); + } + } + + if (response.statusCode == 200) { + print("Batch sync successful: ${response.body}"); + _updateQueueLength(); // Should be 0 (or close to 0) + } else { + throw Exception("Server Error: ${response.statusCode}"); + } + } catch (e) { + print("Sync failed: $e. Re-queueing students."); + // On failure, put the batch back at the HEAD of the queue + for (var student in batch.reversed) { + _uploadQueue.addFirst(student); + } + _updateQueueLength(); + } + } + + void dispose() { + _flushTimer?.cancel(); + _queueLengthSubject.close(); + } +} diff --git a/lib/services/spreadsheet_service.dart b/lib/services/spreadsheet_service.dart deleted file mode 100644 index ee4b9153a7b37b5e6f497cf9601ca5230e510842..0000000000000000000000000000000000000000 --- a/lib/services/spreadsheet_service.dart +++ /dev/null @@ -1,5 +0,0 @@ -class SomeService { - String fetchData() { - return "Data from SomeService via get_it"; - } -} diff --git a/lib/utils/locator.dart b/lib/utils/locator.dart new file mode 100644 index 0000000000000000000000000000000000000000..24050bfe35ae68e19c0c5fb87c60b376f9b83c59 --- /dev/null +++ b/lib/utils/locator.dart @@ -0,0 +1,9 @@ +import 'package:get_it/get_it.dart'; +import '../services/attendance_service.dart'; + +final getIt = GetIt.instance; + +void setupLocator() { + // On enregistre AttendanceService comme un Singleton (Lazy = créé à la première utilisation) + getIt.registerLazySingleton(() => AttendanceService()); +} diff --git a/lib/views/home.dart b/lib/views/home.dart index edfe1e6577f084039163b1d2361d33a193c8a738..ac926c488349fad565e149f42396f226e2c96977 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,22 +1,180 @@ import 'package:flutter/material.dart'; -import '../services/spreadsheet_service.dart'; -import 'package:get_it/get_it.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; // REQUIRES: mobile_scanner package in pubspec.yaml +import '../utils/locator.dart'; +import '../models/student.dart'; +import '../services/attendance_service.dart'; +import '../components/sync_status_indicator.dart'; +import 'manual_entry.dart'; -final getIt = GetIt.instance; +class HomePage extends StatefulWidget { + const HomePage({super.key}); -class Home extends StatelessWidget { - const Home({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _service = getIt(); + final TextEditingController _urlController = TextEditingController(); + + // ONLY simulates the NFC interaction and adds to queue + void _simulateNfcScan() { + if (_urlController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Configurez l'URL d'abord !")), + ); + return; + } + + // 1. Create Data (Scan Time is fixed here) + final newStudent = Student( + firstName: "Jean", + lastName: "Dupont", + //studentId: "E${DateTime.now().millisecondsSinceEpoch}", + studentId: "gm213204", + leoId: "LEO123456", + entryTime: DateTime.now(), // Precise Scan Time + ); + + // 2. Add to Service Queue immediately + _service.pushStudentQueue(newStudent); + + // 3. Instant Visual Feedback + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Scan enregistré : ${newStudent.firstName}"), + backgroundColor: Colors.blue, + duration: const Duration(seconds: 1), + ), + ); + } + + // Launches the QR Scanner screen + Future _scanQrCode() async { + // Push to the scanner page and await the result (the scanned URL) + final String? scannedUrl = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QrScannerScreen()), + ); + + // If a code was returned, update the controller and service + if (scannedUrl != null && scannedUrl.isNotEmpty) { + _service.setSheetUrl(scannedUrl); + setState(() { + _urlController.text = scannedUrl; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("URL configurée avec succès !")), + ); + } + } + } @override Widget build(BuildContext context) { - final someService = getIt(); - return Scaffold( - appBar: AppBar( - title: const Text('Home Page'), + appBar: AppBar(title: const Text("Émargement Batch")), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const SyncStatusIndicator(), // Will update automatically based on Queue size + const SizedBox(height: 20), + + // Configuration Input with QR Scanner Button + TextField( + controller: _urlController, + onChanged: (val) => _service.setSheetUrl(val), + decoration: InputDecoration( + labelText: "URL Script Google", + hintText: "https://script.google.com/...", + border: const OutlineInputBorder(), + // ADDED: Suffix icon to trigger scanner + suffixIcon: IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: "Scanner le QR Code de configuration", + onPressed: _scanQrCode, + ), + ), + ), + + const Spacer(), + + // NFC Button + SizedBox( + width: 200, + height: 200, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: Colors.blueAccent, + ), + onPressed: _simulateNfcScan, + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.nfc, size: 50, color: Colors.white), + Text("SCAN", style: TextStyle(color: Colors.white)), + ], + ), + ), + ), + + const Spacer(), + + // Manual Entry Link + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ManualEntryPage()), + ); + }, + child: const Text("Saisie Manuelle"), + ), + ], + ), ), - body: Center( - child: Text(someService.fetchData()), + ); + } +} + +/// A simple screen that shows the camera and returns the scanned code +class QrScannerScreen extends StatefulWidget { + const QrScannerScreen({super.key}); + + @override + State createState() => _QrScannerScreenState(); +} + +class _QrScannerScreenState extends State { + // Flag to prevent multiple pops if the scanner detects the same code rapidly + bool _hasScanned = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Scanner le QR Code")), + // MobileScanner handles permission requests automatically + body: MobileScanner( + onDetect: (capture) { + if (_hasScanned) return; + + final List barcodes = capture.barcodes; + for (final barcode in barcodes) { + if (barcode.rawValue != null) { + final code = barcode.rawValue!; + // Basic validation: ensure it looks like a URL + if (code.startsWith("http")) { + _hasScanned = true; + Navigator.pop(context, code); + break; + } + } + } + }, ), ); } diff --git a/lib/views/manual_entry.dart b/lib/views/manual_entry.dart new file mode 100644 index 0000000000000000000000000000000000000000..503ed6604c17ecebb7c03b78eb22cb74f732cd2d --- /dev/null +++ b/lib/views/manual_entry.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import '../models/student.dart'; +import '../services/attendance_service.dart'; +import '../utils/locator.dart'; + +class ManualEntryPage extends StatefulWidget { + const ManualEntryPage({super.key}); + + @override + State createState() => _ManualEntryPageState(); +} + +class _ManualEntryPageState extends State { + final _formKey = GlobalKey(); + + // Contrôleurs pour récupérer les textes + final _prenomController = TextEditingController(); + final _nomController = TextEditingController(); + final _studentIdController = TextEditingController(); + final _leoIdController = TextEditingController(); + + @override + void dispose() { + _prenomController.dispose(); + _nomController.dispose(); + _studentIdController.dispose(); + _leoIdController.dispose(); + super.dispose(); + } + + void _submitForm() { + if (_formKey.currentState!.validate()) { + // 1. Création de l'étudiant manuel + // On utilise DateTime.now() pour l'heure d'émargement, comme pour le NFC + final student = Student( + firstName: _prenomController.text, + lastName: _nomController.text, + studentId: _studentIdController.text, + leoId: _leoIdController.text.isNotEmpty + ? _leoIdController.text + : "MANUEL", + entryTime: DateTime.now(), + ); + + // 2. Injection dans le service (C'est ici que la magie opère) + // On utilise la même queue que pour le NFC + getIt().pushStudentQueue(student); + + // 3. Feedback et retour + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Étudiant ${student.firstName} ajouté à la queue de synchro", + ), + ), + ); + + Navigator.pop(context); // Retour à l'accueil + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Saisie Manuelle")), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: ListView( + children: [ + const Text( + "En cas d'oubli de carte, saisissez les infos ici.", + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 20), + + // Champ Prénom + TextFormField( + controller: _prenomController, + decoration: const InputDecoration( + labelText: "Prénom", + border: OutlineInputBorder(), + ), + validator: (value) => + value == null || value.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 15), + + // Champ Nom + TextFormField( + controller: _nomController, + decoration: const InputDecoration( + labelText: "Nom", + border: OutlineInputBorder(), + ), + validator: (value) => + value == null || value.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 15), + + // Champ Numéro Étudiant + TextFormField( + controller: _studentIdController, + decoration: const InputDecoration( + labelText: "Numéro Étudiant", + border: OutlineInputBorder(), + ), + validator: (value) => + value == null || value.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 15), + + // Champ Numéro Leo (Facultatif en manuel) + TextFormField( + controller: _leoIdController, + decoration: const InputDecoration( + labelText: "Numéro LéoCarte (Facultatif)", + border: OutlineInputBorder(), + hintText: "Laisser vide si inconnu", + ), + ), + const SizedBox(height: 30), + + // Bouton Valider + ElevatedButton.icon( + onPressed: _submitForm, + icon: const Icon(Icons.save), + label: const Text("ÉMARGER L'ÉTUDIANT"), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + backgroundColor: + Colors.orange, // Orange pour différencier du scan auto + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index cd627375b017a6c4e64acab1a3b0a508b12453af..abf6b1d1951b462c60febee69f7c2369ea305224 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,11 +65,27 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_lints: dependency: "direct dev" description: @@ -75,6 +99,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get_it: dependency: "direct main" description: @@ -83,6 +112,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" leak_tracker: dependency: transitive description: @@ -139,6 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" nested: dependency: transitive description: @@ -155,6 +216,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" provider: dependency: "direct main" description: @@ -224,6 +293,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: @@ -240,6 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 73a5d9aed26efe67898b4750a0f1ee2b44d063c3..135029f4eca4ca2f93d8d2ffdefc52a43d2ff008 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,13 @@ dependencies: provider: ^6.1.5+1 get_it: ^8.2.0 rxdart: ^0.28.0 + http: ^1.5.0 + intl: ^0.18.0 + uuid: ^4.0.0 + flutter_dotenv: ^5.1.0 + mobile_scanner: ^5.0.0 + # nfc_manager: ^3.3.0 + dev_dependencies: flutter_test: @@ -61,9 +68,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - .env # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/lib/modals/student.dart b/todo.txt similarity index 100% rename from lib/modals/student.dart rename to todo.txt