Jak zrobić formularz zapytania ofertowego we Flutterze
Formularz zapytania ofertowego we Flutterze z wyborem usługi, budżetem i terminem, który wysyła Ci każde zapytanie e-mailem, a klientowi automatyczne potwierdzenie – bez backendu.
Zaktualizowano:
W tym samouczku zbudujesz we Flutterze ekran zapytania ofertowego: klient wybiera usługę, budżet i termin, opisuje projekt i stuka Send. Ty dostajesz starannie sformatowany e-mail, a klient automatyczne potwierdzenie.
Potrzebujesz: darmowego konta Frontmail, projektu we Flutterze 3.24+ i około dwudziestu minut.
1. Podłącz skrzynkę i utwórz szablony
- Usługi e-mail → Dodaj usługę: podłącz swoją skrzynkę lub dostawcę, zaznacz Ustaw jako domyślną, Zweryfikuj i zapisz.
- Szablony → Z galerii → Zapytanie ofertowe (kategoria Biznes) → Użyj szablonu.
- Dodaj potwierdzenie: Z galerii → Automatyczna odpowiedź: dziękujemy za wiadomość →
Użyj szablonu, w jego zakładce Parametry ustaw wartości domyślne
company_nameiresponse_time, a potem wybierz go w szablonie zapytania w Ustawienia → Doręczanie → Szablon automatycznej odpowiedzi.
Szablon zapytania oczekuje parametrów name, email, service i details (wymagane) oraz
phone, company, budget, deadline (opcjonalne). service i budget przyjmują tylko
wartości podane w zakładce Parametry. Przykładowy kod używa wartości z angielskiej wersji
szablonu; szablon utworzony po polsku ma Strona internetowa, Sklep internetowy, Aplikacja
mobilna, Konsultacje, Inne oraz Do 20 tys. zł, 20–100 tys. zł, 100–200 tys. zł, Powyżej
200 tys. zł. Dopasuj listy do swojej oferty – aplikacja musi wysyłać dokładnie te same wartości.
2. Zezwól na aplikacje mobilne i włącz CAPTCHA
- Bezpieczeństwo → Aplikacje mobilne → Zezwól na aplikacje mobilne → Zapisz. Bez
tego żądania bez nagłówka
Originstrony internetowej są odrzucane z kodemorigin_not_allowed. - Szablon zapytania → Ustawienia → Ochrona → Wymagaj CAPTCHA (Turnstile). Aplikacje korzystają ze wspólnego widżetu Frontmail – konto Cloudflare nie jest potrzebne.
3. Instalacja
flutter pub add frontmail webview_flutter url_launcher
Skopiuj TurnstileView z sekcji o Turnstile w dokumentacji Flutter SDK do pliku
lib/turnstile_view.dart.
4. Zbuduj formularz
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),
],
),
);
}
}
Wartości null (brak budżetu, brak terminu) są traktowane jak puste, więc te wiersze po prostu nie
pojawią się w e-mailu. Termin jest wysyłany jako YYYY-MM-DD; w e-mailu zobaczysz go jako
sformatowaną datę.
5. Przetestuj
Wyślij zapytanie, podając jako e-mail klienta swój adres. Dostaniesz zapytanie, a zaraz po nim potwierdzenie. Oba e-maile znajdziesz w Historii.
Każdy e-mail zużywa jeden kredyt – zapytanie z potwierdzeniem kosztuje dwa. Skończyły się kredyty w gorącym tygodniu? Zapytania czekają w kolejce wstrzymanych wiadomości i wyjdą po doładowaniu; status wyniku to
held.
Co dalej
- Włącz Zbieranie kontaktów w ustawieniach szablonu, żeby mieć listę wszystkich, którzy
prosili o wycenę (
email,nameicompanysą już zmapowane). - Wysyłaj
SendOptions(locale: Localizations.localeOf(context).toLanguageTag()), żeby użyć wersji językowej szablonu w języku użytkownika.


