From 002f01a2b5e188c8a4db4d1224fca7d79b1a680f Mon Sep 17 00:00:00 2001 From: amine-aitmokhtar Date: Fri, 30 Jan 2026 18:06:07 +0100 Subject: [PATCH] fix(export): align backup CSV columns with Google Sheet structure - Update the (code.gs) google sheet script --- Code.gs | 17 ++- lib/components/sync_status_indicator.dart | 48 +++--- lib/services/local_storage_service.dart | 45 ++++++ lib/views/student_form_screen.dart | 173 ++++++++++++++++++++++ 4 files changed, 254 insertions(+), 29 deletions(-) create mode 100644 lib/services/local_storage_service.dart create mode 100644 lib/views/student_form_screen.dart diff --git a/Code.gs b/Code.gs index 797423c..0704d70 100644 --- a/Code.gs +++ b/Code.gs @@ -31,6 +31,13 @@ function doPost(e) { // Timestamp from the scanner app var scanTime = new Date(data.timestamp); + // --- NEW LOGIC: DETECT PASSAGE TYPE --- + // If leoId contains "MANUEL", it means manual entry (card forgotten) + var passageType = "Carte (NFC)"; + if (String(leoId).toUpperCase().indexOf("MANUEL") !== -1) { + passageType = "Oubli (Saisie Manuelle)"; + } + // 1. ROBUST SEARCH: Find if student exists var rowIndex = findRowIndex(sheet, studentId); var status = ""; @@ -45,7 +52,8 @@ function doPost(e) { prenom, // D: First Name nom, // E: Last Name formatTime(scanTime), // F: Entry Time (Fixed Column 6) - "" // G: Exit Time (Empty initially) + "", // G: Exit Time (Empty initially) + passageType // H: Type (New Column 8) ]; sheet.appendRow(newRow); status = "Entrée (Start)"; @@ -64,10 +72,15 @@ function doPost(e) { results.push({ "id": studentId, "name": prenom + " " + nom, - "status": status + "status": status, + "type": passageType }); }); + // --- VISUAL IMPROVEMENT --- + // Auto-resize columns A to H (1 to 8) to fit content perfectly + sheet.autoResizeColumns(1, 8); + return ContentService.createTextOutput(JSON.stringify({ "status": "success", "processed": results diff --git a/lib/components/sync_status_indicator.dart b/lib/components/sync_status_indicator.dart index 9ad5ded..73891b4 100644 --- a/lib/components/sync_status_indicator.dart +++ b/lib/components/sync_status_indicator.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:intl/intl.dart'; import '../utils/locator.dart'; import '../services/attendance_service.dart'; import '../models/student.dart'; @@ -9,7 +10,6 @@ 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; @@ -17,39 +17,39 @@ class SyncStatusIndicator extends StatelessWidget { if (students.isEmpty) return; try { - // Build CSV Content final StringBuffer csvBuffer = StringBuffer(); - // Header - csvBuffer.writeln("Prenom,Nom,No Etudiant,No Leo,Timestamp"); + + csvBuffer.writeln("Date,N° Etudiant,N° Leo (UID),Prénom,Nom,Heure Entrée,Heure Sortie,Type"); - // 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() ?? ""; + + final DateTime ts = s.entryTime ?? DateTime.now(); + final String dateStr = DateFormat('dd/MM/yyyy').format(ts); + final String timeStr = DateFormat('HH:mm:ss').format(ts); + + final String type = s.scanType ?? "Inconnu"; - csvBuffer.writeln("$p,$n,$id,$leo,$time"); + csvBuffer.writeln("$dateStr,$id,$leo,$p,$n,$timeStr,,$type"); } - // Save to Temporary File final directory = await getTemporaryDirectory(); - final path = - "${directory.path}/backup_attendance_${DateTime.now().millisecondsSinceEpoch}.csv"; + final path = "${directory.path}/backup_attendance_${DateTime.now().millisecondsSinceEpoch}.csv"; final File file = File(path); await file.writeAsString(csvBuffer.toString()); - // Share File (Allows user to save to Drive, Email, WhatsApp, etc.) await Share.shareXFiles([ XFile(path), - ], text: 'Backup Émargement - ${students.length} étudiants'); + ], text: 'Backup Attendance - ${students.length} students'); + } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("Erreur d'export : $e"))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Export Error: $e")) + ); } } } @@ -72,7 +72,6 @@ class SyncStatusIndicator extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Status Row Row( children: [ Icon( @@ -83,8 +82,8 @@ class SyncStatusIndicator extends StatelessWidget { Expanded( child: Text( isSynced - ? "Toutes les données sont synchronisées." - : "Attention: $count étudiant(s) en attente.", + ? "All data synced." + : "Warning: $count student(s) pending.", style: TextStyle( color: isSynced ? Colors.green[900] @@ -95,11 +94,6 @@ class SyncStatusIndicator extends StatelessWidget { ), ], ), - - // Export Button (Only visible if not synced) - // If !isSynced, scatter the widgets in the given - // array onto the scope of the parent widget, that - // is what the ...[] syntax means if (!isSynced) ...[ const SizedBox(height: 10), SizedBox( @@ -108,7 +102,7 @@ class SyncStatusIndicator extends StatelessWidget { onPressed: () => _exportQueueToCsv(context), icon: const Icon(Icons.save_alt, color: Colors.black87), label: const Text( - "SAUVEGARDER LE CSV DE SECOURS", + "SAVE EMERGENCY CSV", style: TextStyle(color: Colors.black87), ), style: OutlinedButton.styleFrom( @@ -120,7 +114,7 @@ class SyncStatusIndicator extends StatelessWidget { const Padding( padding: EdgeInsets.only(top: 4.0), child: Text( - "Utilisez ce bouton si la connexion échoue pour ne pas perdre les scans.", + "Use this if connection fails to save scans manually.", style: TextStyle( fontSize: 11, fontStyle: FontStyle.italic, @@ -135,4 +129,4 @@ class SyncStatusIndicator extends StatelessWidget { }, ); } -} +} \ No newline at end of file diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart new file mode 100644 index 0000000..f4634b7 --- /dev/null +++ b/lib/services/local_storage_service.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/student.dart'; + +class LocalStorageService { + // Key used to persist data in SharedPreferences + static const String _storageKey = 'known_students_v1'; + + /// Loads all known students from device storage + Future> loadStudents() async { + final prefs = await SharedPreferences.getInstance(); + final String? jsonString = prefs.getString(_storageKey); + + // Return empty map if no data found + if (jsonString == null) return {}; + + try { + // Decode Logic: String -> Map -> Map + final Map decoded = json.decode(jsonString); + + return decoded.map((key, value) { + return MapEntry(key, Student.fromJson(value)); + }); + } catch (e) { + if (kDebugMode) { + print("LocalStorage Error: $e"); + } + // In case of corruption, return empty to avoid crash + return {}; + } + } + + /// Persists the entire student map to storage + Future saveStudents(Map students) async { + final prefs = await SharedPreferences.getInstance(); + + // Serialize: Map -> JSON String + final String encoded = json.encode( + students.map((key, value) => MapEntry(key, value.toJson())), + ); + + await prefs.setString(_storageKey, encoded); + } +} \ No newline at end of file diff --git a/lib/views/student_form_screen.dart b/lib/views/student_form_screen.dart new file mode 100644 index 0000000..175f7de --- /dev/null +++ b/lib/views/student_form_screen.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import '../models/student.dart'; +import '../services/attendance_service.dart'; +import '../utils/locator.dart'; + +class StudentFormScreen extends StatefulWidget { + final String scannedUid; + + const StudentFormScreen({super.key, required this.scannedUid}); + + @override + State createState() => _StudentFormScreenState(); +} + +class _StudentFormScreenState extends State { + final _formKey = GlobalKey(); + + // Controllers + final _firstNameCtrl = TextEditingController(); + final _lastNameCtrl = TextEditingController(); + final _studentIdCtrl = TextEditingController(); + + @override + void dispose() { + _firstNameCtrl.dispose(); + _lastNameCtrl.dispose(); + _studentIdCtrl.dispose(); + super.dispose(); + } + + void _submit() { + if (_formKey.currentState!.validate()) { + final service = getIt(); + + // Create Model + // We force the scanType to NFC since this screen is only reachable via NFC + final newStudent = Student( + firstName: _firstNameCtrl.text.trim(), + lastName: _lastNameCtrl.text.trim(), + studentId: _studentIdCtrl.text.trim(), + leoId: widget.scannedUid, + entryTime: DateTime.now(), + scanType: "Carte (NFC)", + ); + + // Register via Service (Local DB + Queue) + service.registerNewStudent(newStudent); + + // Feedback & Navigation + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Student registered and checked in! ✅"), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Nouvel Étudiant")), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: ConstrainedBox( + // Constraint to keep form looking good on tablets/landscape + constraints: const BoxConstraints(maxWidth: 600), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Visual Feedback for the Scanned UID + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + children: [ + const Icon(Icons.nfc, color: Colors.blue), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Carte inconnue détectée", + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + Text( + widget.scannedUid, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Monospace', + fontSize: 16, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // FIRST NAME + TextFormField( + controller: _firstNameCtrl, + decoration: const InputDecoration( + labelText: "Prénom", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + autofillHints: const [AutofillHints.givenName], + textInputAction: TextInputAction.next, + validator: (v) => v!.isEmpty ? "Requis" : null, + ), + const SizedBox(height: 16), + + // LAST NAME + TextFormField( + controller: _lastNameCtrl, + decoration: const InputDecoration( + labelText: "Nom", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + autofillHints: const [AutofillHints.familyName], + textInputAction: TextInputAction.next, + validator: (v) => v!.isEmpty ? "Requis" : null, + ), + const SizedBox(height: 16), + + // STUDENT ID + TextFormField( + controller: _studentIdCtrl, + decoration: const InputDecoration( + labelText: "N° Étudiant", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.badge), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (v) => v!.isEmpty ? "Requis" : null, + ), + const SizedBox(height: 32), + + // SUBMIT BUTTON + ElevatedButton.icon( + onPressed: _submit, + icon: const Icon(Icons.save), + label: const Text("ENREGISTRER & ÉMARGER"), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file -- GitLab