Skip to content

6. Clients HTTP du service de calcul de l'impôt

Image

6.1. Introduction

Ce chapitre reprend, sans changement de structure, les trois couches successives de clients construites dans le cours TypeScript, chacune raffinant la précédente : un premier client CLI où tout le calcul reste côté serveur ; un deuxième client CLI où le calcul migre côté client (couche [métier]) ; un troisième client, compilé pour le NAVIGATEUR ; et enfin un client HTML complet, avec ses propres vues. Les quatre s'appuient sur le même serveur NestJS, installé au chapitre précédent.

6.2. Client HTTP 1

Image

6.2.1. La couche [dao]

6.2.2. script [dao1]

// dao1.dart
// Porte Dao1.ts : la couche [dao] (Data Access Object) encapsule tous les
// détails de communication HTTP avec le serveur de calcul de l'impôt (URL,
// méthode, cookie de session) derrière des méthodes métier simples
// (initSession, authentifierUtilisateur, calculerImpot...).
import 'package:dio/dio.dart';

class Dao1 {
  final Dio _dio;
  final String _sessionCookieName;
  String _sessionCookie = '';

  // constructeur - reçoit l'instance Dio à utiliser, comme le script
  // TypeScript recevait l'instance axios en paramètre
  Dao1(this._dio) : _sessionCookieName = "connect.sid";

  // init session
  Future<dynamic> initSession() {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'init-session', 'type': 'json'},
    );
  }

  Future<dynamic> authentifierUtilisateur(String user, String password) {
    return _getRemoteData(
      'main.php',
      method: 'POST',
      queryParameters: {'action': 'authentifier-utilisateur'},
      data: {'user': user, 'password': password},
      contentType: Headers.formUrlEncodedContentType,
    );
  }

  // calcul de l'impôt
  Future<dynamic> calculerImpot(String marie, int enfants, int salaire) {
    return _getRemoteData(
      'main.php',
      method: 'POST',
      queryParameters: {'action': 'calculer-impot'},
      data: {'marié': marie, 'enfants': enfants, 'salaire': salaire},
      contentType: Headers.formUrlEncodedContentType,
    );
  }

  // liste des simulations
  Future<dynamic> listeSimulations() {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'lister-simulations'},
    );
  }

  // supprimer une simulation
  Future<dynamic> supprimerSimulation(int index) {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'supprimer-simulation', 'numéro': index},
    );
  }

  Future<dynamic> _getRemoteData(
    String path, {
    required String method,
    Map<String, dynamic>? queryParameters,
    Object? data,
    String? contentType,
  }) async {
    // pour le cookie de session
    final options = Options(
      method: method,
      contentType: contentType,
      headers: {'Cookie': _sessionCookie},
      validateStatus: (_) => true,
    );
    // exécution de la requête HTTP
    final response = await _dio.request(
      path,
      queryParameters: queryParameters,
      data: data,
      options: options,
    );
    // on récupère le cookie de session s'il existe
    final setCookie = response.headers['set-cookie'];
    if (setCookie != null) {
      for (final entete in setCookie) {
        final resultat = RegExp('^($_sessionCookieName.+?);').firstMatch(entete);
        if (resultat != null) {
          _sessionCookie = resultat.group(1)!;
          break;
        }
      }
    }
    // la réponse du serveur est dans [response.data]
    return response.data;
  }
}

Commentons ce code :

  • ligne 48 : La couche [dao] (Data Access Object) encapsule tous les détails de communication HTTP (URL, méthode, cookie de session) derrière des méthodes métier simples - [initSession], [authentifierUtilisateur], [calculerImpot], [listeSimulations], [supprimerSimulation] (ligne 57).
  • ligne 15 : [Dao1(this._dio)] reçoit l'instance [Dio] à utiliser en paramètre du constructeur, exactement comme le script TypeScript recevait l'instance [axios].
  • ligne 87 : [_getRemoteData] (privée, préfixe [_]) centralise le suivi manuel du cookie de session ([response.headers['set-cookie']], [RegExp] (ligne 90)) déjà rencontré en dart_dio_03.dart : chaque méthode publique du DAO s'appuie sur cette même méthode privée.

6.2.3. Le script [main1]

6.2.4. script [main1]

// main1.dart
// Porte main1.ts.
import 'dart:convert';
import 'package:dio/dio.dart';
import 'dao1.dart';

Future<void> main() async {
  // configuration Dio - baseUrl/timeout globaux, comme axios.defaults en
  // TypeScript
  final dio = Dio(BaseOptions(
    baseUrl: 'http://localhost:3000/',
    connectTimeout: const Duration(seconds: 2),
    receiveTimeout: const Duration(seconds: 2),
  ));
  // instanciation couche [dao]
  final dao = Dao1(dio);
  // utilisation de la couche [dao]
  try {
    // init session
    log("-----------init-session");
    dynamic response = await dao.initSession();
    log(response);
    // authentification
    log("-----------authentifier-utilisateur");
    response = await dao.authentifierUtilisateur("admin", "admin");
    log(response);
    // calculs d'impôt - Future.wait est l'équivalent Dart de Promise.all
    // (déjà vu en async_05.dart)
    log("-----------calculer-impot x 3");
    response = await Future.wait([
      dao.calculerImpot("oui", 2, 45000),
      dao.calculerImpot("non", 2, 45000),
      dao.calculerImpot("non", 1, 30000),
    ]);
    log(response);
    // liste des simulations
    log("-----------liste-des-simulations");
    response = await dao.listeSimulations();
    log(response);
    // suppression d'une simulation
    log("-----------suppression simulation n° 1");
    response = await dao.supprimerSimulation(1);
    log(response);
  } catch (erreur) {
    // on logue l'erreur
    print("erreur=$erreur");
  }
}

// log jSON
void log(Object? object) {
  const JsonEncoder encoder = JsonEncoder.withIndent('  ');
  print(encoder.convert(object));
}

Commentons ce code :

  • ligne 16 : [final dao = Dao1(dio);] : la couche [dao] est instanciée une seule fois, puis réutilisée pour toute la séquence d'appels (init-session, authentification, calculs, liste, suppression).
  • lignes 30-31 : [Future.wait([dao.calculerImpot(...), ...])] : les trois calculs d'impôt sont lancés EN PARALLÈLE, l'équivalent Dart de [Promise.all] déjà rencontré en async_05.dart.
  • lignes 52-53 : [JsonEncoder.withIndent(' ').convert(object)] : la fonction utilitaire [log] reproduit l'affichage indexé de [JSON.stringify(x, null, 2)].

Le résultat de l’exécution est le suivant :

"-----------init-session"
{
"action": "init-session",
"état": 700,
"réponse": "session démarrée avec type [json]"
}
"-----------authentifier-utilisateur"
{
"action": "authentifier-utilisateur",
"état": 200,
"réponse": "Authentification réussie [admin, admin]"
}
"-----------calculer-impot x 3"
[
{
"action": "calculer-impot",
"état": 300,
"réponse": {
"marié": "oui",
"enfants": 2,
"salaire": 45000,
"impôt": 502,
"surcôte": 0,
"décôte": 857,
"réduction": 126,
"taux": 0.14
}
},
{
"action": "calculer-impot",
"état": 300,
"réponse": {
"marié": "non",
"enfants": 2,
"salaire": 45000,
"impôt": 3250,
"surcôte": 370,
"décôte": 0,
"réduction": 0,
"taux": 0.3
}
},
{
"action": "calculer-impot",
"état": 300,
"réponse": {
"marié": "non",
"enfants": 1,
"salaire": 30000,
"impôt": 1687,
"surcôte": 0,
"décôte": 0,
"réduction": 0,
"taux": 0.14
}
}
]
"-----------liste-des-simulations"
{
"action": "lister-simulations",
"état": 500,
"réponse": [
{
"marié": "oui",
"enfants": 2,
"salaire": 45000,
"impôt": 502,
"surcôte": 0,
"décôte": 857,
"réduction": 126,
"taux": 0.14
},
{
"marié": "non",
"enfants": 1,
"salaire": 30000,
"impôt": 1687,
"surcôte": 0,
"décôte": 0,
"réduction": 0,
"taux": 0.14
}
]
}
"-----------suppression simulation n° 1"
{
"action": "supprimer-simulation",
"état": 600,
"réponse": [
{
"marié": "oui",
"enfants": 2,
"salaire": 45000,
"impôt": 502,
"surcôte": 0,
"décôte": 857,
"réduction": 126,
"taux": 0.14
}
]
}

6.3. Client HTTP 2

Image

Ce deuxième client déplace le CALCUL de l'impôt du serveur vers le client : le serveur ne fournit plus qu'une fois les données fiscales brutes (taux, plafonds...) via [get-admindata], et c'est désormais la couche [métier] qui calcule.

6.3.1. La classe [Metier]

6.3.2. script [metier]

// metier.dart
// Porte Metier.ts : le calcul de l'impôt, réalisé entièrement côté client
// (dans le navigateur en TypeScript, ici dans le programme Dart), à partir
// des données fiscales brutes obtenues une seule fois du serveur via
// [Dao2.getAdminData] (cf dao2.dart).
class Metier {
  // [dynamic] : la structure exacte des données envoyées par le serveur
  // fiscal (taux, plafonds...) n'est pas documentée ici ; comme en
  // TypeScript, on reste volontairement souple sur ce type
  final Map<String, dynamic> taxAdminData;

  Metier(this.taxAdminData);

  // calcul de l'impôt
  // --------------------------------------------------------------------------
  // marié : oui, non
  // enfants : nombre d'enfants
  // salaire : salaire annuel
  Map<String, dynamic> calculerImpot(String marie, int enfants, num salaire) {
    // calcul de l'impôt avec enfants
    final result1 = _calculerImpot2(marie, enfants, salaire);
    final impot1 = result1['impôt'] as num;
    // calcul de l'impôt sans les enfants
    Map<String, dynamic> result2;
    num impot2;
    num plafondDemiPart;
    if (enfants != 0) {
      result2 = _calculerImpot2(marie, 0, salaire);
      impot2 = result2['impôt'] as num;
      // application du plafonnement du quotient familial
      plafondDemiPart = taxAdminData['plafondQfDemiPart'] as num;
      if (enfants < 3) {
        // plafondDemiPart pour les 2 premiers enfants
        impot2 = impot2 - enfants * plafondDemiPart;
      } else {
        // plafondDemiPart pour les 2 premiers enfants, le double pour les suivants
        impot2 = impot2 - 2 * plafondDemiPart - (enfants - 2) * 2 * plafondDemiPart;
      }
    } else {
      // pas de recalcul de l'impôt
      impot2 = impot1;
      result2 = result1;
    }
    // on prend l'impôt le plus fort dans [impot1, impot2]
    num impot;
    num taux;
    num surcote;
    if (impot1 > impot2) {
      impot = impot1;
      taux = result1['taux'] as num;
      surcote = result1['surcôte'] as num;
    } else {
      surcote = impot2 - impot1 + (result2['surcôte'] as num);
      impot = impot2;
      taux = result2['taux'] as num;
    }
    // calcul d'une éventuelle décôte
    final decote = _getDecote(marie, impot);
    impot -= decote;
    // calcul d'une éventuelle réduction d'impôts
    final reduction = _getReduction(marie, salaire, enfants, impot);
    impot -= reduction;
    // résultat
    return {
      'impôt': impot.floor(),
      'surcôte': surcote,
      'décôte': decote,
      'réduction': reduction,
      'taux': taux,
    };
  }

  // --------------------------------------------------------------------------
  Map<String, dynamic> _calculerImpot2(String marie, int enfants, num salaire) {
    // nombre de parts
    marie = marie.toLowerCase();
    num nbParts;
    if (marie == "oui") {
      nbParts = enfants / 2 + 2;
    } else {
      nbParts = enfants / 2 + 1;
    }
    // 1 part par enfant à partir du 3ième
    if (enfants >= 3) {
      // une demi-part de + pour chaque enfant à partir du 3ième
      nbParts += 0.5 * (enfants - 2);
    }
    // revenu imposable
    final revenuImposable = _getRevenuImposable(salaire);
    // surcôte
    num surcote = (revenuImposable - 0.9 * salaire).floor();
    // pour des pbs d'arrondi
    if (surcote < 0) {
      surcote = 0;
    }
    // quotient familial
    final quotient = revenuImposable / nbParts;
    // calcul de l'impôt
    final List<num> limites = List<num>.from(taxAdminData['limites'] as List);
    final List<num> coeffR = List<num>.from(taxAdminData['coeffR'] as List);
    final List<num> coeffN = List<num>.from(taxAdminData['coeffN'] as List);
    // est mis à la fin de la liste [limites] pour arrêter la boucle qui suit
    limites[limites.length - 1] = quotient;
    // recherche du taux d'imposition
    var i = 0;
    while (quotient > limites[i]) {
      i++;
    }
    // du fait qu'on a placé quotient à la fin de limites, la boucle
    // précédente ne peut déborder de la liste limites
    // maintenant on peut calculer l'impôt
    final impot = (revenuImposable * coeffR[i] - nbParts * coeffN[i]).floor();
    // résultat
    return {'impôt': impot, 'surcôte': surcote, 'taux': coeffR[i]};
  }

  // revenuImposable = salaireAnnuel - abattement (l'abattement a un min et un max)
  num _getRevenuImposable(num salaire) {
    // abattement de 10% du salaire
    num abattement = 0.1 * salaire;
    // cet abattement ne peut dépasser abattementDixPourCentMax
    final abattementMax = taxAdminData['abattementDixPourCentMax'] as num;
    if (abattement > abattementMax) {
      abattement = abattementMax;
    }
    // l'abattement ne peut être inférieur à abattementDixPourcentMin
    final abattementMin = taxAdminData['abattementDixPourcentMin'] as num;
    if (abattement < abattementMin) {
      abattement = abattementMin;
    }
    // revenu imposable
    final revenuImposable = salaire - abattement;
    return revenuImposable.floor();
  }

  // calcule une décôte éventuelle
  num _getDecote(String marie, num impots) {
    // au départ, une décôte nulle
    num decote = 0;
    // montant maximal d'impôt pour avoir la décôte
    final plafondImpotPourDecote = marie == "oui"
        ? taxAdminData['plafondImpotCouplePourDecote'] as num
        : taxAdminData['plafondImpotCelibatairePourDecote'] as num;
    if (impots < plafondImpotPourDecote) {
      // montant maximal de la décôte
      final plafondDecote = marie == "oui"
          ? taxAdminData['plafondDecoteCouple'] as num
          : taxAdminData['plafondDecoteCelibataire'] as num;
      // décôte théorique
      decote = plafondDecote - 0.75 * impots;
      // la décôte ne peut dépasser le montant de l'impôt
      if (decote > impots) {
        decote = impots;
      }
      // pas de décôte < 0
      if (decote < 0) {
        decote = 0;
      }
    }
    // résultat - ceil() est l'équivalent Dart de Math.ceil()
    return decote.ceil();
  }

  // calcule une réduction éventuelle
  num _getReduction(String marie, num salaire, int enfants, num impots) {
    // le plafond des revenus pour avoir droit à la réduction de 20%
    num plafondRevenuPourReduction = marie == "oui"
        ? taxAdminData['plafondRevenusCouplePourReduction'] as num
        : taxAdminData['plafondRevenusCelibatairePourReduction'] as num;
    final valeurReducDemiPart = taxAdminData['valeurReducDemiPart'] as num;
    plafondRevenuPourReduction += enfants * valeurReducDemiPart;
    if (enfants > 2) {
      plafondRevenuPourReduction += (enfants - 2) * valeurReducDemiPart;
    }
    // revenu imposable
    final revenuImposable = _getRevenuImposable(salaire);
    // réduction
    num reduction = 0;
    if (revenuImposable < plafondRevenuPourReduction) {
      // réduction de 20%
      reduction = 0.2 * impots;
    }
    return reduction.ceil();
  }
}

Commentons ce code :

  • ligne 12 : [Metier(this.taxAdminData)] : le constructeur reçoit une seule fois les données fiscales brutes récupérées par le DAO - aucun nouvel appel réseau n'est nécessaire ensuite pour calculer un impôt.
  • ligne 19 : [Map<String, dynamic> calculerImpot(String marie, int enfants, num salaire)] : un portage fidèle, ligne à ligne, de l'algorithme TypeScript - mêmes étapes ([_calculerImpot2], [_getRevenuImposable], [_getDecote], [_getReduction]), même logique de plafonnement du quotient familial.
  • [.floor()]/[.ceil()] (méthodes de [num]) remplacent [Math.floor(...)]/[Math.ceil(...)] : en Dart, ce sont des MÉTHODES d'instance sur le nombre lui-même, pas des fonctions statiques d'une classe utilitaire.
  • Les CLÉS de la [Map] rendue ([impot], [surcote]...) restent accentuées ([impôt], [surcôte], [décôte], [réduction]) : seuls les IDENTIFIANTS Dart (noms de variables, de paramètres) sont désaccentués - une chaîne de caractères peut toujours contenir des accents en Dart (cf simulation.dart, plus loin dans ce chapitre).

6.3.3. La classe [Dao2]

6.3.4. script [dao2]

// dao2.dart
// Porte Dao2.ts : identique à dao1.dart pour initSession et
// authentifierUtilisateur, mais expose [getAdminData] à la place de
// [calculerImpot]/[listeSimulations]/[supprimerSimulation] - le calcul et
// la liste des simulations sont désormais gérés côté CLIENT par la couche
// [métier] (voir metier.dart), le serveur ne servant plus qu'à fournir les
// données fiscales brutes (taux, plafonds...).
import 'package:dio/dio.dart';

class Dao2 {
  final Dio _dio;
  final String _sessionCookieName;
  String _sessionCookie = '';

  Dao2(this._dio) : _sessionCookieName = "connect.sid";

  Future<dynamic> initSession() {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'init-session', 'type': 'json'},
    );
  }

  Future<dynamic> authentifierUtilisateur(String user, String password) {
    return _getRemoteData(
      'main.php',
      method: 'POST',
      queryParameters: {'action': 'authentifier-utilisateur'},
      data: {'user': user, 'password': password},
      contentType: Headers.formUrlEncodedContentType,
    );
  }

  Future<dynamic> getAdminData() {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'get-admindata'},
    );
  }

  Future<dynamic> _getRemoteData(
    String path, {
    required String method,
    Map<String, dynamic>? queryParameters,
    Object? data,
    String? contentType,
  }) async {
    final options = Options(
      method: method,
      contentType: contentType,
      headers: {'Cookie': _sessionCookie},
      validateStatus: (_) => true,
    );
    final response = await _dio.request(
      path,
      queryParameters: queryParameters,
      data: data,
      options: options,
    );
    final setCookie = response.headers['set-cookie'];
    if (setCookie != null) {
      for (final entete in setCookie) {
        final resultat = RegExp('^($_sessionCookieName.+?);').firstMatch(entete);
        if (resultat != null) {
          _sessionCookie = resultat.group(1)!;
          break;
        }
      }
    }
    return response.data;
  }
}

Commentons ce code :

  • ligne 4 : Identique à [Dao1] pour [initSession] et [authentifierUtilisateur], mais expose [getAdminData] à la place de [calculerImpot]/[listeSimulations] (ligne 4)/[supprimerSimulation] (ligne 4) : ces trois opérations sont désormais gérées côté CLIENT par la couche [métier] qui vient d'être présentée.

6.3.5. Le script [main2]

6.3.6. script [main2]

// main2.dart
// Porte main2.ts.
import 'dart:convert';
import 'package:dio/dio.dart';
import 'dao2.dart';
import 'metier.dart';

Future<void> main() async {
  // configuration Dio
  final dio = Dio(BaseOptions(
    baseUrl: 'http://localhost:3000/',
    connectTimeout: const Duration(seconds: 2),
    receiveTimeout: const Duration(seconds: 2),
  ));
  // instanciation couche [dao]
  final dao = Dao2(dio);
  // requêtes HTTP
  Map<String, dynamic> taxAdminData;
  try {
    // init session
    log("-----------init-session");
    dynamic response = await dao.initSession();
    log(response);
    // authentification
    log("-----------authentifier-utilisateur");
    response = await dao.authentifierUtilisateur("admin", "admin");
    log(response);
    // données fiscales
    log("-----------get-admindata");
    response = await dao.getAdminData();
    log(response);
    taxAdminData = response['réponse'] as Map<String, dynamic>;
  } catch (erreur) {
    // on logue l'erreur
    print("erreur=$erreur");
    // fin
    return;
  }

  // instanciation couche [métier]
  final metier = Metier(taxAdminData);

  // calculs d'impôt
  log("-----------calculer-impot x 3");
  final List<Map<String, dynamic>> simulations = [];
  simulations.add(metier.calculerImpot("oui", 2, 45000));
  simulations.add(metier.calculerImpot("non", 2, 45000));
  simulations.add(metier.calculerImpot("non", 1, 30000));
  // liste des simulations
  log("-----------liste-des-simulations");
  log(simulations);
  // suppression d'une simulation
  log("-----------suppression simulation n° 1");
  simulations.removeAt(1);
  log(simulations);
}

// log jSON
void log(Object? object) {
  const JsonEncoder encoder = JsonEncoder.withIndent('  ');
  print(encoder.convert(object));
}

Commentons ce code :

  • ligne 41 : [final metier = Metier(taxAdminData);] : la couche [métier] est instanciée UNE FOIS, juste après avoir récupéré les données fiscales du serveur.
  • [simulations.add(metier.calculerImpot(...));] : les calculs d'impôt n'impliquent plus AUCUN appel réseau - contrairement à main1.dart, où chaque calcul était une requête HTTP distincte.

Le résultat de l’exécution est le suivant :

"-----------init-session"
{
"action": "init-session",
"état": 700,
"réponse": "session démarrée avec type [json]"
}
"-----------authentifier-utilisateur"
{
"action": "authentifier-utilisateur",
"état": 200,
"réponse": "Authentification réussie [admin, admin]"
}
"-----------get-admindata"
{
"action": "get-admindata",
"état": 1000,
"réponse": {
"limites": [
9964,
27519,
73779,
156244,
0
],
"coeffR": [
0,
0.14,
0.3,
0.41,
0.45
],
"coeffN": [
0,
1394.96,
5798,
13913.69,
20163.45
],
"plafondQfDemiPart": 1551,
"plafondRevenusCelibatairePourReduction": 21037,
"plafondRevenusCouplePourReduction": 42074,
"valeurReducDemiPart": 3797,
"plafondDecoteCelibataire": 1196,
"plafondDecoteCouple": 1970,
"plafondImpotCouplePourDecote": 2627,
"plafondImpotCelibatairePourDecote": 1595,
"abattementDixPourcentMax": 12502,
"abattementDixPourcentMin": 437
}
}
"-----------calculer-impot x 3"
"-----------liste-des-simulations"
[
{
"impôt": 502,
"surcôte": 0,
"décôte": 857,
"réduction": 126,
"taux": 0.14
},
{
"impôt": 3250,
"surcôte": 370,
"décôte": 0,
"réduction": 0,
"taux": 0.3
},
{
"impôt": 1687,
"surcôte": 0,
"décôte": 0,
"réduction": 0,
"taux": 0.14
}
]
"-----------suppression simulation n° 1"
[
{
"impôt": 502,
"surcôte": 0,
"décôte": 857,
"réduction": 126,
"taux": 0.14
},
{
"impôt": 1687,
"surcôte": 0,
"décôte": 0,
"réduction": 0,
"taux": 0.14
}
]

6.4. Client HTTP 3

Image

6.4.1. Évolution de l'outillage depuis 2019

Ce troisième client reprend le même duo [dao]/[métier] que le client HTTP 2, mais COMPILÉ POUR LE NAVIGATEUR plutôt qu'exécuté en ligne de commande. Côté TypeScript, cette compilation exige webpack et babel, ainsi que des polyfills ([core-js/stable], [regenerator-runtime/runtime]) pour que le code moderne fonctionne sur d'anciens navigateurs. Côté Dart, le SDK fournit lui-même son compilateur web ([dart compile js]) : aucun polyfill n'est nécessaire, [Future]/[async]/[await], les records, les patterns... sont compilés directement vers du JavaScript équivalent, quelle que soit la cible.

6.4.2. Compilation et exécution du projet

Depuis le dossier [client http 3] :

Le serveur de développement [webdev serve] n'est pas fourni par le SDK Dart : le paquet [webdev] doit d'abord être installé une seule fois, globalement, avec [dart pub global activate webdev].

Cette commande installe l'exécutable [webdev] dans le cache pub global, un dossier qui n'est PAS automatiquement dans le PATH de Windows. Pour l'y ajouter une fois pour toutes (utilisateur courant), ouvrez un terminal PowerShell et exécutez :

[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:LOCALAPPDATA\Pub\Cache\bin", "User")

Il faut ensuite fermer puis rouvrir le terminal (ou VSCode) pour que le nouveau PATH soit pris en compte. Puis toujours dans le dossier [client http 3] :

dart pub get
webdev serve

En développement, [webdev serve] lance un serveur qui recompile automatiquement à chaque modification (rechargement à chaud) : le terminal indique l'URL à ouvrir dans un navigateur (par défaut http://localhost:8080). Pour un build de production, la commande [dart compile js index.dart -o dist/main.dart.js] reste disponible : une page HTML minimale charge alors [dist/main.dart.js] pour tester le résultat - la même idée que le point d'entrée minimal [src/index.ts] du cours TypeScript.

6.4.3. Le script [main3]

6.4.4. script [main3]

// main3.dart
// Porte main3.ts.
//
// [ATTENTION, différence de fond avec TypeScript] main3.ts avait besoin de
// deux polyfills (core-js/stable, regenerator-runtime) pour que
// async/await et les fonctionnalités JavaScript récentes fonctionnent sur
// d'anciens navigateurs, une fois transpilé par babel/webpack. Le
// compilateur Dart pour le web (`dart compile js`) n'a besoin d'AUCUN
// polyfill de ce genre : Future/async/await, les Records, les patterns...
// sont compilés directement vers du JavaScript ou du WebAssembly
// équivalent, quelle que soit la cible - un avantage direct de compiler
// depuis un langage et un outillage unifiés, plutôt que de transpiler du
// JavaScript "moderne" vers du JavaScript "ancien".
import 'dart:convert';
import 'package:http/browser_client.dart';
import 'dao3.dart';
import 'metier.dart';

Future<void> main() async {
  // client HTTP spécifique au navigateur, avec withCredentials=true -
  // l'équivalent Dart de "axios.defaults.withCredentials = true"
  final client = BrowserClient()..withCredentials = true;
  final dao = Dao3(client, 'http://localhost:3000');

  Map<String, dynamic> taxAdminData;
  try {
    log("-----------init-session");
    Map<String, dynamic> response = await dao.initSession();
    log(response);
    if (response['état'] != 700) {
      throw Exception(jsonEncode(response['réponse']));
    }
    log("-----------authentifier-utilisateur");
    response = await dao.authentifierUtilisateur("admin", "admin");
    log(response);
    if (response['état'] != 200) {
      throw Exception(jsonEncode(response['réponse']));
    }
    log("-----------get-admindata");
    response = await dao.getAdminData();
    log(response);
    if (response['état'] != 1000) {
      throw Exception(jsonEncode(response['réponse']));
    }
    taxAdminData = response['réponse'] as Map<String, dynamic>;
  } catch (erreur) {
    print("erreur=$erreur");
    return;
  } finally {
    client.close();
  }

  // instanciation couche [métier]
  final metier = Metier(taxAdminData);

  log("-----------calculer-impot x 3");
  final List<Map<String, dynamic>> simulations = [];
  simulations.add(metier.calculerImpot("oui", 2, 45000));
  simulations.add(metier.calculerImpot("non", 2, 45000));
  simulations.add(metier.calculerImpot("non", 1, 30000));
  log("-----------liste-des-simulations");
  log(simulations);
  log("-----------suppression simulation n° 1");
  simulations.removeAt(1);
  log(simulations);
}

// log jSON
void log(Object? object) {
  const JsonEncoder encoder = JsonEncoder.withIndent('  ');
  print(encoder.convert(object));
}

Commentons ce code :

  • ligne 22 : [final client = BrowserClient()..withCredentials = true;] : [BrowserClient] ([package:http/browser_client.dart]) n'existe QUE lorsque le code est compilé pour le web ; son option [withCredentials] est l'équivalent Dart de [axios.defaults.withCredentials = true] - le NAVIGATEUR gère alors le cookie de session tout seul.
  • Le compilateur Dart web n'a besoin d'AUCUN polyfill (contrairement à main3.ts) : [Future]/[async]/[await] et les fonctionnalités récentes du langage sont compilées directement, un avantage direct de partir d'un langage et d'un outillage unifiés plutôt que de transpiler du JavaScript moderne vers du JavaScript ancien.

6.4.5. La classe [Dao3]

6.4.6. script [dao3]

// dao3.dart
// Porte client impots/client http 3/src/Dao3.ts.
//
// Différence avec dao2.dart : ce fichier est destiné à être compilé pour le
// NAVIGATEUR (avec `dart compile js`, cf. README de ce dossier), pas
// exécuté en CLI avec `dart run`. Dans un navigateur, le COOKIE DE SESSION
// est géré automatiquement par le navigateur lui-même (comme pour n'importe
// quelle page web) dès lors qu'on autorise leur envoi - il n'y a donc plus
// besoin du suivi MANUEL du cookie fait à la main dans dao1.dart/dao2.dart
// (exactement le raisonnement, déjà présent, qui a permis à Dao3.ts de
// supprimer tout le code de gestion de cookie de Dao2.ts).
//
// [correspondance avec TypeScript] "axios.defaults.withCredentials = true"
// (posé dans main3.dart, pas ici) correspond, côté paquet Dart [http], à
// l'option [withCredentials] du client spécifique au web,
// [BrowserClient] (package:http/browser_client.dart) - une classe qui
// n'existe QUE lorsque le code est compilé pour le web, contrairement à
// http.Client, qui fonctionne partout.
import 'dart:convert';
import 'package:http/http.dart' as http;

class Dao3 {
  final http.Client _client;
  final String _baseUrl;

  Dao3(this._client, this._baseUrl);

  Future<dynamic> initSession() {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'init-session', 'type': 'json'},
    );
  }

  Future<dynamic> authentifierUtilisateur(String user, String password) {
    return _getRemoteData(
      'main.php',
      method: 'POST',
      queryParameters: {'action': 'authentifier-utilisateur'},
      body: {'user': user, 'password': password},
    );
  }

  Future<dynamic> getAdminData() {
    return _getRemoteData(
      'main.php',
      method: 'GET',
      queryParameters: {'action': 'get-admindata'},
    );
  }

  Future<dynamic> _getRemoteData(
    String path, {
    required String method,
    Map<String, dynamic>? queryParameters,
    Map<String, String>? body,
  }) async {
    final uri = Uri.parse('$_baseUrl/$path').replace(
      queryParameters: queryParameters?.map((k, v) => MapEntry(k, '$v')),
    );
    final http.Response response;
    if (method == 'POST') {
      response = await _client.post(uri, body: body);
    } else {
      response = await _client.get(uri);
    }
    // [différence avec le client CLI] pas de lecture manuelle de l'entête
    // set-cookie ici : dans un navigateur, response.headers ne l'expose
    // même pas (entête protégée par le navigateur, invisible au code
    // JavaScript/Dart, précisément parce qu'il est déjà géré tout seul)
    return jsonDecode(response.body);
  }
}

Commentons ce code :

  • Plus AUCUN suivi manuel du cookie de session (contrairement à [Dao1]/[Dao2]) : dans un navigateur, le cookie de session est géré automatiquement par le navigateur lui-même, dès lors qu'on autorise leur envoi ([withCredentials], posé dans main3.dart).
  • [response.headers] n'expose même plus l'en-tête [set-cookie] dans un navigateur : une en-tête protégée, invisible au code JavaScript/Dart côté client, précisément parce qu'elle est déjà gérée toute seule.

6.4.7. Compilation et exécution

Voir la section [Compilation et exécution du projet] ci-dessus : la même commande [dart compile js] compile [main3.dart] comme elle compile [index.dart].

On lance le serveur de développement pour exécuter l’application web du client Http3 :


PS C:\Data\st-2026\dev\dart-sept-2026-scripts\dart-fondamentaux\client-impots\dart-fondamentaux\client impots\client http 3> webdev serve
[INFO] Serving `web` on http://127.0.0.1:8080
[INFO] ------------------------------------------------------------------------------------
[INFO] Injected debugging metadata for entrypoint at http://127.0.0.1:8080/main.dart.bootstr
[INFO] Injected debugging metadata for entrypoint at http://localhost:8080/main.dart.bootstrap.js

Le résultat de l’exécution est le suivant :

Image

  • en [1], ouvrez un navigateur et demandez l’URL [http://localhost:8080] ;
  • en [2], le texte affiché par la page demandée ;
  • en [3], le résultat de l’exécution se trouve dans la console du navigateur (F12, puis option Console). On a le même résultat qu’auparavant mais cette fois-ci dans un navigateur ;

6.5. Client HTML

Image

6.5.1. Architecture de la solution

Ce quatrième et dernier client ajoute une vraie interface HTML au-dessus du client HTTP 3 : les couches [dao] ([Dao3]) et [métier] ([Metier]) sont REPRISES SANS MODIFICATION, et deux nouvelles couches viennent s'ajouter - les VUES (construction du DOM) et le CONTRÔLEUR (orchestration). Le projet est organisé en deux dossiers, [web/] (point d'entrée, HTML, CSS) et [lib/] (le code partagé), à la façon d'un projet Dart web standard - voir le README.md de ce dossier pour le détail de cette convention.

6.5.2. Le gabarit HTML : le fichier [web/index.html]

Repris à l'identique de [src/index.html] (mêmes classes CSS : [bandeau], [menu], [contenu], [titre-section], [ligne-champ], [resultat], [erreur], [simulations]...), à un seul détail près : le script chargé est [main.dart.js] (nom FIXE produit par l'outillage web Dart), là où [index.html] de Vues.ts chargeait un bundle webpack au nom HASHÉ ([main.a44f6e1c12ab338daedc.js]).

6.5.3. La configuration [webpack.config.js] modifiée

Aucun équivalent n'est nécessaire côté Dart : pas de fichier de configuration de bundler à écrire. [web/main.dart] joue, par sa seule POSITION dans l'arborescence (directement sous [web/]), le rôle que jouait la ligne [entry: './src/main3.ts'] de [webpack.config.js] - une convention de nom et d'emplacement remplace un fichier de configuration entier.

6.5.4. Les couches [dao] et [métier] : deux fichiers réutilisés sans modification

[lib/dao3.dart] et [lib/metier.dart] sont des copies EXACTES des fichiers du même nom du client HTTP 3 - la même réutilisation, sans la moindre modification, que celle opérée par Vues.ts et AppHtml.ts sur [Dao3.ts] et [Metier.ts].

6.5.5. La couche [vues] : le fichier [Vues.ts]

6.5.6. scripts [simulation, vues]

La couche vues est portée par TROIS fichiers : simulation.dart (la donnée affichée), vues.dart (la construction du DOM) et app_html.dart (le contrôleur, section suivante). Commençons par simulation.dart :

// simulation.dart
// Le résultat d'un calcul d'impôt, tel que rendu par metier.dart, complété
// par les paramètres saisis. Porte l'interface [Simulation] de Vues.ts.
//
// [NOUVEAU, Dart 3] on utilise ici un RECORD nommé (cf
// dart-fondamentaux/dart3/dart3_01_records.dart) plutôt qu'une classe : un
// simple alias de type [typedef], sans constructeur ni fichier séparé à
// écrire, pour une donnée qui n'est qu'un simple "sac" de champs, sans
// aucun comportement associé - exactement l'esprit de l'interface
// TypeScript d'origine.
//
// [NOTE sur les noms de champs] contrairement à TypeScript/JavaScript, où
// un identifiant peut contenir des lettres accentuées ('marié', 'impôt' -
// utilisées tout du long dans le cours d'origine), Dart réserve les
// identifiants aux lettres ASCII : les accents ne sont possibles que dans
// les CHAÎNES DE CARACTÈRES (ce que fait metier.dart, dont les clés de Map
// 'impôt', 'décôte'... restent accentuées), jamais dans un nom de
// variable, de champ, de classe ou de record. C'est pourquoi ce cours
// utilise systématiquement des noms sans accent (marie, impot, decote...)
// pour tout ce qui est un IDENTIFIANT Dart.
typedef Simulation = ({
  String marie,
  int enfants,
  num salaire,
  num impot,
  num surcote,
  num decote,
  num reduction,
  num taux,
});

Commentons ce code :

  • lignes 21-24 : [typedef Simulation = ({String marie, int enfants, num salaire, ...});] : là où Vues.ts déclarait une INTERFACE [Simulation], ce fichier utilise un RECORD NOMMÉ Dart 3 (cf dart3_01_records.dart) - un simple alias de type, sans constructeur ni fichier séparé, pour une donnée purement descriptive.
  • Les noms de champs sont systématiquement DÉSACCENTUÉS ([marie], [impot], [decote]...) : les IDENTIFIANTS Dart sont réservés aux lettres ASCII, contrairement à TypeScript/JavaScript, où [marié]/[impôt] sont des noms de champs valides.

[lib/vues.dart], qui construit le DOM avec [package:web] :

// vues.dart
// Porte Vues.ts : construction des vues (DOM) de l'application - vue
// authentification, vue calcul de l'impôt, vue liste des simulations. Ce
// module ne fait aucun appel réseau : il construit le DOM et délègue les
// actions de l'utilisateur à des fonctions de rappel fournies par le
// contrôleur (voir app_html.dart).
//
// [choix technique] on utilise ici [package:web] (pub.dev/packages/web),
// le remplaçant moderne de l'ancien dart:html : il expose une API DOM très
// proche du DOM standard utilisé par Vues.ts (document.createElement,
// .textContent, .addEventListener...). Une différence de surface à
// connaître : un gestionnaire d'événement Dart (une simple fonction) doit
// être converti en fonction JavaScript avec [.toJS] (extension de
// dart:js_interop) avant d'être passé à [addEventListener] - le "pont"
// entre le monde Dart et le monde JavaScript, rendu explicite, là où
// TypeScript n'a besoin d'aucune conversion (tout y est déjà du
// JavaScript).
import 'dart:js_interop';
import 'package:web/web.dart' as web;
import 'simulation.dart';

// une décoration générique (pas la photo du serveur PHP d'origine)
const String _logoSvg = '''
<svg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg">
  <circle cx="30" cy="30" r="29" fill="#eef1ec" stroke="#ccc" />
  <g fill="none" stroke="#8a9a7a" stroke-width="2">
    <path d="M30 46 C30 32 22 28 17 19" />
    <path d="M30 46 C30 30 39 26 44 17" />
    <path d="M30 46 C30 37 30 30 30 21" />
  </g>
  <g fill="#b9c9a6">
    <circle cx="17" cy="19" r="4.5" />
    <circle cx="44" cy="17" r="4.5" />
    <circle cx="30" cy="19" r="4.5" />
  </g>
</svg>''';

// callbacks de la vue [authentification] - [différence avec TypeScript]
// une interface TypeScript de callbacks se traduit naturellement en un
// RECORD de fonctions en Dart (cf simulation.dart) : plus léger qu'une
// classe, pour un simple groupe de fonctions
typedef CallbacksAuthentification = ({void Function(String user, String password) onValider});

typedef CallbacksCalcul = ({
  void Function(String marie, int enfants, int salaire) onValider,
  void Function() onListe,
  void Function() onFinSession,
});

typedef CallbacksListe = ({
  void Function(int numero) onSupprimer,
  void Function() onCalcul,
  void Function() onFinSession,
});

class Vues {
  final web.HTMLElement racine;
  late web.HTMLDivElement _bandeau;
  late web.HTMLDivElement _menu;
  late web.HTMLDivElement _contenu;

  // construit la structure fixe de la page (bandeau + zone menu/contenu) à
  // l'intérieur de l'élément [racine]
  Vues(this.racine) {
    // [ATTENTION] contrairement aux propriétés DOM ordinaires (textContent,
    // value...), le setter [innerHTML] de package:web attend un [JSAny], pas
    // un [String] Dart brut - une particularité de sa génération liée à
    // l'attribut WebIDL [LegacyNullToEmptyString] du DOM standard : il faut
    // donc systématiquement convertir avec [.toJS].
    racine.innerHTML = ''.toJS;

    _bandeau = _div()..className = 'bandeau';
    final logo = _div()..className = 'logo';
    logo.innerHTML = _logoSvg.toJS;
    final titre = web.document.createElement('h1') as web.HTMLHeadingElement;
    titre.textContent = 'Calculez votre impôt';
    _bandeau
      ..appendChild(logo)
      ..appendChild(titre);

    final corps = _div()..className = 'corps';
    _menu = _div()..className = 'menu';
    _contenu = _div()..className = 'contenu';
    corps
      ..appendChild(_menu)
      ..appendChild(_contenu);

    racine
      ..appendChild(_bandeau)
      ..appendChild(corps);
  }

  // --------------------------------------------------------------------
  // outils de construction du DOM
  // --------------------------------------------------------------------

  web.HTMLDivElement _div() => web.document.createElement('div') as web.HTMLDivElement;

  void _viderMenu() {
    _menu.innerHTML = ''.toJS;
    _menu.style.display = 'none';
    _contenu.classList.add('pleine-largeur');
  }

  web.HTMLAnchorElement _lienMenu(String texte, void Function() onClick) {
    final a = web.document.createElement('a') as web.HTMLAnchorElement;
    a.textContent = texte;
    a.addEventListener('click', ((web.Event event) => onClick()).toJS);
    return a;
  }

  void _afficherMenu(List<web.HTMLAnchorElement> liens) {
    _menu.innerHTML = ''.toJS;
    _menu.style.display = '';
    _contenu.classList.remove('pleine-largeur');
    for (final lien in liens) {
      _menu.appendChild(lien);
    }
  }

  web.HTMLElement? _banniereErreur(String? message) {
    if (message == null) return null;
    final div = _div()..className = 'erreur';
    div.textContent = message;
    return div;
  }

  // une ligne de formulaire [label + champ texte/mot de passe/nombre]
  (web.HTMLDivElement ligne, web.HTMLInputElement input) _ligneChamp(
    String libelle,
    String type,
    String valeurInitiale, {
    String? aide,
  }) {
    final ligne = _div()..className = 'ligne-champ';
    final label = web.document.createElement('label') as web.HTMLLabelElement;
    label.textContent = libelle;
    final champ = _div()..className = 'champ';
    final input = web.document.createElement('input') as web.HTMLInputElement;
    input.type = type;
    input.value = valeurInitiale;
    if (type == 'number') {
      input.min = '0';
    }
    champ.appendChild(input);
    if (aide != null) {
      final div = _div()..className = 'aide';
      div.textContent = aide;
      champ.appendChild(div);
    }
    ligne
      ..appendChild(label)
      ..appendChild(champ);
    return (ligne, input);
  }

  // --------------------------------------------------------------------
  // vue [authentification]
  // --------------------------------------------------------------------
  void vueAuthentification(String? erreur, CallbacksAuthentification callbacks) {
    _viderMenu();
    _contenu.innerHTML = ''.toJS;

    final titre = _div()
      ..className = 'titre-section'
      ..textContent = 'Veuillez vous authentifier';
    _contenu.appendChild(titre);

    final banniere = _banniereErreur(erreur);
    if (banniere != null) _contenu.appendChild(banniere);

    final champUser = _ligneChamp("Nom d'utilisateur", 'text', 'admin');
    final champPassword = _ligneChamp('Mot de passe', 'password', '');
    _contenu
      ..appendChild(champUser.$1)
      ..appendChild(champPassword.$1);

    final bouton = web.document.createElement('button') as web.HTMLButtonElement;
    bouton.textContent = 'Valider';
    bouton.addEventListener(
      'click',
      ((web.Event event) {
        callbacks.onValider(champUser.$2.value.trim(), champPassword.$2.value);
      }).toJS,
    );
    _contenu.appendChild(bouton);
  }

  // --------------------------------------------------------------------
  // vue [calcul de l'impôt]
  // --------------------------------------------------------------------
  void vueCalcul(Simulation? resultat, String? erreur, CallbacksCalcul callbacks) {
    _afficherMenu([
      _lienMenu('Liste des simulations', callbacks.onListe),
      _lienMenu('Fin de session', callbacks.onFinSession),
    ]);

    _contenu.innerHTML = ''.toJS;

    final titre = _div()
      ..className = 'titre-section'
      ..textContent = 'Remplissez le formulaire ci-dessous puis validez-le';
    _contenu.appendChild(titre);

    final banniere = _banniereErreur(erreur);
    if (banniere != null) _contenu.appendChild(banniere);

    // ligne des radios [marié(e)]
    final ligneMarie = _div()..className = 'ligne-champ';
    final labelMarie = web.document.createElement('label') as web.HTMLLabelElement;
    labelMarie.textContent = 'Etes-vous marié(e) ou pacsé(e) ?';
    final champMarie = _div()..className = 'champ radios';

    final radioOui = web.document.createElement('input') as web.HTMLInputElement;
    radioOui.type = 'radio';
    radioOui.name = 'marie';
    radioOui.value = 'oui';
    radioOui.checked = true;
    final labelOui = web.document.createElement('label') as web.HTMLLabelElement;
    labelOui.appendChild(radioOui);
    labelOui.appendChild(web.document.createTextNode(' Oui'));

    final radioNon = web.document.createElement('input') as web.HTMLInputElement;
    radioNon.type = 'radio';
    radioNon.name = 'marie';
    radioNon.value = 'non';
    final labelNon = web.document.createElement('label') as web.HTMLLabelElement;
    labelNon.appendChild(radioNon);
    labelNon.appendChild(web.document.createTextNode(' Non'));

    champMarie
      ..appendChild(labelOui)
      ..appendChild(labelNon);
    ligneMarie
      ..appendChild(labelMarie)
      ..appendChild(champMarie);
    _contenu.appendChild(ligneMarie);

    final champEnfants = _ligneChamp("Nombre d'enfants à charge", 'number', '0');
    _contenu.appendChild(champEnfants.$1);

    final champSalaire = _ligneChamp(
      'Salaire annuel',
      'number',
      '',
      aide: "Arrondissez à l'euro inférieur",
    );
    _contenu.appendChild(champSalaire.$1);

    final bouton = web.document.createElement('button') as web.HTMLButtonElement;
    bouton.textContent = 'Valider';
    bouton.addEventListener(
      'click',
      ((web.Event event) {
        final marie = radioOui.checked ? 'oui' : 'non';
        final enfants = int.tryParse(champEnfants.$2.value) ?? 0;
        final salaire = int.tryParse(champSalaire.$2.value) ?? 0;
        callbacks.onValider(marie, enfants, salaire);
      }).toJS,
    );
    _contenu.appendChild(bouton);

    // résultat de la dernière simulation (encadré vert), s'il y en a un
    if (resultat != null) {
      final box = _div()..className = 'resultat';
      final tauxPourcent = (resultat.taux * 100).round();
      box.innerHTML = ("Montant de l'impôt : ${resultat.impot} euros<br>"
              "Décôte : ${resultat.decote} euros<br>"
              "Réduction : ${resultat.reduction} euros<br>"
              "Surcôte : ${resultat.surcote} euros<br>"
              "Taux d'imposition : $tauxPourcent %")
          .toJS;
      _contenu.appendChild(box);
    }
  }

  // --------------------------------------------------------------------
  // vue [liste des simulations]
  // --------------------------------------------------------------------
  void vueListe(List<Simulation> simulations, String? erreur, CallbacksListe callbacks) {
    _afficherMenu([
      _lienMenu("Calcul de l'impôt", callbacks.onCalcul),
      _lienMenu('Fin de session', callbacks.onFinSession),
    ]);

    _contenu.innerHTML = ''.toJS;

    final titre = _div()
      ..className = 'titre-section'
      ..textContent = 'Liste de vos simulations';
    _contenu.appendChild(titre);

    final banniere = _banniereErreur(erreur);
    if (banniere != null) _contenu.appendChild(banniere);

    if (simulations.isEmpty) {
      final vide = _div()
        ..className = 'simulations-vide'
        ..textContent = 'Aucune simulation enregistrée.';
      _contenu.appendChild(vide);
      return;
    }

    final table = web.document.createElement('table') as web.HTMLTableElement;
    table.className = 'simulations';
    final thead = web.document.createElement('thead') as web.HTMLTableSectionElement;
    thead.innerHTML = ("<tr><th>#</th><th>Marié</th><th>Nombre d'enfants</th>"
            "<th>Salaire annuel</th><th>Montant impôt</th><th>Surcôte</th>"
            "<th>Décôte</th><th>Réduction</th><th>Taux</th><th></th></tr>")
        .toJS;
    table.appendChild(thead);

    final tbody = web.document.createElement('tbody') as web.HTMLTableSectionElement;
    for (var numero = 0; numero < simulations.length; numero++) {
      final simulation = simulations[numero];
      final tr = web.document.createElement('tr') as web.HTMLTableRowElement;
      final tauxPourcent = (simulation.taux * 100).round();
      tr.innerHTML = ("<td>$numero</td>"
              "<td>${simulation.marie}</td>"
              "<td>${simulation.enfants}</td>"
              "<td>${simulation.salaire}</td>"
              "<td>${simulation.impot}</td>"
              "<td>${simulation.surcote}</td>"
              "<td>${simulation.decote}</td>"
              "<td>${simulation.reduction}</td>"
              "<td>$tauxPourcent %</td>")
          .toJS;
      final tdSupprimer = web.document.createElement('td') as web.HTMLTableCellElement;
      final lienSupprimer = web.document.createElement('a') as web.HTMLAnchorElement;
      lienSupprimer.className = 'supprimer';
      lienSupprimer.textContent = 'Supprimer';
      lienSupprimer.addEventListener(
        'click',
        ((web.Event event) => callbacks.onSupprimer(numero)).toJS,
      );
      tdSupprimer.appendChild(lienSupprimer);
      tr.appendChild(tdSupprimer);
      tbody.appendChild(tr);
    }
    table.appendChild(tbody);
    _contenu.appendChild(table);
  }
}

Commentons ce code :

  • ligne 19 : [import 'package:web/web.dart' as web;] : le remplaçant moderne de l'ancien [dart:html], avec une API DOM très proche du DOM standard utilisé par Vues.ts ([document.createElement], [.textContent], [.addEventListener]...).
  • lignes 44-45 : [typedef CallbacksCalcul = ({void Function(...) onValider, ...});] : une interface de callbacks TypeScript se traduit naturellement en un RECORD DE FONCTIONS Dart 3 - plus léger qu'une classe pour un simple groupe de fonctions.
  • ligne 108 : [a.addEventListener('click', ((web.Event event) { ... }).toJS);] : un gestionnaire d'événement Dart (une simple fonction) doit être converti en fonction JavaScript avec [.toJS] (extension de [dart:js_interop]) avant d'être passé à [addEventListener] - le «pont» entre le monde Dart et le monde JavaScript, rendu explicite, là où TypeScript n'a besoin d'aucune conversion.
  • ligne 141 : [input.value = valeurInitiale;], [titre.textContent = '...';] (ligne 76) : la plupart des propriétés DOM de type chaîne restent de simples [String] Dart, sans conversion nécessaire. [innerHTML] fait EXCEPTION : [dart analyze] le signale si on lui affecte directement un [String] - son setter attend un [JSAny] (une particularité de sa génération, liée à l'attribut WebIDL [LegacyNullToEmptyString] du DOM standard) - d'où [racine.innerHTML = ''.toJS;] : une conversion [.toJS] supplémentaire, au-delà des seules fonctions, qu'il faut garder en tête chaque fois qu'on manipule [innerHTML].
  • ligne 129 : [(web.HTMLDivElement ligne, web.HTMLInputElement input) _ligneChamp(...)] : un RECORD positionnel comme type de retour, pour rendre deux éléments DOM à la fois sans classe intermédiaire - encore un usage concret des records Dart 3.

6.5.7. Le contrôleur : le fichier [AppHtml.ts]

6.5.8. script [app_html]

// app_html.dart
// Porte AppHtml.ts : le contrôleur de l'application HTML. Il orchestre les
// appels au serveur (couche [dao], dao3.dart), les calculs (couche
// [métier], metier.dart) et l'affichage des vues (couche [vues],
// vues.dart).
//
// Comme main3.dart, ce client ne sollicite le serveur NestJS que pour trois
// actions : [init-session], [authentifier-utilisateur] et [get-admindata].
// Une fois les données fiscales (taux, plafonds...) récupérées avec
// [get-admindata], c'est la couche [métier] qui calcule l'impôt dans le
// navigateur, sans nouvel appel réseau. La liste des simulations est elle
// aussi conservée uniquement dans le navigateur (champ [simulations]),
// exactement comme le fait main3.dart avec sa variable locale
// [simulations] (`simulations.add(...)`, `simulations.removeAt(...)`).
import 'dart:js_interop';
import 'package:http/browser_client.dart';
import 'dao3.dart';
import 'metier.dart';
import 'simulation.dart';
import 'vues.dart';
import 'package:web/web.dart' as web;

class AppHtml {
  final Dao3 dao;
  final Vues vues;
  // couche [métier] : n'existe qu'une fois les données fiscales récupérées
  // (après une authentification réussie) - cf [initialiserMetier]. Un
  // champ NULLABLE (Metier?), pas un champ tardif ([late]) : contrairement
  // à Vues.ts où [métier?: Métier] reste simplement "undefined" tant qu'il
  // n'est pas affecté, un champ [late] Dart lèverait une erreur si on le
  // LISAIT avant affectation (ce que fait justement [finSession] en le
  // remettant à néant) - le type nullable est donc ici le bon choix, pas
  // seulement une question de style.
  Metier? _metier;
  // liste des simulations déjà calculées, conservée dans le navigateur
  List<Simulation> _simulations = [];

  // [différence avec TypeScript] AppHtml.ts configure une instance GLOBALE
  // d'axios (axios.defaults.timeout/baseURL/withCredentials) puis la confie
  // au Dao. Ici, [BrowserClient] (package:http/browser_client.dart, déjà
  // rencontré dans main3.dart) porte l'équivalent de
  // "axios.defaults.withCredentials = true" ; il n'y a pas d'équivalent
  // direct à un "timeout global" sur http.Client - on l'appliquerait au cas
  // par cas avec `.timeout(Duration(...))` sur chaque appel, comme dans le
  // chapitre [http] (dart_fetch_01.dart).
  AppHtml()
      : dao = Dao3(
          BrowserClient()..withCredentials = true,
          'http://localhost:3000',
        ),
        vues = Vues(web.document.getElementById('app') as web.HTMLElement);

  // point d'entrée de l'application
  Future<void> demarrer() async {
    try {
      // init session - obligatoire avant toute autre action (cf main.php)
      final reponse = await dao.initSession() as Map<String, dynamic>;
      if (reponse['état'] != 700) {
        vues.vueAuthentification(
          _formaterErreur(reponse['réponse']),
          (onValider: (user, password) => _authentifier(user, password)),
        );
        return;
      }
    } catch (erreur) {
      vues.vueAuthentification(
        "Impossible de contacter le serveur ($erreur)",
        (onValider: (user, password) => _authentifier(user, password)),
      );
      return;
    }
    // session initialisée : on affiche l'authentification
    _afficherAuthentification();
  }

  // --------------------------------------------------------------------
  // vue [authentification]
  // --------------------------------------------------------------------
  void _afficherAuthentification([String? erreur]) {
    vues.vueAuthentification(
      erreur,
      (onValider: (user, password) => _authentifier(user, password)),
    );
  }

  Future<void> _authentifier(String user, String password) async {
    try {
      final reponse = await dao.authentifierUtilisateur(user, password)
          as Map<String, dynamic>;
      if (reponse['état'] == 200) {
        // authentification réussie : comme dans main3.dart, on récupère
        // maintenant, une bonne fois pour toutes, les données fiscales
        // nécessaires à la couche [métier]
        await _initialiserMetier();
      } else {
        // échec (mauvais login/mot de passe, ou paramètres manquants)
        _afficherAuthentification(_formaterErreur(reponse['réponse']));
      }
    } catch (erreur) {
      _afficherAuthentification("Impossible de contacter le serveur ($erreur)");
    }
  }

  // récupère les données de l'administration fiscale et instancie la
  // couche [métier] avec ces données - un seul appel serveur pour toute la
  // session, exactement comme main3.dart
  Future<void> _initialiserMetier() async {
    try {
      final reponse = await dao.getAdminData() as Map<String, dynamic>;
      if (reponse['état'] == 1000) {
        _metier = Metier(reponse['réponse'] as Map<String, dynamic>);
        // nouvelle session de travail : on repart d'une liste vide
        _simulations = [];
        _afficherCalcul();
      } else {
        _afficherAuthentification(_formaterErreur(reponse['réponse']));
      }
    } catch (erreur) {
      _afficherAuthentification("Impossible de contacter le serveur ($erreur)");
    }
  }

  // --------------------------------------------------------------------
  // vue [calcul de l'impôt]
  // --------------------------------------------------------------------
  void _afficherCalcul([Simulation? resultat, String? erreur]) {
    vues.vueCalcul(
      resultat,
      erreur,
      (
        onValider: (marie, enfants, salaire) =>
            _calculer(marie, enfants, salaire),
        onListe: () => _afficherListe(),
        onFinSession: () => _finSession(),
      ),
    );
  }

  // calcul de l'impôt fait entièrement dans le navigateur (couche
  // [métier]) : aucun appel au serveur, contrairement à la première version
  // de ce client (client http 1 / client http 2)
  void _calculer(String marie, int enfants, int salaire) {
    // _metier a forcément été initialisé : on n'arrive sur la vue [calcul]
    // qu'après _initialiserMetier (le [!] est donc sûr ici, comme le [!]
    // de TypeScript sur [this.métier!])
    final resultat = _metier!.calculerImpot(marie, enfants, salaire);
    // [différence avec TypeScript] Vues.ts construit la simulation avec un
    // simple spread d'objet : `{ marié, enfants, salaire, ...résultat }`.
    // Ici, [Simulation] est un RECORD (simulation.dart), pas une Map : un
    // record ne peut pas être "complété" par spread à partir d'une Map, et
    // ses champs sont nommés SANS accent (impot, surcote...) alors que
    // [resultat], renvoyé par metier.dart, est une Map dont les clés
    // GARDENT les accents ('impôt', 'surcôte'..., cf metier.dart). On
    // reconstruit donc le record champ par champ - un peu plus verbeux
    // qu'un spread, mais entièrement vérifié par le compilateur (un champ
    // oublié ou mal typé est une erreur de compilation, pas une surprise à
    // l'exécution).
    final Simulation simulation = (
      marie: marie,
      enfants: enfants,
      salaire: salaire,
      impot: resultat['impôt'] as num,
      surcote: resultat['surcôte'] as num,
      decote: resultat['décôte'] as num,
      reduction: resultat['réduction'] as num,
      taux: resultat['taux'] as num,
    );
    // mémorisation dans le navigateur (comme `simulations.add(...)` dans
    // main3.dart)
    _simulations.add(simulation);
    _afficherCalcul(simulation);
  }

  // --------------------------------------------------------------------
  // vue [liste des simulations]
  // --------------------------------------------------------------------
  // plus aucun appel serveur ici : la liste est déjà entièrement dans
  // [_simulations]
  void _afficherListe([String? erreur]) {
    vues.vueListe(
      _simulations,
      erreur,
      (
        onSupprimer: (numero) => _supprimer(numero),
        onCalcul: () => _afficherCalcul(),
        onFinSession: () => _finSession(),
      ),
    );
  }

  void _supprimer(int numero) {
    // suppression locale de [_simulations], comme
    // `simulations.removeAt(numéro)` dans main3.dart - toujours aucun
    // appel serveur
    _simulations.removeAt(numero);
    _afficherListe();
  }

  // --------------------------------------------------------------------
  // fin de session
  // --------------------------------------------------------------------
  void _finSession() {
    // fin de session côté client uniquement : on oublie la couche [métier]
    // (donc les données fiscales) et les simulations déjà calculées, puis
    // on revient à l'écran d'authentification. Ce client n'a pas besoin de
    // solliciter le serveur pour cela.
    _metier = null;
    _simulations = [];
    _afficherAuthentification();
  }

  // --------------------------------------------------------------------
  // met en forme le champ [réponse] d'une réponse d'erreur du serveur, qui
  // est soit une chaîne, soit une liste de chaînes (cf contrôleurs PHP)
  // --------------------------------------------------------------------
  String _formaterErreur(Object? reponse) {
    if (reponse is List) {
      return reponse.join(' ; ');
    }
    return '$reponse';
  }
}

// exécution - [différence avec TypeScript] le gestionnaire d'événement
// passé à addEventListener doit être converti en fonction JavaScript avec
// [.toJS] (cf vues.dart) ; ce n'est là que la version "point d'entrée" de
// la même règle.
void main() {
  web.window.addEventListener(
    'DOMContentLoaded',
    ((web.Event event) {
      AppHtml().demarrer();
    }).toJS,
  );
}

Commentons ce code :

  • lignes 46-51 : [AppHtml() : dao = Dao3(BrowserClient()..withCredentials = true, ...), vues = Vues(...)] : la construction du DAO et des VUES dans une LISTE D'INITIALISATION de constructeur - même idée que le constructeur TypeScript, avec la syntaxe [: champ1 = ..., champ2 = ...] propre à Dart.
  • ligne 34 : [Metier? _metier;] : un champ NULLABLE, là où AppHtml.ts utilisait [métier?: Métier] - la même idée («pas encore initialisé»), avec le même opérateur [!] à l'usage pour affirmer qu'il est bien renseigné à un point donné du code.
  • lignes 158-162 : [final Simulation simulation = (marie: marie, ..., impot: resultat['impôt'] as num, ...);] : contrairement au simple spread [{ marié, enfants, salaire, ...résultat }] de Vues.ts, un RECORD ne peut pas être complété par spread à partir d'une [Map] - le record est donc reconstruit CHAMP PAR CHAMP, un peu plus verbeux mais entièrement vérifié par le compilateur.
  • lignes 229-233 : [web.window.addEventListener('DOMContentLoaded', ((web.Event event) { AppHtml().demarrer(); }).toJS);] : le même point d'entrée que AppHtml.ts, avec la même conversion [.toJS] du callback que dans vues.dart.

6.5.9. Compilation et exécution

Voir le README.md de ce dossier : [dart pub get] puis [webdev serve] pour le développement (rechargement à chaud inclus), ou [dart compile js web/main.dart -o web/main.dart.js] pour un build de production - l'équivalent Dart de [npm run build], sans aucune étape de configuration de bundler préalable.

Si le serveur [webserve] est encore actif pour l’application web [client http3], vous devez l’arrêter (Ctrl-C dans le terminal qui l’a lancé). Puis dans un terminal du dossier [client html] :

dart pub get
webdev serve

La ligne 2 lance le serveur web qui va servir l’application du client HTML :

Image

  • en [1], dans un navigateur demandez l’URL [http://localhost:8080] ;
  • en [2-3], tapez les identifiants admin / admin ;
  • en [4], validez la page ;

Image

  • en [2], remplissez les champs ;
  • en [3], validez la page ;

Image

Le montant de l’impôt est affiché en [1]. Faites plusieurs simulations puis demandez-en la liste [2] ;

Image

  • en [1], supprimez une simulation ;

Image

  • en [1], terminez la session ;

Image

6.6. Conclusion

Les quatre clients de ce chapitre illustrent, avec Dart, exactement la même progression qu'avec TypeScript : du calcul côté serveur au calcul côté client, puis du script CLI au client web complet. Les différences systématiques à retenir sont le suivi manuel du cookie de session (nécessaire en CLI, superflu dans un navigateur), la conversion [.toJS] imposée par [dart:js_interop] pour franchir la frontière Dart/JavaScript dans les vues, et l'absence totale de polyfills à la compilation web - un avantage net de l'outillage Dart sur la chaîne webpack/babel.