diff --git a/lib/components/scanner_overlay.dart b/lib/components/scanner_overlay.dart index 5c1bdfc536d4adb5242016682a47ea6fed1bf49d..35097ea6e5028ff1ad736eb14e3a93dfa0429f79 100644 --- a/lib/components/scanner_overlay.dart +++ b/lib/components/scanner_overlay.dart @@ -1,6 +1,8 @@ +import 'dart:math' as math; import 'package:flutter/material.dart'; -/// Main overlay widget that displays the scanner frame +/// Main overlay widget that displays the scanner frame. +/// Refactored to support Landscape mode and Desktop window resizing. class ScannerOverlay extends StatelessWidget { const ScannerOverlay({super.key}); @@ -8,8 +10,11 @@ class ScannerOverlay extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - // Calculate size and position of the scan area (square in center) - final size = constraints.maxWidth * 0.7; + + final double shortestSide = math.min(constraints.maxWidth, constraints.maxHeight); + + final double size = math.min(shortestSide * 0.7, 300.0); + final left = (constraints.maxWidth - size) / 2; final top = (constraints.maxHeight - size) / 2; @@ -72,8 +77,8 @@ class _ScannerMaskPainter extends CustomPainter { class _CornerBorderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - const cornerLength = 28.0; // length of each corner line - const strokeWidth = 4.0; // thickness of corner lines + final double cornerLength = size.width * 0.1; + const double strokeWidth = 4.0; final paint = Paint() ..color = Colors.greenAccent @@ -82,40 +87,22 @@ class _CornerBorderPainter extends CustomPainter { ..strokeCap = StrokeCap.square; // Top-left corner - canvas.drawLine(const Offset(0, 0), const Offset(cornerLength, 0), paint); - canvas.drawLine(const Offset(0, 0), const Offset(0, cornerLength), paint); + canvas.drawLine(const Offset(0, 0), Offset(cornerLength, 0), paint); + canvas.drawLine(const Offset(0, 0), Offset(0, cornerLength), paint); // Top-right corner - canvas.drawLine( - Offset(size.width, 0), - Offset(size.width - cornerLength, 0), - paint); - canvas.drawLine( - Offset(size.width, 0), - Offset(size.width, cornerLength), - paint); + canvas.drawLine(Offset(size.width, 0), Offset(size.width - cornerLength, 0), paint); + canvas.drawLine(Offset(size.width, 0), Offset(size.width, cornerLength), paint); // Bottom-left corner - canvas.drawLine( - Offset(0, size.height), - Offset(cornerLength, size.height), - paint); - canvas.drawLine( - Offset(0, size.height), - Offset(0, size.height - cornerLength), - paint); + canvas.drawLine(Offset(0, size.height), Offset(cornerLength, size.height), paint); + canvas.drawLine(Offset(0, size.height), Offset(0, size.height - cornerLength), paint); // Bottom-right corner - canvas.drawLine( - Offset(size.width, size.height), - Offset(size.width - cornerLength, size.height), - paint); - canvas.drawLine( - Offset(size.width, size.height), - Offset(size.width, size.height - cornerLength), - paint); + canvas.drawLine(Offset(size.width, size.height), Offset(size.width - cornerLength, size.height), paint); + canvas.drawLine(Offset(size.width, size.height), Offset(size.width, size.height - cornerLength), paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} +} \ No newline at end of file diff --git a/lib/components/sync_status_indicator.dart b/lib/components/sync_status_indicator.dart index 9ad5ded23282ea6108dca1bdf3bd8711f31e2792..3be0e0fd3ddf25cac054d94058a7536beac8d8d0 100644 --- a/lib/components/sync_status_indicator.dart +++ b/lib/components/sync_status_indicator.dart @@ -17,14 +17,14 @@ class SyncStatusIndicator extends StatelessWidget { if (students.isEmpty) return; try { - // Build CSV Content + // Build CSV Content using StringBuffer for performance 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) + // Data Sanitization: Prevent CSV injection/breakage by removing commas final p = s.firstName.replaceAll(',', ''); final n = s.lastName.replaceAll(',', ''); final id = s.studentId; @@ -34,22 +34,21 @@ class SyncStatusIndicator extends StatelessWidget { csvBuffer.writeln("$p,$n,$id,$leo,$time"); } - // Save to Temporary File + // IO Operation: Write to temp storage 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.) + // Trigger Native Share Sheet (Works on Mobile & Desktop) 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"))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Erreur d'export : $e")), + ); } } } @@ -66,7 +65,9 @@ class SyncStatusIndicator extends StatelessWidget { final isSynced = count == 0; return Card( + // Semantic coloring for quick status recognition color: isSynced ? Colors.green[100] : Colors.orange[100], + elevation: 2, child: Padding( padding: const EdgeInsets.all(12.0), child: Column( @@ -80,6 +81,7 @@ class SyncStatusIndicator extends StatelessWidget { color: isSynced ? Colors.green : Colors.deepOrange, ), const SizedBox(width: 10), + // Expanded ensures text wraps instead of overflowing Expanded( child: Text( isSynced @@ -102,6 +104,7 @@ class SyncStatusIndicator extends StatelessWidget { // is what the ...[] syntax means if (!isSynced) ...[ const SizedBox(height: 10), + // SizedBox(width: double.infinity) makes the button stretch SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -135,4 +138,4 @@ class SyncStatusIndicator extends StatelessWidget { }, ); } -} +} \ No newline at end of file diff --git a/lib/views/home.dart b/lib/views/home.dart index 4a214220cdb62c25cd91a1482d2050a731502d34..7d65f72f60c972e98a6876c71438bed48f9ab587 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -14,10 +14,11 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + // Dependency Injection: Retrieve the service singleton final _service = getIt(); final TextEditingController _urlController = TextEditingController(); - // Launches the QR Scanner screen for configuration + /// Navigates to the QR Scanner and handles the result Future _scanQrCode() async { // Push to the scanner page and await the result (the scanned URL) final String? scannedUrl = await Navigator.push( @@ -44,72 +45,89 @@ class _HomePageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Émargement")), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - const SyncStatusIndicator(), // Will update automatically based on Queue size - const SizedBox(height: 20), - - // Configuration Input with QR Scanner Button - TextField( - controller: _urlController, - onChanged: (val) => _service.setSheetUrl(val), - decoration: InputDecoration( - labelText: "URL Script Google", - hintText: "https://script.google.com/...", - border: const OutlineInputBorder(), - // Suffix icon to trigger scanner - suffixIcon: IconButton( - icon: const Icon(Icons.qr_code_scanner), - tooltip: "Scanner le QR Code de configuration", - onPressed: _scanQrCode, - ), + // LayoutBuilder to get the viewport constraints (screen height) + body: LayoutBuilder( + builder: (context, constraints) { + // Wraped the UI in a ScrollView to prevent overflow when keyboard opens + return SingleChildScrollView( + // constrain the box to be at least the height of the screen + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, ), - ), + // IntrinsicHeight is required here to let Spacers calculate + // the remaining vertical space within a ScrollView + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const SyncStatusIndicator(), + const SizedBox(height: 20), - const Spacer(), + // Configuration Input + TextField( + controller: _urlController, + onChanged: (val) => _service.setSheetUrl(val), + decoration: InputDecoration( + labelText: "URL Script Google", + hintText: "https://script.google.com/...", + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: "Scanner le QR Code de configuration", + onPressed: _scanQrCode, + ), + ), + ), - // NFC Scan Button - SizedBox( - width: 200, - height: 200, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: Colors.blueAccent, - ), - onPressed: () { - // Navigate to the real NFC scanning screen - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const NfcScanScreen()), - ); - }, - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.nfc, size: 50, color: Colors.white), - Text("SCAN NFC", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - ], - ), - ), - ), - const Spacer(), + const Spacer(), + + // NFC Scan Button + SizedBox( + width: 200, + height: 200, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: Colors.blueAccent, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const NfcScanScreen()), + ); + }, + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.nfc, size: 50, color: Colors.white), + Text("SCAN NFC", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ], + ), + ), + ), + + const Spacer(), - // Manual Entry Link - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const ManualEntryPage()), - ); - }, - child: const Text("Saisie Manuelle"), + // Manual Entry Link + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ManualEntryPage()), + ); + }, + child: const Text("Saisie Manuelle"), + ), + ], + ), + ), + ), ), - ], - ), + ); + }, ), ); } diff --git a/lib/views/manual_entry.dart b/lib/views/manual_entry.dart index f01363c4dcac1a79fa35c011506410c5683ef404..9db69f52668519e60e32209b9b2a8982b00060e4 100644 --- a/lib/views/manual_entry.dart +++ b/lib/views/manual_entry.dart @@ -13,7 +13,7 @@ class ManualEntryPage extends StatefulWidget { class _ManualEntryPageState extends State { final _formKey = GlobalKey(); - // In-view text bindings (get user input) + // Text Controllers final _prenomController = TextEditingController(); final _nomController = TextEditingController(); final _studentIdController = TextEditingController(); @@ -49,16 +49,17 @@ class _ManualEntryPageState extends State { // student record to the queue. getIt().pushStudentQueue(student); - // User feedback - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Étudiant ${student.firstName} ajouté à la queue de synchro", + // UI Feedback + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Étudiant ${student.firstName} ajouté à la queue de synchro", + ), ), - ), - ); - - Navigator.pop(context); // back to previous page + ); + Navigator.pop(context); + } } } @@ -66,75 +67,102 @@ class _ManualEntryPageState extends State { 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), - - TextFormField( - controller: _prenomController, - decoration: const InputDecoration( - labelText: "Prénom", - border: OutlineInputBorder(), - ), - validator: (value) => - value == null || value.isEmpty ? 'Champ requis' : null, - ), - const SizedBox(height: 15), - - TextFormField( - controller: _nomController, - decoration: const InputDecoration( - labelText: "Nom", - border: OutlineInputBorder(), - ), - validator: (value) => - value == null || value.isEmpty ? 'Champ requis' : null, + + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "En cas d'oubli de carte, saisissez les infos ici.", + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // FIRST NAME + TextFormField( + controller: _prenomController, + // Show "Next" on keyboard + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.givenName], + decoration: const InputDecoration( + labelText: "Prénom", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + validator: (value) => + value == null || value.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 15), + + // LAST NAME + TextFormField( + controller: _nomController, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.familyName], + decoration: const InputDecoration( + labelText: "Nom", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + validator: (value) => + value == null || value.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 15), + + // STUDENT ID + TextFormField( + controller: _studentIdController, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: "Numéro Étudiant", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.badge), + ), + validator: (value) => + value == null || value.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 15), + + // 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)", + border: OutlineInputBorder(), + hintText: "Laisser vide si inconnu", + prefixIcon: Icon(Icons.nfc), + ), + ), + const SizedBox(height: 30), + + // SUBMIT BUTTON + 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, + foregroundColor: Colors.white, + ), + ), + ], ), - const SizedBox(height: 15), - - 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), - - TextFormField( - controller: _leoIdController, - decoration: const InputDecoration( - labelText: "Numéro LéoCarte (Facultatif)", - border: OutlineInputBorder(), - hintText: "Laisser vide si inconnu", - ), - ), - const SizedBox(height: 30), - - 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, - foregroundColor: Colors.white, - ), - ), - ], + ), ), ), ), ); } -} +} \ No newline at end of file diff --git a/lib/views/nfc_scan_screen.dart b/lib/views/nfc_scan_screen.dart index 338bddfc3be3aeedeb4fa2c8bcd82cce3d8cc81f..8dd900af3e167a2662501eae89207b929c076b17 100644 --- a/lib/views/nfc_scan_screen.dart +++ b/lib/views/nfc_scan_screen.dart @@ -73,8 +73,11 @@ class _NfcScanScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("NFC Scan")), + // Center, to keep the layout vertically centered when it fits. body: Center( - child: Padding( + // Wrap the content in SingleChildScrollView to allow scrolling + // if the device is in landscape mode or has large accessibility fonts. + child: SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/views/qr_scanner_screen.dart b/lib/views/qr_scanner_screen.dart index 0544a0af9d09f816f431e6d1f12a954a2c5b0df0..67c39e33dd57ee0a75314d683f74d5b3aceed28d 100644 --- a/lib/views/qr_scanner_screen.dart +++ b/lib/views/qr_scanner_screen.dart @@ -10,33 +10,81 @@ class QrScannerScreen extends StatefulWidget { } class _QrScannerScreenState extends State { + // Instantiate a controller to manage camera hardware (Torch/Facing) + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.noDuplicates, + returnImage: false, + ); + bool _hasScanned = false; + @override + void dispose() { + // Ensure the hardware resources are released when the view is closed + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Scanner le QR Code")), + appBar: AppBar( + title: const Text("Configuration"), + actions: [ + // Toggle Flashlight + IconButton( + icon: const Icon(Icons.flash_on), + tooltip: "Toggle Torch", + onPressed: () => _controller.toggleTorch(), + ), + // Switch Camera (Front/Back) + IconButton( + icon: const Icon(Icons.cameraswitch), + tooltip: "Switch Camera", + onPressed: () => _controller.switchCamera(), + ), + ], + ), body: Stack( children: [ - /// Camera MobileScanner( + controller: _controller, + // Error Handling + errorBuilder: (context, error, child) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error, color: Colors.red, size: 50), + const SizedBox(height: 10), + Text( + "Camera Error: ${error.errorCode}", + style: const TextStyle(color: Colors.red), + ), + ], + ), + ); + }, onDetect: (capture) { if (_hasScanned) return; for (final barcode in capture.barcodes) { final code = barcode.rawValue; + // Validate URL format before accepting if (code != null && code.startsWith("http")) { _hasScanned = true; + // Feedback: Audio or vibration could be added here Navigator.pop(context, code); break; } } }, ), - + + // Visual Overlay to guide the user const ScannerOverlay(), ], ), ); } -} +} \ No newline at end of file