neat_form 1.2.4 copy "neat_form: ^1.2.4" to clipboard
neat_form: ^1.2.4 copied to clipboard

A clean, lightweight, type-safe form state management and validation library for Flutter & Dart.

example/lib/main.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:neat_form/neat_form.dart';
import 'screens/dynamic_checkout_showcase_screen.dart';

void main() {
  runApp(const NeatFormShowcaseApp());
}

class NeatFormShowcaseApp extends StatelessWidget {
  const NeatFormShowcaseApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'neat_form Showcase',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        splashFactory: InkRipple.splashFactory,
        colorSchemeSeed: const Color(0xFF4F46E5),
        brightness: Brightness.light,
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: Colors.grey.shade50,
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
            borderSide: BorderSide(color: Colors.grey.shade300),
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
            borderSide: BorderSide(color: Colors.grey.shade300),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
            borderSide: const BorderSide(color: Color(0xFF4F46E5), width: 2),
          ),
        ),
      ),
      home: const FormShowcaseHomePage(),
    );
  }
}

class FormShowcaseHomePage extends StatelessWidget {
  const FormShowcaseHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 4,
      child: Scaffold(
        appBar: AppBar(
          title: const Row(
            children: [
              Icon(Icons.assignment_turned_in_rounded, color: Colors.white),
              SizedBox(width: 8),
              Text(
                'neat_form Showcase',
                style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
              ),
            ],
          ),
          actions: [
            TextButton.icon(
              style: TextButton.styleFrom(
                foregroundColor: Colors.white,
                backgroundColor: Colors.white.withOpacity(0.2),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
              ),
              icon: const Icon(Icons.rocket_launch_rounded, size: 16),
              label: const Text('Full Demo', style: TextStyle(fontWeight: FontWeight.bold)),
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => const DynamicCheckoutShowcaseScreen(),
                  ),
                );
              },
            ),
            const SizedBox(width: 8),
          ],
          flexibleSpace: Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                colors: [Color(0xFF4F46E5), Color(0xFF7C3AED)],
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
              ),
            ),
          ),
          bottom: const TabBar(
            isScrollable: true,
            tabAlignment: TabAlignment.start,
            indicatorColor: Colors.white,
            indicatorWeight: 3,
            labelColor: Colors.white,
            unselectedLabelColor: Colors.white70,
            labelStyle: TextStyle(fontWeight: FontWeight.bold),
            tabs: [
              Tab(icon: Icon(Icons.person_add_rounded), text: 'Tài khoản & Auth'),
              Tab(icon: Icon(Icons.credit_card_rounded), text: 'Fintech & Thẻ'),
              Tab(icon: Icon(Icons.hotel_rounded), text: 'Booking & E-Com'),
              Tab(icon: Icon(Icons.flight_takeoff_rounded), text: 'Dynamic Array'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            AuthRegistrationTab(),
            FintechPaymentTab(),
            BookingCrossFieldTab(),
            DynamicPassengerArrayTab(),
          ],
        ),
      ),
    );
  }
}

// ==========================================
// TAB 1: AUTH & REGISTRATION FORM
// ==========================================

enum AuthFormKey {
  fullName,
  email,
  username,
  password,
  confirmPassword,
  termsAccepted,
}

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

  @override
  State<AuthRegistrationTab> createState() => _AuthRegistrationTabState();
}

class _AuthRegistrationTabState extends State<AuthRegistrationTab> {
  late final NeatFormController<AuthFormKey> _form;
  Timer? _usernameDebounce;

  @override
  void initState() {
    super.initState();
    _form = NeatFormController<AuthFormKey>(
      initialFields: {
        AuthFormKey.fullName: const NeatFieldState<String>(value: ''),
        AuthFormKey.email: const NeatFieldState<String>(value: ''),
        AuthFormKey.username: const NeatFieldState<String>(value: ''),
        AuthFormKey.password: const NeatFieldState<String>(value: ''),
        AuthFormKey.confirmPassword: const NeatFieldState<String>(value: ''),
        AuthFormKey.termsAccepted: const NeatFieldState<bool>(value: false),
      },
      validators: {
        AuthFormKey.fullName: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập họ và tên'),
          NeatValidators.latinOnly(message: 'Họ tên chỉ được chứa chữ cái không dấu'),
          NeatValidators.noSpecialChars(message: 'Không được chứa ký tự đặc biệt'),
        ]),
        AuthFormKey.email: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập email'),
          NeatValidators.email(message: 'Email không đúng định dạng'),
        ]),
        AuthFormKey.username: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập tên đăng nhập'),
          NeatValidators.minLength(4, message: 'Tên đăng nhập tối thiểu 4 ký tự'),
          NeatValidators.noSpaces(message: 'Tên đăng nhập không được chứa khoảng trắng'),
        ]),
        AuthFormKey.password: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập mật khẩu'),
          NeatValidators.minLength(8, message: 'Mật khẩu tối thiểu 8 ký tự'),
          NeatValidators.passwordStrength(
            message: 'Mật khẩu cần ít nhất 1 chữ hoa, 1 chữ thường, 1 số và 1 ký tự đặc biệt',
          ),
        ]),
        AuthFormKey.confirmPassword: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng xác nhận mật khẩu'),
          NeatValidators.match(
            () => _form.getField<String>(AuthFormKey.password).value,
            message: 'Mật khẩu xác nhận không khớp',
          ),
        ]),
        AuthFormKey.termsAccepted: NeatValidators.mustBeTrue(
          message: 'Bạn phải đồng ý với Điều khoản dịch vụ',
        ),
      },
    );
  }

  @override
  void dispose() {
    _usernameDebounce?.cancel();
    _form.dispose();
    super.dispose();
  }

  void _onUsernameChanged(String val) {
    _form.setField(AuthFormKey.username, val);
    _usernameDebounce?.cancel();
    if (val.length >= 4) {
      _usernameDebounce = Timer(const Duration(milliseconds: 400), () {
        _form.validateFieldAsync<String>(
          AuthFormKey.username,
          (username) async {
            await Future<void>.delayed(const Duration(milliseconds: 600));
            if (username?.toLowerCase() == 'admin' || username?.toLowerCase() == 'root') {
              return const NeatValidationError(
                'username_taken',
                message: 'Tên đăng nhập này đã có người sử dụng!',
              );
            }
            return null;
          },
        );
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _form,
      builder: (context, _) {
        final name = _form.getField<String>(AuthFormKey.fullName);
        final email = _form.getField<String>(AuthFormKey.email);
        final username = _form.getField<String>(AuthFormKey.username);
        final password = _form.getField<String>(AuthFormKey.password);
        final confirm = _form.getField<String>(AuthFormKey.confirmPassword);
        final terms = _form.getField<bool>(AuthFormKey.termsAccepted);

        return ListView(
          padding: const EdgeInsets.all(20),
          children: [
            _buildSectionHeader('Đăng ký tài khoản & Bảo mật', Icons.lock_outline),
            const SizedBox(height: 16),
            TextField(
              decoration: InputDecoration(
                labelText: 'Họ và tên (In trên thẻ/Vé máy bay)',
                hintText: 'NGUYEN VAN A',
                prefixIcon: const Icon(Icons.badge_outlined),
                errorText: name.errorMessage,
              ),
              onChanged: (val) => _form.setField(AuthFormKey.fullName, val),
            ),
            const SizedBox(height: 16),
            TextField(
              decoration: InputDecoration(
                labelText: 'Email',
                prefixIcon: const Icon(Icons.email_outlined),
                errorText: email.errorMessage,
              ),
              keyboardType: TextInputType.emailAddress,
              onChanged: (val) => _form.setField(AuthFormKey.email, val),
            ),
            const SizedBox(height: 16),
            TextField(
              decoration: InputDecoration(
                labelText: 'Tên đăng nhập (Kiểm tra trùng async)',
                hintText: 'Thử nhập "admin"',
                prefixIcon: const Icon(Icons.alternate_email_rounded),
                suffixIcon: username.isValidating
                    ? const Padding(
                        padding: EdgeInsets.all(12),
                        child: SizedBox(
                          width: 20,
                          height: 20,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        ),
                      )
                    : null,
                errorText: username.errorMessage,
              ),
              onChanged: _onUsernameChanged,
            ),
            const SizedBox(height: 16),
            TextField(
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Mật khẩu bảo mật',
                prefixIcon: const Icon(Icons.key_outlined),
                errorText: password.errorMessage,
              ),
              onChanged: (val) => _form.setField(AuthFormKey.password, val),
            ),
            const SizedBox(height: 16),
            TextField(
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Xác nhận mật khẩu',
                prefixIcon: const Icon(Icons.lock_reset_rounded),
                errorText: confirm.errorMessage,
              ),
              onChanged: (val) => _form.setField(AuthFormKey.confirmPassword, val),
            ),
            const SizedBox(height: 12),
            CheckboxListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('Tôi đồng ý với Điều khoản dịch vụ & Chính sách bảo mật'),
              value: terms.value,
              subtitle: terms.isErrorVisible
                  ? Text(
                      terms.error?.message ?? '',
                      style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
                    )
                  : null,
              onChanged: (val) => _form.setField(AuthFormKey.termsAccepted, val ?? false),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF4F46E5),
                foregroundColor: Colors.white,
                padding: const EdgeInsets.symmetric(vertical: 16),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
              ),
              onPressed: _form.submissionStatus.isSubmitting
                  ? null
                  : () async {
                      final success = await _form.submitForm(
                        onSubmit: (values) async {
                          await Future<void>.delayed(const Duration(seconds: 1));
                          if (context.mounted) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('🎉 Đăng ký tài khoản thành công!'),
                                backgroundColor: Colors.green,
                              ),
                            );
                          }
                        },
                      );
                      if (!success && context.mounted) {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(
                            content: Text('⚠️ Vui lòng kiểm tra lại các trường báo lỗi'),
                            backgroundColor: Colors.redAccent,
                          ),
                        );
                      }
                    },
              child: _form.submissionStatus.isSubmitting
                  ? const CircularProgressIndicator(color: Colors.white)
                  : const Text('Đăng Ký Ngay', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            ),
          ],
        );
      },
    );
  }
}

// ==========================================
// TAB 2: FINTECH & CREDIT CARD PAYMENT FORM
// ==========================================

enum FintechKey {
  cardNumber,
  cardExpiry,
  cvv,
  amount,
}

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

  @override
  State<FintechPaymentTab> createState() => _FintechPaymentTabState();
}

class _FintechPaymentTabState extends State<FintechPaymentTab> {
  late final NeatFormController<FintechKey> _form;
  final _currencyFormatter = NeatInputFormatters.currency(
    thousandSeparator: '.',
    decimalSeparator: ',',
    suffix: ' ₫',
  );

  @override
  void initState() {
    super.initState();
    _form = NeatFormController<FintechKey>(
      initialFields: {
        FintechKey.cardNumber: const NeatFieldState<String>(value: ''),
        FintechKey.cardExpiry: const NeatFieldState<String>(value: ''),
        FintechKey.cvv: const NeatFieldState<String>(value: ''),
        FintechKey.amount: const NeatFieldState<num?>(value: null),
      },
      validators: {
        FintechKey.cardNumber: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập số thẻ'),
          NeatValidators.creditCard(message: 'Số thẻ không hợp lệ theo thuật toán Luhn'),
        ]),
        FintechKey.cardExpiry: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập ngày hết hạn (MM/YY)'),
          NeatValidators.pattern(
            RegExp(r'^(0[1-9]|1[0-2])\/?([0-9]{2})$'),
            message: 'Định dạng hạn thẻ phải là MM/YY (ví dụ: 12/28)',
          ),
        ]),
        FintechKey.cvv: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập mã CVV'),
          NeatValidators.exactLength(3, message: 'Mã CVV phải có đúng 3 chữ số'),
          NeatValidators.numeric(message: 'CVV chỉ gồm các chữ số'),
        ]),
        FintechKey.amount: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập số tiền thanh toán'),
          NeatValidators.positive(message: 'Số tiền giao dịch phải lớn hơn 0'),
          NeatValidators.maxValue(100000000, message: 'Hạn mức giao dịch tối đa 100.000.000đ'),
        ]),
      },
    );
  }

  @override
  void dispose() {
    _form.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _form,
      builder: (context, _) {
        final card = _form.getField<String>(FintechKey.cardNumber);
        final expiry = _form.getField<String>(FintechKey.cardExpiry);
        final cvv = _form.getField<String>(FintechKey.cvv);
        final amount = _form.getField<num?>(FintechKey.amount);

        return ListView(
          padding: const EdgeInsets.all(20),
          children: [
            _buildSectionHeader('Thanh toán thẻ & Fintech', Icons.payment_rounded),
            const SizedBox(height: 16),
            TextField(
              decoration: InputDecoration(
                labelText: 'Số thẻ tín dụng / Ghi nợ (Tự động format & Luhn)',
                hintText: '4532 0151 1283 0366',
                prefixIcon: const Icon(Icons.credit_card_outlined),
                errorText: card.errorMessage,
              ),
              keyboardType: TextInputType.number,
              inputFormatters: [NeatInputFormatters.creditCard()],
              onChanged: (val) => _form.setField(
                FintechKey.cardNumber,
                NeatCardFormatter.getCleanCardNumber(val),
              ),
            ),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: TextField(
                    decoration: InputDecoration(
                      labelText: 'Hạn thẻ (MM/YY)',
                      hintText: '12/28',
                      prefixIcon: const Icon(Icons.calendar_month_outlined),
                      errorText: expiry.errorMessage,
                    ),
                    keyboardType: TextInputType.number,
                    inputFormatters: [
                      NeatInputFormatters.date(format: NeatDateFormat.mmYy),
                    ],
                    onChanged: (val) => _form.setField(FintechKey.cardExpiry, val),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: TextField(
                    decoration: InputDecoration(
                      labelText: 'CVV / CVC',
                      hintText: '123',
                      prefixIcon: const Icon(Icons.security_rounded),
                      errorText: cvv.errorMessage,
                    ),
                    keyboardType: TextInputType.number,
                    inputFormatters: [
                      FilteringTextInputFormatter.digitsOnly,
                      LengthLimitingTextInputFormatter(3),
                    ],
                    onChanged: (val) => _form.setField(FintechKey.cvv, val),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            TextField(
              decoration: InputDecoration(
                labelText: 'Số tiền thanh toán (Tự động format tiền tệ)',
                hintText: '500.000 ₫',
                prefixIcon: const Icon(Icons.monetization_on_outlined),
                errorText: amount.errorMessage,
              ),
              keyboardType: TextInputType.number,
              inputFormatters: [_currencyFormatter],
              onChanged: (val) => _form.setField(
                FintechKey.amount,
                _currencyFormatter.getNumericValue(val),
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF059669),
                foregroundColor: Colors.white,
                padding: const EdgeInsets.symmetric(vertical: 16),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
              ),
              onPressed: _form.submissionStatus.isSubmitting
                  ? null
                  : () async {
                      final success = await _form.submitForm(
                        onSubmit: (values) async {
                          await Future<void>.delayed(const Duration(seconds: 1));
                          if (context.mounted) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('💳 Thanh toán thành công!'),
                                backgroundColor: Color(0xFF059669),
                              ),
                            );
                          }
                        },
                      );
                      if (!success && context.mounted) {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(
                            content: Text('⚠️ Thông tin thẻ hoặc số tiền không hợp lệ'),
                            backgroundColor: Colors.redAccent,
                          ),
                        );
                      }
                    },
              child: _form.submissionStatus.isSubmitting
                  ? const CircularProgressIndicator(color: Colors.white)
                  : const Text('Xác Nhận Thanh Toán', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            ),
          ],
        );
      },
    );
  }
}

// ==========================================
// TAB 3: BOOKING & CROSS-FIELD FORM
// ==========================================

enum BookingKey {
  checkInDate,
  checkOutDate,
  guestCount,
  promoCode,
  requestVAT,
  companyTaxId,
  tags,
}

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

  @override
  State<BookingCrossFieldTab> createState() => _BookingCrossFieldTabState();
}

class _BookingCrossFieldTabState extends State<BookingCrossFieldTab> {
  late final NeatFormController<BookingKey> _form;

  @override
  void initState() {
    super.initState();
    _form = NeatFormController<BookingKey>(
      initialFields: {
        BookingKey.checkInDate: const NeatFieldState<DateTime?>(value: null),
        BookingKey.checkOutDate: const NeatFieldState<DateTime?>(value: null),
        BookingKey.guestCount: const NeatFieldState<int>(value: 1),
        BookingKey.promoCode: const NeatFieldState<String>(value: '', isOptional: true),
        BookingKey.requestVAT: const NeatFieldState<bool>(value: false),
        BookingKey.companyTaxId: const NeatFieldState<String>(value: '', isOptional: true),
        BookingKey.tags: const NeatFieldState<List<String>>(value: []),
      },
      validators: {
        BookingKey.checkInDate: NeatValidators.required(message: 'Vui lòng chọn ngày nhận phòng'),
        BookingKey.checkOutDate: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng chọn ngày trả phòng'),
          (val) {
            final checkIn = _form.getField<DateTime?>(BookingKey.checkInDate).value;
            final checkOut = val as DateTime?;
            if (checkIn != null && checkOut != null) {
              if (!checkOut.isAfter(checkIn)) {
                return const NeatValidationError(
                  'checkout_before_checkin',
                  message: 'Ngày trả phòng phải sau ngày nhận phòng ít nhất 1 ngày',
                );
              }
            }
            return null;
          },
        ]),
        BookingKey.guestCount: NeatValidators.combine([
          NeatValidators.minValue(1, message: 'Tối thiểu 1 khách'),
          NeatValidators.maxValue(10, message: 'Tối đa 10 khách'),
        ]),
        BookingKey.companyTaxId: NeatValidators.when(
          () => _form.getField<bool>(BookingKey.requestVAT).value,
          NeatValidators.combine([
            NeatValidators.required(message: 'Mã số thuế bắt buộc khi yêu cầu xuất VAT'),
            NeatValidators.exactLength(10, message: 'Mã số thuế doanh nghiệp phải đúng 10 chữ số'),
            NeatValidators.numeric(message: 'Mã số thuế chỉ gồm các chữ số'),
          ]),
        ),
        BookingKey.tags: NeatValidators.combine([
          NeatValidators.minItems(1, message: 'Vui lòng chọn ít nhất 1 tiện ích yêu cầu'),
          NeatValidators.uniqueItems(message: 'Không được chọn trùng lặp tiện ích'),
        ]),
      },
    );
  }

  @override
  void dispose() {
    _form.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _form,
      builder: (context, _) {
        final checkIn = _form.getField<DateTime?>(BookingKey.checkInDate);
        final checkOut = _form.getField<DateTime?>(BookingKey.checkOutDate);
        final guests = _form.getField<int>(BookingKey.guestCount);
        final vat = _form.getField<bool>(BookingKey.requestVAT);
        final taxId = _form.getField<String>(BookingKey.companyTaxId);
        final tags = _form.getField<List<String>>(BookingKey.tags);

        return ListView(
          padding: const EdgeInsets.all(20),
          children: [
            _buildSectionHeader('Đặt phòng khách sạn & Cross-field', Icons.hotel_class_rounded),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: ListTile(
                    contentPadding: EdgeInsets.zero,
                    title: const Text('Ngày nhận phòng', style: TextStyle(fontWeight: FontWeight.bold)),
                    subtitle: Text(
                      checkIn.value != null ? '${checkIn.value!.day}/${checkIn.value!.month}/${checkIn.value!.year}' : 'Chọn ngày',
                    ),
                    trailing: const Icon(Icons.calendar_today_rounded),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now().add(const Duration(days: 1)),
                        firstDate: DateTime.now(),
                        lastDate: DateTime.now().add(const Duration(days: 365)),
                      );
                      if (picked != null) {
                        _form.setAndValidateField(BookingKey.checkInDate, picked);
                      }
                    },
                  ),
                ),
                Expanded(
                  child: ListTile(
                    contentPadding: EdgeInsets.zero,
                    title: const Text('Ngày trả phòng', style: TextStyle(fontWeight: FontWeight.bold)),
                    subtitle: Text(
                      checkOut.value != null ? '${checkOut.value!.day}/${checkOut.value!.month}/${checkOut.value!.year}' : 'Chọn ngày',
                    ),
                    trailing: const Icon(Icons.event_available_rounded),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now().add(const Duration(days: 2)),
                        firstDate: DateTime.now(),
                        lastDate: DateTime.now().add(const Duration(days: 365)),
                      );
                      if (picked != null) {
                        _form.setAndValidateField(BookingKey.checkOutDate, picked);
                      }
                    },
                  ),
                ),
              ],
            ),
            if (checkOut.isErrorVisible)
              Text(
                checkOut.error?.message ?? '',
                style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
              ),
            const SizedBox(height: 16),
            TextField(
              decoration: InputDecoration(
                labelText: 'Số lượng khách',
                prefixIcon: const Icon(Icons.group_outlined),
                errorText: guests.errorMessage,
              ),
              keyboardType: TextInputType.number,
              controller: TextEditingController(text: guests.value.toString()),
              onChanged: (val) => _form.setField(BookingKey.guestCount, int.tryParse(val) ?? 1),
            ),
            const SizedBox(height: 16),
            SwitchListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('Yêu cầu xuất hóa đơn công ty (VAT)'),
              value: vat.value,
              onChanged: (val) {
                _form.setField(BookingKey.requestVAT, val);
                _form.validateField(BookingKey.companyTaxId);
              },
            ),
            if (vat.value) ...[
              const SizedBox(height: 8),
              TextField(
                decoration: InputDecoration(
                  labelText: 'Mã số thuế doanh nghiệp (10 số)',
                  prefixIcon: const Icon(Icons.receipt_long_rounded),
                  errorText: taxId.errorMessage,
                ),
                keyboardType: TextInputType.number,
                onChanged: (val) => _form.setField(BookingKey.companyTaxId, val),
              ),
            ],
            const SizedBox(height: 16),
            const Text('Tiện ích yêu cầu thêm (Chọn ít nhất 1):', style: TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              children: ['Bữa sáng Buffet', 'Đưa đón sân bay', 'Phòng view biển', 'Late Check-out'].map((item) {
                final isSelected = tags.value.contains(item);
                return FilterChip(
                  label: Text(item),
                  selected: isSelected,
                  onSelected: (selected) {
                    final current = List<String>.from(tags.value);
                    if (selected) {
                      current.add(item);
                    } else {
                      current.remove(item);
                    }
                    _form.setAndValidateField(BookingKey.tags, current);
                  },
                );
              }).toList(),
            ),
            if (tags.isErrorVisible)
              Padding(
                padding: const EdgeInsets.only(top: 8),
                child: Text(
                  tags.error?.message ?? '',
                  style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
                ),
              ),
            const SizedBox(height: 24),
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF7C3AED),
                foregroundColor: Colors.white,
                padding: const EdgeInsets.symmetric(vertical: 16),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
              ),
              onPressed: _form.submissionStatus.isSubmitting
                  ? null
                  : () async {
                      final success = await _form.submitForm(
                        onSubmit: (values) async {
                          await Future<void>.delayed(const Duration(seconds: 1));
                          if (context.mounted) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(
                                content: Text('🏨 Đặt phòng thành công!'),
                                backgroundColor: Color(0xFF7C3AED),
                              ),
                            );
                          }
                        },
                      );
                      if (!success && context.mounted) {
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(
                            content: Text('⚠️ Vui lòng hoàn tất thông tin đặt phòng'),
                            backgroundColor: Colors.redAccent,
                          ),
                        );
                      }
                    },
              child: _form.submissionStatus.isSubmitting
                  ? const CircularProgressIndicator(color: Colors.white)
                  : const Text('Hoàn Tất Đặt Phòng', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            ),
          ],
        );
      },
    );
  }
}

Widget _buildSectionHeader(String title, IconData icon) {
  return Row(
    children: [
      Icon(icon, color: const Color(0xFF4F46E5), size: 24),
      const SizedBox(width: 8),
      Text(
        title,
        style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
      ),
    ],
  );
}

// ==========================================
// TAB 4: DYNAMIC PASSENGER ARRAY FORM
// ==========================================

enum PassengerField { fullName, passportNumber, seatType }

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

  @override
  State<DynamicPassengerArrayTab> createState() => _DynamicPassengerArrayTabState();
}

class _DynamicPassengerArrayTabState extends State<DynamicPassengerArrayTab> {
  late final NeatFormArrayController<PassengerField> _passengers;

  @override
  void initState() {
    super.initState();
    _passengers = NeatFormArrayController<PassengerField>(
      initialItems: [
        {
          PassengerField.fullName: 'Nguyen Van A',
          PassengerField.passportNumber: 'B1234567',
          PassengerField.seatType: 'Economy',
        },
      ],
      itemValidators: {
        PassengerField.fullName: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập họ và tên hành khách'),
          NeatValidators.minLength(3, message: 'Tên phải từ 3 ký tự trở lên'),
        ]),
        PassengerField.passportNumber: NeatValidators.combine([
          NeatValidators.required(message: 'Vui lòng nhập số hộ chiếu/CCCD'),
          NeatValidators.alphanumericOnly(message: 'Số hộ chiếu chỉ gồm chữ cái và số'),
          NeatValidators.minLength(6, message: 'Tối thiểu 6 ký tự'),
        ]),
      },
      arrayValidators: [
        NeatArrayValidators.minItems(1, message: 'Cần ít nhất 1 hành khách cho chuyến bay'),
        NeatArrayValidators.maxItems(4, message: 'Tối đa 4 hành khách mỗi lượt đặt chỗ'),
        NeatArrayValidators.uniqueBy<PassengerField, String>(
          (form) => form.valueOf<String>(PassengerField.passportNumber),
          message: 'Số hộ chiếu không được trùng lặp giữa các hành khách',
        ),
      ],
    );
  }

  @override
  void dispose() {
    _passengers.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _passengers,
      builder: (context, _) {
        return ListView(
          padding: const EdgeInsets.all(20),
          children: [
            _buildSectionHeader(
              'Danh Sách Hành Khách Bay (${_passengers.length}/4)',
              Icons.airplanemode_active_rounded,
            ),
            const SizedBox(height: 8),
            Text(
              'Quản lý danh sách sub-forms động: thêm, bớt, sắp xếp và validate không trùng lặp số hộ chiếu.',
              style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
            ),
            const SizedBox(height: 16),

            if (_passengers.state.isErrorVisible)
              Container(
                margin: const EdgeInsets.only(bottom: 16),
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.red.shade50,
                  borderRadius: BorderRadius.circular(12),
                  border: Border.all(color: Colors.red.shade200),
                ),
                child: Row(
                  children: [
                    const Icon(Icons.error_outline, color: Colors.red),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Text(
                        _passengers.state.errorMessage ?? '',
                        style: const TextStyle(color: Colors.red, fontWeight: FontWeight.w600),
                      ),
                    ),
                  ],
                ),
              ),

            ..._passengers.items.asMap().entries.map((entry) {
              final index = entry.key;
              final item = entry.value;
              final form = item.form;

              return Card(
                key: ValueKey(item.id),
                margin: const EdgeInsets.only(bottom: 16),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(16),
                  side: BorderSide(
                    color: !form.isValid ? Colors.red.shade300 : Colors.grey.shade200,
                  ),
                ),
                elevation: 2,
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          Row(
                            children: [
                              CircleAvatar(
                                radius: 14,
                                backgroundColor: const Color(0xFF4F46E5),
                                child: Text(
                                  '${index + 1}',
                                  style: const TextStyle(
                                    color: Colors.white,
                                    fontSize: 12,
                                    fontWeight: FontWeight.bold,
                                  ),
                                ),
                              ),
                              const SizedBox(width: 8),
                              Text(
                                'Hành Khách #${index + 1}',
                                style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
                              ),
                            ],
                          ),
                          if (_passengers.length > 1)
                            IconButton(
                              icon: const Icon(Icons.delete_outline_rounded, color: Colors.redAccent),
                              tooltip: 'Xóa hành khách này',
                              onPressed: () => _passengers.removeItemAt(index),
                            ),
                        ],
                      ),
                      const Divider(height: 24),
                      TextFormField(
                        initialValue: form.valueOf<String>(PassengerField.fullName),
                        decoration: InputDecoration(
                          labelText: 'Họ và Tên (In hoa không dấu)*',
                          prefixIcon: const Icon(Icons.person_outline),
                          errorText: form.field(PassengerField.fullName).errorMessage,
                        ),
                        inputFormatters: [
                          NeatInputFormatters.uppercase(),
                        ],
                        onChanged: (v) => _passengers.setArrayField(index, PassengerField.fullName, v),
                      ),
                      const SizedBox(height: 12),
                      Row(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Expanded(
                            flex: 3,
                            child: TextFormField(
                              initialValue: form.valueOf<String>(PassengerField.passportNumber),
                              decoration: InputDecoration(
                                labelText: 'Số Hộ Chiếu / CCCD*',
                                prefixIcon: const Icon(Icons.badge_outlined),
                                errorText: form.field(PassengerField.passportNumber).errorMessage,
                              ),
                              inputFormatters: [
                                NeatInputFormatters.uppercase(),
                                FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9]')),
                              ],
                              onChanged: (v) => _passengers.setArrayField(index, PassengerField.passportNumber, v),
                            ),
                          ),
                          const SizedBox(width: 12),
                          Expanded(
                            flex: 2,
                            child: DropdownButtonFormField<String>(
                              value: form.valueOf<String>(PassengerField.seatType) ?? 'Economy',
                              decoration: const InputDecoration(
                                labelText: 'Hạng Vé',
                              ),
                              items: const [
                                DropdownMenuItem(value: 'Economy', child: Text('Phổ thông')),
                                DropdownMenuItem(value: 'Business', child: Text('Thương gia')),
                                DropdownMenuItem(value: 'First', child: Text('Hạng nhất')),
                              ],
                              onChanged: (v) {
                                if (v != null) {
                                  _passengers.setArrayField(index, PassengerField.seatType, v);
                                }
                              },
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              );
            }),

            const SizedBox(height: 8),

            OutlinedButton.icon(
              style: OutlinedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 14),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
                side: const BorderSide(color: Color(0xFF4F46E5), width: 1.5),
              ),
              onPressed: _passengers.length >= 4
                  ? null
                  : () {
                      _passengers.addItem({
                        PassengerField.fullName: '',
                        PassengerField.passportNumber: '',
                        PassengerField.seatType: 'Economy',
                      });
                    },
              icon: const Icon(Icons.person_add_alt_1_rounded, color: Color(0xFF4F46E5)),
              label: Text(
                _passengers.length >= 4
                    ? 'Đã đạt giới hạn tối đa 4 hành khách'
                    : '+ Thêm Hành Khách',
                style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF4F46E5)),
              ),
            ),

            const SizedBox(height: 24),

            ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF4F46E5),
                foregroundColor: Colors.white,
                padding: const EdgeInsets.symmetric(vertical: 16),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
              ),
              onPressed: _passengers.submissionStatus.isSubmitting
                  ? null
                  : () async {
                      final success = await _passengers.submitForm(
                        onSubmit: (values) async {
                          await Future<void>.delayed(const Duration(seconds: 1));
                          if (context.mounted) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              SnackBar(
                                content: Text('✈️ Đặt vé thành công cho ${values.length} hành khách!'),
                                backgroundColor: const Color(0xFF4F46E5),
                              ),
                            );
                          }
                        },
                        onError: (arrayError, itemErrors) {
                          if (context.mounted) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              SnackBar(
                                content: Text(
                                  arrayError?.message ?? '⚠️ Vui lòng kiểm tra lại thông tin các hành khách',
                                ),
                                backgroundColor: Colors.redAccent,
                              ),
                            );
                          }
                        },
                      );
                    },
              child: _passengers.submissionStatus.isSubmitting
                  ? const CircularProgressIndicator(color: Colors.white)
                  : const Text('Xác Nhận & Xuất Vé', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            ),
          ],
        );
      },
    );
  }
}
0
likes
0
points
739
downloads

Publisher

unverified uploader

Weekly Downloads

A clean, lightweight, type-safe form state management and validation library for Flutter & Dart.

Repository (GitHub)
View/report issues

Topics

#form #validation #form-validation #state-management #riverpod

License

unknown (license)

Dependencies

flutter, meta

More

Packages that depend on neat_form