unified_multi_step_form 1.1.3 copy "unified_multi_step_form: ^1.1.3" to clipboard
unified_multi_step_form: ^1.1.3 copied to clipboard

A reusable Flutter package for state-preserving multi-step forms.

example/lib/main.dart

import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:unified_multi_step_form/unified_multi_step_form.dart';

// Example app entry point for the package example.
//
// Run this app from the example folder to see the package UI in action.

/// Launches the example app.
void main() {
  runApp(const UnifiedMultiStepFormExampleApp());
}

/// Root widget for the example application.
class UnifiedMultiStepFormExampleApp extends StatelessWidget {
  /// Creates the example app.
  const UnifiedMultiStepFormExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true),
      home: const CompanyInfoDemoScreen(),
    );
  }
}

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

  @override
  State<CompanyInfoDemoScreen> createState() => _CompanyInfoDemoScreenState();
}

class _CompanyInfoDemoScreenState extends State<CompanyInfoDemoScreen> {
  final GlobalKey<FormState> _step0Key = GlobalKey<FormState>();
  final GlobalKey<FormState> _step1Key = GlobalKey<FormState>();
  final GlobalKey<FormState> _step2Key = GlobalKey<FormState>();

  final TextEditingController registeredNameController =
      TextEditingController();
  final TextEditingController cnicController = TextEditingController();
  final TextEditingController ntnController = TextEditingController();
  final TextEditingController strnController = TextEditingController();
  final TextEditingController contactController = TextEditingController();
  final TextEditingController webAddressController = TextEditingController();
  final TextEditingController ownerNameController = TextEditingController();
  final TextEditingController emailController = TextEditingController();
  final TextEditingController fiscalStartDateController =
      TextEditingController();
  final TextEditingController fiscalEndDateController = TextEditingController();
  final TextEditingController currencySymbolController =
      TextEditingController();
  final TextEditingController destinationProvinceController =
      TextEditingController();
  final TextEditingController termsController = TextEditingController();

  bool canEdit = true;

  // Extra UI state
  bool acceptTerms = true;
  bool enableNotifications = false;
  String companyType = 'Private';
  XFile? pickedImage;
  final _controller = UnifiedMultiStepFormController();

  final List<String> _currencyCodes = const [
    'PKR',
    'USD',
    'AED',
    'SAR',
    'GBP',
    'EUR',
  ];
  final List<String> _provinces = const [
    'Punjab',
    'Sindh',
    'KPK',
    'Balochistan',
    'Islamabad',
    'Gilgit Baltistan',
    'Azad Kashmir',
  ];

  @override
  void initState() {
    super.initState();
    // Sample data for testing
    registeredNameController.text = 'ABC Corporation Ltd';
    cnicController.text = '12345-6789012-3';
    ntnController.text = '1234567-8';
    strnController.text = 'AB-123456';
    contactController.text = '+92-300-1234567';
    webAddressController.text = 'abc-corporation.com';
    ownerNameController.text = 'Muhammad Ahmad';
    emailController.text = 'contact@abc-corp.com';
    fiscalStartDateController.text = '2024-01-01';
    fiscalEndDateController.text = '2024-12-31';
    currencySymbolController.text = 'PKR';
    destinationProvinceController.text = 'Punjab';
    termsController.text =
        'All terms and conditions apply to this transaction as per our policy agreement.';

    // Example: register a couple async validators (simulate server checks)
    _controller.registerAsyncValidator(
      name: 'email',
      validator: (value) async {
        await Future.delayed(const Duration(milliseconds: 600));
        if (value == null || value.isEmpty) return 'Email required';
        if (value.contains('blocked')) return 'Email blocked by server';
        return null;
      },
    );

    _controller.registerAsyncValidator(
      name: 'phone',
      validator: (value) async {
        await Future.delayed(const Duration(milliseconds: 600));
        if (value == null || value.isEmpty) return 'Phone required';
        if (!value.startsWith('+')) return 'Phone must include country code';
        return null;
      },
    );
  }

  @override
  void dispose() {
    registeredNameController.dispose();
    cnicController.dispose();
    ntnController.dispose();
    strnController.dispose();
    contactController.dispose();
    webAddressController.dispose();
    ownerNameController.dispose();
    emailController.dispose();
    fiscalStartDateController.dispose();
    fiscalEndDateController.dispose();
    currencySymbolController.dispose();
    destinationProvinceController.dispose();
    termsController.dispose();
    super.dispose();
  }

  Future<void> _pickDate({required TextEditingController controller}) async {
    final picked = await showDatePicker(
      context: context,
      initialDate: DateTime.now(),
      firstDate: DateTime(2000),
      lastDate: DateTime(2100),
    );
    if (picked != null) {
      controller.text =
          '${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}';
    }
  }

  Future<void> _pickImage() async {
    final picker = ImagePicker();
    final file = await picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 1200,
    );
    if (file != null) setState(() => pickedImage = file);
  }

  Widget _field({
    required TextEditingController controller,
    required String label,
    required String hint,
    TextInputType keyboardType = TextInputType.text,
    String? Function(String?)? validator,
    int maxLines = 1,
    bool readOnly = false,
    VoidCallback? onTap,
  }) {
    return TextFormField(
      controller: controller,
      keyboardType: keyboardType,
      validator: validator,
      maxLines: maxLines,
      readOnly: readOnly,
      onTap: onTap,
      decoration: InputDecoration(
        labelText: label,
        hintText: hint,
        border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
      ),
    );
  }

  Widget _sectionTitle(String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Text(
        title,
        style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
      ),
    );
  }

  Widget _step0() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _sectionTitle('Basic Information'),
        _field(
          controller: registeredNameController,
          label: 'Registered Name',
          hint: 'Register Name',
          keyboardType: TextInputType.name,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter registered name';
            }
            if (value.length < 3) {
              return 'Name must be at least 3 characters';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        _field(
          controller: cnicController,
          label: 'CNIC',
          hint: 'XXXXX-XXXXXXX-X',
          keyboardType: TextInputType.number,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter CNIC number';
            }
            if (!RegExp(r'^\d{5}-\d{7}-\d{1}$').hasMatch(value)) {
              return 'Use CNIC format XXXXX-XXXXXXX-X';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        _field(
          controller: ntnController,
          label: 'NTN',
          hint: '1234567-8',
          keyboardType: TextInputType.number,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter NTN number';
            }
            if (!RegExp(r'^\d{7}-\d$').hasMatch(value)) {
              return 'Use NTN format 1234567-8';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        _field(
          controller: strnController,
          label: 'STRN',
          hint: 'AA-123456',
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter STRN number';
            }
            if (!RegExp(r'^[A-Z]{2}-\d{6}$').hasMatch(value)) {
              return 'Use STRN format AA-123456';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        _field(
          controller: contactController,
          label: 'Contact Number',
          hint: '+92-300-1234567',
          keyboardType: TextInputType.phone,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter contact number';
            }
            if (!RegExp(r'^\+92-\d{3}-\d{7}$').hasMatch(value)) {
              return 'Use phone format +92-XXX-XXXXXXX';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        _field(
          controller: webAddressController,
          label: 'Web Address',
          hint: 'example.com',
          keyboardType: TextInputType.url,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter web address';
            }
            final urlRegex = RegExp(r'^(https?:\/\/)?([\w-]+\.)+[\w-]+$');
            if (!urlRegex.hasMatch(value.trim())) {
              return 'Please enter a valid web address';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        // Image picker card
        Text('Company Logo', style: TextStyle(fontWeight: FontWeight.w600)),
        const SizedBox(height: 8),
        GestureDetector(
          onTap: canEdit ? _pickImage : null,
          child: Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              border: Border.all(color: Colors.grey.shade300),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Row(
              children: [
                if (pickedImage != null)
                  ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: Image.file(
                      File(pickedImage!.path),
                      width: 72,
                      height: 72,
                      fit: BoxFit.cover,
                    ),
                  )
                else
                  Container(
                    width: 72,
                    height: 72,
                    color: Colors.grey.shade200,
                    child: const Icon(Icons.image),
                  ),
                const SizedBox(width: 12),
                Expanded(
                  child: Text(
                    pickedImage == null
                        ? 'Tap to pick logo'
                        : pickedImage!.name,
                  ),
                ),
                const Icon(Icons.chevron_right),
              ],
            ),
          ),
        ),
      ],
    );
  }

  Widget _step1() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _sectionTitle('Owner, Fiscal Year & Settings'),
        _field(
          controller: ownerNameController,
          label: 'Owner Name',
          hint: 'Enter owner name',
          readOnly: !canEdit,
          validator: (value) => (value == null || value.isEmpty)
              ? 'Please enter owner name'
              : null,
        ),
        const SizedBox(height: 16),
        _field(
          controller: emailController,
          label: 'Email Address',
          hint: 'example@domain.com',
          keyboardType: TextInputType.emailAddress,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter email address';
            }
            if (!RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(value.trim())) {
              return 'Please enter a valid email address';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        _field(
          controller: fiscalStartDateController,
          label: 'Fiscal Start Date',
          hint: 'Select start date',
          readOnly: true,
          onTap: canEdit
              ? () => _pickDate(controller: fiscalStartDateController)
              : null,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please select fiscal start date';
            }
            return null;
          },
        ),
        const SizedBox(height: 16),
        // Checkbox + Switch + segmented choice
        Row(
          children: [
            Expanded(
              child: CheckboxListTile(
                value: acceptTerms,
                onChanged: canEdit
                    ? (v) => setState(() => acceptTerms = v ?? false)
                    : null,
                title: const Text('Accept Terms'),
                controlAffinity: ListTileControlAffinity.leading,
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: SwitchListTile(
                value: enableNotifications,
                onChanged: canEdit
                    ? (v) => setState(() => enableNotifications = v)
                    : null,
                title: const Text('Enable Notifications'),
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        Text(
          'Company Type',
          style: const TextStyle(fontWeight: FontWeight.w600),
        ),
        const SizedBox(height: 8),
        SegmentedButton<String>(
          segments: const [
            ButtonSegment<String>(value: 'Private', label: Text('Private')),
            ButtonSegment<String>(value: 'Public', label: Text('Public')),
          ],
          selected: {companyType},
          onSelectionChanged: canEdit
              ? (selection) => setState(() => companyType = selection.first)
              : null,
        ),
        const SizedBox(height: 16),
        _field(
          controller: fiscalEndDateController,
          label: 'Fiscal End Date',
          hint: 'Select end date',
          readOnly: true,
          onTap: canEdit
              ? () => _pickDate(controller: fiscalEndDateController)
              : null,
          validator: (value) => (value == null || value.isEmpty)
              ? 'Please select fiscal end date'
              : null,
        ),
        const SizedBox(height: 16),
        DropdownButtonFormField<String>(
          initialValue: currencySymbolController.text.isEmpty
              ? null
              : currencySymbolController.text,
          items: _currencyCodes
              .map((code) => DropdownMenuItem(value: code, child: Text(code)))
              .toList(),
          onChanged: canEdit
              ? (value) =>
                    setState(() => currencySymbolController.text = value ?? '')
              : null,
          decoration: InputDecoration(
            labelText: 'Currency Code',
            hintText: 'Select currency code',
            border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
          ),
          validator: (value) => (value == null || value.isEmpty)
              ? 'Please select currency code'
              : null,
        ),
        const SizedBox(height: 16),
        DropdownButtonFormField<String>(
          initialValue: destinationProvinceController.text.isEmpty
              ? null
              : destinationProvinceController.text,
          items: _provinces
              .map(
                (province) =>
                    DropdownMenuItem(value: province, child: Text(province)),
              )
              .toList(),
          onChanged: canEdit
              ? (value) => setState(
                  () => destinationProvinceController.text = value ?? '',
                )
              : null,
          decoration: InputDecoration(
            labelText: 'Destination Province',
            hintText: 'Select province',
            border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
          ),
          validator: (value) => (value == null || value.isEmpty)
              ? 'Please select destination province'
              : null,
        ),
        const SizedBox(height: 16),
        _field(
          controller: termsController,
          label: 'Terms & Conditions',
          hint: 'Enter terms and conditions',
          maxLines: 4,
          readOnly: !canEdit,
          validator: (value) {
            if (value == null || value.isEmpty) {
              return 'Please enter terms and conditions';
            }
            if (value.length < 10) {
              return 'Terms should be at least 10 characters';
            }
            return null;
          },
        ),
      ],
    );
  }

  Widget _step2() {
    final summary = <String, String>{
      'CompanyName': registeredNameController.text.trim(),
      'CNIC': cnicController.text.trim(),
      'NTNNo': ntnController.text.trim(),
      'STNNo': strnController.text.trim(),
      'Phone': contactController.text.trim(),
      'WebAddress': webAddressController.text.trim(),
      'OwnerName': ownerNameController.text.trim(),
      'Email': emailController.text.trim(),
      'FiscalStartDateTime': fiscalStartDateController.text.trim(),
      'FiscalEndDateTime': fiscalEndDateController.text.trim(),
      'CurrencySymbol': currencySymbolController.text.trim(),
      'Province': destinationProvinceController.text.trim(),
      'TermsAndConditions': termsController.text.trim(),
      'AcceptedTerms': acceptTerms ? 'Yes' : 'No',
      'Notifications': enableNotifications ? 'Yes' : 'No',
      'CompanyType': companyType,
      'Logo': pickedImage?.name ?? 'Not selected',
    };

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _sectionTitle('Review & Submit'),
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: Colors.grey.shade100,
            borderRadius: BorderRadius.circular(16),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: summary.entries
                .map(
                  (entry) => Padding(
                    padding: const EdgeInsets.only(bottom: 6),
                    child: Text('${entry.key}: ${entry.value}'),
                  ),
                )
                .toList(),
          ),
        ),
        const SizedBox(height: 12),
        // Custom card preview
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(12),
            color: Colors.white,
            boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 6)],
          ),
          child: Row(
            children: [
              if (pickedImage != null)
                ClipRRect(
                  borderRadius: BorderRadius.circular(8),
                  child: Image.file(
                    File(pickedImage!.path),
                    width: 72,
                    height: 72,
                    fit: BoxFit.cover,
                  ),
                )
              else
                Container(
                  width: 72,
                  height: 72,
                  color: Colors.grey.shade200,
                  child: const Icon(Icons.business),
                ),
              const SizedBox(width: 12),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      registeredNameController.text,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                    Text(ownerNameController.text),
                  ],
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 20),
        Text(
          'This demo keeps the same flow: mounted pages, step validation, and final submit payload.',
          style: TextStyle(color: Colors.grey.shade700),
        ),
      ],
    );
  }

  void _submit() {
    final payload = <String, dynamic>{
      'CompanyName': registeredNameController.text.trim(),
      'CNIC': cnicController.text.trim(),
      'NTNNo': ntnController.text.trim(),
      'STNNo': strnController.text.trim(),
      'Phone': contactController.text.trim(),
      'WebAddress': webAddressController.text.trim(),
      'OwnerName': ownerNameController.text.trim(),
      'Email': emailController.text.trim(),
      'FiscalStartDateTime': fiscalStartDateController.text.trim(),
      'FiscalEndDateTime': fiscalEndDateController.text.trim(),
      'CurrencySymbol': currencySymbolController.text.trim(),
      'Province': destinationProvinceController.text.trim(),
      'TermsAndConditions': termsController.text.trim(),
      'AcceptedTerms': acceptTerms,
      'Notifications': enableNotifications,
      'CompanyType': companyType,
      'LogoPath': pickedImage?.path,
    };

    showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Demo Submit'),
        content: SingleChildScrollView(
          child: Text(
            '${const JsonEncoder.withIndent('  ').convert({'note': 'Payload is generated from the multi-step form state.'})}\n\n${payload.toString()}',
            style: const TextStyle(fontSize: 13),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Company Info Demo'),
        actions: [
          TextButton(
            onPressed: () => setState(() => canEdit = !canEdit),
            child: Text(
              canEdit ? 'Editing' : 'Locked',
              style: const TextStyle(color: Colors.white),
            ),
          ),
        ],
      ),
      body: SafeArea(
        child: UnifiedMultiStepForm(
          controller: _controller,
          usePageView: true,
          transitionType: TransitionType.slide,
          indicatorType: IndicatorType.numbers,
          indicatorActiveColor: Colors.red,
          pages: [
            MultiStepFormPage(
              formKey: _step0Key,
              // title: '',
              builder: (_) => _step0(),
            ),
            MultiStepFormPage(
              formKey: _step1Key,
              // title: '',
              builder: (_) => _step1(),
            ),
            MultiStepFormPage(
              formKey: _step2Key,
              // title: 'Review & Submit',
              builder: (_) => _step2(),
            ),
          ],
          onSubmit: _submit,
        ),
      ),
    );
  }
}
3
likes
160
points
173
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A reusable Flutter package for state-preserving multi-step forms.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, image_picker

More

Packages that depend on unified_multi_step_form