From 91b23c122f00d283cedf39d3f717c85e58d893e6 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 18:19:06 +0100 Subject: [PATCH 1/6] updated the ./README.md --- README.md | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 49288df..2b475f1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ 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. +3. Collez le code `doPost(e)` du fichier `Code.gs`. 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.* @@ -48,23 +48,11 @@ Avant de lancer l'application, il faut préparer le fichier de destination. ### 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 : +lancez : ```bash -flutter pub get -flutter run +flutter pub get # fetch all the dependencies +flutter run # run the app ``` ----- -- GitLab From ba3293e48132d8bebcaf2843e6db718873517e5e Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 18:19:43 +0100 Subject: [PATCH 2/6] made the app not use .env vars, since we do not need to --- lib/main.dart | 6 ++-- lib/services/attendance_service.dart | 47 ++++++++++++++-------------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 40e25b3..0d84ef5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'utils/locator.dart'; import 'views/home.dart'; -Future main() async { +void main() { // 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 572cca1..0f3a9ce 100644 --- a/lib/services/attendance_service.dart +++ b/lib/services/attendance_service.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:rxdart/rxdart.dart'; import '../models/student.dart'; @@ -25,25 +25,23 @@ 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(); } void setSheetUrl(String url) { _sheetApiUrl = url; - print("URL Configured: $_sheetApiUrl"); + if (kDebugMode) { + print("URL Configured: $_sheetApiUrl"); + } } /// Adds a student to the queue with the current timestamp (Scan Time) void pushStudentQueue(Student student) { _uploadQueue.add(student); _updateQueueLength(); - print("Student added to queue. Total: ${_uploadQueue.length}"); + if (kDebugMode) { + print("Student added to queue. Total: ${_uploadQueue.length}"); + } } void _updateQueueLength() { @@ -64,7 +62,8 @@ class AttendanceService { /// 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 + // Snapshot current batch to avoid concurrency issues + // if new students are added during upload final int batchSize = _uploadQueue.length; final List batch = []; @@ -72,14 +71,13 @@ class AttendanceService { 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("Flushing batch of ${batch.length} students..."); - - // 2. Prepare JSON Payload - // Note: We send .toIso8601String() for the timestamp so Google Script can parse it + 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( @@ -88,14 +86,13 @@ class AttendanceService { "no_leo": s.leoId, "prenom": s.firstName, "nom": s.lastName, - "timestamp": s.entryTime - ?.toIso8601String(), // CRITICAL: Send scan time + "timestamp": s.entryTime?.toIso8601String(), }, ) .toList(), }; - // 3. Send POST Request + // Send POST Request var response = await http.post( Uri.parse(_sheetApiUrl!), body: jsonEncode(payload), @@ -110,13 +107,17 @@ class AttendanceService { } if (response.statusCode == 200) { - print("Batch sync successful: ${response.body}"); - _updateQueueLength(); // Should be 0 (or close to 0) + if (kDebugMode) { + print("Batch sync successful: ${response.body}"); + } + _updateQueueLength(); } else { throw Exception("Server Error: ${response.statusCode}"); } } catch (e) { - print("Sync failed: $e. Re-queueing students."); + if (kDebugMode) { + 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); -- GitLab From feb55d248dbf2e0a6b2d3e836566b0f648996e64 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 18:20:30 +0100 Subject: [PATCH 3/6] removed the qr_code_scanner compoenent file since it is now implemented in a view widget --- lib/components/qr_code_scanner.dart | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 lib/components/qr_code_scanner.dart diff --git a/lib/components/qr_code_scanner.dart b/lib/components/qr_code_scanner.dart deleted file mode 100644 index e69de29..0000000 -- GitLab From 5dd778b90ca5a5a2b103ee44e0d73de23d5140ee Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 18:21:14 +0100 Subject: [PATCH 4/6] views widgets refactor --- lib/views/home.dart | 53 +++++--------------------------- lib/views/manual_entry.dart | 27 ++++++++-------- lib/views/qr_scanner_screen.dart | 41 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 61 deletions(-) create mode 100644 lib/views/qr_scanner_screen.dart diff --git a/lib/views/home.dart b/lib/views/home.dart index ac926c4..052259b 100644 --- a/lib/views/home.dart +++ b/lib/views/home.dart @@ -1,10 +1,10 @@ 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'; import '../components/sync_status_indicator.dart'; import 'manual_entry.dart'; +import 'qr_scanner_screen.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -17,7 +17,7 @@ class _HomePageState extends State { final _service = getIt(); final TextEditingController _urlController = TextEditingController(); - // ONLY simulates the NFC interaction and adds to queue + // simulates the NFC interaction and adds to queue void _simulateNfcScan() { if (_urlController.text.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -26,7 +26,7 @@ class _HomePageState extends State { return; } - // 1. Create Data (Scan Time is fixed here) + // Create Data (Scan Time is fixed here) final newStudent = Student( firstName: "Jean", lastName: "Dupont", @@ -36,10 +36,10 @@ class _HomePageState extends State { entryTime: DateTime.now(), // Precise Scan Time ); - // 2. Add to Service Queue immediately + // Add to Service Queue immediately _service.pushStudentQueue(newStudent); - // 3. Instant Visual Feedback + // Instant Visual Feedback ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Scan enregistré : ${newStudent.firstName}"), @@ -75,7 +75,7 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Émargement Batch")), + appBar: AppBar(title: const Text("Émargement")), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -91,7 +91,7 @@ class _HomePageState extends State { labelText: "URL Script Google", hintText: "https://script.google.com/...", border: const OutlineInputBorder(), - // ADDED: Suffix icon to trigger scanner + // Suffix icon to trigger scanner suffixIcon: IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: "Scanner le QR Code de configuration", @@ -140,42 +140,3 @@ 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/lib/views/manual_entry.dart b/lib/views/manual_entry.dart index 503ed66..f01363c 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(); - // Contrôleurs pour récupérer les textes + // In-view text bindings (get user input) final _prenomController = TextEditingController(); final _nomController = TextEditingController(); final _studentIdController = TextEditingController(); @@ -30,8 +30,11 @@ class _ManualEntryPageState extends State { 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 + // Create a student record + // As for the NFC scan we use DateTime.now() + + // Values are not null thanks to the in-widget + // validation logic final student = Student( firstName: _prenomController.text, lastName: _nomController.text, @@ -42,11 +45,11 @@ class _ManualEntryPageState extends State { 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 + // Fetch the service singleton instance to push the + // student record to the queue. getIt().pushStudentQueue(student); - // 3. Feedback et retour + // User feedback ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -55,7 +58,7 @@ class _ManualEntryPageState extends State { ), ); - Navigator.pop(context); // Retour à l'accueil + Navigator.pop(context); // back to previous page } } @@ -75,7 +78,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 20), - // Champ Prénom TextFormField( controller: _prenomController, decoration: const InputDecoration( @@ -87,7 +89,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 15), - // Champ Nom TextFormField( controller: _nomController, decoration: const InputDecoration( @@ -99,7 +100,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 15), - // Champ Numéro Étudiant TextFormField( controller: _studentIdController, decoration: const InputDecoration( @@ -111,7 +111,6 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 15), - // Champ Numéro Leo (Facultatif en manuel) TextFormField( controller: _leoIdController, decoration: const InputDecoration( @@ -122,15 +121,13 @@ class _ManualEntryPageState extends State { ), const SizedBox(height: 30), - // Bouton Valider ElevatedButton.icon( onPressed: _submitForm, icon: const Icon(Icons.save), - label: const Text("ÉMARGER L'ÉTUDIANT"), + 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 + backgroundColor: Colors.orange, foregroundColor: Colors.white, ), ), diff --git a/lib/views/qr_scanner_screen.dart b/lib/views/qr_scanner_screen.dart new file mode 100644 index 0000000..658afab --- /dev/null +++ b/lib/views/qr_scanner_screen.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// 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; + } + } + } + }, + ), + ); + } +} -- GitLab From dd4f0b0a9943b8eef198f0a6d3887cd36fa0cbe8 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 18:22:07 +0100 Subject: [PATCH 5/6] minor tweaks + documentation --- lib/components/sync_status_indicator.dart | 11 +++++++---- lib/models/student.dart | 12 ++++++------ lib/utils/locator.dart | 5 ++++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/components/sync_status_indicator.dart b/lib/components/sync_status_indicator.dart index 6fabb9d..9ad5ded 100644 --- a/lib/components/sync_status_indicator.dart +++ b/lib/components/sync_status_indicator.dart @@ -17,7 +17,7 @@ class SyncStatusIndicator extends StatelessWidget { if (students.isEmpty) return; try { - // 1. Build CSV Content + // Build CSV Content final StringBuffer csvBuffer = StringBuffer(); // Header csvBuffer.writeln("Prenom,Nom,No Etudiant,No Leo,Timestamp"); @@ -34,14 +34,14 @@ class SyncStatusIndicator extends StatelessWidget { csvBuffer.writeln("$p,$n,$id,$leo,$time"); } - // 2. Save to Temporary File + // 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.) + // Share File (Allows user to save to Drive, Email, WhatsApp, etc.) await Share.shareXFiles([ XFile(path), ], text: 'Backup Émargement - ${students.length} étudiants'); @@ -96,7 +96,10 @@ class SyncStatusIndicator extends StatelessWidget { ], ), - // [NEW] Export Button (Only visible if not synced) + // 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( diff --git a/lib/models/student.dart b/lib/models/student.dart index 209208f..b8b3763 100644 --- a/lib/models/student.dart +++ b/lib/models/student.dart @@ -1,10 +1,10 @@ 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 + final String studentId; + final String leoId; + final DateTime? entryTime; + final DateTime? exitTime; Student({ required this.firstName, @@ -15,7 +15,7 @@ class Student { this.exitTime, }); - // Méthode utilitaire pour copier l'objet avec une heure de sortie mise à jour + // Deep Copy & update the exit time Student copyWithExitTime(DateTime time) { return Student( firstName: firstName, @@ -27,7 +27,7 @@ class Student { ); } - // Conversion en Map pour l'envoi vers Google Sheets + // toJson encoder Map toJson() { return { 'prenom': firstName, diff --git a/lib/utils/locator.dart b/lib/utils/locator.dart index 24050bf..19b0685 100644 --- a/lib/utils/locator.dart +++ b/lib/utils/locator.dart @@ -4,6 +4,9 @@ import '../services/attendance_service.dart'; final getIt = GetIt.instance; void setupLocator() { - // On enregistre AttendanceService comme un Singleton (Lazy = créé à la première utilisation) + // We register a single instance of AttendanceService as a singleton, this is done + // lazily, meaning that the creation of this singleton is only initiated upon the + // first use, then afterwards, we can use getIt to fetch this singleton and retreive + // the service methods and state like the students queue to push new records on to it. getIt.registerLazySingleton(() => AttendanceService()); } -- GitLab From 8fb1170914269420388f7c4d7ff10b19669b73d6 Mon Sep 17 00:00:00 2001 From: Massiles Ghernaout <749-gm213204@users.noreply.www-apps.univ-lehavre.fr> Date: Sun, 7 Dec 2025 18:31:13 +0100 Subject: [PATCH 6/6] Updated readme and linked to ./project_description.txt in it --- README.md | 25 +++++++++------ project_description.txt | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 project_description.txt diff --git a/README.md b/README.md index 2b475f1..333c57b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Le projet repose sur une architecture robuste conçue pour gérer la connectivit * **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. + +Veuillez lire aussi `./project_description.txt`. ----- ## 📂 Structure des Fichiers (`lib/`) @@ -18,15 +20,20 @@ Le projet repose sur une architecture robuste conçue pour gérer la connectivit 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 + . + ├── components + │ └── sync_status_indicator.dart # composant visuel pour ne pas fermer l'appli trop tot. + ├── main.dart # point d'entree de l'application + ├── models + │ └── student.dart # notre model de donnees + ├── services + │ └── attendance_service.dart # notre service layer (partie la plus importante de l'appli) + ├── utils + │ └── locator.dart # notre gestionnaire de singleton (Dependency injection seeding) + └── views + ├── home.dart # page principale + ├── manual_entry.dart # formulaire d'emargement manuel + └── qr_scanner_screen.dart # page de scan du QR code qui mene vers le GoogleSheet. ``` ----- diff --git a/project_description.txt b/project_description.txt new file mode 100644 index 0000000..ecdc834 --- /dev/null +++ b/project_description.txt @@ -0,0 +1,70 @@ +Project architecture overview: + + +On veux avoir une application que le prof pourra utiliser pour emarger les etudiant durant un exam. + +Pour cela, on va partir du principe que le prof veux suivre les etudiants sur un spreadsheet (google +sheet). + +Donc, + +(processus hors appli) +1 - Le prof cree un spread sheet sur son drive (google drive) +2 - le prof prend le lien qui donne acces en lecture et ecriture. +3 - le prof cree un QR code a partir de ce lien. +4 - le prof scan ce qr code depuis l'appli qu'on va dev. + + +(processus dans l'appli) +Puis, a partir de ce moment, le prof peut suivre sur son ecran les update sur le spreadsheet +et nous, notre mission est de faire en sorte de update le fichier spreadsheet au fur et a mesure +que les etudiant s'emargent. + +Pour se faire: +On va declarer un modele/datatype qui va representer la strucutre de donnee qui va stocker un etudiant. +on va stocker son prenom, nom, numero etudiant, numero leo carte, et l'heure d'emargement. + +* Attention: + il faut stocker l'heur d'arrive et de sortie pour voir le temps total que l'etudiant a passe en + exam. + + +Donc, on doit s'assurer que l'appli reporte bien les nouveaux emargements vers le google sheet. + +Pour cela, on va avoir un service, qui va suivre une queue. Cette queue va contenir les etudiants +qui ont emarger recemment et qui ne sont pas encore ecrit sur le spreadsheet distant. + +Ce service va exposer un observable, qui va suivre la longueure de la queue (en gros est-ce qu'il y +a des etudiants qu'on a pas encore save sur le spreadsheet) + +et ce service va aussi exposer une methode qui va nous permettre d'ajouter un etudiant dans la +queue. (push_student_queue) + + +Avec ca, nos composant peuvent utiliser et ecouter cet observable. +Justement, on va avoir un composant qui va ecouter l'observable et qui va donner un indicateur +visuel au prof si il y a des donnee dans la queue pas encore synchronisees avec le google sheet, +cet indicateur est important pour eviter que le prof ferme l'appli trop tot. + +Et on va avoir le composant qui nous permet d'emarger l'etudiant (composnant qui s'occupe du scan +NFC), qui celui ci va utiliser la method push_student_queue pour mettre a jour l'etat de notre +service. + + + +Donc, tout le genie de l'architecture est le fait d'avoir ce layer service, a travers un service qui +sera un singleton et qu'on injectera dans nos composants (dependency injection) + +Pour declarer le service en tant que singleton on doit le mettre dans le providers de l'app (utiliser +get_it) + +Et pour recup le singleton, on va utiliser get_it qui est une lib dart/flutter qui permet justement +de recup les singleton qu'on declare dans les app modules. + +Pour les observable, on va utiliser la lib RxDart. (Reactive programming in Dart) + + +Voila comment on va structure l'appli + +Donc, on doit commencer l'integration du spread sheet en premier et le dev du service pour que aprs +on pourra l'utiliser dans nos composant. -- GitLab