Files
mljxxx 2995582a5e feat: add flutter-dev skill
Add comprehensive Flutter cross-platform development guide covering:
- Widget patterns and const optimization
- Riverpod/Bloc state management
- GoRouter navigation
- Performance optimization
- Testing strategies
- Platform-specific implementations

Made-with: Cursor
2026-03-26 17:35:55 +08:00

17 KiB

Forms

Form validation, FormField patterns, input formatting, and reusable form components for Flutter.

Basic Form Setup

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

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLoading = false;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (!_formKey.currentState!.validate()) return;

    setState(() => _isLoading = true);

    try {
      await authService.login(
        email: _emailController.text.trim(),
        password: _passwordController.text,
      );
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.email_outlined),
            ),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            autocorrect: false,
            validator: Validators.email,
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            decoration: const InputDecoration(
              labelText: 'Password',
              prefixIcon: Icon(Icons.lock_outlined),
            ),
            obscureText: true,
            textInputAction: TextInputAction.done,
            onFieldSubmitted: (_) => _submit(),
            validator: Validators.password,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _isLoading ? null : _submit,
            child: _isLoading
                ? const SizedBox(
                    height: 20,
                    width: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Text('Login'),
          ),
        ],
      ),
    );
  }
}

Validators

class Validators {
  static String? required(String? value) {
    if (value == null || value.trim().isEmpty) {
      return 'This field is required';
    }
    return null;
  }

  static String? email(String? value) {
    if (value == null || value.trim().isEmpty) {
      return 'Email is required';
    }
    final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!regex.hasMatch(value.trim())) {
      return 'Enter a valid email address';
    }
    return null;
  }

  static String? password(String? value) {
    if (value == null || value.isEmpty) {
      return 'Password is required';
    }
    if (value.length < 8) {
      return 'Password must be at least 8 characters';
    }
    return null;
  }

  static String? strongPassword(String? value) {
    if (value == null || value.isEmpty) {
      return 'Password is required';
    }
    if (value.length < 8) {
      return 'Password must be at least 8 characters';
    }
    if (!RegExp(r'[A-Z]').hasMatch(value)) {
      return 'Password must contain an uppercase letter';
    }
    if (!RegExp(r'[a-z]').hasMatch(value)) {
      return 'Password must contain a lowercase letter';
    }
    if (!RegExp(r'[0-9]').hasMatch(value)) {
      return 'Password must contain a number';
    }
    return null;
  }

  static String? phone(String? value) {
    if (value == null || value.trim().isEmpty) {
      return 'Phone number is required';
    }
    final digits = value.replaceAll(RegExp(r'\D'), '');
    if (digits.length < 10 || digits.length > 15) {
      return 'Enter a valid phone number';
    }
    return null;
  }

  static String? minLength(int min) {
    return (String? value) {
      if (value == null || value.length < min) {
        return 'Must be at least $min characters';
      }
      return null;
    };
  }

  static String? maxLength(int max) {
    return (String? value) {
      if (value != null && value.length > max) {
        return 'Must be at most $max characters';
      }
      return null;
    };
  }

  static String? Function(String?) combine(List<String? Function(String?)> validators) {
    return (String? value) {
      for (final validator in validators) {
        final error = validator(value);
        if (error != null) return error;
      }
      return null;
    };
  }

  static String? match(String pattern, String message) {
    return (String? value) {
      if (value != null && !RegExp(pattern).hasMatch(value)) {
        return message;
      }
      return null;
    };
  }

  static String? confirmPassword(TextEditingController passwordController) {
    return (String? value) {
      if (value != passwordController.text) {
        return 'Passwords do not match';
      }
      return null;
    };
  }
}

Input Formatters

import 'package:flutter/services.dart';

class PhoneInputFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
    final buffer = StringBuffer();

    for (int i = 0; i < digits.length && i < 10; i++) {
      if (i == 3 || i == 6) buffer.write('-');
      buffer.write(digits[i]);
    }

    return TextEditingValue(
      text: buffer.toString(),
      selection: TextSelection.collapsed(offset: buffer.length),
    );
  }
}

class CreditCardFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
    final buffer = StringBuffer();

    for (int i = 0; i < digits.length && i < 16; i++) {
      if (i > 0 && i % 4 == 0) buffer.write(' ');
      buffer.write(digits[i]);
    }

    return TextEditingValue(
      text: buffer.toString(),
      selection: TextSelection.collapsed(offset: buffer.length),
    );
  }
}

class CurrencyInputFormatter extends TextInputFormatter {
  final int decimalPlaces;

  CurrencyInputFormatter({this.decimalPlaces = 2});

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    if (newValue.text.isEmpty) return newValue;

    final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
    if (digits.isEmpty) return const TextEditingValue(text: '');

    final value = int.parse(digits) / 100;
    final formatted = value.toStringAsFixed(decimalPlaces);

    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}

class UpperCaseFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    return newValue.copyWith(text: newValue.text.toUpperCase());
  }
}

Using Formatters

TextFormField(
  decoration: const InputDecoration(labelText: 'Phone'),
  keyboardType: TextInputType.phone,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    PhoneInputFormatter(),
  ],
)

TextFormField(
  decoration: const InputDecoration(labelText: 'Amount'),
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
    CurrencyInputFormatter(),
  ],
)

Custom FormFields

Dropdown FormField

class DropdownFormField<T> extends FormField<T> {
  DropdownFormField({
    super.key,
    required List<DropdownMenuItem<T>> items,
    super.initialValue,
    super.validator,
    super.onSaved,
    String? labelText,
    String? hintText,
    ValueChanged<T?>? onChanged,
  }) : super(
          builder: (state) {
            return InputDecorator(
              decoration: InputDecoration(
                labelText: labelText,
                errorText: state.errorText,
              ),
              child: DropdownButtonHideUnderline(
                child: DropdownButton<T>(
                  value: state.value,
                  hint: hintText != null ? Text(hintText) : null,
                  isExpanded: true,
                  items: items,
                  onChanged: (value) {
                    state.didChange(value);
                    onChanged?.call(value);
                  },
                ),
              ),
            );
          },
        );
}

Checkbox FormField

class CheckboxFormField extends FormField<bool> {
  CheckboxFormField({
    super.key,
    required Widget label,
    super.initialValue = false,
    super.validator,
    super.onSaved,
  }) : super(
          builder: (state) {
            return Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Checkbox(
                      value: state.value ?? false,
                      onChanged: state.didChange,
                    ),
                    Expanded(child: GestureDetector(
                      onTap: () => state.didChange(!(state.value ?? false)),
                      child: label,
                    )),
                  ],
                ),
                if (state.hasError)
                  Padding(
                    padding: const EdgeInsets.only(left: 12, top: 4),
                    child: Text(
                      state.errorText!,
                      style: TextStyle(
                        color: Theme.of(state.context).colorScheme.error,
                        fontSize: 12,
                      ),
                    ),
                  ),
              ],
            );
          },
        );
}

Date Picker FormField

class DatePickerFormField extends FormField<DateTime> {
  DatePickerFormField({
    super.key,
    super.initialValue,
    super.validator,
    super.onSaved,
    String? labelText,
    DateTime? firstDate,
    DateTime? lastDate,
  }) : super(
          builder: (state) {
            return GestureDetector(
              onTap: () async {
                final picked = await showDatePicker(
                  context: state.context,
                  initialDate: state.value ?? DateTime.now(),
                  firstDate: firstDate ?? DateTime(1900),
                  lastDate: lastDate ?? DateTime(2100),
                );
                if (picked != null) {
                  state.didChange(picked);
                }
              },
              child: InputDecorator(
                decoration: InputDecoration(
                  labelText: labelText,
                  errorText: state.errorText,
                  suffixIcon: const Icon(Icons.calendar_today),
                ),
                child: Text(
                  state.value != null
                      ? DateFormat.yMMMd().format(state.value!)
                      : 'Select date',
                ),
              ),
            );
          },
        );
}

Form with Hooks

import 'package:flutter_hooks/flutter_hooks.dart';

class HookLoginForm extends HookWidget {
  const HookLoginForm({super.key});

  @override
  Widget build(BuildContext context) {
    final formKey = useMemoized(GlobalKey<FormState>.new);
    final emailController = useTextEditingController();
    final passwordController = useTextEditingController();
    final emailFocus = useFocusNode();
    final passwordFocus = useFocusNode();
    final isLoading = useState(false);

    Future<void> submit() async {
      if (!formKey.currentState!.validate()) return;

      isLoading.value = true;
      try {
        await authService.login(
          email: emailController.text.trim(),
          password: passwordController.text,
        );
      } finally {
        isLoading.value = false;
      }
    }

    return Form(
      key: formKey,
      child: Column(
        children: [
          TextFormField(
            controller: emailController,
            focusNode: emailFocus,
            decoration: const InputDecoration(labelText: 'Email'),
            textInputAction: TextInputAction.next,
            onFieldSubmitted: (_) => passwordFocus.requestFocus(),
            validator: Validators.email,
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: passwordController,
            focusNode: passwordFocus,
            decoration: const InputDecoration(labelText: 'Password'),
            obscureText: true,
            onFieldSubmitted: (_) => submit(),
            validator: Validators.password,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: isLoading.value ? null : submit,
            child: isLoading.value
                ? const CircularProgressIndicator()
                : const Text('Login'),
          ),
        ],
      ),
    );
  }
}

Server-Side Validation

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

  @override
  State<ServerValidationForm> createState() => _ServerValidationFormState();
}

class _ServerValidationFormState extends State<ServerValidationForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  Map<String, List<String>> _serverErrors = {};

  String? _emailValidator(String? value) {
    final clientError = Validators.email(value);
    if (clientError != null) return clientError;

    final serverError = _serverErrors['email'];
    if (serverError != null && serverError.isNotEmpty) {
      return serverError.first;
    }
    return null;
  }

  Future<void> _submit() async {
    setState(() => _serverErrors = {});

    if (!_formKey.currentState!.validate()) return;

    try {
      await api.register(email: _emailController.text);
    } on ValidationException catch (e) {
      setState(() => _serverErrors = e.errors);
      _formKey.currentState!.validate();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(labelText: 'Email'),
            validator: _emailValidator,
            onChanged: (_) {
              if (_serverErrors.containsKey('email')) {
                setState(() => _serverErrors.remove('email'));
              }
            },
          ),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Register'),
          ),
        ],
      ),
    );
  }
}

Auto-Save Form

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

  @override
  State<AutoSaveForm> createState() => _AutoSaveFormState();
}

class _AutoSaveFormState extends State<AutoSaveForm> {
  final _formKey = GlobalKey<FormState>();
  Timer? _debounce;
  bool _hasChanges = false;

  void _onChanged() {
    setState(() => _hasChanges = true);

    _debounce?.cancel();
    _debounce = Timer(const Duration(seconds: 2), _autoSave);
  }

  Future<void> _autoSave() async {
    if (!_hasChanges) return;
    if (!_formKey.currentState!.validate()) return;

    _formKey.currentState!.save();
    await saveToServer();
    setState(() => _hasChanges = false);
  }

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

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      onChanged: _onChanged,
      child: Column(
        children: [
          if (_hasChanges)
            const Text('Saving...', style: TextStyle(color: Colors.grey)),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Title'),
            onSaved: (value) => saveField('title', value),
          ),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Description'),
            maxLines: 3,
            onSaved: (value) => saveField('description', value),
          ),
        ],
      ),
    );
  }
}

Common Keyboard Types

Type Usage
TextInputType.text General text
TextInputType.emailAddress Email with @ keyboard
TextInputType.phone Phone number pad
TextInputType.number Numeric keyboard
TextInputType.numberWithOptions(decimal: true) Numbers with decimal
TextInputType.multiline Multi-line text
TextInputType.url URL with shortcuts

Form Checklist

Item Implementation
GlobalKey GlobalKey<FormState>() for form
Dispose controllers Clean up in dispose()
Validation Client + server-side
Input formatters Phone, currency, etc.
Keyboard types Match input type
Text actions textInputAction for flow
Loading state Disable during submission
Error display Show below fields

Flutter and Material Design are trademarks of Google LLC.