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:
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
- Servicios de correo → Añadir servicio: conecta tu buzón o proveedor, activa Marcar como predeterminado y pulsa Verificar y guardar.
- Plantillas → Desde la galería → Solicitud de presupuesto (categoría Negocios) → Usar plantilla.
- 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_nameyresponse_timey 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
Originde sitio web se rechazan conorigin_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,nameycompanyya 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.


