Bonjour et Bienvenue dans ce nouvel article, dédié au développement d’application avec le framework Flutter. Dans l’article développez votre première application mobile en Flutter, nous avons entamé le développement d’une application de gestion de bibliothèque que nous avons appelé bibliotheca. Nous avons concrètement conçu les écrans de notre application. Ensuite, nous avons vu comment utiliser une base de données dans une application Flutter, où nous avons initialisé une base de données relationnelle et fait quelques requêtes pour manipuler nos données.
Aujourd’hui, je vous propose de finaliser cette application. Nous allons poursuivre pour finaliser les fonctionnalités de tous les écrans implémentés dans les précédents articles.
Opérations sur la base de données
Dans l’article utiliser une base de données dans une application Flutter, nous avons écrit quelques méthodes dans notre classe Dao qui montraient des exemples de manipulation des données dans la base de données que nous avions initiée. C’est déjà un bon début. Je vous propose maintenant d’écrire des méthodes pour faire des insertions, modifications, lectures et suppressions des données de nos différentes tables.
La table des catégories
Insertion
static Future<Categorie> createCategorie(Categorie categorie) async {
final db = await database;
final id = await db.insert("categorie", categorie.toJson());
categorie.id = id;
return categorie;
}
Lecture des données
static Future<List<Categorie>> listeCategorie() async {
final db = await database;
final maps = await db.query(
"categorie",
columns: ["*"],
);
if (maps.isNotEmpty) {
return maps.map((e) => Categorie.fromJson(e)).toList();
} else {
return [];
}
}
Mise à jour (update)
static Future<int> updateCategorie(Categorie categorie) async {
final db = await database;
return db.update(
"categorie",
categorie.toJson().remove("id"),
where: 'id = ?',
whereArgs: [categorie.id],
);
}
Suppression
static Future<int> deleteCategorie(int id) async {
final db = await database;
return await db.delete(
"categorie",
where: 'id = ?',
whereArgs: [id],
);
}
La table des auteurs
Insertion
static Future<Auteur> createAuteur(Auteur auteur) async {
final db = await database;
final id = await db.insert("auteur", auteur.toJson());
auteur.id = id;
return auteur;
}
Lecture des données
static Future<List<Auteur>> listeAuteur() async {
final db = await database;
final maps = await db.query(
"auteur",
columns: ["*"],
);
if (maps.isNotEmpty) {
return maps.map((e) => Auteur.fromJson(e)).toList();
} else {
return [];
}
}
Mise à jour (update)
static Future<int> updateAuteur(Auteur auteur) async {
final db = await database;
return db.update(
"auteur",
auteur.toJson()..remove("id"),
where: 'id = ?',
whereArgs: [auteur.id],
);
}
Suppression
static Future<int> deleteAuteur(int id) async {
final db = await database;
return await db.delete(
"auteur",
where: 'id = ?',
whereArgs: [id],
);
}
La table des livres
Insertion
static Future<Livre> createLivre(Livre livre) async {
final db = await database;
final id = await db.insert("livre", livre.toJson());
livre.id = id;
return livre;
}
Lecture des données
Dans cette partie, nous allons écrire deux méthodes. Une pour lire l’ensemble des données présentent dans la table des livres et une autre pour lire les données d’un livre en fournissant son identifiant.
- Lecture de toutes les données
static Future<List<Livre>> listeLivre() async {
final db = await database;
final maps = await db.query(
"livre",
columns: ["*"],
);
if (maps.isNotEmpty) {
return maps.map((e) => Livre.fromJson(e)).toList();
} else {
return [];
}
}
- Lecture d’un livre par son identifiant
static Future<Livre?> livreParId({required int id}) async {
final db = await database;
final maps = await db.query(
"livre",
columns: ["*"],
where: "id = ?",
whereArgs: [id],
);
if (maps.isNotEmpty) {
return Livre.fromJson(maps.first);
} else {
return null;
}
}
Comme vous pouvez le voir, la méthode retourne un Future<Livre?>. Le symbole ? permet de dire que le retour de la méthode peut être également null. Nous avons abordé ce concept dans l’article Null-safety en Flutter. En effet, dans le cas où aucun livre dont l’identifiant correspond à l’identifiant fourni n’est trouvé, on retourne alors une valeur nulle.
Mise à jour (update)
static Future<int> updateLivre(Livre livre) async {
final db = await database;
return db.update(
"livre",
livre.toJson().remove("id"),
where: 'id = ?',
whereArgs: [livre.id],
);
}
Suppression
static Future<int> deleteLivre(int id) async {
final db = await database;
return await db.delete(
"livre",
where: 'id = ?',
whereArgs: [id],
);
}
Opérations sur les interfaces utilisateur
Dans cette partie nous allons utiliser les méthodes de base de données que nous avons écrites précédemment pour les lier à des contrôles de l’interface utilisateur (Bouton, champs saisi, etc.).
Ecran de Liste
Ce qu’on voudrait, c’est que lorsque l’utilisateur charge la page de liste des catégories on fasse une requête dans la base de données pour récupérer la liste de catégories en base puis de les afficher à l’écran. Voici le rendu que nous souhaitons atteindre :
La Liste des catégories
import 'package:bibliotheca/models/categorie.dart';
import 'package:bibliotheca/models/database/dao.dart';
import 'package:bibliotheca/views/edition_categorie.dart';
import 'package:flutter/material.dart';
class ListeCategorie extends StatefulWidget {
const ListeCategorie({Key? key}) : super(key: key);
@override
State<ListeCategorie> createState() => _ListeCategorieState();
}
class _ListeCategorieState extends State<ListeCategorie> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text("Liste des catégories"),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => ouvrirEdition(),
),
body: FutureBuilder<List<Categorie>>(
//Requête à la base de données avec Dao.listeCategorie()
future: Dao.listeCategorie(),
initialData: const [],
builder: (context, snapshot) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, i) {
var cat = snapshot.data![i];
return ListTile(
leading: const Icon(Icons.book),
title: Text(cat.libelle!),
onTap: () => ouvrirEdition(categorie: cat),
);
});
}),
);
ouvrirEdition({Categorie? categorie}) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EditionCategorie(
categorie: categorie,
),
),
);
setState(() {});
}
}
Analysons le code ci-dessus. Nous pouvons voir que nous avons utilisé de nouveaux widgets tels que :
FutureBuilder : futureBuilder est un widget qui permet de construire la vue à partir de l’exécution d’un Future. C’est à dire qu’il permet de construire la vue à partir de méthode dont l’exécution peut durer dans le temps. Nous reviendrons un peu plus en détail sur le terme de Future dans un autre article.
FutureBuilder<List<Categorie>>(
future: Dao.listeCategorie(),
initialData: const [],
builder: (context, snapshot) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, i) {
var cat = snapshot.data![i];
return ListTile(
leading: const Icon(Icons.book),
title: Text(cat.libelle!),
onTap: () => ouvrirEdition(categorie: cat),
);
});
}),
FutureBuilder prend en paramètre :
- future : qui permet de préciser le future à exécuter
- initialData (facultatif) : permet de donner une valeur par défaut qui sera utilisée avant que le future soit complètement exécuté.
- builder : builder nous permet de construire la vue. Il attend une fonction anonyme qui retourne un widget. Deux paramètres de types respectifs BuildContext et AsyncSnapshot doivent être fournis. NB: on pourra utiliser le paramètre snapshot de type AsyncSnapshot pour lire les données renvoyées par le future exécuté.
FloatingActionButton : floatingActionButton est le bouton circulaire qui s’affiche en bas à droite de l’écran.
FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => ouvrirEdition(),
),
Nous l’utilisons ici pour ajouter un nouvel élément dans notre liste.
ListTile : qui nous permet de construire chaque ligne de notre liste, comme le montre l’image ci-dessous :
ListTile(
leading: const Icon(Icons.book),
title: Text(cat.libelle!),
onTap: () => ouvrirEdition(categorie: cat),
);
Nous pouvons remarquer aussi que ListTile contient un paramètre onTap qui permet d’exécuter un traitement quand l’utilisateur clique sur un élément dans la liste. Ici la méthode ouvrirEdition est exécutée. ouvrirEdition est une méthode qui prend en paramètre une catégorie qui est un paramètre optionnel.
ouvrirEdition({Categorie? categorie}) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EditionCategorie(
categorie: categorie,
),
),
);
setState(() {});
}
Elle permet d’ouvrir la page d’édition. Nous fournissons à la page EditionCategorie une catégorie lorsque nous voulons effectuer une modification. Si aucune catégorie n’est fournie alors la valeur de ce paramètre est nulle. Alors cela veut dire que nous voulons effectuer un nouvel ajout.
Aussi, nous utilisons le mot clé async et await pour marquer que nous voulons attendre que le programme attend que l’utilisateur soit de retour sur l’écran de liste avant d’exécuter le code à la ligne 10.
IMPORTANT: à la ligne 10, nous avons utilisé une nouvelle méthode très importante, la méthode setState. Elle est une méthode fournie par la classe State dont notre écran hérite. setState permet d’actualiser l’écran afin de mettre à jour l’écran avec les valeurs des variables qui ont changé.
Ecrans de formulaire
import 'package:bibliotheca/models/categorie.dart';
import 'package:bibliotheca/models/database/dao.dart';
import 'package:flutter/material.dart';
class EditionCategorie extends StatefulWidget {
final Categorie? categorie;
const EditionCategorie({this.categorie, Key? key}) : super(key: key);
@override
State<EditionCategorie> createState() => _EditionCategorieState();
}
class _EditionCategorieState extends State<EditionCategorie> {
var libelle = TextEditingController();
var formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
if (widget.categorie != null) {
libelle.text = widget.categorie!.libelle!;
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text("Edition de catégorie"),
),
body: Form(
key: formKey,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
TextFormField(
controller: libelle,
decoration: const InputDecoration(labelText: "Nom catégorie*"),
validator: (e) => e!.isEmpty ? "Champ obligatoire" : null,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async => submit(),
child: const Text("Enregistrer"),
),
],
),
),
);
Future<void> submit() async {
if (formKey.currentState!.validate()) {
if (widget.categorie == null) {
Categorie categorie = Categorie();
categorie.libelle = libelle.text;
await Dao.createCategorie(categorie);
} else {
Categorie categorie = widget.categorie!;
categorie.libelle = libelle.text;
await Dao.updateCategorie(categorie);
}
if (mounted) {
Navigator.pop(context);
}
}
}
}
Dans cet écran, nous pouvons voir les variables :
var libelle = TextEditingController();
var formKey = GlobalKey<FormState>();
libelle : est de type TextEditingController. Elle permet de contrôler un champ de saisi. Nous utilisons libelle pour récupérer la valeur saisie dans un champs de saisi grâce à l’accesseur « text » comme le montre le code ci-dessous.
categorie.libelle = libelle.text;
formKey : est de type GlobalKey<FormState>. Elle nous permet de gérer l’état de notre formulaire. C’est aussi avec cette variable que nous validerons notre formulaire.
Nous pouvons voir qu’à la ligne 2, nous avons assigné la variable formKey à la propriété key du widget Form. Ensuite, nous avons assigné le contrôleur libelle à la propriété controller du widget TextFormField (ligne 7). Enfin, nous appelons la méthode submit avec notre bouton à la ligne 13. Voyons ensemble le code de cette méthode.
Form(
key: formKey,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
TextFormField(
controller: libelle,
decoration: const InputDecoration(labelText: "Nom catégorie*"),
validator: (e) => e!.isEmpty ? "Champ obligatoire" : null,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async => submit(),
child: const Text("Enregistrer"),
),
],
),
),
submit est un future de void. A la ligne 2, on utilise la variable formKey pour valider le formulaire.
Future<void> submit() async {
if (formKey.currentState!.validate()) {
if (widget.categorie == null) {
Categorie categorie = Categorie();
categorie.libelle = libelle.text;
await Dao.createCategorie(categorie);
} else {
Categorie categorie = widget.categorie!;
categorie.libelle = libelle.text;
await Dao.updateCategorie(categorie);
}
if (mounted) {
Navigator.pop(context); //Permet de partir à l'écran précédent
}
}
}
- La validation validation du formulaire se fera avec les conditions décrites par la méthode validation des champs de saisi de notre formulaire.
TextFormField(
controller: libelle,
decoration: const InputDecoration(labelText: "Nom catégorie*"),
validator: (e) => e!.isEmpty ? "Champ obligatoire" : null,
),
Ici, nous disons que si la valeur du champ est vide au moment de la validation du formulaire, alors nous allons afficher le text « Champ obligatoire ».
- Ensuite, nous vérifions si la variable widget.categorie est nulle alors, nous créons une nouvelle catégorie puis nous l’enregistrons. Dans le cas contraire, nous assignons la valeur de widget.categorie dans une autre variable de type Categorie puis nous procédons à la mise à jour de cette catégorie.
if (widget.categorie == null) {
Categorie categorie = Categorie();
categorie.libelle = libelle.text;
await Dao.createCategorie(categorie);
} else {
Categorie categorie = widget.categorie!;
categorie.libelle = libelle.text;
await Dao.updateCategorie(categorie);
}
NB: la variable widget.categorie provient de la déclaration un peu plus haut d’une variable Categorie qui recevra un catégorie qu’on voudrait modifier.
class EditionCategorie extends StatefulWidget {
final Categorie? categorie;
const EditionCategorie({this.categorie, Key? key}) : super(key: key);
@override
State<EditionCategorie> createState() => _EditionCategorieState();
}
Lorsque nous recevons une valeur dans la variable categorie, nous prenons soins de faire des assignations dans la méthode initState. initState est une des premières méthodes qui s’exécutent à l’ouverture d’un écran Flutter. Ainsi, on l’utilise pour faire des initialisations de variables. Un peu comme montre le code suivant :
@override
void initState() {
super.initState();
if (widget.categorie != null) {
libelle.text = widget.categorie!.libelle!;
}
}
Voilà, vous pourrez aussi effectuer les mêmes actions pour la fonctionnalité de gestion des auteurs.
Intéressons-nous, maintenant, plus en détail à la fonctionnalité de gestion des livres, qui je pense, est un petit peu complexe à comprendre. Voici les objectifs à atteindre.
En nous aidant de l’exemple de la catégorie, on peut dire que nous savons comment lister les livres. La petite nouveauté ici, se trouve au niveau de la suppression.
ListTile(
leading: const Icon(Icons.book),
title: Text(livre.libelle!),
subtitle: Text(livre.description!),
onTap: () => ouvrirEdition(livre: livre),
trailing: IconButton(
onPressed: () => onDelete(livre),
icon: const Icon(Icons.delete),
),
);
Dans le widget ListTile nous avons un paramètre trailing qui nous permet de définir un widget en fin de ligne. Ici, nous définissons un IconButton qui permet de déclarer un bouton. Il attend une fonction anonyme ou call-back et un widget à fournir au paramètre icon. Ici, nous utilisons le widget Icon(Icons.delete) pour définir une icône de suppression dans le bouton.
IconButton appel une méthode onDelete(livre). Voyons le code ensemble :
Future<void> onDelete(Livre livre) async {
await Dao.deleteLivre(livre.id!);
setState(() {});
}
Dans ce code, nous ne faisons qu’utiliser la méthode de suppression deleteLivre de la classe Dao que nous avons défini plus haut. Ensuite, nous fournissons l’identifiant du livre à supprimer. Ensuite, nous rafraîchissons l’écran avec setState.
Formulaire d’ajout de livre
import 'dart:io';
import 'package:bibliotheca/models/auteur.dart';
import 'package:bibliotheca/models/categorie.dart';
import 'package:bibliotheca/models/database/dao.dart';
import 'package:bibliotheca/models/livre.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class EditionLivre extends StatefulWidget {
final Livre? livre;
const EditionLivre({this.livre, Key? key}) : super(key: key);
@override
State<EditionLivre> createState() => _EditionLivreState();
}
class _EditionLivreState extends State<EditionLivre> {
List<Categorie> catList = [];
List<Auteur> autList = [];
int? selectedCat;
int? selectedAut;
String? imgPath;
var titre = TextEditingController();
var description = TextEditingController();
var nbPage = TextEditingController();
var formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
getData();
if (widget.livre != null) {
selectedAut = widget.livre!.auteurId;
selectedCat = widget.livre!.categorieId;
titre.text = widget.livre!.libelle!;
description.text = widget.livre!.description ?? "";
imgPath = widget.livre!.image;
if (widget.livre!.nbPage != null) {
nbPage.text = widget.livre!.nbPage.toString();
}
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text("Edition de livre"),
),
body: Form(
key: formKey,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
MaterialButton(
onPressed: () => getImage(),
child: CircleAvatar(
radius: 50,
backgroundImage: (imgPath != null)
? FileImage(File(imgPath!)) as ImageProvider
: null,
),
),
TextFormField(
validator: (e) => e!.isEmpty ? "Champ obligatoire" : null,
controller: titre,
decoration: const InputDecoration(labelText: "Titre du livre"),
),
TextFormField(
validator: (e) => e!.isEmpty ? "Champ obligatoire" : null,
controller: nbPage,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: "Nombre de page"),
),
DropdownButtonFormField<int>(
validator: (e) => e == null ? "Champ obligatoire" : null,
value: selectedCat,
items: catList
.map(
(e) => DropdownMenuItem<int>(
value: e.id,
child: Text(e.libelle!),
),
)
.toList(),
onChanged: (value) {
setState(() {
selectedCat = value;
});
},
decoration: const InputDecoration(labelText: "Catégorie"),
),
DropdownButtonFormField<int>(
validator: (e) => e == null ? "Champ obligatoire" : null,
value: selectedAut,
items: autList
.map(
(e) => DropdownMenuItem<int>(
value: e.id,
child: Text("${e.nom} ${e.prenoms}"),
),
)
.toList(),
onChanged: (value) {
setState(() {
selectedAut = value;
});
},
decoration: const InputDecoration(labelText: "Auteur"),
),
TextFormField(
controller: description,
maxLines: 10,
decoration:
const InputDecoration(labelText: "Description du livre"),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => submit(),
child: const Text("Enregistrer"),
),
],
),
),
);
Future<void> getData() async {
catList = await Dao.listeCategorie();
autList = await Dao.listeAuteur();
setState(() {});
}
Future<void> submit() async {
if (formKey.currentState!.validate()) {
if (widget.livre == null) {
Livre livre = Livre();
livre.libelle = titre.text;
livre.auteurId = selectedAut;
livre.categorieId = selectedCat;
livre.description = description.text;
livre.nbPage = int.tryParse(nbPage.text);
livre.image = imgPath;
Dao.createLivre(livre);
} else {
Livre livre = widget.livre!;
livre.libelle = titre.text;
livre.auteurId = selectedAut;
livre.categorieId = selectedCat;
livre.description = description.text;
livre.nbPage = int.tryParse(nbPage.text);
livre.image = imgPath;
Dao.updateLivre(livre);
}
if (mounted) {
Navigator.pop(context); //Permet de partir à l'écran précédent
}
}
}
Future<void> getImage() async {
final ImagePicker picker = ImagePicker();
var img = await picker.pickImage(source: ImageSource.gallery);
if (img?.path != null) {
setState(() {
imgPath = img?.path;
});
}
}
}
Pour remplir les champs DropdownButtonFormField, nous utilisons des variables de type List. Prenons le cas de auteur, par exemple.
DropdownButtonFormField<int>(
validator: (e) => e == null ? "Champ obligatoire" : null,
value: selectedAut,
items: autList
.map(
(e) => DropdownMenuItem<int>(
value: e.id,
child: Text("${e.nom} ${e.prenoms}"),
),
)
.toList(),
onChanged: (value) {
setState(() {
selectedAut = value;
});
},
decoration: const InputDecoration(labelText: "Auteur"),
),
Nous faisons une itération sur les données de la liste des auteurs. Nous les formatons pour les renvoyer sous la forme de DropdownMenuItem<int>. Nous utilisons aussi la méthode onChanged qui est exécutée lorsqu’une ligne est sélectionnée dans le champ. Lorsqu’une ligne est sélectionnée, nous assignons la valeur de l’élément sélectionné dans la variable selectedAut en prenant soins, bien-sûr, d’actualiser la page avec setState. Il faut aussi noter que la valeur de la variable selectedAut est assigné au paramètre value du champ DropdownMenuItem (ligne 3).
La méthode getData nous permet de charger la liste des auteurs et des catégories depuis la base de données. Notez également que vous prenons soins d’exécuter cette méthode à l’ouverture de la page, de sorte que les données soient rapidement chargées et accessible à l’écran.
Future<void> getData() async {
catList = await Dao.listeCategorie();
autList = await Dao.listeAuteur();
setState(() {});
}
Choix d’une image dans la galerie
Pour choisir une image dans la galerie, nous utilisons le plugin image_picker que nous avons ajouté à notre fichier pubspec.yaml.
dependencies:
flutter:
sdk: flutter
sqflite: ^2.0.3+1
path: ^1.8.1
image_picker: ^0.8.6
Une fois ajouté, si vous testez votre application sur un iPhone, il va falloir ajouter ces lignes dans votre fichier ios > Runner > Info.plist.
...
<!-- Demande l'accès à la galerie -->
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to access to library</string>
<!-- Demande l'accès à l'appareil photo
(si vous avez besoin d'y accéder) -->
<key>NSCameraUsageDescription</key>
<string>We need to access to camera</string>
...
Revenons maintenant dans le fichier d’édition de livre. Nous avons ajouté la méthode getImage. Elle nous permet d’ouvrir la galerie pour assigner le lien de l’image choisie à la variable imgPath.
Future<void> getImage() async {
final ImagePicker picker = ImagePicker();
var img = await picker.pickImage(source: ImageSource.gallery);
if (img?.path != null) {
setState(() {
imgPath = img?.path;
});
}
}
Ensuite, nous affichons l’image avec le widget CirculeAvatar :
CircleAvatar(
radius: 50,
backgroundImage: (imgPath != null)
? FileImage(File(imgPath!)) as ImageProvider
: null,
),
Nous utilisons un bouton pour appeler la méthode getImage :
MaterialButton(
onPressed: () => getImage(),
child: CircleAvatar(
radius: 50,
backgroundImage: (imgPath != null)
? FileImage(File(imgPath!)) as ImageProvider
: null,
),
),
Une fois que le formulaire renseigné, nous appelons la méthode submit pour effectuer l’enregistrement.
Future<void> submit() async {
if (formKey.currentState!.validate()) {
if (widget.livre == null) {
Livre livre = Livre();
livre.libelle = titre.text;
livre.auteurId = selectedAut;
livre.categorieId = selectedCat;
livre.description = description.text;
livre.nbPage = int.tryParse(nbPage.text);
livre.image = imgPath;
Dao.createLivre(livre);
} else {
Livre livre = widget.livre!;
livre.libelle = titre.text;
livre.auteurId = selectedAut;
livre.categorieId = selectedCat;
livre.description = description.text;
livre.nbPage = int.tryParse(nbPage.text);
livre.image = imgPath;
Dao.updateLivre(livre);
}
if (mounted) {
Navigator.pop(context);
}
}
}
Conclusion
Nous sommes à la fin de cet aventure. Nous avons vu ensemble pas-à-pas comment développer une application mobile de gestion d’une bibliothèque. Je vous partage le code complet du projet. Vous pouvez aussi le télécharger depuis github. J’ai pris plaisir à écrire cet article, j’espère qu’il vous servira grandement. N’hésitez pas à laisser un commentaire, Peace 😉 !
An outstanding share! I’ve just forwarded this onto a coworker who was
conducting a little research on this. And he actually ordered me
breakfast simply because I found it for him… lol.
So let me reword this…. Thanks for the meal!!
But yeah, thanks for spending some time to discuss this subject here on your web
site.
Merci, n’hésitez pas à nous faire des suggestions pour améliorer le service. Cordialement.
Bonjour Grand chef
j ‘avoue que le document/la formation est vraiment riche
grand merci a toi
Merci. N’hésitez pas faire des suggestions de sujets que aimeriez que les rédacteurs de youskil traitent 🙃
Grand merci a toi ça m’aide beaucoup
Merci. N’hésitez pas nous faire des suggestions de sujets d’article. Peace 😉 !