Association d'aide animalière du Nord de la France (59) regroupant bénévoles, familles d'accueil et membres actifs. L'association souhaitait offrir à ses membres une boutique interne pour commander des produits officiels de l'asso.
Animal'And est une association à but non lucratif dédiée à la protection animale dans le Nord de la France. Son objet associatif couvre la prise en charge des animaux domestiques (chiens, chats, NAC), la lutte contre le trafic d'animaux sur les plateformes en ligne, la sensibilisation du public via des événements et conférences, ainsi que l'aide financière aux personnes à faibles revenus pour la stérilisation de leurs chats.
Cadre Perso est une auto-entreprise de e-commerce spécialisée dans la vente de cadres personnalisés sur le thème du football et du sport. Les clients choisissent leur club, ajoutent leur nom et numéro préféré, et reçoivent une affiche encadrée imprimée en HD sur papier premium, livrée avec encadrement en bois. La boutique fonctionne sur Shopify et propose également des magnets et produits dérivés.
PDF · Dernière mise à jour : 2026
Passionné de design depuis plusieurs années, j'ai développé un style graphique reconnaissable autour de l'univers du football. Mon passage chez Paris Team (+100K abonnés) m'a permis de travailler sous contrainte de temps dans un environnement médiatique exigeant — affiches de match, visuels de transferts, mises en avant de joueurs.
Application mobile e-commerce interne développée durant mon stage chez Animal'And. Conçue pour permettre aux membres et bénévoles de l'association de commander des produits officiels (vêtements, accessoires) directement depuis leur téléphone — boutique filtrée par unité, panier persistant, validation de commande avec formulaire, et tableau de bord administrateur complet.
Association d'aide animalière du Nord de la France (59) regroupant bénévoles, familles d'accueil et membres actifs. L'association souhaitait offrir à ses membres une boutique interne pour commander des produits officiels de l'asso.
Créer de zéro une application mobile e-commerce complète — catalogue produits filtrable par unité, panier persistant, commandes avec formulaire de livraison, suivi de commande et gestion admin des produits et commandes.
Développé en parallèle d'Animal'And Chat durant le stage (Janvier–Février 2025), en autonomie totale. Deuxième application Flutter du stage, avec un backend REST partagé entre les deux apps.
Concevoir un flow complet catalogue → panier → commande → notification, avec gestion des états et persistance côté serveur.
AuthProvider comme source de vérité unique pour l'état utilisateur — partage propre entre tous les écrans sans setState excessif.
Créer MilitaryTheme comme source unique de couleurs, typo et composants — garantit une UI cohérente sur toute l'app.
Formatters sur chaque champ sensible, rate limiter anti-bruteforce, politique MDP partagée entre auth et profil.
Gérer des données en CRUD depuis l'app mobile elle-même, avec upload d'image, gestion des rôles et feedback en temps réel.
Développer deux applications Flutter en parallèle sur le même stage, en partageant le backend REST — organisation et priorisation essentielles.
auth_screen.dart et profile_screen.dart, une source unique centralise toute la politique de mot de passe. La fonction computePasswordStrength attribue un score pondéré (longueur, casse, chiffres, spéciaux) et renvoie un label + une couleur directement utilisables dans l'UI pour la barre de progression.// password_policy.dart — validateur commun (auth + profil)
String? validatePassword(String? v) {
if (v == null || v.isEmpty) return 'Veuillez saisir votre mot de passe';
if (v.contains(' ')) return 'Aucun espace autorisé';
if (v.length < kPasswordMinLength) return 'Minimum $kPasswordMinLength caractères';
if (!v.contains(RegExp(r'[A-Z]'))) return 'Au moins une majuscule';
if (!v.contains(RegExp(r'[0-9]'))) return 'Au moins un chiffre';
if (!v.contains(RegExp(r'[!@#\$%^&*]'))) return 'Au moins un caractère spécial';
return null; // ✅ valide
}
// Score pondéré → barre de force dans l'UI
PasswordStrength computePasswordStrength(String password) {
double strength = 0.0;
if (password.length >= 8) strength += 0.15;
if (password.length >= 16) strength += 0.05;
if (password.contains(RegExp(r'[A-Z]'))) strength += 0.175;
if (password.contains(RegExp(r'[0-9]'))) strength += 0.175;
if (password.contains(RegExp(r'[!@#\$%^&*]'))) strength += 0.225;
// → renvoie label + Color selon le score (Très faible → Très fort)
return PasswordStrength(value: strength, label: ..., color: ...);
}
initialize() applique un pattern en deux temps : on charge d'abord l'utilisateur sauvegardé localement (instantané, zéro latence) pour afficher l'écran principal immédiatement, puis on appelle /auth/me en arrière-plan pour vérifier que le token est encore valide côté serveur et mettre à jour les données. L'UI n'attend jamais le réseau.// auth_provider.dart — chargement 2 temps : cache → API
Future<void> initialize() async {
_isLoading = true;
notifyListeners();
try {
// 1️⃣ Chargement local (instantané — SharedPrefs)
final saved = await _api.getSavedUser();
if (saved != null) {
_user = saved;
notifyListeners(); // ← l'UI s'affiche DÉJÀ ici
}
// 2️⃣ Vérification réseau (arrière-plan)
final fresh = await _api.getMe(); // → GET /auth/me
_user = fresh; // met à jour si token encore valide
} catch (_) {
_user = null; // token expiré → retour login
} finally {
_isLoading = false;
notifyListeners();
}
}
signOut() détecte si l'utilisateur est un invité et supprime proprement la session serveur via DELETE /auth/guest, évitant l'accumulation de faux comptes en base.// api_service.dart — création du compte invité temporaire
Future<AppUser> loginAsGuest() async {
final response = await http.post(
Uri.parse('${ApiConfig.apiUrl}/auth/guest'),
headers: await _headers(withAuth: false),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
await saveToken(data['token']);
return AppUser.fromJson(data['user']);
}
throw Exception(_parseError(response));
}
// Déconnexion propre — supprime le compte invité côté serveur
Future<void> signOut() async {
final user = await getSavedUser();
if (user != null && user.isGuest) {
// ← invité → DELETE /auth/guest (nettoyage BDD)
await http.delete(
Uri.parse('${ApiConfig.apiUrl}/auth/guest'),
headers: await _headers(),
);
}
await clearSession(); // supprime JWT local dans tous les cas
}
flutter_local_notifications avec canal dédié), en background (handler top-level obligatoire en dehors de toute classe), et au tap si l'app était fermée.// notification_service.dart — handler BG top-level (obligatoire)
@pragma('vm:entry-point') // ← garde la fonction en mode release
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage msg) async {
debugPrint('📩 [BG] Notif : ${msg.notification?.title}');
}
// Canal Android dédié aux commandes (importance MAX)
static const AndroidNotificationChannel _ordersChannel =
AndroidNotificationChannel(
'orders_channel', 'Nouvelles commandes',
importance: Importance.max,
playSound: true, enableVibration: true,
);
// api_service.dart — enregistrement du token FCM après login
Future<void> registerFcmToken(String fcmToken) async {
await http.post(
Uri.parse('${ApiConfig.apiUrl}/auth/fcm-token'),
headers: await _headers(),
body: jsonEncode({'token': fcmToken}),
);
}
// Suppression propre à la déconnexion
Future<void> unregisterFcmToken() async {
await http.delete(Uri.parse('${ApiConfig.apiUrl}/auth/fcm-token'),
headers: await _headers());
}
Application mobile développée avec Flutter pour les clients de Galaxy Swiss Bourdin. L'outil présente la liste des praticiens de la région avec leurs deux notes sur 5 — clientèle et experts —, un bouton détail affichant l'ensemble des avis commentés, et la possibilité de trier les praticiens selon leurs notes.
Afin d'accroître la confiance entre les médecins et les clients, les responsables de GSB souhaitent déployer une application mobile pour les clients permettant de consulter et comparer les praticiens de la région.
Développer une application mobile Flutter consommant l'API REST de la Mission 2. L'app liste les praticiens avec leurs deux notes (clientèle et experts sur 5), propose la consultation détaillée des avis et permet le tri par note.
Framework Flutter imposé. Consommation de l'API REST développée en Mission 2. Gestion des états asynchrones (FutureBuilder), affichage des étoiles, tri côté client et navigation entre la liste et le détail des praticiens.
?sort= transmis à l'API pour un tri côté serveur.http. Les données sont désérialisées depuis le JSON retourné par l'endpoint /api/v1/praticiens développé en Mission 2.evaluation stockant les notes clients et experts par praticien. 9 tables · FK CASCADE · Triggers · ENUM statuts.
Praticien ·
PraticienDetail ·
Evaluation
— désérialisation JSON via factory fromJson.
ApiService centralisant les appels HTTP :
getPraticiens(sort) et getDetail(id).
Gestion des erreurs et codes HTTP.
ListePage (StatefulWidget) avec FutureBuilder, barre de recherche locale, menu de tri et cards praticiens cliquables.
DetailPage avec TabBar (onglets Experts / Clients), ScoreCard, EvalList et affichage étoiles dynamiques.
Première application mobile complète avec Flutter. Maîtrise du cycle widget → state → rebuild, gestion de la navigation entre pages et adaptation à l'affichage mobile.
Mise en pratique concrète de l'API conçue en Mission 2 : requêtes HTTP asynchrones, parsing JSON avec factory Dart, gestion des états de chargement et d'erreur.
Gestion de la programmation asynchrone Dart : Future, async/await, FutureBuilder pour le rendu conditionnel (chargement, erreur, données).
Découpage en composants réutilisables : _ScoreCard, _EvalList, _InfoRow… Séparation claire entre logique métier et présentation.
La Mission 3 consomme directement l'API REST conçue en Mission 2, illustrant concrètement la chaîne backend → API → mobile d'un projet professionnel réel.
Design Material 3 personnalisé : palette cohérente, étoiles dynamiques, TabBar, cards avec bordures colorées par type de praticien, gestion du scroll et des états vides.
fromJson() est le pattern standard pour désérialiser du JSON en objet typé. Chaque champ est extrait de la Map retournée par jsonDecode(), avec l'opérateur ?? pour gérer les valeurs nulles de l'API. Le .toDouble() assure la compatibilité de type car JSON ne distingue pas int et double en Dart. Les getters nomComplet et hasNotes ajoutent de la logique calculée sans alourdir le constructeur.// main.dart — désérialisation JSON → objet Dart typé
class Praticien {
final int id;
final String nom, prenom, type;
final double noteExpert, noteClient, noteGlobale;
// Factory : construit un Praticien depuis une Map JSON
factory Praticien.fromJson(Map<String, dynamic> j) => Praticien(
id: j['id'],
nom: j['nom'],
type: j['type'] ?? '', // ?? → valeur par défaut si null
noteExpert: (j['note_expert'] ?? 0).toDouble(), // int → double
noteGlobale: (j['note_globale'] ?? 0).toDouble(),
);
// Getters calculés — accessibles comme de simples propriétés
String get nomComplet => '$prenom $nom';
bool get hasNotes => noteGlobale > 0;
}
ApiService centralise tous les appels réseau en méthodes static async. Le package http envoie la requête GET, on vérifie le statusCode — si c'est 200, jsonDecode() parse la réponse et .map() transforme chaque élément JSON en objet Dart via le factory fromJson. Si le serveur renvoie une erreur, une Exception est levée et remontée à l'appelant, qui l'attrape pour afficher l'écran d'erreur. C'est le pont direct entre l'API Laravel de la Mission 2 et l'app Flutter.// main.dart — pont Flutter ↔ API Laravel
class ApiService {
static Future<List<Praticien>> getPraticiens({String sort = 'nom'}) async {
final res = await http.get(
Uri.parse('$BASE_URL/praticiens?sort=$sort'),
headers: {'Accept': 'application/json'},
);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
// .map() applique fromJson sur chaque élément de la liste
return (data['data'] as List)
.map((e) => Praticien.fromJson(e))
.toList();
}
// Erreur HTTP → Exception remontée au widget appelant
throw Exception('Erreur serveur (${res.statusCode})');
}
}
_praticiens garde toujours la liste complète chargée depuis le serveur, tandis que _filtered est la copie filtrée affichée dans le ListView. La méthode where() itère sur chaque praticien et ne garde que ceux dont le nom complet ou le type contient la saisie (insensible à la casse via toLowerCase()). L'appel à setState() déclenche la reconstruction du widget avec la nouvelle liste filtrée.// main.dart — filtre temps réel côté app (sans rappel API)
void _search(String q) {
setState(() {
_filtered = q.isEmpty
? _praticiens // ← vide : on remet toute la liste
: _praticiens.where((p) =>
p.nomComplet.toLowerCase().contains(q.toLowerCase()) ||
p.type.toLowerCase().contains(q.toLowerCase())
).toList();
// setState() → Flutter reconstruit le ListView avec _filtered
});
}
// Branché sur le TextField via onChanged :
// TextField(onChanged: _search, ...)
List.generate(5, ...) crée une liste de 5 widgets Icon en une seule ligne. Pour chaque position i, on compare à note.round() : si i < note, l'étoile est pleine (star_rounded), sinon vide (star_outline_rounded). La couleur est également conditionnelle : si la note est à 0 (has faux), tout passe en gris. Ce pattern évite d'écrire 5 Icon à la main et s'adapte automatiquement à n'importe quelle valeur de note.// main.dart — 5 étoiles générées dynamiquement selon la note
final has = note > 0; // false si le praticien n'est pas encore noté
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
5,
(i) => Icon(
i < note.round()
? Icons.star_rounded // ← étoile pleine
: Icons.star_outline_rounded, // ← étoile vide
size: 15,
color: has ? color : Colors.grey.shade300,
),
),
),
// → ★★★☆☆ pour une note de 3/5 · ☆☆☆☆☆ gris si pas encore noté
evaluation — nouveauté M3
🔗 FK relationnelles + CASCADE
⚡ Triggers recalcul échelon/salaire
📊 Grille salariale 13 échelons · 5 types
Glisser · Molette pour zoomer · Échap pour fermer
Application web développée avec Laravel pour le service RH de Galaxy Swiss Bourdin. L'outil permet de consulter et de gérer les salaires des praticiens selon une grille salariale à 13 échelons, avec mise à jour automatique via triggers SQL, filtres avancés, fiche détaillée et une API REST pour portage mobile.
Suite à une restructuration du service RH, le responsable souhaite une plateforme web accessible pour consulter et attribuer les salaires des praticiens selon leur spécialité et leur ancienneté.
Développer une interface web Laravel permettant au service RH de gérer les salaires des praticiens. La base de données a été retravaillée pour intégrer une grille salariale à 13 échelons liée à l'ancienneté.
Framework Laravel imposé. Connexion sécurisée avec droits spécifiques. Triggers SQL pour le recalcul automatique des échelons et salaires. Anticipation d'un portage mobile via API REST.
utilisateur. Middleware Laravel custom protégeant toutes les routes./api/praticiens retournant la liste des praticiens avec leur ancienneté et salaire au format JSON, en vue d'un portage mobile futur.| ÉCHELON | ID PRATICIEN (PLAGE) | ANCIENNETÉ MIN | DÉCLENCHEUR TRIGGER |
|---|---|---|---|
| 1 | 1 — 20 000 | 1 an | ancienneté ≥ 1 |
| 2 | 20 001 — 40 000 | 3 ans | ancienneté ≥ 3 |
| 3 | 40 001 — 60 000 | 5 ans | ancienneté ≥ 5 |
| 4 | 60 001 — 80 000 | 7 ans | ancienneté ≥ 7 |
| 5 | 80 001 — 100 000 | 9 ans | ancienneté ≥ 9 |
| 6 | 100 001 — 120 000 | 11 ans | ancienneté ≥ 11 |
| 7 | 120 001 — 140 000 | 13 ans | ancienneté ≥ 13 |
| 8 | 140 001 — 160 000 | 15 ans | ancienneté ≥ 15 |
| 9 | 160 001 — 180 000 | 19 ans | ancienneté ≥ 19 |
| 10 | 180 001 — 200 000 | 23 ans | ancienneté ≥ 23 |
| 11 | 200 001 — 220 000 | 27 ans | ancienneté ≥ 27 |
| 12 | 220 001 — 240 000 | 31 ans | ancienneté ≥ 31 |
| 13 | 240 001 — 281 086 | 32+ ans | ancienneté ≥ 32 |
Praticien·
GrilleSalariale·
TypePraticien·
Ville·
Departement
— relations Eloquent entre toutes les entités.
AuthController gère la session custom.
PraticienController orchestre filtres, pagination, fiche détaillée, mise à jour ancienneté et statistiques.
layout.blade.php
étendu par index / show / stats / login.
Tailwind CSS chargé via CDN.
CheckAuth.php vérifie la présence de la session user_id. Toutes les routes praticiens sont protégées via le groupe auth.custom.
Maîtrise du cycle requête → route → middleware → controller → view. Séparation claire des responsabilités, conventions de nommage Eloquent.
Écriture de triggers SQL pour automatiser le recalcul de l'échelon et du salaire à chaque modification de date d'embauche — sans intervention manuelle.
Première mise en place d'un endpoint JSON dans Laravel, anticipant un portage mobile. Sérialisation des modèles Eloquent en réponse API.
Intégration de Tailwind pour construire une interface responsive et soignée directement dans les vues Blade, sans écrire de CSS custom.
Filtres dynamiques cumulables, tri multi-colonnes, pagination avec conservation des paramètres, agrégats (avg, sum, count, groupBy) pour les stats.
Gestion de la session sans le guard Laravel natif (base de données non standard). Middleware CheckAuth appliqué sur les routes protégées.
getNomCompletAttribute() concatène prénom + nom, et getSalaireFormatAttribute() formate le salaire en euros avec séparateurs français. Dans une vue Blade, on appelle juste $praticien->nom_complet ou $praticien->salaire_format — sans jamais réécrire la logique de formatage.// Praticien.php — accesseurs calculés sur le modèle
public function getNomCompletAttribute()
{
return $this->prenom . ' ' . $this->nom;
// → $praticien->nom_complet dans les vues
}
public function getSalaireFormatAttribute()
{
return number_format($this->salaire, 2, ',', ' ') . ' €';
// → "2 450,00 €" via $praticien->salaire_format
}
// Dans la vue Blade — aucune logique de formatage à réécrire :
// {{ $praticien->nom_complet }} → "Marie Dupont"
// {{ $praticien->salaire_format }} → "2 450,00 €"
index() construit une requête Eloquent de façon conditionnelle et cumulative : chaque filtre (nom, type, échelon, ancienneté min/max, salaire min/max) est ajouté à la query seulement si le paramètre est présent dans la requête HTTP. filled() ignore les chaînes vides. La recherche par nom utilise une closure pour grouper les conditions OR sans casser les autres filtres AND. La pagination conserve automatiquement tous les paramètres GET grâce à withQueryString().// PraticienController.php — filtres cumulables et pagination
$query = Praticien::with(['typePraticien', 'ville']);
// Recherche nom/prénom — closure pour grouper les OR
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nom', 'LIKE', "%{$search}%")
->orWhere('prenom', 'LIKE', "%{$search}%");
});
}
// Filtres numériques — uniquement si renseignés
if ($request->filled('anciennete_min'))
$query->where('anciennete', '>=', $request->anciennete_min);
if ($request->filled('salaire_max'))
$query->where('salaire', '<=', $request->salaire_max);
// Tri dynamique + pagination avec params GET conservés
$query->orderBy($request->get('sort_by', 'nom'), $request->get('sort_order', 'asc'));
$praticiens = $query->paginate(20)->withQueryString();
email, mot de passe hashé en SHA256+HMAC), le système d'auth natif de Laravel ne peut pas être utilisé. L'authentification est donc gérée manuellement via la Session : après vérification du mot de passe, on stocke user_id, user_nom et is_admin en session. Le middleware CheckAuth vérifie simplement la présence de user_id avant chaque route protégée.// AuthController.php — login custom sans guard Laravel
$utilisateur = Utilisateur::where('nom', $request->nom)->first();
if ($utilisateur && $utilisateur->checkPassword($request->mdp)) {
Session::put('user_id', $utilisateur->id);
Session::put('user_nom', $utilisateur->nom);
Session::put('is_admin', $utilisateur->isAdmin());
// → redirige vers la liste des praticiens
return redirect()->route('praticiens.index');
}
// CheckAuth.php — middleware qui protège toutes les routes
public function handle(Request $request, Closure $next)
{
if (!Session::has('user_id')) {
return redirect()->route('login')
->with('error', 'Veuillez vous connecter.');
}
return $next($request); // ← laisse passer si connecté
}
save(), puis un trigger MySQL côté serveur recalcule automatiquement l'ancienneté, l'échelon et le salaire selon la grille salariale. Le refresh() après le save force Eloquent à relire la ligne en base pour récupérer les valeurs recalculées par le trigger, qu'on peut ensuite afficher dans le message de succès.// PraticienController.php — save() déclenche le trigger SQL
$praticien = Praticien::findOrFail($id);
// On modifie uniquement la date d'embauche...
$praticien->date_embauche = $request->date_embauche;
$praticien->save();
// ...le trigger MySQL recalcule ancienneté + échelon + salaire
// refresh() : recharge depuis la BDD pour avoir les valeurs du trigger
$praticien->refresh();
return redirect()->route('praticiens.show', $id)
->with('success',
'Ancienneté mise à jour. ' .
'Échelon : ' . $praticien->echelon .
' — Salaire : ' . $praticien->salaire_format
);
Application Windows de gestion des congés des praticiens, réalisée dans le cadre de l'AP BTS SIO. Basée sur le dépôt GsbVille, l'application a été entièrement repensée : architecture orientée objet, deux espaces distincts (Praticien & RH), logique de validation/refus et connexion MySQL via Dapper.
Entreprise pharmaceutique fictive utilisée comme support pédagogique en BTS SIO. L'AP consiste à développer des outils internes pour les praticiens et les équipes RH.
Repartir du projet GsbVille pour concevoir une application C# WinForms de gestion des congés : réécriture orientée objet, gestion des rôles, interface RH complète et logique de validation.
Modèles C# obligatoires pour mapper les entités BDD. ORM Dapper pour les requêtes SQL. Interface adaptée aux deux profils : praticien et responsable RH.
Utilisateur.cs et
Conge.cs mappent les tables MySQL.
Dapper désérialise les résultats SQL directement en objets typés.
MySqlConnection.
Changement de serveur (local / distant) en un seul endroit.
praticien → PraticienForm,
sinon → RhForm. LoginForm se masque (Hide()) sans se fermer.
Première utilisation d'un ORM léger en C#. Mapping automatique des résultats SQL vers des objets — bien plus propre que les DataReader bruts.
Séparation logique métier / UI avec les classes Designer. Pattern "formulaire par rôle", navigation entre fenêtres sans fermeture de la fenêtre mère.
Authentification avec redirection selon le rôle (praticien / RH), en passant le contexte utilisateur entre les formulaires.
Utilisation de BeginTransaction() pour sécuriser les mises à jour critiques (acceptation d'un congé), avec rollback en cas d'erreur.
Compréhension du schéma GSB_total : relations FK entre praticien, ville, departement, region et type_praticien. ENUM MySQL pour les statuts de congés dans userconge.
Analyser, comprendre et refactoriser un code hérité (GsbVille) pour en faire une base solide — sans tout casser et en respectant les conventions.
Db centralise tout. Un seul endroit à modifier pour changer de serveur (local ↔ distant) — ici on voit même la ligne commentée pour le serveur de l'école. Chaque appel à Db.GetConnection() retourne une nouvelle instance MySqlConnection prête à l'emploi.// Db.cs — point d'entrée unique vers la base de données
public static class Db
{
private static string connectionString =
"Server=localhost;Database=gsb_opti;Uid=root;Pwd=;";
// Serveur distant (commenté) — un seul endroit à changer
// "Server=172.23.80.2;Database=gsbeh;Uid=wassil;Pwd=1234;"
public static MySqlConnection GetConnection()
{
return new MySqlConnection(connectionString);
}
}
QueryFirstOrDefault<Utilisateur> de Dapper retourne directement un objet C# typé (ou null si introuvable) — pas de DataReader, pas de cast manuel. Si les identifiants sont valides, le rôle stocké en BDD détermine quelle fenêtre ouvrir : PraticienForm ou RhForm. Le LoginForm se masque avec Hide() sans se fermer, pour rester en mémoire le temps de la session.// LoginForm.cs — auth Dapper + routing automatique par rôle
var user = conn.QueryFirstOrDefault<Utilisateur>(
"SELECT * FROM utilisateurs WHERE email=@Email AND pwd=@Mdp",
new { Email = txtEmail.Text.Trim(), Mdp = txtPassword.Text.Trim() }
);
if (user != null)
{
if (user.Role == "praticien")
{
var f = new PraticienForm(user);
f.Show(); // ← espace praticien
}
else
{
var f = new RhForm(user);
f.Show(); // ← espace RH
}
this.Hide(); // LoginForm masqué, pas fermé
}
else
{
MessageBox.Show("Identifiants invalides !", "Erreur",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
LoadConges() accepte un paramètre statutFiltre optionnel. Si le filtre vaut "Tous", on exécute la requête de base — sinon on ajoute un WHERE c.statut = @statut avec un paramètre Dapper pour éviter toute injection SQL. Le résultat est branché directement sur le DataSource du DataGridView, puis on renomme les en-têtes et on masque les colonnes techniques (id, idUtilisateurFK) pour ne montrer que ce qui est utile au RH.// RhForm.cs — requête filtrée selon le statut sélectionné
private void LoadConges(string statutFiltre = "Tous")
{
using (var conn = Db.GetConnection())
{
conn.Open();
string sql = "SELECT c.id, u.nom, u.prenom, c.dateDebut, c.dateFin, c.statut " +
"FROM conges c JOIN utilisateurs u ON c.idUtilisateurFK = u.id";
// Filtre conditionnel — paramètre Dapper anti-injection
var data = statutFiltre != "Tous"
? conn.Query(sql + " WHERE c.statut = @statut", new { statut = statutFiltre })
: conn.Query(sql);
dataGridView1.DataSource = data.ToList();
}
// Renommer les colonnes pour l'affichage RH
if (dataGridView1.Columns["nom"] != null)
dataGridView1.Columns["nom"].HeaderText = "Nom";
// Masquer les colonnes techniques (id, FK...)
if (dataGridView1.Columns["id"] != null)
dataGridView1.Columns["id"].Visible = false;
}
BeginTransaction() crée une transaction que Dapper reçoit en troisième argument — la transaction est passée explicitement à chaque Execute(). Si une erreur survient avant tr.Commit(), un rollback automatique annule tout, garantissant la cohérence de la base.// RhForm.cs — transaction pour sécuriser l'acceptation
using (var conn = Db.GetConnection())
{
conn.Open();
using (var tr = conn.BeginTransaction())
{
// Mise à jour du statut — dans la transaction
conn.Execute(
"UPDATE conges SET statut='accepte' WHERE id=@id",
new { id = congeId }, tr // ← tr passé à Dapper
);
// D'autres Execute() pourraient s'enchaîner ici
// ex: décompter les jours du praticien
// conn.Execute("UPDATE utilisateurs SET conges_restant -= @j ...
tr.Commit(); // ✅ tout réussi → on valide
// si une erreur est levée avant → rollback automatique
}
}
LoadConges(); // rafraîchit la grille
Application mobile de messagerie temps réel développée durant mon stage chez Animal'And, une association d'aide animalière du Nord de la France. Conçue pour permettre aux membres, bénévoles et familles d'accueil de communiquer instantanément — messagerie privée, groupes de discussion, sondages, notifications push et tableau de bord administrateur.
Association d'aide animalière implantée dans le Nord de la France (59). Elle regroupe bénévoles, familles d'accueil, adoptants et membres actifs qui avaient besoin d'un outil de communication centralisé et sécurisé.
Concevoir et développer de zéro une application mobile complète permettant aux membres de communiquer en temps réel — remplaçant les échanges éparpillés entre SMS, mails et réseaux sociaux.
Développement sur 2 mois en stage (Janvier–Février 2025), en autonomie complète. Première application Flutter de cette envergure, avec backend REST et WebSocket intégrés.
Architecture Provider, gestion du cycle de vie, navigation, widgets custom — sur une vraie app multi-écrans de bout en bout.
Connexion Socket.IO depuis Flutter, gestion des listeners, reconnexion automatique, événements bidirectionnels.
Intégration FCM complète : tokens, canaux Android, handlers foreground / background, enregistrement côté serveur.
JWT, device fingerprinting SHA-256, gestion du ban en temps réel, validation côté serveur — les bonnes pratiques de sécurité mobile.
Organiser ~30 fichiers Dart en couches (models / services / screens / widgets) proprement, réutilisables et maintenables.
Concevoir, développer et livrer seul une application complète en 2 mois, de zéro, en découvrant Flutter et le backend en parallèle.
account_banned via Socket.IO. Le client le reçoit instantanément, déconnecte le socket, efface le token JWT local et redirige vers l'écran de login — sans que l'utilisateur puisse continuer à utiliser l'app.// socket_service.dart — écoute du ban côté client
_socket?.on('account_banned', (data) {
if (_onBanCallback != null && data is Map<String, dynamic>) {
_onBanCallback!(data); // ← remonte l'event à l'UI
}
disconnect(); // ← ferme le socket immédiatement
});
// auth_service.dart — gestion du ban (effacement session)
void _handleBanResponse(Map<String, dynamic> data) {
if (data['banned'] == true || data['error'] == 'ACCOUNT_BANNED') {
_isBanned = true;
_banDetails = data['details'] as Map<String, dynamic>?;
_token = null;
_isAuthenticated = false;
SharedPreferences.getInstance().then((prefs) {
prefs.remove('auth_token'); // supprime le JWT local
prefs.remove('user_data');
});
notifyListeners(); // ← l'UI se reconstruit sur isBanned=true
}
}
exp) est dépassée. Si c'est le cas, la session est effacée automatiquement et l'utilisateur est redirigé vers le login.// auth_service.dart — vérification expiry JWT côté client
bool _isTokenExpired(String token) {
try {
final parts = token.split('.');
if (parts.length != 3) return true;
// Décodage Base64URL du payload (partie centrale du JWT)
String payload = parts[1];
switch (payload.length % 4) {
case 2: payload += '=='; break;
case 3: payload += '='; break;
}
final decoded = utf8.decode(base64Url.decode(payload));
final data = jsonDecode(decoded);
if (data['exp'] == null) return false;
final expiry = DateTime.fromMillisecondsSinceEpoch(data['exp'] * 1000);
return DateTime.now().isAfter(expiry); // true = expiré
} catch (e) {
return true; // token malformé → on considère expiré
}
}
// login_screen.dart — génération du fingerprint appareil
Future<String> _generateDeviceFingerprint() async {
final deviceInfo = DeviceInfoPlugin();
String rawId = '';
if (Platform.isAndroid) {
final info = await deviceInfo.androidInfo;
rawId = '${info.model}|${info.version.release}|${info.id}';
} else if (Platform.isIOS) {
final info = await deviceInfo.iosInfo;
rawId = '${info.model}|${info.systemVersion}|${info.identifierForVendor}';
}
// Hachage SHA-256 → empreinte de 64 chars hex
final bytes = utf8.encode(rawId);
final digest = sha256.convert(bytes);
return digest.toString();
}
// Envoyé au serveur avec chaque login / inscription
final fingerprint = await _generateDeviceFingerprint();
await authService.login(username, password,
deviceFingerprint: fingerprint,
);
Timer envoie l'événement typing=false automatiquement après 2 secondes d'inactivité pour éviter les spams. Côté Socket.IO, le serveur propage l'événement uniquement aux autres membres de la conversation.// socket_service.dart — émission de l'état de frappe
void sendTyping(String conversationId, bool isTyping, String displayName) {
if (_isConnected && _socket != null) {
_socket?.emit('typing', {
'conversationId': conversationId,
'isTyping': isTyping,
'displayName': displayName,
});
}
}
// chat_screen.dart — Timer anti-spam (s'annule à chaque frappe)
Timer? _typingTimer;
void _onTextChanged(String _) {
socketService.sendTyping(widget.conversationId, true, displayName);
_typingTimer?.cancel(); // repart à zéro à chaque lettre
_typingTimer = Timer(const Duration(seconds: 2), () {
socketService.sendTyping(widget.conversationId, false, displayName);
});
}
// Réception : met à jour l'UI avec le nom de la personne
socketService.onUserTyping((data) {
if (data['conversationId'] == widget.conversationId) {
setState(() {
_typingUser = data['isTyping'] == true
? data['displayName']
: null;
});
}
});