diff --git a/README.md b/README.md index 49288df4f4735e91095649c040d2d36d1a75e01f..333c57bab15455abec836712a7113f727e053cc8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Le projet repose sur une architecture robuste conçue pour gérer la connectivit * **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. + +Veuillez lire aussi `./project_description.txt`. ----- ## 📂 Structure des Fichiers (`lib/`) @@ -18,15 +20,20 @@ Le projet repose sur une architecture robuste conçue pour gérer la connectivit 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 + . + ├── components + │ └── sync_status_indicator.dart # composant visuel pour ne pas fermer l'appli trop tot. + ├── main.dart # point d'entree de l'application + ├── models + │ └── student.dart # notre model de donnees + ├── services + │ └── attendance_service.dart # notre service layer (partie la plus importante de l'appli) + ├── utils + │ └── locator.dart # notre gestionnaire de singleton (Dependency injection seeding) + └── views + ├── home.dart # page principale + ├── manual_entry.dart # formulaire d'emargement manuel + └── qr_scanner_screen.dart # page de scan du QR code qui mene vers le GoogleSheet. ``` ----- @@ -39,7 +46,7 @@ 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. +3. Collez le code `doPost(e)` du fichier `Code.gs`. 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.* @@ -48,23 +55,11 @@ Avant de lancer l'application, il faut préparer le fichier de destination. ### 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 : +lancez : ```bash -flutter pub get -flutter run +flutter pub get # fetch all the dependencies +flutter run # run the app ``` ----- diff --git a/lib/components/qr_code_scanner.dart b/lib/components/qr_code_scanner.dart deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/lib/components/sync_status_indicator.dart b/lib/components/sync_status_indicator.dart index 6fabb9dfe827f240fe5e1d597971cd004cc368d1..9ad5ded23282ea6108dca1bdf3bd8711f31e2792 100644 --- a/lib/components/sync_status_indicator.dart +++ b/lib/components/sync_status_indicator.dart @@ -17,7 +17,7 @@ class SyncStatusIndicator extends StatelessWidget { if (students.isEmpty) return; try { - // 1. Build CSV Content + // Build CSV Content final StringBuffer csvBuffer = StringBuffer(); // Header csvBuffer.writeln("Prenom,Nom,No Etudiant,No Leo,Timestamp"); @@ -34,14 +34,14 @@ class SyncStatusIndicator extends StatelessWidget { csvBuffer.writeln("$p,$n,$id,$leo,$time"); } - // 2. Save to Temporary File + // Save to Temporary File final directory = await getTemporaryDirectory(); final path = "${directory.path}/backup_attendance_${DateTime.now().millisecondsSinceEpoch}.csv"; final File file = File(path); await file.writeAsString(csvBuffer.toString()); - // 3. Share File (Allows user to save to Drive, Email, WhatsApp, etc.) + // Share File (Allows user to save to Drive, Email, WhatsApp, etc.) await Share.shareXFiles([ XFile(path), ], text: 'Backup Émargement - ${students.length} étudiants'); @@ -96,7 +96,10 @@ class SyncStatusIndicator extends StatelessWidget { ], ), - // [NEW] Export Button (Only visible if not synced) + // Export Button (Only visible if not synced) + // If !isSynced, scatter the widgets in the given + // array onto the scope of the parent widget, that + // is what the ...[] syntax means if (!isSynced) ...[ const SizedBox(height: 10), SizedBox( diff --git a/lib/main.dart b/lib/main.dart index 40e25b326b5b1dfd508209566dad5a0d53e133e3..0d84ef58e233af34488bd4bb158c4633daf8d4b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'utils/locator.dart'; import 'views/home.dart'; -Future main() async { +void main() { // 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()); } diff --git a/lib/models/student.dart b/lib/models/student.dart index 209208fcb5dd3885a30b78671da63d8701fdedc0..b8b37633afd80430e254a667e520ce137ad2e197 100644 --- a/lib/models/student.dart +++ b/lib/models/student.dart @@ -1,10 +1,10 @@ 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 + final String studentId; + final String leoId; + final DateTime? entryTime; + final DateTime? exitTime; Student({ required this.firstName, @@ -15,7 +15,7 @@ class Student { this.exitTime, }); - // Méthode utilitaire pour copier l'objet avec une heure de sortie mise à jour + // Deep Copy & update the exit time Student copyWithExitTime(DateTime time) { return Student( firstName: firstName, @@ -27,7 +27,7 @@ class Student { ); } - // Conversion en Map pour l'envoi vers Google Sheets + // toJson encoder Map toJson() { return { 'prenom': firstName, diff --git a/lib/services/attendance_service.dart b/lib/services/attendance_service.dart index 572cca1330451ed0d265af871cd47e27c15742a4..0f3a9ce930fecf3dc0941517a17668feb24a815f 100644 --- a/lib/services/attendance_service.dart +++ b/lib/services/attendance_service.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:rxdart/rxdart.dart'; import '../models/student.dart'; @@ -25,25 +25,23 @@ class AttendanceService { 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"); + if (kDebugMode) { + 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}"); + if (kDebugMode) { + print("Student added to queue. Total: ${_uploadQueue.length}"); + } } void _updateQueueLength() { @@ -64,7 +62,8 @@ class AttendanceService { /// 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 + // Snapshot current batch to avoid concurrency issues + // if new students are added during upload final int batchSize = _uploadQueue.length; final List batch = []; @@ -72,14 +71,13 @@ class AttendanceService { 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 + if (kDebugMode) { + print("Flushing batch of ${batch.length} students..."); + } + // Prepare JSON Payload + // Note: We send .toIso8601String() for the timestamp so + // as Google Script can parse it final Map payload = { "students": batch .map( @@ -88,14 +86,13 @@ class AttendanceService { "no_leo": s.leoId, "prenom": s.firstName, "nom": s.lastName, - "timestamp": s.entryTime - ?.toIso8601String(), // CRITICAL: Send scan time + "timestamp": s.entryTime?.toIso8601String(), }, ) .toList(), }; - // 3. Send POST Request + // Send POST Request var response = await http.post( Uri.parse(_sheetApiUrl!), body: jsonEncode(payload), @@ -110,13 +107,17 @@ class AttendanceService { } if (response.statusCode == 200) { - print("Batch sync successful: ${response.body}"); - _updateQueueLength(); // Should be 0 (or close to 0) + if (kDebugMode) { + print("Batch sync successful: ${response.body}"); + } + _updateQueueLength(); } else { throw Exception("Server Error: ${response.statusCode}"); } } catch (e) { - print("Sync failed: $e. Re-queueing students."); + if (kDebugMode) { + 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); diff --git a/lib/utils/locator.dart b/lib/utils/locator.dart index 24050bfe35ae68e19c0c5fb87c60b376f9b83c59..19b0685bf51eb4a8f5d19007656873ab414137b5 100644 --- a/lib/utils/locator.dart +++ b/lib/utils/locator.dart @@ -4,6 +4,9 @@ import '../services/attendance_service.dart'; final getIt = GetIt.instance; void setupLocator() { - // On enregistre AttendanceService comme un Singleton (Lazy = créé à la première utilisation) + // We register a single instance of AttendanceService as a singleton, this is done + // lazily, meaning that the creation of this singleton is only initiated upon the + // first use, then afterwards, we can use getIt to fetch this singleton and retreive + // the service methods and state like the students queue to push new records on to it. getIt.registerLazySingleton(() => AttendanceService()); } diff --git a/lib/views/home.dart b/lib/views/home.dart index ac926c488349fad565e149f42396f226e2c96977..052259bf95de27add2fe2686cdd8fb2aa33c1b81 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.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'; +import 'qr_scanner_screen.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -17,7 +17,7 @@ class _HomePageState extends State { final _service = getIt(); final TextEditingController _urlController = TextEditingController(); - // ONLY simulates the NFC interaction and adds to queue + // simulates the NFC interaction and adds to queue void _simulateNfcScan() { if (_urlController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -26,7 +26,7 @@ class _HomePageState extends State { return; } - // 1. Create Data (Scan Time is fixed here) + // Create Data (Scan Time is fixed here) final newStudent = Student( firstName: "Jean", lastName: "Dupont", @@ -36,10 +36,10 @@ class _HomePageState extends State { entryTime: DateTime.now(), // Precise Scan Time ); - // 2. Add to Service Queue immediately + // Add to Service Queue immediately _service.pushStudentQueue(newStudent); - // 3. Instant Visual Feedback + // Instant Visual Feedback ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Scan enregistré : ${newStudent.firstName}"), @@ -75,7 +75,7 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Émargement Batch")), + appBar: AppBar(title: const Text("Émargement")), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -91,7 +91,7 @@ class _HomePageState extends State { labelText: "URL Script Google", hintText: "https://script.google.com/...", border: const OutlineInputBorder(), - // ADDED: Suffix icon to trigger scanner + // Suffix icon to trigger scanner suffixIcon: IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: "Scanner le QR Code de configuration", @@ -140,42 +140,3 @@ class _HomePageState extends State { ); } } - -/// 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 index 503ed6604c17ecebb7c03b78eb22cb74f732cd2d..f01363c4dcac1a79fa35c011506410c5683ef404 100644 --- a/lib/views/manual_entry.dart +++ b/lib/views/manual_entry.dart @@ -13,7 +13,7 @@ class ManualEntryPage extends StatefulWidget { class _ManualEntryPageState extends State { final _formKey = GlobalKey(); - // Contrôleurs pour récupérer les textes + // In-view text bindings (get user input) final _prenomController = TextEditingController(); final _nomController = TextEditingController(); final _studentIdController = TextEditingController(); @@ -30,8 +30,11 @@ class _ManualEntryPageState extends State { 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 + // Create a student record + // As for the NFC scan we use DateTime.now() + + // Values are not null thanks to the in-widget + // validation logic final student = Student( firstName: _prenomController.text, lastName: _nomController.text, @@ -42,11 +45,11 @@ class _ManualEntryPageState extends State { 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 + // Fetch the service singleton instance to push the + // student record to the queue. getIt().pushStudentQueue(student); - // 3. Feedback et retour + // User feedback ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -55,7 +58,7 @@ class _ManualEntryPageState extends State { ), ); - Navigator.pop(context); // Retour à l'accueil + Navigator.pop(context); // back to previous page } } @@ -75,7 +78,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 20), - // Champ Prénom TextFormField( controller: _prenomController, decoration: const InputDecoration( @@ -87,7 +89,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 15), - // Champ Nom TextFormField( controller: _nomController, decoration: const InputDecoration( @@ -99,7 +100,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 15), - // Champ Numéro Étudiant TextFormField( controller: _studentIdController, decoration: const InputDecoration( @@ -111,7 +111,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 15), - // Champ Numéro Leo (Facultatif en manuel) TextFormField( controller: _leoIdController, decoration: const InputDecoration( @@ -122,15 +121,13 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 30), - // Bouton Valider ElevatedButton.icon( onPressed: _submitForm, icon: const Icon(Icons.save), - label: const Text("ÉMARGER L'ÉTUDIANT"), + 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 + backgroundColor: Colors.orange, foregroundColor: Colors.white, ), ), diff --git a/lib/views/qr_scanner_screen.dart b/lib/views/qr_scanner_screen.dart new file mode 100644 index 0000000000000000000000000000000000000000..658afab38d1de56ea14c7c23a208fa0b43162d00 --- /dev/null +++ b/lib/views/qr_scanner_screen.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// 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/project_description.txt b/project_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..ecdc8344a44a8ac9727d2954a80e35a7c3429753 --- /dev/null +++ b/project_description.txt @@ -0,0 +1,70 @@ +Project architecture overview: + + +On veux avoir une application que le prof pourra utiliser pour emarger les etudiant durant un exam. + +Pour cela, on va partir du principe que le prof veux suivre les etudiants sur un spreadsheet (google +sheet). + +Donc, + +(processus hors appli) +1 - Le prof cree un spread sheet sur son drive (google drive) +2 - le prof prend le lien qui donne acces en lecture et ecriture. +3 - le prof cree un QR code a partir de ce lien. +4 - le prof scan ce qr code depuis l'appli qu'on va dev. + + +(processus dans l'appli) +Puis, a partir de ce moment, le prof peut suivre sur son ecran les update sur le spreadsheet +et nous, notre mission est de faire en sorte de update le fichier spreadsheet au fur et a mesure +que les etudiant s'emargent. + +Pour se faire: +On va declarer un modele/datatype qui va representer la strucutre de donnee qui va stocker un etudiant. +on va stocker son prenom, nom, numero etudiant, numero leo carte, et l'heure d'emargement. + +* Attention: + il faut stocker l'heur d'arrive et de sortie pour voir le temps total que l'etudiant a passe en + exam. + + +Donc, on doit s'assurer que l'appli reporte bien les nouveaux emargements vers le google sheet. + +Pour cela, on va avoir un service, qui va suivre une queue. Cette queue va contenir les etudiants +qui ont emarger recemment et qui ne sont pas encore ecrit sur le spreadsheet distant. + +Ce service va exposer un observable, qui va suivre la longueure de la queue (en gros est-ce qu'il y +a des etudiants qu'on a pas encore save sur le spreadsheet) + +et ce service va aussi exposer une methode qui va nous permettre d'ajouter un etudiant dans la +queue. (push_student_queue) + + +Avec ca, nos composant peuvent utiliser et ecouter cet observable. +Justement, on va avoir un composant qui va ecouter l'observable et qui va donner un indicateur +visuel au prof si il y a des donnee dans la queue pas encore synchronisees avec le google sheet, +cet indicateur est important pour eviter que le prof ferme l'appli trop tot. + +Et on va avoir le composant qui nous permet d'emarger l'etudiant (composnant qui s'occupe du scan +NFC), qui celui ci va utiliser la method push_student_queue pour mettre a jour l'etat de notre +service. + + + +Donc, tout le genie de l'architecture est le fait d'avoir ce layer service, a travers un service qui +sera un singleton et qu'on injectera dans nos composants (dependency injection) + +Pour declarer le service en tant que singleton on doit le mettre dans le providers de l'app (utiliser +get_it) + +Et pour recup le singleton, on va utiliser get_it qui est une lib dart/flutter qui permet justement +de recup les singleton qu'on declare dans les app modules. + +Pour les observable, on va utiliser la lib RxDart. (Reactive programming in Dart) + + +Voila comment on va structure l'appli + +Donc, on doit commencer l'integration du spread sheet en premier et le dev du service pour que aprs +on pourra l'utiliser dans nos composant.