Przejdź do treści

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:

Film jest po angielsku; poniżej znajdziesz pełny samouczek krok po kroku.

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

  1. Usługi e-mail → Dodaj usługę: podłącz swoją skrzynkę lub dostawcę, zaznacz Ustaw jako domyślną, Zweryfikuj i zapisz.
  2. Szablony → Z galerii → Zapytanie ofertowe (kategoria Biznes) → Użyj szablonu.
  3. Dodaj potwierdzenie: Z galerii → Automatyczna odpowiedź: dziękujemy za wiadomość → Użyj szablonu, w jego zakładce Parametry ustaw wartości domyślne company_name i response_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 Origin strony internetowej są odrzucane z kodem origin_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, name i company są już zmapowane).
  • Wysyłaj SendOptions(locale: Localizations.localeOf(context).toLanguageTag()), żeby użyć wersji językowej szablonu w języku użytkownika.
Wszystkie samouczki