Saltar al contenido

Cómo crear un formulario de solicitud de presupuesto en Flutter

Un formulario de presupuesto en Flutter con selector de servicio, presupuesto y fecha límite que te envía cada solicitud por correo y manda al cliente una confirmación automática – sin backend.

Actualizado:

El vídeo está en inglés; los pasos de abajo son el tutorial completo.

En este tutorial crearás una pantalla de solicitud de presupuesto en Flutter: el cliente elige un servicio, un presupuesto y una fecha límite, describe su proyecto y pulsa Request a quote. Tú recibes un correo bien formateado y el cliente, una confirmación automática.

Necesitas: una cuenta gratuita de Frontmail, un proyecto de Flutter 3.24+ y unos veinte minutos.

1. Conecta tu buzón y crea las plantillas

  1. Servicios de correo → Añadir servicio: conecta tu buzón o proveedor, activa Marcar como predeterminado y pulsa Verificar y guardar.
  2. Plantillas → Desde la galería → Solicitud de presupuesto (categoría Negocios) → Usar plantilla.
  3. Añade la confirmación: Desde la galería → Respuesta automática: gracias por tu mensaje → Usar plantilla, define en su pestaña Parámetros valores por defecto para company_name y response_time y selecciónala en la plantilla de presupuesto en Ajustes → Entrega → Plantilla de respuesta automática.

La plantilla de presupuesto espera name, email, service y details (obligatorios) y phone, company, budget, deadline (opcionales). service y budget solo aceptan los valores de la pestaña Parámetros. El código de ejemplo usa los de la plantilla en inglés; si creas la plantilla en español, son Sitio web, Tienda online, App móvil, Consultoría, Otro y Menos de 5000 €, 5000–20 000 €, 20 000–50 000 €, Más de 50 000 €. Adapta las listas a lo que vendes y usa en la app exactamente los mismos textos.

2. Permite las apps móviles y activa la CAPTCHA

  • Seguridad → Apps móviles → Permitir apps móviles → Guardar. Sin esto, las solicitudes sin un Origin de sitio web se rechazan con origin_not_allowed.
  • Plantilla de presupuesto → Ajustes → Protección → Exigir CAPTCHA (Turnstile). Las apps usan el widget compartido de Frontmail: no necesitas cuenta de Cloudflare.

3. Instala

flutter pub add frontmail webview_flutter url_launcher

Copia TurnstileView de la sección Turnstile de la documentación del SDK de Flutter en lib/turnstile_view.dart.

4. Crea el formulario

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),
        ],
      ),
    );
  }
}

Los valores null (sin presupuesto, sin fecha límite) se tratan como vacíos, así que esas filas simplemente no aparecen en el correo. La fecha límite se envía como YYYY-MM-DD; el correo la muestra como una fecha formateada.

5. Pruébalo

Envía una solicitud con tu propia dirección como correo del cliente. Recibirás la solicitud y, justo después, la confirmación. Ambas aparecen en Historial.

Cada correo consume un crédito: una solicitud con su confirmación cuesta dos. ¿Te quedas sin créditos en una semana de mucho trabajo? Las solicitudes esperan en la cola de correos retenidos y salen en cuanto recargas; el estado del resultado es held.

Próximos pasos

  • Activa Recogida de contactos en los ajustes de la plantilla para tener una lista de todas las personas que han pedido presupuesto (email, name y company ya están asignados).
  • Envía SendOptions(locale: Localizations.localeOf(context).toLanguageTag()) para usar la variante de idioma de la plantilla que corresponde al idioma del usuario.
Todos los tutoriales