From a44dabbcf745cd2621e80fc3d932b57217e8cf9a Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Fri, 5 Dec 2025 11:22:03 +0100 Subject: [PATCH 1/2] first version of the export to CSV feature --- lib/components/sync_status_indicator.dart | 116 +++++++++++-- lib/services/attendance_service.dart | 3 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 154 +++++++++++++++++- pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 8 files changed, 272 insertions(+), 16 deletions(-) diff --git a/lib/components/sync_status_indicator.dart b/lib/components/sync_status_indicator.dart index 08f24ef..6fabb9d 100644 --- a/lib/components/sync_status_indicator.dart +++ b/lib/components/sync_status_indicator.dart @@ -1,15 +1,63 @@ +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import '../utils/locator.dart'; import '../services/attendance_service.dart'; +import '../models/student.dart'; class SyncStatusIndicator extends StatelessWidget { const SyncStatusIndicator({super.key}); + /// Generates a CSV string from the queue and triggers the system share sheet + Future _exportQueueToCsv(BuildContext context) async { + final service = getIt(); + final List students = service.queueSnapshot; + + if (students.isEmpty) return; + + try { + // 1. Build CSV Content + final StringBuffer csvBuffer = StringBuffer(); + // Header + csvBuffer.writeln("Prenom,Nom,No Etudiant,No Leo,Timestamp"); + + // Rows + for (final s in students) { + // Sanitize strings to avoid CSV breakage (remove commas from names if any) + final p = s.firstName.replaceAll(',', ''); + final n = s.lastName.replaceAll(',', ''); + final id = s.studentId; + final leo = s.leoId; + final time = s.entryTime?.toIso8601String() ?? ""; + + csvBuffer.writeln("$p,$n,$id,$leo,$time"); + } + + // 2. 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.) + await Share.shareXFiles([ + XFile(path), + ], text: 'Backup Émargement - ${students.length} étudiants'); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Erreur d'export : $e"))); + } + } + } + @override Widget build(BuildContext context) { final service = getIt(); - // Le composant écoute l'observable return StreamBuilder( stream: service.queueLengthStream, initialData: 0, @@ -21,24 +69,62 @@ class SyncStatusIndicator extends StatelessWidget { color: isSynced ? Colors.green[100] : Colors.orange[100], child: Padding( padding: const EdgeInsets.all(12.0), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - isSynced ? Icons.check_circle : Icons.sync_problem, - color: isSynced ? Colors.green : Colors.orange, + // Status Row + Row( + children: [ + Icon( + isSynced ? Icons.check_circle : Icons.sync_problem, + color: isSynced ? Colors.green : Colors.deepOrange, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isSynced + ? "Toutes les données sont synchronisées." + : "Attention: $count étudiant(s) en attente.", + style: TextStyle( + color: isSynced + ? Colors.green[900] + : Colors.deepOrange[900], + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), - 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, + + // [NEW] Export Button (Only visible if not synced) + if (!isSynced) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _exportQueueToCsv(context), + icon: const Icon(Icons.save_alt, color: Colors.black87), + label: const Text( + "SAUVEGARDER LE CSV DE SECOURS", + style: TextStyle(color: Colors.black87), + ), + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white.withOpacity(0.5), + side: const BorderSide(color: Colors.black54), + ), ), ), - ), + const Padding( + padding: EdgeInsets.only(top: 4.0), + child: Text( + "Utilisez ce bouton si la connexion échoue pour ne pas perdre les scans.", + style: TextStyle( + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ), + ], ], ), ), diff --git a/lib/services/attendance_service.dart b/lib/services/attendance_service.dart index d5e6584..572cca1 100644 --- a/lib/services/attendance_service.dart +++ b/lib/services/attendance_service.dart @@ -18,6 +18,9 @@ class AttendanceService { ); Stream get queueLengthStream => _queueLengthSubject.stream; + // Used for exporting data manually in case of network failure. + List get queueSnapshot => _uploadQueue.toList(); + // Timer for batch flushing Timer? _flushTimer; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/pubspec.lock b/pubspec.lock index abf6b1d..5339b02 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" crypto: dependency: transitive description: @@ -65,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -192,6 +216,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" mobile_scanner: dependency: "direct main" description: @@ -216,6 +248,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -240,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" sky_engine: dependency: transitive description: flutter @@ -301,6 +405,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -333,6 +469,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 135029f..b9db18d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,8 @@ dependencies: uuid: ^4.0.0 flutter_dotenv: ^5.1.0 mobile_scanner: ^5.0.0 + path_provider: ^2.1.1 + share_plus: ^10.0.0 # nfc_manager: ^3.3.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..c3384ec 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..01d3836 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST -- GitLab From 84f02072bd2504296b719a4bb4a907d583b8f281 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 17:21:52 +0100 Subject: [PATCH 2/2] Instead of searching for the next available empty column (which creates an infinite list of timestamps), the script now targets Column 7 (Column G) specifically. It will overwrite this cell every time a student scans their card after the initial entry --- Code.gs | 50 +++++++++++++++++--------------------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/Code.gs b/Code.gs index cf481f2..797423c 100644 --- a/Code.gs +++ b/Code.gs @@ -37,42 +37,28 @@ function doPost(e) { if (rowIndex == -1) { // --- SCENARIO A: STUDENT NOT FOUND -> NEW ROW (ENTRY) --- + // This handles the "first one will be when the student enters" requirement var newRow = [ - formatDate(scanTime), // A: Timestamp (Date Only) - studentId, // B: ID - leoId, // C: Leo - prenom, // D: First Name - nom, // E: Last Name - formatTime(scanTime),// F: Entry Time - "" // G: Exit Time (Empty) + formatDate(scanTime), // A: Timestamp (Date Only) + studentId, // B: ID + leoId, // C: Leo + prenom, // D: First Name + nom, // E: Last Name + formatTime(scanTime), // F: Entry Time (Fixed Column 6) + "" // G: Exit Time (Empty initially) ]; sheet.appendRow(newRow); - status = "Entrée (Nouveau)"; + status = "Entrée (Start)"; } else { - // --- SCENARIO B: STUDENT FOUND -> ADD TIME TO EXISTING ROW --- + // --- SCENARIO B: STUDENT FOUND -> UPDATE EXIT COLUMN --- - // 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; - } + // We explicitly target Column 7 (G) for the Exit time. + // This fulfills the requirement: "override whatever was in the second column cell" + var exitColumnIndex = 7; - // Write the timestamp in the found column - sheet.getRange(rowIndex, targetCol).setValue(formatTime(scanTime)); - status = "Sortie/Update (Col " + targetCol + ")"; + // Write/Overwrite the timestamp in Column G + sheet.getRange(rowIndex, exitColumnIndex).setValue(formatTime(scanTime)); + status = "Sortie (Updated)"; } results.push({ @@ -103,13 +89,11 @@ function doPost(e) { 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(); - + var searchId = String(idToFind).trim().toUpperCase(); // Loop through rows (skip header row 0) for (var i = 1; i < data.length; i++) { // Clean the ID in the sheet (Column B is index 1) var sheetId = String(data[i][1]).trim().toUpperCase(); - if (sheetId === searchId) { return i + 1; // Return the actual row number (1-based) } -- GitLab