From 6260edee6c1526472d514b9c4a74c1fbd93b841f Mon Sep 17 00:00:00 2001 From: amine-aitmokhtar Date: Fri, 30 Jan 2026 12:34:08 +0100 Subject: [PATCH] feat(nfc): implement smart card detection logic - Implemented 'Smart Scan' logic: automatically distinguishes between registered and unregistered NFC tags based on UID lookup. - Added immediate check-in for known nfc uid - Added auto redirection to studentfrom for unknown nfc uid. - Refactored Student model to include scanType (NFC or Manual) for better analytics. --- lib/models/student.dart | 40 +++++- lib/services/attendance_service.dart | 131 +++++++++--------- lib/services/local_storage_service.dart | 45 ++++++ lib/views/manual_entry.dart | 27 ++-- lib/views/nfc_scan_screen.dart | 106 +++++++++------ lib/views/student_form_screen.dart | 173 ++++++++++++++++++++++++ 6 files changed, 392 insertions(+), 130 deletions(-) create mode 100644 lib/services/local_storage_service.dart create mode 100644 lib/views/student_form_screen.dart diff --git a/lib/models/student.dart b/lib/models/student.dart index b8b3763..fc991fc 100644 --- a/lib/models/student.dart +++ b/lib/models/student.dart @@ -4,7 +4,8 @@ class Student { final String studentId; final String leoId; final DateTime? entryTime; - final DateTime? exitTime; + final DateTime? exitTime; + final String scanType; Student({ required this.firstName, @@ -13,21 +14,45 @@ class Student { required this.leoId, this.entryTime, this.exitTime, + this.scanType = "Carte (NFC)", }); - // Deep Copy & update the exit time - Student copyWithExitTime(DateTime time) { + // Standard CopyWith for immutability + Student copyWith({ + DateTime? entryTime, + DateTime? exitTime, + String? scanType, + }) { return Student( firstName: firstName, lastName: lastName, studentId: studentId, leoId: leoId, - entryTime: entryTime, - exitTime: time, + entryTime: entryTime ?? this.entryTime, + exitTime: exitTime ?? this.exitTime, + scanType: scanType ?? this.scanType, ); } - // toJson encoder + // Local Storage Decoder (Expects keys from toJson) + factory Student.fromJson(Map json) { + return Student( + firstName: json['prenom'] ?? '', + lastName: json['nom'] ?? '', + studentId: json['no_etudiant'] ?? '', + leoId: json['no_leo'] ?? '', + entryTime: json['entree'] != null + ? DateTime.parse(json['entree']) + : null, + exitTime: json['sortie'] != null + ? DateTime.parse(json['sortie']) + : null, + scanType: json['type'] ?? "Carte (NFC)", + ); + } + + // JSON Encoder (Used for API & Local Storage) + // Maps fields to French keys to match Google Script requirements Map toJson() { return { 'prenom': firstName, @@ -36,6 +61,7 @@ class Student { 'no_leo': leoId, 'entree': entryTime?.toIso8601String(), 'sortie': exitTime?.toIso8601String(), + 'type': scanType, }; } -} +} \ No newline at end of file diff --git a/lib/services/attendance_service.dart b/lib/services/attendance_service.dart index 89af299..5ab6ce8 100644 --- a/lib/services/attendance_service.dart +++ b/lib/services/attendance_service.dart @@ -2,30 +2,32 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:flutter_nfc_kit/flutter_nfc_kit.dart'; +import 'package:flutter_nfc_kit/flutter_nfc_kit.dart'; import 'package:http/http.dart' as http; import 'package:rxdart/rxdart.dart'; import '../models/student.dart'; +import 'local_storage_service.dart'; -class AttendanceService { +class AttendanceService extends ChangeNotifier { String? _sheetApiUrl; + // Services & State + final LocalStorageService _localStorage = LocalStorageService(); + Map _knownStudents = {}; + // Queue to store students locally before sync final Queue _uploadQueue = Queue(); // Observable for UI - final BehaviorSubject _queueLengthSubject = BehaviorSubject.seeded( - 0, - ); + final BehaviorSubject _queueLengthSubject = BehaviorSubject.seeded(0); 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; AttendanceService() { + _loadCache(); _startBatchTimer(); } @@ -36,20 +38,50 @@ class AttendanceService { } } - /// Adds a student to the queue with the current timestamp (Scan Time) - void pushStudentQueue(Student student) { - _uploadQueue.add(student); - _updateQueueLength(); + // Load local DB into RAM + Future _loadCache() async { + _knownStudents = await _localStorage.loadStudents(); if (kDebugMode) { - print("Student added to queue. Total: ${_uploadQueue.length}"); + print("Cache loaded: ${_knownStudents.length} students"); } } + // ========================================== + // LOGIC FLOWS + // ========================================== + + // Check if student exists in local DB + Student? getStudentByUid(String uid) { + return _knownStudents[uid]; + } + + // Register new student (Form Submit) -> Save Local + Queue + Future registerNewStudent(Student student) async { + _knownStudents[student.leoId] = student; + await _localStorage.saveStudents(_knownStudents); + _pushStudentQueue(student); + } + + // Existing Student Scan -> Update time & Queue + void recordStudentPassage(Student student) { + final checkIn = student.copyWith( + entryTime: DateTime.now(), + scanType: "Carte (NFC)", + ); + _pushStudentQueue(checkIn); + } + + // Adds a student to the queue + void _pushStudentQueue(Student student) { + _uploadQueue.add(student); + _updateQueueLength(); + } + void _updateQueueLength() { _queueLengthSubject.add(_uploadQueue.length); } - /// Starts the 10-second periodic flush + // Starts the 10-second periodic flush void _startBatchTimer() { _flushTimer?.cancel(); _flushTimer = Timer.periodic(const Duration(seconds: 10), (timer) { @@ -61,10 +93,8 @@ class AttendanceService { }); } - /// Sends all students in the queue to Google Sheets in one batch + // Sends all students in the queue to Google Sheets in one batch Future _flushQueue() async { - // Snapshot current batch to avoid concurrency issues - // if new students are added during upload final int batchSize = _uploadQueue.length; final List batch = []; @@ -76,21 +106,17 @@ class AttendanceService { if (kDebugMode) { print("Flushing batch of ${batch.length} students..."); } + // Prepare JSON Payload - // Note: We send .toIso8601String() for the timestamp so - // as Google Script can parse it final Map payload = { - "students": batch - .map( - (s) => { - "no_etudiant": s.studentId, - "no_leo": s.leoId, - "prenom": s.firstName, - "nom": s.lastName, - "timestamp": s.entryTime?.toIso8601String(), - }, - ) - .toList(), + "students": batch.map((s) => { + "no_etudiant": s.studentId, + "no_leo": s.leoId, + "prenom": s.firstName, + "nom": s.lastName, + "timestamp": s.entryTime?.toIso8601String(), + "type": s.scanType, // Added for analytics + }).toList(), }; // Send POST Request @@ -109,7 +135,7 @@ class AttendanceService { if (response.statusCode == 200) { if (kDebugMode) { - print("Batch sync successful: ${response.body}"); + print("Batch sync successful"); } _updateQueueLength(); } else { @@ -131,60 +157,29 @@ class AttendanceService { // NFC Handling // ========================================== - /// Scans a card, creates a temporary Student object, and adds it to the queue. - /// Returns the scanned Badge ID. - Future scanStudentBadge() async { - // Check Hardware Availability + // Scans a card and returns the UID + Future scanCardUid() async { var availability = await FlutterNfcKit.nfcAvailability; if (availability != NFCAvailability.available) { - throw Exception("NFC not available (State: $availability)"); + throw Exception("NFC not available"); } try { // Poll for tag (10s timeout) - // We explicitly look for ISO14443A (Mifare / Student Cards) NFCTag tag = await FlutterNfcKit.poll( timeout: const Duration(seconds: 10), - iosAlertMessage: "Hold iPhone near the student card", - readIso14443A: true, - ); - - // Extract Badge ID - String badgeId = tag.id; - if (kDebugMode) { - print("NFC Service: Badge detected -> $badgeId"); - } - - // Create Student Object - // Note: Filling required fields with placeholders. - // The Google Sheet logic will map the ID to the real Name. - Student scannedStudent = Student( - firstName: "Unknown", - lastName: "Pending Sync", - studentId: "N/A", - leoId: badgeId, // Real Data from NFC - entryTime: DateTime.now(), //Real Scan Time + readIso14443A: true, ); - - // Add to local queue (using existing logic) - pushStudentQueue(scannedStudent); - - return badgeId; - - } catch (e) { - if (kDebugMode) { - print("Scan Error: $e"); - } - rethrow; // Pass error to UI + return tag.id; } finally { - - // Clean up must always finish the session to release hardware await FlutterNfcKit.finish(); } } + @override void dispose() { _flushTimer?.cancel(); _queueLengthSubject.close(); + super.dispose(); } } \ 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/manual_entry.dart b/lib/views/manual_entry.dart index 9db69f5..cd5810f 100644 --- a/lib/views/manual_entry.dart +++ b/lib/views/manual_entry.dart @@ -32,30 +32,33 @@ class _ManualEntryPageState extends State { if (_formKey.currentState!.validate()) { // Create a student record // As for the NFC scan we use DateTime.now() + + // Generate unique ID if not provided + final String fallbackId = "MANUEL_${DateTime.now().millisecondsSinceEpoch}"; - // Values are not null thanks to the in-widget - // validation logic final student = Student( firstName: _prenomController.text, lastName: _nomController.text, studentId: _studentIdController.text, leoId: _leoIdController.text.isNotEmpty ? _leoIdController.text - : "MANUEL", + : fallbackId, entryTime: DateTime.now(), + scanType: "Oubli (Saisie Manuelle)", // Explicit type for stats ); - // Fetch the service singleton instance to push the - // student record to the queue. - getIt().pushStudentQueue(student); + // Fetch the service singleton instance to register the student + // Uses the new logic (Local DB + Queue) + getIt().registerNewStudent(student); // UI Feedback if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - "Étudiant ${student.firstName} ajouté à la queue de synchro", + "Student ${student.firstName} registered and queued!", ), + backgroundColor: Colors.green, ), ); Navigator.pop(context); @@ -88,8 +91,8 @@ class _ManualEntryPageState extends State { // FIRST NAME TextFormField( controller: _prenomController, - // Show "Next" on keyboard textInputAction: TextInputAction.next, + // Use Autofill hints (Group's feature) autofillHints: const [AutofillHints.givenName], decoration: const InputDecoration( labelText: "Prénom", @@ -134,23 +137,23 @@ class _ManualEntryPageState extends State { // LEO ID (Optional) TextFormField( controller: _leoIdController, - // "Done" action submits the form logically textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _submitForm(), decoration: const InputDecoration( - labelText: "Numéro LéoCarte (Facultatif)", + labelText: "UID Carte (Facultatif)", border: OutlineInputBorder(), - hintText: "Laisser vide si inconnu", + hintText: "Laisser vide si pas de scan", prefixIcon: Icon(Icons.nfc), ), ), const SizedBox(height: 30), // SUBMIT BUTTON + // Kept Group's Orange color style ElevatedButton.icon( onPressed: _submitForm, icon: const Icon(Icons.save), - label: const Text("Émarger l'étudiant"), + label: const Text("ENREGISTRER"), style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 50), backgroundColor: Colors.orange, diff --git a/lib/views/nfc_scan_screen.dart b/lib/views/nfc_scan_screen.dart index 8dd900a..b15ac41 100644 --- a/lib/views/nfc_scan_screen.dart +++ b/lib/views/nfc_scan_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import '../models/student.dart'; import '../services/attendance_service.dart'; import '../utils/locator.dart'; +import 'student_form_screen.dart'; class NfcScanScreen extends StatefulWidget { const NfcScanScreen({super.key}); @@ -14,10 +16,19 @@ class _NfcScanScreenState extends State { final AttendanceService _service = getIt(); bool _isScanning = false; - String _statusMessage = "Ready to scan"; + String _statusMessage = "Initializing..."; Color _statusColor = Colors.grey; + @override + void initState() { + super.initState(); + // Start scanning automatically when screen opens + _startNfcScan(); + } + Future _startNfcScan() async { + if (!mounted) return; + // Update UI to show scanning state setState(() { _isScanning = true; @@ -26,57 +37,67 @@ class _NfcScanScreenState extends State { }); try { - // Trigger the NFC scan logic defined in the service layer - String badgeId = await _service.scanStudentBadge(); + // 1. Hardware Scan: Get the UID only + final String uid = await _service.scanCardUid(); - // Handle successful scan - if (mounted) { - setState(() { - _statusMessage = "✅ Success!\nBadge: $badgeId"; - _statusColor = Colors.green; - }); - - // Provide visual feedback - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Badge $badgeId added to upload queue"), - backgroundColor: Colors.green, - ), - ); + // 2. Local DB Lookup: Do we know this student? + final Student? knownStudent = _service.getStudentByUid(uid); - // Close screen automatically after a short delay - await Future.delayed(const Duration(milliseconds: 1500)); - if (mounted) { - Navigator.pop(context); - } + if (knownStudent != null) { + // --- CASE A: KNOWN STUDENT --- + _handleKnownStudent(knownStudent); + } else { + // --- CASE B: UNKNOWN STUDENT --- + _handleUnknownStudent(uid); } + } catch (e) { // Handle errors (hardware unavailable, timeout, etc.) if (mounted) { setState(() { - // Clean up the exception message for display - _statusMessage = "Info:\n${e.toString().replaceAll('Exception: ', '')}"; - _statusColor = Colors.orange; - }); - } - } finally { - // Reset scanning state (run in finally to ensure execution) - if (mounted) { - setState(() { + _statusMessage = "Error or Cancelled"; + _statusColor = Colors.orange; _isScanning = false; }); } } } + void _handleKnownStudent(Student student) async { + // Business Logic: Record the passage (Check-in/Check-out) + _service.recordStudentPassage(student); + + // Visual Feedback + if (mounted) { + setState(() { + _statusMessage = "✅ Success!\n${student.firstName} ${student.lastName}"; + _statusColor = Colors.green; + }); + } + + // Close screen automatically after a short delay + await Future.delayed(const Duration(milliseconds: 1500)); + if (mounted) Navigator.pop(context); + } + + void _handleUnknownStudent(String uid) { + // Redirect to Registration Form + // We use pushReplacement to avoid going back to an empty scan screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => StudentFormScreen(scannedUid: uid)), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("NFC Scan")), - // Center, to keep the layout vertically centered when it fits. + // Center content vertically body: Center( - // Wrap the content in SingleChildScrollView to allow scrolling - // if the device is in landscape mode or has large accessibility fonts. + // SingleChildScrollView allows scrolling on small screens/landscape child: SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Column( @@ -108,20 +129,19 @@ class _NfcScanScreenState extends State { color: _statusColor, ), ), - const SizedBox(height: 50), - - // Action Button - if (!_isScanning) + + // Retry Button (Only visible if not scanning) + if (!_isScanning) ...[ + const SizedBox(height: 30), ElevatedButton.icon( onPressed: _startNfcScan, - icon: const Icon(Icons.wifi_tethering), - label: const Text("START SCAN"), + icon: const Icon(Icons.refresh), + label: const Text("RETRY SCAN"), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), ), - ) - else - const CircularProgressIndicator(), + ), + ] ], ), ), 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