From cca72453dbe5741e3be00025ed963b6caf26b409 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Wed, 22 Oct 2025 16:59:20 +0200 Subject: [PATCH 01/10] first working example that writes to the spreadsheet from our flutter app --- lib/main.dart | 19 +++---- lib/services/spreadsheet_service.dart | 30 +++++++++- lib/views/home.dart | 80 ++++++++++++++++++++++++--- pubspec.lock | 32 +++++++++++ pubspec.yaml | 1 + 5 files changed, 140 insertions(+), 22 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 75ab520..2c282c6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,16 @@ +// lib/main.dart + +import 'package:checkmein/services/spreadsheet_service.dart'; +import 'package:checkmein/views/home.dart'; 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()); + // Register singleton + getIt.registerSingleton(SheetService()); runApp(const MyApp()); } @@ -18,15 +21,11 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Check me in', + title: 'Google Sheet Web App Demo', theme: ThemeData( primarySwatch: Colors.blue, ), - initialRoute: '/', - routes: { - '/': (context) => const Home(), - // Other routes here - }, + home: const Home(), ); } } diff --git a/lib/services/spreadsheet_service.dart b/lib/services/spreadsheet_service.dart index ee4b915..564c12f 100644 --- a/lib/services/spreadsheet_service.dart +++ b/lib/services/spreadsheet_service.dart @@ -1,5 +1,29 @@ -class SomeService { - String fetchData() { - return "Data from SomeService via get_it"; +// lib/services/sheet_service.dart + +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class SheetService { + String? _endpoint; + + // Set this from QR scan or app config + void setEndpoint(String url) { + _endpoint = url; + } + + Future writeMessage(String message) async { + if (_endpoint == null || _endpoint!.isEmpty) { + throw Exception('Google Apps Script endpoint is not set.'); + } + + final response = await http.post( + Uri.parse(_endpoint!), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'message': message}), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to write to Google Sheet: ${response.body}'); + } } } diff --git a/lib/views/home.dart b/lib/views/home.dart index edfe1e6..286b3d4 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,22 +1,84 @@ +// lib/home.dart + +import 'package:checkmein/services/spreadsheet_service.dart'; import 'package:flutter/material.dart'; -import '../services/spreadsheet_service.dart'; import 'package:get_it/get_it.dart'; + final getIt = GetIt.instance; -class Home extends StatelessWidget { +class Home extends StatefulWidget { const Home({super.key}); @override - Widget build(BuildContext context) { - final someService = getIt(); + State createState() => _HomeState(); +} + +class _HomeState extends State { + final _controller = TextEditingController(); + final _endpointController = TextEditingController(); + + late SheetService _sheetService; + String _status = ''; + + @override + void initState() { + super.initState(); + _sheetService = getIt(); + } + + void _submit() async { + final endpoint = _endpointController.text.trim(); + final message = _controller.text.trim(); + + if (endpoint.isEmpty || message.isEmpty) { + setState(() => _status = '❌ Endpoint and message are required.'); + return; + } + + try { + _sheetService.setEndpoint(endpoint); + await _sheetService.writeMessage(message); + setState(() => _status = '✅ Message sent: "$message"'); + _controller.clear(); + } catch (e) { + print(e); + setState(() => _status = '❌ Error: $e'); + } + } + @override + Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Home Page'), - ), - body: Center( - child: Text(someService.fetchData()), + appBar: AppBar(title: const Text('Send to Google Sheet')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _endpointController, + decoration: const InputDecoration( + labelText: 'Google Apps Script Web App URL', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Message to write', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _submit, + child: const Text('Send to Sheet'), + ), + const SizedBox(height: 20), + Text(_status), + ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index cd62737..232de4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -83,6 +83,22 @@ 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" leak_tracker: dependency: transitive description: @@ -224,6 +240,14 @@ 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" vector_math: dependency: transitive description: @@ -240,6 +264,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" diff --git a/pubspec.yaml b/pubspec.yaml index 73a5d9a..972592e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: provider: ^6.1.5+1 get_it: ^8.2.0 rxdart: ^0.28.0 + http: ^1.5.0 dev_dependencies: flutter_test: -- GitLab From 388e1a9d39053c1267ae482f636d437c7de1eeba Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Wed, 22 Oct 2025 18:08:20 +0200 Subject: [PATCH 02/10] first proof of concept of writing to our spreadsheet --- lib/gsheet_web_app_script.js | 90 ++++++++++++++++++++++++ lib/modals/student.dart | 40 +++++++++++ lib/services/spreadsheet_service.dart | 15 ++-- lib/views/home.dart | 98 ++++++++++++++++++++++----- 4 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 lib/gsheet_web_app_script.js diff --git a/lib/gsheet_web_app_script.js b/lib/gsheet_web_app_script.js new file mode 100644 index 0000000..0f9364f --- /dev/null +++ b/lib/gsheet_web_app_script.js @@ -0,0 +1,90 @@ +// This is our small web app script that we will have to add for our spread sheet +// instance on google, then we will deploy it as a web app that is usable by everyone +// and then we will get the generated that will be the bases of our Qr Code. + +// TODO: maybe we need to add the student modal here to be able to correclty +// deconstruct the data + +function doPost(e) { + var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; // Auto-select first sheet + + try { + var data = JSON.parse(e.postData.contents); + + + var message = data.message; + sheet.appendRow(message); + + return ContentService + .createTextOutput(JSON.stringify({ result: 'Success' })) + .setMimeType(ContentService.MimeType.JSON); + } catch (error) { + return ContentService + .createTextOutput(JSON.stringify({ result: 'Error', error: error.message })) + .setMimeType(ContentService.MimeType.JSON); + } +} + + + +// more advanced script +function doPost(e) { + const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; + + try { + const data = JSON.parse(e.postData.contents); + const student = data.message; + + if (!Array.isArray(student) || student.length < 4) { + throw new Error("Invalid student data format. Expected an array of at least 4 values."); + } + + const [firstName, lastName, studentId, studentCardNumber] = student.map(item => String(item).trim()); + const timestamp = new Date(); + + const lastRow = sheet.getLastRow(); + const idColumn = 3; // Column C: studentId + let foundRow = -1; + + // Search for existing studentId (column 3) + for (let row = 2; row <= lastRow; row++) { + const cellValue = sheet.getRange(row, idColumn).getValue(); + if (String(cellValue).trim() === studentId) { + foundRow = row; + break; + } + } + + if (foundRow === -1) { + // Student not found: add a new row with their info + arrival time + lastRow.setValues([ + firstName, + lastName, + studentId, + studentCardNumber, + timestamp, // Column E: Arrival Time + ]); + } else { + // Student found: write departure time in next empty cell on their row + const rowValues = sheet.getRange(foundRow, 1, 1, sheet.getLastColumn()).getValues()[0]; + + // Find next empty column in the row + let nextEmptyCol = rowValues.findIndex(cell => cell === ""); + if (nextEmptyCol === -1) { + nextEmptyCol = rowValues.length; + } + + // Add timestamp to next empty cell (remember 1-indexed column numbers) + sheet.getRange(foundRow, nextEmptyCol + 1).setValue(timestamp); + } + + return ContentService + .createTextOutput(JSON.stringify({ result: "Success" })) + .setMimeType(ContentService.MimeType.JSON); + + } catch (error) { + return ContentService + .createTextOutput(JSON.stringify({ result: "Error", error: error.message })) + .setMimeType(ContentService.MimeType.JSON); + } +} diff --git a/lib/modals/student.dart b/lib/modals/student.dart index e69de29..bc87771 100644 --- a/lib/modals/student.dart +++ b/lib/modals/student.dart @@ -0,0 +1,40 @@ +class Student { + final String firstName; + final String lastName; + final String studentId; + final String studentCardNumber; + + Student({ + required this.firstName, + required this.lastName, + required this.studentId, + required this.studentCardNumber, + }); + + /// Returns the student data as an array (List) + /// to be used with the Apps Script `appendRow` call + List toArray() { + return [ + firstName, + lastName, + studentId, + studentCardNumber, + ]; + } + + /// Optional: Convert to JSON if needed + Map toJson() => { + 'firstName': firstName, + 'lastName': lastName, + 'studentId': studentId, + 'studentCardNumber': studentCardNumber, + }; + + /// Optional: Create from JSON + factory Student.fromJson(Map json) => Student( + firstName: json['firstName'], + lastName: json['lastName'], + studentId: json['studentId'], + studentCardNumber: json['studentCardNumber'], + ); +} diff --git a/lib/services/spreadsheet_service.dart b/lib/services/spreadsheet_service.dart index 564c12f..dba8d36 100644 --- a/lib/services/spreadsheet_service.dart +++ b/lib/services/spreadsheet_service.dart @@ -6,24 +6,31 @@ import 'package:http/http.dart' as http; class SheetService { String? _endpoint; - // Set this from QR scan or app config + // Set this from QR scan or manual input void setEndpoint(String url) { _endpoint = url; } - Future writeMessage(String message) async { + /// Write a row of student data (as a list of strings) to the sheet + Future writeMessage(List messageArray) async { if (_endpoint == null || _endpoint!.isEmpty) { throw Exception('Google Apps Script endpoint is not set.'); } + final body = jsonEncode({ + 'message': messageArray, + }); + print(body); + final response = await http.post( Uri.parse(_endpoint!), headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'message': message}), + body: body, ); if (response.statusCode != 200) { - throw Exception('Failed to write to Google Sheet: ${response.body}'); + throw Exception( + 'Failed to write to Google Sheet: ${response.statusCode} ${response.body}'); } } } diff --git a/lib/views/home.dart b/lib/views/home.dart index 286b3d4..a9470e0 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,5 +1,6 @@ // lib/home.dart +import 'package:checkmein/modals/student.dart'; import 'package:checkmein/services/spreadsheet_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; @@ -15,8 +16,13 @@ class Home extends StatefulWidget { } class _HomeState extends State { - final _controller = TextEditingController(); + final _formKey = GlobalKey(); + final _endpointController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _studentIdController = TextEditingController(); + final _cardNumberController = TextEditingController(); late SheetService _sheetService; String _status = ''; @@ -29,18 +35,35 @@ class _HomeState extends State { void _submit() async { final endpoint = _endpointController.text.trim(); - final message = _controller.text.trim(); - if (endpoint.isEmpty || message.isEmpty) { - setState(() => _status = '❌ Endpoint and message are required.'); + if (endpoint.isEmpty) { + setState(() => _status = '❌ Web App endpoint is required.'); + return; + } + + if (!_formKey.currentState!.validate()) { + setState(() => _status = '❌ Please fill out all fields.'); return; } + final student = Student( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + studentId: _studentIdController.text.trim(), + studentCardNumber: _cardNumberController.text.trim(), + ); + try { _sheetService.setEndpoint(endpoint); - await _sheetService.writeMessage(message); - setState(() => _status = '✅ Message sent: "$message"'); - _controller.clear(); + await _sheetService.writeMessage(student.toArray()); + + setState(() { + _status = '✅ Submitted: ${student.firstName} ${student.lastName}'; + _firstNameController.clear(); + _lastNameController.clear(); + _studentIdController.clear(); + _cardNumberController.clear(); + }); } catch (e) { print(e); setState(() => _status = '❌ Error: $e'); @@ -50,30 +73,69 @@ class _HomeState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Send to Google Sheet')), - body: Padding( + appBar: AppBar(title: const Text('Student Check-in')), + body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ TextField( controller: _endpointController, decoration: const InputDecoration( - labelText: 'Google Apps Script Web App URL', + labelText: 'Web App Endpoint URL', border: OutlineInputBorder(), ), ), - const SizedBox(height: 12), - TextField( - controller: _controller, - decoration: const InputDecoration( - labelText: 'Message to write', - border: OutlineInputBorder(), + const SizedBox(height: 24), + Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration( + labelText: 'First Name', + border: OutlineInputBorder(), + ), + validator: (value) => + value!.isEmpty ? 'First name is required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration( + labelText: 'Last Name', + border: OutlineInputBorder(), + ), + validator: (value) => + value!.isEmpty ? 'Last name is required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _studentIdController, + decoration: const InputDecoration( + labelText: 'Student ID', + border: OutlineInputBorder(), + ), + validator: (value) => + value!.isEmpty ? 'Student ID is required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _cardNumberController, + decoration: const InputDecoration( + labelText: 'Student Card Number', + border: OutlineInputBorder(), + ), + validator: (value) => + value!.isEmpty ? 'Card number is required' : null, + ), + ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 20), ElevatedButton( onPressed: _submit, - child: const Text('Send to Sheet'), + child: const Text('Submit to Sheet'), ), const SizedBox(height: 20), Text(_status), -- GitLab From e54f2421b4d3f83a3d3dfe63f49b66b745d2272f Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Wed, 3 Dec 2025 22:17:37 +0100 Subject: [PATCH 03/10] first implementation of our project ui --- Code.gs | 104 +++++++++ README.md | 103 +++++++- devtools_options.yaml | 3 + lib/components/sync_status_indicator.dart | 49 ++++ lib/main.dart | 22 +- lib/modals/student.dart | 40 ---- lib/models/student.dart | 41 ++++ lib/services/attendance_service.dart | 84 +++++++ lib/utils/locator.dart | 9 + lib/views/home.dart | 271 +++++++++++++--------- lib/views/manual_entry.dart | 143 ++++++++++++ pubspec.lock | 32 +++ pubspec.yaml | 4 + 13 files changed, 728 insertions(+), 177 deletions(-) create mode 100644 Code.gs create mode 100644 devtools_options.yaml create mode 100644 lib/components/sync_status_indicator.dart delete mode 100644 lib/modals/student.dart create mode 100644 lib/models/student.dart create mode 100644 lib/services/attendance_service.dart create mode 100644 lib/utils/locator.dart create mode 100644 lib/views/manual_entry.dart diff --git a/Code.gs b/Code.gs new file mode 100644 index 0000000..038b457 --- /dev/null +++ b/Code.gs @@ -0,0 +1,104 @@ +// C'est la fonction qui gère les requêtes HTTP POST venant de l'appli Flutter +function doPost(e) { + // On utilise un verrou pour éviter que deux scans simultanés ne corrompent le fichier + var lock = LockService.getScriptLock(); + lock.tryLock(10000); // On attend jusqu'à 10 sec si le script est occupé + + try { + var doc = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = doc.getActiveSheet(); + + // 1. Parsing des données envoyées par Flutter + // Les clés doivent correspondre exactement au .toJson() du modèle Flutter + var rawData = e.postData.contents; + var data = JSON.parse(rawData); + + var studentId = data.no_etudiant; + var leoId = data.no_leo; + var prenom = data.prenom; + var nom = data.nom; + var timeScanned = new Date(); // On utilise l'heure de réception du serveur pour être sûr + + // 2. Vérification : Est-ce une Entrée ou une Sortie ? + // On cherche si l'étudiant est déjà dans la liste + var rowIndex = findRowIndex(sheet, studentId); + + var status = ""; + + if (rowIndex == -1) { + // CAS 1 : L'étudiant n'est pas trouvé -> C'est une ENTREE (Création) [cite: 8] + var newRow = [ + timeScanned, // Timestamp global + studentId, + leoId, + prenom, + nom, + formatTime(timeScanned), // Heure d'arrivée + "" // Heure de sortie (vide pour l'instant) + ]; + sheet.appendRow(newRow); + status = "Entrée enregistrée"; + + } else { + // CAS 2 : L'étudiant existe déjà -> C'est une SORTIE (Mise à jour) + // On met à jour la colonne G (index 7) pour l'heure de sortie + // Note: On assume que la structure des colonnes est fixe (A=Timestamp, F=Entrée, G=Sortie) + var exitCell = sheet.getRange(rowIndex, 7); + + // On vérifie qu'il n'est pas déjà sorti pour éviter d'écraser + if (exitCell.getValue() === "") { + exitCell.setValue(formatTime(timeScanned)); + status = "Sortie enregistrée"; + } else { + status = "Déjà sorti (Ignoré)"; + } + } + + // 3. Retourner une réponse succès (JSON) au service Flutter [cite: 13] + var result = { + "status": "success", + "message": status, + "student": prenom + " " + nom + }; + + return ContentService.createTextOutput(JSON.stringify(result)) + .setMimeType(ContentService.MimeType.JSON); + + } catch (error) { + // Gestion d'erreur retournée à l'appli + var errorResult = { + "status": "error", + "message": error.toString() + }; + return ContentService.createTextOutput(JSON.stringify(errorResult)) + .setMimeType(ContentService.MimeType.JSON); + + } finally { + lock.releaseLock(); + } +} + +// Fonction utilitaire pour trouver la ligne d'un étudiant via son ID +function findRowIndex(sheet, idToFind) { + var data = sheet.getDataRange().getValues(); + // On commence à la ligne 1 (index 1) pour sauter les en-têtes + for (var i = 1; i < data.length; i++) { + // On suppose que l'ID étudiant est dans la colonne B (index 1 du tableau) + if (data[i][1] == idToFind) { + return i + 1; // +1 car les rangées Sheet commencent à 1 + } + } + return -1; +} + +// Formatage simple de l'heure HH:mm:ss +function formatTime(date) { + return Utilities.formatDate(date, Session.getScriptTimeZone(), "HH:mm:ss"); +} + +// Fonction utilitaire pour initialiser les en-têtes (à lancer une fois manuellement) +function setupSheet() { + var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); + sheet.clear(); + sheet.appendRow(["Timestamp", "No Etudiant", "No Leo", "Prénom", "Nom", "Heure Arrivée", "Heure Sortie"]); +} \ No newline at end of file diff --git a/README.md b/README.md index 74152bb..49288df 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 0000000..fa0b357 --- /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 0000000..08f24ef --- /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 2c282c6..a7271db 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,9 @@ -// lib/main.dart - -import 'package:checkmein/services/spreadsheet_service.dart'; -import 'package:checkmein/views/home.dart'; import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; - - -final getIt = GetIt.instance; +import 'utils/locator.dart'; +import 'views/home.dart'; void main() { - // Register singleton - getIt.registerSingleton(SheetService()); - + setupLocator(); // Initialisation du Singleton runApp(const MyApp()); } @@ -21,11 +13,9 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Google Sheet Web App Demo', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - home: const Home(), + title: 'Exam Émargement', + theme: ThemeData(primarySwatch: Colors.blue), + home: const HomePage(), ); } } diff --git a/lib/modals/student.dart b/lib/modals/student.dart deleted file mode 100644 index bc87771..0000000 --- a/lib/modals/student.dart +++ /dev/null @@ -1,40 +0,0 @@ -class Student { - final String firstName; - final String lastName; - final String studentId; - final String studentCardNumber; - - Student({ - required this.firstName, - required this.lastName, - required this.studentId, - required this.studentCardNumber, - }); - - /// Returns the student data as an array (List) - /// to be used with the Apps Script `appendRow` call - List toArray() { - return [ - firstName, - lastName, - studentId, - studentCardNumber, - ]; - } - - /// Optional: Convert to JSON if needed - Map toJson() => { - 'firstName': firstName, - 'lastName': lastName, - 'studentId': studentId, - 'studentCardNumber': studentCardNumber, - }; - - /// Optional: Create from JSON - factory Student.fromJson(Map json) => Student( - firstName: json['firstName'], - lastName: json['lastName'], - studentId: json['studentId'], - studentCardNumber: json['studentCardNumber'], - ); -} diff --git a/lib/models/student.dart b/lib/models/student.dart new file mode 100644 index 0000000..209208f --- /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 0000000..8a57dbe --- /dev/null +++ b/lib/services/attendance_service.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:collection'; +import 'package:rxdart/rxdart.dart'; +import '../models/student.dart'; + +class AttendanceService { + // L'URL du Google Sheet (récupérée via QR Code) + String? _sheetApiUrl; + + // La Queue qui stocke les étudiants en attente de sync + final Queue _uploadQueue = Queue(); + + // Observable pour suivre la taille de la queue (état de sync) + // BehaviorSubject permet aux nouveaux abonnés de recevoir la dernière valeur émise. + final BehaviorSubject _queueLengthSubject = BehaviorSubject.seeded( + 0, + ); + + // Stream public exposé aux composants UI + Stream get queueLengthStream => _queueLengthSubject.stream; + + // Constructeur + AttendanceService() { + // On lance un worker qui surveille la queue périodiquement + _startBackgroundSync(); + } + + // Configuration de l'URL via le scan QR Code + void setSheetUrl(String url) { + _sheetApiUrl = url; + print("URL Google Sheet configurée: $_sheetApiUrl"); + } + + // Méthode pour ajouter un étudiant dans la queue (push_student_queue) + void pushStudentQueue(Student student) { + // Ici, on pourrait ajouter une logique pour vérifier si c'est une entrée ou une sortie + // Pour l'exemple, on push simplement dans la queue. + _uploadQueue.add(student); + + // On met à jour l'observable pour que l'UI réagisse + _updateQueueLength(); + } + + void _updateQueueLength() { + _queueLengthSubject.add(_uploadQueue.length); + } + + // Processus de synchronisation (worker) + void _startBackgroundSync() { + // Vérifie la queue toutes les 5 secondes (simulation de process background) + Timer.periodic(const Duration(seconds: 5), (timer) async { + if (_uploadQueue.isNotEmpty && _sheetApiUrl != null) { + await _processQueue(); + } + }); + } + + Future _processQueue() async { + // On prend le premier étudiant sans le retirer tout de suite (au cas où ça fail) + final studentToSync = _uploadQueue.first; + + try { + print( + "Tentative de sync pour : ${studentToSync.firstName} vers $_sheetApiUrl", + ); + + // SIMULATION de l'appel HTTP vers Google Sheet + // Dans la réalité: await http.post(Uri.parse(_sheetApiUrl), body: studentToSync.toJson()); + await Future.delayed(const Duration(milliseconds: 1000)); + + // Si succès, on retire de la queue + _uploadQueue.removeFirst(); + _updateQueueLength(); + print("Sync réussie. Restant dans la queue : ${_uploadQueue.length}"); + } catch (e) { + print("Erreur de sync: $e. On réessaiera plus tard."); + } + } + + // Nettoyage lors de la fermeture de l'app (optionnel) + void dispose() { + _queueLengthSubject.close(); + } +} diff --git a/lib/utils/locator.dart b/lib/utils/locator.dart new file mode 100644 index 0000000..24050bf --- /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 a9470e0..423332a 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,144 +1,193 @@ -// lib/home.dart - -import 'package:checkmein/modals/student.dart'; -import 'package:checkmein/services/spreadsheet_service.dart'; import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; - +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 Home extends StatefulWidget { - const Home({super.key}); +class HomePage extends StatefulWidget { + const HomePage({super.key}); @override - State createState() => _HomeState(); + State createState() => _HomePageState(); } -class _HomeState extends State { - final _formKey = GlobalKey(); - - final _endpointController = TextEditingController(); - final _firstNameController = TextEditingController(); - final _lastNameController = TextEditingController(); - final _studentIdController = TextEditingController(); - final _cardNumberController = TextEditingController(); +class _HomePageState extends State { + final _service = getIt(); + final TextEditingController _urlController = TextEditingController(); + + // Simulation du scan NFC [cite: 16] + void _simulateNfcScan() { + // Dans la vraie vie, ceci serait déclenché par le plugin NFC + // final tag = await NfcManager.instance.tagSession... + + final newStudent = Student( + firstName: "Jean", + lastName: "Dupont", + studentId: + "E${DateTime.now().millisecondsSinceEpoch}", // ID bidon pour test + leoId: "LEO123456", + entryTime: DateTime.now(), // [cite: 9] Stockage heure d'arrivée + // exitTime sera null pour l'instant + ); - late SheetService _sheetService; - String _status = ''; + // [cite: 16] Utilisation de push_student_queue pour mettre à jour l'état + _service.pushStudentQueue(newStudent); - @override - void initState() { - super.initState(); - _sheetService = getIt(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Étudiant ${newStudent.firstName} émargé (mis en queue)"), + ), + ); } - void _submit() async { - final endpoint = _endpointController.text.trim(); - - if (endpoint.isEmpty) { - setState(() => _status = '❌ Web App endpoint is required.'); - return; - } + // Simulation du scan QR Code pour configurer le sheet [cite: 5] + void _scanQrCodeConfiguration() { + // Simulation d'un lien obtenu par QR Code + const simulatedUrl = "https://script.google.com/macros/s/xxxx/exec"; + _service.setSheetUrl(simulatedUrl); - if (!_formKey.currentState!.validate()) { - setState(() => _status = '❌ Please fill out all fields.'); - return; - } + setState(() { + _urlController.text = simulatedUrl; + }); - final student = Student( - firstName: _firstNameController.text.trim(), - lastName: _lastNameController.text.trim(), - studentId: _studentIdController.text.trim(), - studentCardNumber: _cardNumberController.text.trim(), + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Configuration Google Sheet chargée via QR !"), + ), ); - - try { - _sheetService.setEndpoint(endpoint); - await _sheetService.writeMessage(student.toArray()); - - setState(() { - _status = '✅ Submitted: ${student.firstName} ${student.lastName}'; - _firstNameController.clear(); - _lastNameController.clear(); - _studentIdController.clear(); - _cardNumberController.clear(); - }); - } catch (e) { - print(e); - setState(() => _status = '❌ Error: $e'); - } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Student Check-in')), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + appBar: AppBar(title: const Text("Émargement Examen")), + body: Padding( + padding: const EdgeInsets.all(16.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextField( - controller: _endpointController, - decoration: const InputDecoration( - labelText: 'Web App Endpoint URL', - border: OutlineInputBorder(), - ), + // Indicateur visuel d'état (Sync) [cite: 15] + const SyncStatusIndicator(), + + const SizedBox(height: 20), + const Divider(), + const Text( + "Configuration", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - const SizedBox(height: 24), - Form( - key: _formKey, - child: Column( - children: [ - TextFormField( - controller: _firstNameController, - decoration: const InputDecoration( - labelText: 'First Name', - border: OutlineInputBorder(), + // Section Configuration (QR Code OU Saisie Manuelle) + const SizedBox(width: 0, height: 15), + Row( + children: [ + Expanded( + child: TextField( + controller: _urlController, + // 1. On autorise l'écriture + readOnly: false, + keyboardType: + TextInputType.url, // Clavier optimisé pour URL + // 2. On met à jour le service dès que le prof tape au clavier + onChanged: (value) { + _service.setSheetUrl(value); + }, + + decoration: InputDecoration( + labelText: "Lien API Google Sheet", + hintText: "https://script.google.com/...", + border: const OutlineInputBorder(), + // Petit bouton croix pour effacer facilement + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _urlController.clear(); + _service.setSheetUrl( + "", + ); // On vide aussi dans le service + }, + ), ), - validator: (value) => - value!.isEmpty ? 'First name is required' : null, ), - const SizedBox(height: 12), - TextFormField( - controller: _lastNameController, - decoration: const InputDecoration( - labelText: 'Last Name', - border: OutlineInputBorder(), - ), - validator: (value) => - value!.isEmpty ? 'Last name is required' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _studentIdController, - decoration: const InputDecoration( - labelText: 'Student ID', - border: OutlineInputBorder(), + ), + const SizedBox(width: 10), + + // Le bouton Scan reste là en option rapide + FloatingActionButton.small( + onPressed: _scanQrCodeConfiguration, + tooltip: "Scanner le QR de configuration", + child: const Icon(Icons.qr_code_scanner), + ), + ], + ), + + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 20), + + const Text( + "Émargement", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + + // Gros bouton d'émargement (Scan NFC) + Expanded( + child: Center( + child: SizedBox( + width: 200, + height: 200, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: Colors.blueAccent, ), - validator: (value) => - value!.isEmpty ? 'Student ID is required' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _cardNumberController, - decoration: const InputDecoration( - labelText: 'Student Card Number', - border: OutlineInputBorder(), + onPressed: _urlController.text.isNotEmpty + ? _simulateNfcScan + : null, // Désactivé si pas configuré + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.nfc, size: 50, color: Colors.white), + SizedBox(height: 10), + Text( + "SCANNER CARTE\nÉTUDIANT", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], ), - validator: (value) => - value!.isEmpty ? 'Card number is required' : null, ), - ], + ), ), ), const SizedBox(height: 20), - ElevatedButton( - onPressed: _submit, - child: const Text('Submit to Sheet'), + + const SizedBox(height: 10), + const Divider(), + const SizedBox(height: 10), + + TextButton.icon( + onPressed: () { + // Navigation vers le formulaire + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ManualEntryPage(), + ), + ); + }, + icon: const Icon(Icons.edit_note), + label: const Text("Pas de carte ? Saisie manuelle"), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 20, + ), + textStyle: const TextStyle(fontSize: 16), + ), ), - const SizedBox(height: 20), - Text(_status), ], ), ), diff --git a/lib/views/manual_entry.dart b/lib/views/manual_entry.dart new file mode 100644 index 0000000..503ed66 --- /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 232de4e..c52e9ed 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,6 +65,14 @@ 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 @@ -99,6 +115,14 @@ packages: 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: @@ -248,6 +272,14 @@ packages: 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: diff --git a/pubspec.yaml b/pubspec.yaml index 972592e..ed85c06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,10 @@ dependencies: get_it: ^8.2.0 rxdart: ^0.28.0 http: ^1.5.0 + intl: ^0.18.0 + uuid: ^4.0.0 + # nfc_manager: ^3.3.0 + # mobile_scanner: ^3.5.0 dev_dependencies: flutter_test: -- GitLab From 89227a2b47998aae7aa3ac0de444e5136a32910c Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Thu, 4 Dec 2025 12:14:08 +0100 Subject: [PATCH 04/10] implemented batch syncs with google sheets through our service, updated the UI to match the application state and give real time feedback. --- Code.gs | 126 +++++++------ ..._app_script.js => gsheet_web_app_script.js | 0 lib/services/attendance_service.dart | 110 +++++++---- lib/services/spreadsheet_service.dart | 36 ---- lib/views/home.dart | 177 +++++------------- 5 files changed, 190 insertions(+), 259 deletions(-) rename lib/gsheet_web_app_script.js => gsheet_web_app_script.js (100%) delete mode 100644 lib/services/spreadsheet_service.dart diff --git a/Code.gs b/Code.gs index 038b457..f157f03 100644 --- a/Code.gs +++ b/Code.gs @@ -1,71 +1,84 @@ -// C'est la fonction qui gère les requêtes HTTP POST venant de l'appli Flutter +/** + * Handle POST requests from Flutter + * Expects a JSON payload: { "students": [ { ...student1 }, { ...student2 } ] } + */ function doPost(e) { - // On utilise un verrou pour éviter que deux scans simultanés ne corrompent le fichier var lock = LockService.getScriptLock(); - lock.tryLock(10000); // On attend jusqu'à 10 sec si le script est occupé + // Wait up to 30 seconds to avoid collisions during batch writes + lock.tryLock(30000); try { var doc = SpreadsheetApp.getActiveSpreadsheet(); var sheet = doc.getActiveSheet(); - // 1. Parsing des données envoyées par Flutter - // Les clés doivent correspondre exactement au .toJson() du modèle Flutter + // Parse the JSON body var rawData = e.postData.contents; - var data = JSON.parse(rawData); + var jsonPayload = JSON.parse(rawData); - var studentId = data.no_etudiant; - var leoId = data.no_leo; - var prenom = data.prenom; - var nom = data.nom; - var timeScanned = new Date(); // On utilise l'heure de réception du serveur pour être sûr + // Check if we received a list of students + var studentsList = jsonPayload.students; - // 2. Vérification : Est-ce une Entrée ou une Sortie ? - // On cherche si l'étudiant est déjà dans la liste - var rowIndex = findRowIndex(sheet, studentId); - - var status = ""; - - if (rowIndex == -1) { - // CAS 1 : L'étudiant n'est pas trouvé -> C'est une ENTREE (Création) [cite: 8] - var newRow = [ - timeScanned, // Timestamp global - studentId, - leoId, - prenom, - nom, - formatTime(timeScanned), // Heure d'arrivée - "" // Heure de sortie (vide pour l'instant) - ]; - sheet.appendRow(newRow); - status = "Entrée enregistrée"; + if (!studentsList || !Array.isArray(studentsList)) { + throw new Error("Invalid format: 'students' array missing."); + } + + var results = []; + + // Process each student in the batch + studentsList.forEach(function(data) { + var studentId = data.no_etudiant; + var leoId = data.no_leo; + var prenom = data.prenom; + var nom = data.nom; + + // CRITICAL: Use the timestamp sent from the App (scan time), not Server time + var scanTime = new Date(data.timestamp); - } else { - // CAS 2 : L'étudiant existe déjà -> C'est une SORTIE (Mise à jour) - // On met à jour la colonne G (index 7) pour l'heure de sortie - // Note: On assume que la structure des colonnes est fixe (A=Timestamp, F=Entrée, G=Sortie) - var exitCell = sheet.getRange(rowIndex, 7); + // Find if student exists + var rowIndex = findRowIndex(sheet, studentId); + var status = ""; - // On vérifie qu'il n'est pas déjà sorti pour éviter d'écraser - if (exitCell.getValue() === "") { - exitCell.setValue(formatTime(timeScanned)); - status = "Sortie enregistrée"; + if (rowIndex == -1) { + // --- ENTRY (New Row) --- + var newRow = [ + scanTime, // A: Timestamp (Scan time) + 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"; } else { - status = "Déjà sorti (Ignoré)"; + // --- EXIT (Update Row) --- + var exitCell = sheet.getRange(rowIndex, 7); // Column G + if (exitCell.getValue() === "") { + exitCell.setValue(formatTime(scanTime)); + status = "Sortie"; + } else { + status = "Déjà sorti"; + } } - } + + results.push({ + "id": studentId, + "name": prenom + " " + nom, + "status": status + }); + }); - // 3. Retourner une réponse succès (JSON) au service Flutter [cite: 13] - var result = { + var responseOutput = { "status": "success", - "message": status, - "student": prenom + " " + nom + "processed_count": results.length, + "details": results }; - - return ContentService.createTextOutput(JSON.stringify(result)) + + return ContentService.createTextOutput(JSON.stringify(responseOutput)) .setMimeType(ContentService.MimeType.JSON); } catch (error) { - // Gestion d'erreur retournée à l'appli var errorResult = { "status": "error", "message": error.toString() @@ -78,27 +91,18 @@ function doPost(e) { } } -// Fonction utilitaire pour trouver la ligne d'un étudiant via son ID +// Helper: Find row by Student ID (Column B/Index 1) function findRowIndex(sheet, idToFind) { var data = sheet.getDataRange().getValues(); - // On commence à la ligne 1 (index 1) pour sauter les en-têtes for (var i = 1; i < data.length; i++) { - // On suppose que l'ID étudiant est dans la colonne B (index 1 du tableau) - if (data[i][1] == idToFind) { - return i + 1; // +1 car les rangées Sheet commencent à 1 + if (data[i][1] == idToFind) { // strict equality might need type check + return i + 1; } } return -1; } -// Formatage simple de l'heure HH:mm:ss +// Helper: Format time HH:mm:ss function formatTime(date) { return Utilities.formatDate(date, Session.getScriptTimeZone(), "HH:mm:ss"); -} - -// Fonction utilitaire pour initialiser les en-têtes (à lancer une fois manuellement) -function setupSheet() { - var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); - sheet.clear(); - sheet.appendRow(["Timestamp", "No Etudiant", "No Leo", "Prénom", "Nom", "Heure Arrivée", "Heure Sortie"]); } \ No newline at end of file diff --git a/lib/gsheet_web_app_script.js b/gsheet_web_app_script.js similarity index 100% rename from lib/gsheet_web_app_script.js rename to gsheet_web_app_script.js diff --git a/lib/services/attendance_service.dart b/lib/services/attendance_service.dart index 8a57dbe..89b04c1 100644 --- a/lib/services/attendance_service.dart +++ b/lib/services/attendance_service.dart @@ -1,84 +1,122 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:rxdart/rxdart.dart'; import '../models/student.dart'; class AttendanceService { - // L'URL du Google Sheet (récupérée via QR Code) String? _sheetApiUrl; - // La Queue qui stocke les étudiants en attente de sync + // Queue to store students locally before sync final Queue _uploadQueue = Queue(); - // Observable pour suivre la taille de la queue (état de sync) - // BehaviorSubject permet aux nouveaux abonnés de recevoir la dernière valeur émise. + // Observable for UI final BehaviorSubject _queueLengthSubject = BehaviorSubject.seeded( 0, ); - - // Stream public exposé aux composants UI Stream get queueLengthStream => _queueLengthSubject.stream; - // Constructeur + // Timer for batch flushing + Timer? _flushTimer; + AttendanceService() { - // On lance un worker qui surveille la queue périodiquement - _startBackgroundSync(); + _startBatchTimer(); } - // Configuration de l'URL via le scan QR Code void setSheetUrl(String url) { _sheetApiUrl = url; - print("URL Google Sheet configurée: $_sheetApiUrl"); + print("URL Configured: $_sheetApiUrl"); } - // Méthode pour ajouter un étudiant dans la queue (push_student_queue) + /// Adds a student to the queue with the current timestamp (Scan Time) void pushStudentQueue(Student student) { - // Ici, on pourrait ajouter une logique pour vérifier si c'est une entrée ou une sortie - // Pour l'exemple, on push simplement dans la queue. _uploadQueue.add(student); - - // On met à jour l'observable pour que l'UI réagisse _updateQueueLength(); + print("Student added to queue. Total: ${_uploadQueue.length}"); } void _updateQueueLength() { _queueLengthSubject.add(_uploadQueue.length); } - // Processus de synchronisation (worker) - void _startBackgroundSync() { - // Vérifie la queue toutes les 5 secondes (simulation de process background) - Timer.periodic(const Duration(seconds: 5), (timer) async { - if (_uploadQueue.isNotEmpty && _sheetApiUrl != null) { - await _processQueue(); + /// 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(); } }); } - Future _processQueue() async { - // On prend le premier étudiant sans le retirer tout de suite (au cas où ça fail) - final studentToSync = _uploadQueue.first; + /// 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( - "Tentative de sync pour : ${studentToSync.firstName} vers $_sheetApiUrl", + 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), ); - // SIMULATION de l'appel HTTP vers Google Sheet - // Dans la réalité: await http.post(Uri.parse(_sheetApiUrl), body: studentToSync.toJson()); - await Future.delayed(const Duration(milliseconds: 1000)); + // Handle Google Redirect (302) + if (response.statusCode == 302) { + final String? redirectUrl = response.headers['location']; + if (redirectUrl != null) { + response = await http.get(Uri.parse(redirectUrl)); + } + } - // Si succès, on retire de la queue - _uploadQueue.removeFirst(); - _updateQueueLength(); - print("Sync réussie. Restant dans la queue : ${_uploadQueue.length}"); + 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("Erreur de sync: $e. On réessaiera plus tard."); + 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(); } } - // Nettoyage lors de la fermeture de l'app (optionnel) 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 dba8d36..0000000 --- a/lib/services/spreadsheet_service.dart +++ /dev/null @@ -1,36 +0,0 @@ -// lib/services/sheet_service.dart - -import 'dart:convert'; -import 'package:http/http.dart' as http; - -class SheetService { - String? _endpoint; - - // Set this from QR scan or manual input - void setEndpoint(String url) { - _endpoint = url; - } - - /// Write a row of student data (as a list of strings) to the sheet - Future writeMessage(List messageArray) async { - if (_endpoint == null || _endpoint!.isEmpty) { - throw Exception('Google Apps Script endpoint is not set.'); - } - - final body = jsonEncode({ - 'message': messageArray, - }); - print(body); - - final response = await http.post( - Uri.parse(_endpoint!), - headers: {'Content-Type': 'application/json'}, - body: body, - ); - - if (response.statusCode != 200) { - throw Exception( - 'Failed to write to Google Sheet: ${response.statusCode} ${response.body}'); - } - } -} diff --git a/lib/views/home.dart b/lib/views/home.dart index 423332a..3698cbb 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -16,177 +16,102 @@ class _HomePageState extends State { final _service = getIt(); final TextEditingController _urlController = TextEditingController(); - // Simulation du scan NFC [cite: 16] + // ONLY simulates the NFC interaction and adds to queue void _simulateNfcScan() { - // Dans la vraie vie, ceci serait déclenché par le plugin NFC - // final tag = await NfcManager.instance.tagSession... - + 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}", // ID bidon pour test + studentId: "E${DateTime.now().millisecondsSinceEpoch}", leoId: "LEO123456", - entryTime: DateTime.now(), // [cite: 9] Stockage heure d'arrivée - // exitTime sera null pour l'instant + entryTime: DateTime.now(), // Precise Scan Time ); - // [cite: 16] Utilisation de push_student_queue pour mettre à jour l'état + // 2. Add to Service Queue immediately _service.pushStudentQueue(newStudent); + // 3. Instant Visual Feedback ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text("Étudiant ${newStudent.firstName} émargé (mis en queue)"), + content: Text("Scan enregistré : ${newStudent.firstName}"), + backgroundColor: Colors.blue, + duration: const Duration(seconds: 1), ), ); } - // Simulation du scan QR Code pour configurer le sheet [cite: 5] void _scanQrCodeConfiguration() { - // Simulation d'un lien obtenu par QR Code - const simulatedUrl = "https://script.google.com/macros/s/xxxx/exec"; + // Update this URL to your deployed web app URL + const simulatedUrl = + "https://script.google.com/macros/s/AKfycbzBaGKZQoCTg4H3zRsebK-NKUDzhZfNKNLM9EPPvFOc-gK98D-CjuJxApFjnmpoztQ2/exec"; _service.setSheetUrl(simulatedUrl); setState(() { _urlController.text = simulatedUrl; }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Configuration Google Sheet chargée via QR !"), - ), - ); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Émargement Examen")), + appBar: AppBar(title: const Text("Émargement Batch")), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Indicateur visuel d'état (Sync) [cite: 15] - const SyncStatusIndicator(), - + const SyncStatusIndicator(), // Will update automatically based on Queue size const SizedBox(height: 20), - const Divider(), - const Text( - "Configuration", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - // Section Configuration (QR Code OU Saisie Manuelle) - const SizedBox(width: 0, height: 15), - Row( - children: [ - Expanded( - child: TextField( - controller: _urlController, - // 1. On autorise l'écriture - readOnly: false, - keyboardType: - TextInputType.url, // Clavier optimisé pour URL - // 2. On met à jour le service dès que le prof tape au clavier - onChanged: (value) { - _service.setSheetUrl(value); - }, - decoration: InputDecoration( - labelText: "Lien API Google Sheet", - hintText: "https://script.google.com/...", - border: const OutlineInputBorder(), - // Petit bouton croix pour effacer facilement - suffixIcon: IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _urlController.clear(); - _service.setSheetUrl( - "", - ); // On vide aussi dans le service - }, - ), - ), - ), - ), - const SizedBox(width: 10), - - // Le bouton Scan reste là en option rapide - FloatingActionButton.small( - onPressed: _scanQrCodeConfiguration, - tooltip: "Scanner le QR de configuration", - child: const Icon(Icons.qr_code_scanner), - ), - ], + // Configuration Input + TextField( + controller: _urlController, + onChanged: (val) => _service.setSheetUrl(val), + decoration: const InputDecoration( + labelText: "URL Script Google", + border: OutlineInputBorder(), + ), ), - const SizedBox(height: 20), - const Divider(), - const SizedBox(height: 20), - - const Text( - "Émargement", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), + const Spacer(), - // Gros bouton d'émargement (Scan NFC) - Expanded( - child: Center( - child: SizedBox( - width: 200, - height: 200, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: Colors.blueAccent, - ), - onPressed: _urlController.text.isNotEmpty - ? _simulateNfcScan - : null, // Désactivé si pas configuré - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.nfc, size: 50, color: Colors.white), - SizedBox(height: 10), - Text( - "SCANNER CARTE\nÉTUDIANT", - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), + // 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 SizedBox(height: 20), - const SizedBox(height: 10), - const Divider(), - const SizedBox(height: 10), + const Spacer(), - TextButton.icon( + // Manual Entry Link + TextButton( onPressed: () { - // Navigation vers le formulaire Navigator.push( context, - MaterialPageRoute( - builder: (context) => const ManualEntryPage(), - ), + MaterialPageRoute(builder: (_) => const ManualEntryPage()), ); }, - icon: const Icon(Icons.edit_note), - label: const Text("Pas de carte ? Saisie manuelle"), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: 15, - horizontal: 20, - ), - textStyle: const TextStyle(fontSize: 16), - ), + child: const Text("Saisie Manuelle"), ), ], ), -- GitLab From 61ab14b50d538e48fcad46cfba51bda078265a0b Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Thu, 4 Dec 2025 12:22:09 +0100 Subject: [PATCH 05/10] Added env support, about to revoke the used token, do not bother ! --- .gitignore | 4 ++++ lib/main.dart | 7 ++++++- lib/services/attendance_service.dart | 7 +++++++ lib/views/home.dart | 3 +-- pubspec.lock | 8 ++++++++ pubspec.yaml | 6 +++--- 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 3820a95..347de1b 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/lib/main.dart b/lib/main.dart index a7271db..40e25b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'utils/locator.dart'; import 'views/home.dart'; -void main() { +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()); } diff --git a/lib/services/attendance_service.dart b/lib/services/attendance_service.dart index 89b04c1..d5e6584 100644 --- a/lib/services/attendance_service.dart +++ b/lib/services/attendance_service.dart @@ -1,6 +1,7 @@ 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'; @@ -21,6 +22,12 @@ 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(); } diff --git a/lib/views/home.dart b/lib/views/home.dart index 3698cbb..29b97e8 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -49,8 +49,7 @@ class _HomePageState extends State { void _scanQrCodeConfiguration() { // Update this URL to your deployed web app URL - const simulatedUrl = - "https://script.google.com/macros/s/AKfycbzBaGKZQoCTg4H3zRsebK-NKUDzhZfNKNLM9EPPvFOc-gK98D-CjuJxApFjnmpoztQ2/exec"; + const simulatedUrl = "simulatedUrl"; _service.setSheetUrl(simulatedUrl); setState(() { diff --git a/pubspec.lock b/pubspec.lock index c52e9ed..bcf4fc6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -78,6 +78,14 @@ packages: 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: diff --git a/pubspec.yaml b/pubspec.yaml index ed85c06..7ec8d52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: http: ^1.5.0 intl: ^0.18.0 uuid: ^4.0.0 + flutter_dotenv: ^5.1.0 # nfc_manager: ^3.3.0 # mobile_scanner: ^3.5.0 @@ -66,9 +67,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 -- GitLab From 3c9517c14390dabcc325699bac986fd538082a0a Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Thu, 4 Dec 2025 12:29:11 +0100 Subject: [PATCH 06/10] added todo file --- todo.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 todo.txt diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..101061c --- /dev/null +++ b/todo.txt @@ -0,0 +1,2 @@ +* Add back the qr code support to the home widget +* Give the user an option to download a csv file if the sync does not work for some reason. -- GitLab From 446d6146265fd5c649dcd0f0e24a7e3689bbbd0d Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Fri, 5 Dec 2025 10:11:45 +0100 Subject: [PATCH 07/10] conducted some tests to see how the UI reacts and if the data if well flushed to google sheet --- Code.gs | 90 +++++++++++++++++++++++++++------------------ lib/views/home.dart | 3 +- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/Code.gs b/Code.gs index f157f03..cf481f2 100644 --- a/Code.gs +++ b/Code.gs @@ -1,11 +1,10 @@ /** - * Handle POST requests from Flutter - * Expects a JSON payload: { "students": [ { ...student1 }, { ...student2 } ] } + * Handle POST requests + * Expects JSON: { "students": [ { ...student1 }, { ...student2 } ] } */ function doPost(e) { var lock = LockService.getScriptLock(); - // Wait up to 30 seconds to avoid collisions during batch writes - lock.tryLock(30000); + lock.tryLock(30000); // Wait up to 30s for other processes to finish try { var doc = SpreadsheetApp.getActiveSpreadsheet(); @@ -14,34 +13,32 @@ function doPost(e) { // Parse the JSON body var rawData = e.postData.contents; var jsonPayload = JSON.parse(rawData); - - // Check if we received a list of students var studentsList = jsonPayload.students; - + if (!studentsList || !Array.isArray(studentsList)) { throw new Error("Invalid format: 'students' array missing."); } var results = []; - // Process each student in the batch + // 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; - // CRITICAL: Use the timestamp sent from the App (scan time), not Server time + // Timestamp from the scanner app var scanTime = new Date(data.timestamp); - // Find if student exists + // 1. ROBUST SEARCH: Find if student exists var rowIndex = findRowIndex(sheet, studentId); var status = ""; if (rowIndex == -1) { - // --- ENTRY (New Row) --- + // --- SCENARIO A: STUDENT NOT FOUND -> NEW ROW (ENTRY) --- var newRow = [ - scanTime, // A: Timestamp (Scan time) + formatDate(scanTime), // A: Timestamp (Date Only) studentId, // B: ID leoId, // C: Leo prenom, // D: First Name @@ -50,16 +47,32 @@ function doPost(e) { "" // G: Exit Time (Empty) ]; sheet.appendRow(newRow); - status = "Entrée"; + status = "Entrée (Nouveau)"; } else { - // --- EXIT (Update Row) --- - var exitCell = sheet.getRange(rowIndex, 7); // Column G - if (exitCell.getValue() === "") { - exitCell.setValue(formatTime(scanTime)); - status = "Sortie"; - } else { - status = "Déjà sorti"; + // --- 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({ @@ -69,40 +82,45 @@ function doPost(e) { }); }); - var responseOutput = { + return ContentService.createTextOutput(JSON.stringify({ "status": "success", - "processed_count": results.length, - "details": results - }; - - return ContentService.createTextOutput(JSON.stringify(responseOutput)) - .setMimeType(ContentService.MimeType.JSON); + "processed": results + })).setMimeType(ContentService.MimeType.JSON); } catch (error) { - var errorResult = { + return ContentService.createTextOutput(JSON.stringify({ "status": "error", "message": error.toString() - }; - return ContentService.createTextOutput(JSON.stringify(errorResult)) - .setMimeType(ContentService.MimeType.JSON); - + })).setMimeType(ContentService.MimeType.JSON); } finally { lock.releaseLock(); } } -// Helper: Find row by Student ID (Column B/Index 1) +// --- 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++) { - if (data[i][1] == idToFind) { // strict equality might need type check - return i + 1; + // 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; } -// Helper: Format time HH:mm:ss 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/lib/views/home.dart b/lib/views/home.dart index 29b97e8..0b53b65 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -29,7 +29,8 @@ class _HomePageState extends State { final newStudent = Student( firstName: "Jean", lastName: "Dupont", - studentId: "E${DateTime.now().millisecondsSinceEpoch}", + //studentId: "E${DateTime.now().millisecondsSinceEpoch}", + studentId: "gm213204", leoId: "LEO123456", entryTime: DateTime.now(), // Precise Scan Time ); -- GitLab From abe419ecaa9fa8d7d57072dc8a3bf3fbec1a2e9b Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Fri, 5 Dec 2025 10:27:02 +0100 Subject: [PATCH 08/10] added the QR code scanner integration --- lib/views/home.dart | 80 +++++++++++++++++++++++++++++++++++++++------ pubspec.lock | 23 ++++++++++++- pubspec.yaml | 3 +- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/lib/views/home.dart b/lib/views/home.dart index 0b53b65..ac926c4 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,4 +1,5 @@ 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'; @@ -48,14 +49,27 @@ class _HomePageState extends State { ); } - void _scanQrCodeConfiguration() { - // Update this URL to your deployed web app URL - const simulatedUrl = "simulatedUrl"; - _service.setSheetUrl(simulatedUrl); + // 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; + }); - setState(() { - _urlController.text = simulatedUrl; - }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("URL configurée avec succès !")), + ); + } + } } @override @@ -69,13 +83,20 @@ class _HomePageState extends State { const SyncStatusIndicator(), // Will update automatically based on Queue size const SizedBox(height: 20), - // Configuration Input + // Configuration Input with QR Scanner Button TextField( controller: _urlController, onChanged: (val) => _service.setSheetUrl(val), - decoration: const InputDecoration( + decoration: InputDecoration( labelText: "URL Script Google", - border: OutlineInputBorder(), + 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, + ), ), ), @@ -119,3 +140,42 @@ 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/pubspec.lock b/pubspec.lock index bcf4fc6..abf6b1d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -99,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: @@ -187,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: @@ -203,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: @@ -314,4 +335,4 @@ packages: 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 7ec8d52..135029f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,8 +41,9 @@ dependencies: intl: ^0.18.0 uuid: ^4.0.0 flutter_dotenv: ^5.1.0 + mobile_scanner: ^5.0.0 # nfc_manager: ^3.3.0 - # mobile_scanner: ^3.5.0 + dev_dependencies: flutter_test: -- GitLab From d9830690485c54677d213e042d64303b251966c7 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Fri, 5 Dec 2025 10:51:17 +0100 Subject: [PATCH 09/10] migrated todos from todo.txt to issues in our repo --- todo.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/todo.txt b/todo.txt index 101061c..e69de29 100644 --- a/todo.txt +++ b/todo.txt @@ -1,2 +0,0 @@ -* Add back the qr code support to the home widget -* Give the user an option to download a csv file if the sync does not work for some reason. -- GitLab From 6b4259c08aef9ce0b01e90c68b13b439d5052c6a Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Fri, 5 Dec 2025 10:51:50 +0100 Subject: [PATCH 10/10] removed old google sheet app script --- gsheet_web_app_script.js | 90 ---------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 gsheet_web_app_script.js diff --git a/gsheet_web_app_script.js b/gsheet_web_app_script.js deleted file mode 100644 index 0f9364f..0000000 --- a/gsheet_web_app_script.js +++ /dev/null @@ -1,90 +0,0 @@ -// This is our small web app script that we will have to add for our spread sheet -// instance on google, then we will deploy it as a web app that is usable by everyone -// and then we will get the generated that will be the bases of our Qr Code. - -// TODO: maybe we need to add the student modal here to be able to correclty -// deconstruct the data - -function doPost(e) { - var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; // Auto-select first sheet - - try { - var data = JSON.parse(e.postData.contents); - - - var message = data.message; - sheet.appendRow(message); - - return ContentService - .createTextOutput(JSON.stringify({ result: 'Success' })) - .setMimeType(ContentService.MimeType.JSON); - } catch (error) { - return ContentService - .createTextOutput(JSON.stringify({ result: 'Error', error: error.message })) - .setMimeType(ContentService.MimeType.JSON); - } -} - - - -// more advanced script -function doPost(e) { - const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; - - try { - const data = JSON.parse(e.postData.contents); - const student = data.message; - - if (!Array.isArray(student) || student.length < 4) { - throw new Error("Invalid student data format. Expected an array of at least 4 values."); - } - - const [firstName, lastName, studentId, studentCardNumber] = student.map(item => String(item).trim()); - const timestamp = new Date(); - - const lastRow = sheet.getLastRow(); - const idColumn = 3; // Column C: studentId - let foundRow = -1; - - // Search for existing studentId (column 3) - for (let row = 2; row <= lastRow; row++) { - const cellValue = sheet.getRange(row, idColumn).getValue(); - if (String(cellValue).trim() === studentId) { - foundRow = row; - break; - } - } - - if (foundRow === -1) { - // Student not found: add a new row with their info + arrival time - lastRow.setValues([ - firstName, - lastName, - studentId, - studentCardNumber, - timestamp, // Column E: Arrival Time - ]); - } else { - // Student found: write departure time in next empty cell on their row - const rowValues = sheet.getRange(foundRow, 1, 1, sheet.getLastColumn()).getValues()[0]; - - // Find next empty column in the row - let nextEmptyCol = rowValues.findIndex(cell => cell === ""); - if (nextEmptyCol === -1) { - nextEmptyCol = rowValues.length; - } - - // Add timestamp to next empty cell (remember 1-indexed column numbers) - sheet.getRange(foundRow, nextEmptyCol + 1).setValue(timestamp); - } - - return ContentService - .createTextOutput(JSON.stringify({ result: "Success" })) - .setMimeType(ContentService.MimeType.JSON); - - } catch (error) { - return ContentService - .createTextOutput(JSON.stringify({ result: "Error", error: error.message })) - .setMimeType(ContentService.MimeType.JSON); - } -} -- GitLab