Aller au contenu

Comment créer un formulaire de demande de devis dans Flutter

Un formulaire de demande de devis Flutter avec choix de la prestation, budget et échéance, qui vous envoie chaque demande par e-mail et envoie au client une confirmation automatique – sans backend.

Mis à jour:

La vidéo est en anglais ; le texte ci-dessous est le tutoriel complet.

Ce tutoriel crée un écran de demande de devis dans Flutter : le client choisit une prestation, un budget et une échéance, décrit son projet et touche Send. Vous recevez un e-mail proprement mis en forme, le client reçoit une confirmation automatique.

Il vous faut : un compte Frontmail gratuit, un projet Flutter 3.24+ et une vingtaine de minutes.

1. Connectez votre boîte mail et créez les modèles

  1. Services d’envoi → Ajouter un service : connectez votre boîte mail ou votre fournisseur, cochez Définir par défaut, puis Vérifier et enregistrer.
  2. Modèles → Depuis la galerie → Demande de devis (catégorie Entreprise) → Utiliser ce modèle.
  3. Ajoutez la confirmation : Depuis la galerie → Réponse automatique : merci pour votre message → Utiliser ce modèle, définissez les valeurs par défaut de company_name et response_time dans ses Paramètres, puis sélectionnez-le dans le modèle de devis sous Paramètres → Envoi → Modèle de réponse automatique.

Le modèle de devis attend name, email, service et details (obligatoires) ainsi que phone, company, budget et deadline (facultatifs). service et budget n’acceptent que les valeurs listées dans Paramètres. Le code ci-dessous utilise les valeurs du modèle anglais ; si vous créez le modèle en français, ce sont Site web, Boutique en ligne, Application mobile, Conseil, Autre et Moins de 5 000 €, 5 000 – 20 000 €, 20 000 – 50 000 €, Plus de 50 000 €. Adaptez les listes à votre offre et reprenez exactement les mêmes chaînes dans l’application.

2. Autorisez les applications mobiles et activez le CAPTCHA

  • Sécurité → Applications mobiles → Autoriser les applications mobiles → Enregistrer. Sans cela, les requêtes sans Origin de site web sont refusées avec origin_not_allowed.
  • Modèle de devis → Paramètres → Protection → Exiger un CAPTCHA (Turnstile). Les applications utilisent le widget partagé de Frontmail – aucun compte Cloudflare n’est nécessaire.

3. Installez

flutter pub add frontmail webview_flutter url_launcher

Copiez TurnstileView depuis la section Turnstile de la documentation du SDK Flutter dans lib/turnstile_view.dart.

4. Créez le formulaire

import 'package:flutter/material.dart';
import 'package:frontmail/frontmail.dart';

import 'turnstile_view.dart';

final frontmail = Frontmail(publicKey: 'pk_…');

const services = ['Website', 'E-shop', 'Mobile app', 'Consulting', 'Other'];
const budgets = ['Under \$5k', '\$5k–20k', '\$20k–50k', 'Over \$50k'];

class QuoteScreen extends StatefulWidget {
  const QuoteScreen({super.key});

  @override
  State<QuoteScreen> createState() => _QuoteScreenState();
}

class _QuoteScreenState extends State<QuoteScreen> {
  final _name = TextEditingController();
  final _email = TextEditingController();
  final _details = TextEditingController();
  String _service = services.first;
  String? _budget;
  DateTime? _deadline;
  TurnstileSite? _site;
  String? _token;
  int _resetId = 0;
  String _info = '';

  @override
  void initState() {
    super.initState();
    Turnstile.site(client: frontmail).then((site) => setState(() => _site = site));
  }

  Future<void> _pickDeadline() async {
    final now = DateTime.now();
    final picked = await showDatePicker(context: context, firstDate: now, lastDate: DateTime(now.year + 2));
    if (picked != null) setState(() => _deadline = picked);
  }

  Future<void> _send() async {
    final site = _site, token = _token;
    if (site == null || token == null) return;
    setState(() {
      _token = null; // a token works only once
      _resetId++;
    });
    try {
      final deadline = _deadline;
      await frontmail.send(
        templateId: 'tpl_…',
        params: {
          'name': _name.text,
          'email': _email.text,
          'service': _service,
          'budget': _budget,
          'deadline': deadline == null ? null : deadline.toIso8601String().substring(0, 10),
          'details': _details.text,
        },
        options: SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey),
      );
      setState(() => _info = 'Thanks! We’ve emailed you a confirmation.');
    } on FrontmailException catch (e) {
      setState(() => _info = e.message);
    }
  }

  @override
  Widget build(BuildContext context) {
    final site = _site;
    return Scaffold(
      appBar: AppBar(title: const Text('Request a quote')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
          TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
          DropdownButtonFormField<String>(
            value: _service,
            decoration: const InputDecoration(labelText: 'What do you need?'),
            items: [for (final s in services) DropdownMenuItem(value: s, child: Text(s))],
            onChanged: (v) => setState(() => _service = v ?? services.first),
          ),
          DropdownButtonFormField<String>(
            value: _budget,
            decoration: const InputDecoration(labelText: 'Budget (optional)'),
            items: [for (final b in budgets) DropdownMenuItem(value: b, child: Text(b))],
            onChanged: (v) => setState(() => _budget = v),
          ),
          ListTile(
            contentPadding: EdgeInsets.zero,
            title: Text(_deadline == null ? 'Deadline (optional)' : 'Deadline: ${MaterialLocalizations.of(context).formatMediumDate(_deadline!)}'),
            trailing: const Icon(Icons.calendar_today),
            onTap: _pickDeadline,
          ),
          TextField(controller: _details, decoration: const InputDecoration(labelText: 'Project details'), maxLines: 5),
          const SizedBox(height: 16),
          if (site != null)
            TurnstileView(
              site: site,
              resetId: _resetId,
              onMessage: (m) => setState(() => _token = m is TurnstileToken ? m.token : null),
            ),
          FilledButton(onPressed: _token == null ? null : _send, child: const Text('Request a quote')),
          Text(_info),
        ],
      ),
    );
  }
}

Les valeurs null (pas de budget, pas d’échéance) sont traitées comme vides : ces lignes n’apparaissent tout simplement pas dans l’e-mail. L’échéance est envoyée au format YYYY-MM-DD ; l’e-mail l’affiche comme une date mise en forme.

5. Testez

Envoyez une demande avec votre propre adresse comme e-mail du client. Vous recevez la demande puis, juste après, la confirmation. Les deux figurent dans l’Historique.

Chaque e-mail consomme un crédit – une demande et sa confirmation en coûtent deux. Plus de crédits pendant une semaine chargée ? Les demandes patientent dans la file d’attente des e-mails retenus et partent dès que vous rechargez ; le statut du résultat est held.

Étapes suivantes

  • Activez la Collecte des contacts dans les paramètres du modèle pour garder la liste de toutes les personnes qui ont demandé un devis (email, name et company sont déjà associés).
  • Envoyez SendOptions(locale: Localizations.localeOf(context).toLanguageTag()) pour utiliser la variante linguistique du modèle correspondant à la langue de l’utilisateur.
Tous les tutoriels