563 lines
18 KiB
Dart
563 lines
18 KiB
Dart
import 'package:flutter/services.dart';
|
||
|
||
class Validators {
|
||
Validators._();
|
||
|
||
static final RegExp _gstinPattern =
|
||
RegExp(r'^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$');
|
||
static final RegExp _panPattern = RegExp(r'^[A-Z]{5}\d{4}[A-Z]$');
|
||
static final RegExp _emailPattern = RegExp(
|
||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||
);
|
||
static final RegExp _ifscPattern = RegExp(r'^[A-Z]{4}0[A-Z0-9]{6}$');
|
||
static final RegExp _pincodePattern = RegExp(r'^[1-9]\d{5}$');
|
||
|
||
static String? required(String? value, {String fieldName = 'This field'}) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return '$fieldName is required';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String? email(String? value) {
|
||
if (value == null || value.trim().isEmpty) return 'Email is required';
|
||
return _validateEmail(value.trim());
|
||
}
|
||
|
||
/// Validates email only when a value is entered.
|
||
static String? optionalEmail(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validateEmail(value.trim());
|
||
}
|
||
|
||
static String? _validateEmail(String raw) {
|
||
if (!_emailPattern.hasMatch(raw)) {
|
||
return 'Enter a valid email address';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Required 9–18 digit bank account number.
|
||
static String? accountNumber(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'Account number is required';
|
||
}
|
||
return _validateAccountNumber(value.trim());
|
||
}
|
||
|
||
/// Validates account number only when a value is entered.
|
||
static String? optionalAccountNumber(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validateAccountNumber(value.trim());
|
||
}
|
||
|
||
static String? _validateAccountNumber(String digits) {
|
||
if (!RegExp(r'^\d+$').hasMatch(digits)) {
|
||
return 'Account number must contain digits only';
|
||
}
|
||
if (digits.length < 9 || digits.length > 18) {
|
||
return 'Account number must be 9 to 18 digits';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Required 11-character IFSC code (e.g. SBIN0001234).
|
||
static String? ifsc(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'IFSC is required';
|
||
}
|
||
return _validateIfsc(value.trim());
|
||
}
|
||
|
||
/// Validates IFSC only when a value is entered.
|
||
static String? optionalIfsc(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validateIfsc(value.trim());
|
||
}
|
||
|
||
static String? _validateIfsc(String raw) {
|
||
final normalized = raw.toUpperCase();
|
||
if (normalized.length != 11) {
|
||
return 'IFSC must be exactly 11 characters';
|
||
}
|
||
if (!_ifscPattern.hasMatch(normalized)) {
|
||
return 'Enter a valid IFSC code (e.g. SBIN0001234)';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Required 6-digit Indian pincode.
|
||
static String? pincode(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'Pincode is required';
|
||
}
|
||
return _validatePincode(value.trim());
|
||
}
|
||
|
||
/// Validates pincode only when a value is entered.
|
||
static String? optionalPincode(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validatePincode(value.trim());
|
||
}
|
||
|
||
static String? _validatePincode(String digits) {
|
||
if (!RegExp(r'^\d+$').hasMatch(digits)) {
|
||
return 'Pincode must contain digits only';
|
||
}
|
||
if (!_pincodePattern.hasMatch(digits)) {
|
||
return 'Enter a valid 6-digit pincode';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Required 10-digit mobile — digits only.
|
||
static String? mobile(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'Mobile number is required';
|
||
}
|
||
return _validateMobileDigits(value.trim());
|
||
}
|
||
|
||
/// Alias for [mobile].
|
||
static String? phone(String? value) => mobile(value);
|
||
|
||
/// Validates mobile only when a value is entered.
|
||
static String? optionalMobile(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validateMobileDigits(value.trim());
|
||
}
|
||
|
||
static String? _validateMobileDigits(String digits) {
|
||
if (!RegExp(r'^\d+$').hasMatch(digits)) {
|
||
return 'Mobile number must contain digits only';
|
||
}
|
||
if (digits.length != 10) {
|
||
return 'Mobile number must be exactly 10 digits';
|
||
}
|
||
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';
|
||
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter';
|
||
if (!RegExp(r'[a-z]').hasMatch(value)) return 'Must contain a lowercase letter';
|
||
if (!RegExp(r'[0-9]').hasMatch(value)) return 'Must contain a number';
|
||
return null;
|
||
}
|
||
|
||
/// Required 15-character GSTIN pattern.
|
||
static String? gstin(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'GSTIN is required';
|
||
}
|
||
return _validateGstin(value.trim());
|
||
}
|
||
|
||
/// Validates GSTIN only when a value is entered.
|
||
static String? optionalGstin(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validateGstin(value.trim());
|
||
}
|
||
|
||
/// Alias for [optionalGstin] (legacy company forms).
|
||
static String? gstNumber(String? value) => optionalGstin(value);
|
||
|
||
static String? _validateGstin(String raw) {
|
||
final normalized = raw.toUpperCase();
|
||
if (normalized.length != 15) {
|
||
return 'GSTIN must be exactly 15 characters';
|
||
}
|
||
if (!_gstinPattern.hasMatch(normalized)) {
|
||
return 'Enter a valid GSTIN (e.g. 27ABCDE1234F1Z5)';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Required 10-character PAN pattern (e.g. ABCDE1234F).
|
||
static String? pan(String? value) {
|
||
if (value == null || value.trim().isEmpty) {
|
||
return 'PAN is required';
|
||
}
|
||
return _validatePan(value.trim());
|
||
}
|
||
|
||
/// Validates PAN only when a value is entered.
|
||
static String? optionalPan(String? value) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
return _validatePan(value.trim());
|
||
}
|
||
|
||
static String? _validatePan(String raw) {
|
||
final normalized = raw.toUpperCase();
|
||
if (normalized.length != 10) {
|
||
return 'PAN must be exactly 10 characters';
|
||
}
|
||
if (!_panPattern.hasMatch(normalized)) {
|
||
return 'Enter a valid PAN (e.g. ABCDE1234F)';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String? minLength(String? value, int min, {String fieldName = 'Field'}) {
|
||
if (value == null || value.length < min) {
|
||
return '$fieldName must be at least $min characters';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Validates a positive decimal when a value is entered.
|
||
static String? optionalPositiveDouble(
|
||
String? value, {
|
||
String fieldName = 'Value',
|
||
}) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
final parsed = double.tryParse(value.trim());
|
||
if (parsed == null) return 'Enter a valid number';
|
||
if (parsed <= 0) return '$fieldName must be greater than 0';
|
||
return null;
|
||
}
|
||
|
||
/// Validates a non-negative decimal when a value is entered.
|
||
static String? optionalNonNegativeDouble(
|
||
String? value, {
|
||
String fieldName = 'Value',
|
||
}) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
final parsed = double.tryParse(value.trim());
|
||
if (parsed == null) return 'Enter a valid number';
|
||
if (parsed < 0) return '$fieldName cannot be negative';
|
||
return null;
|
||
}
|
||
|
||
/// Validates a positive whole number when a value is entered.
|
||
static String? optionalPositiveInt(
|
||
String? value, {
|
||
String fieldName = 'Value',
|
||
}) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
final parsed = int.tryParse(value.trim());
|
||
if (parsed == null) return 'Enter a valid whole number';
|
||
if (parsed <= 0) return '$fieldName must be greater than 0';
|
||
return null;
|
||
}
|
||
|
||
/// Validates a percentage between 0 and 100 when a value is entered.
|
||
static String? optionalPercentage(
|
||
String? value, {
|
||
String fieldName = 'Percentage',
|
||
}) {
|
||
if (value == null || value.trim().isEmpty) return null;
|
||
final parsed = double.tryParse(value.trim());
|
||
if (parsed == null) return 'Enter a valid percentage';
|
||
if (parsed < 0 || parsed > 100) {
|
||
return '$fieldName must be between 0 and 100';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static final RegExp _roleNamePattern = RegExp(r'^[a-zA-Z0-9 ]+$');
|
||
static final RegExp _masterNamePattern =
|
||
RegExp(r'^[A-Za-z0-9 _/&.()\-]+$');
|
||
static final RegExp _masterNameCharPattern =
|
||
RegExp(r'[A-Za-z0-9 _/&.()\-]');
|
||
|
||
static bool isMasterNameFieldKey(String key) {
|
||
final normalizedKey = key.trim().toLowerCase();
|
||
return normalizedKey == 'name' || normalizedKey == 'item_name';
|
||
}
|
||
|
||
/// Required master name — allowed: A-Z a-z 0-9 space - _ / & . ( )
|
||
static String? masterName(String? value, {String fieldName = 'Name'}) {
|
||
final requiredError = required(value, fieldName: fieldName);
|
||
if (requiredError != null) return requiredError;
|
||
|
||
return _validateMasterNameChars(value!.trim(), fieldName: fieldName);
|
||
}
|
||
|
||
static String? _validateMasterNameChars(
|
||
String raw, {
|
||
String fieldName = 'Name',
|
||
}) {
|
||
if (!_masterNamePattern.hasMatch(raw)) {
|
||
return '$fieldName can only contain letters, numbers, spaces, and - _ / & . ( )';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Validates master name format and uniqueness against existing records.
|
||
static String? uniqueMasterName(
|
||
String? value, {
|
||
required String nameKey,
|
||
required Iterable<Map<String, dynamic>> existingRecords,
|
||
String? currentRecordId,
|
||
String fieldName = 'Name',
|
||
}) {
|
||
final formatError = masterName(value, fieldName: fieldName);
|
||
if (formatError != null) return formatError;
|
||
|
||
final normalized = value!.trim().toLowerCase();
|
||
for (final record in existingRecords) {
|
||
final recordId = record['id']?.toString();
|
||
if (currentRecordId != null && recordId == currentRecordId) continue;
|
||
|
||
final existingName = record[nameKey]?.toString().trim().toLowerCase();
|
||
if (existingName != null && existingName.isNotEmpty && existingName == normalized) {
|
||
return '$fieldName must be unique';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static List<TextInputFormatter> get masterNameInput => [
|
||
FilteringTextInputFormatter.allow(_masterNameCharPattern),
|
||
];
|
||
|
||
static final RegExp _masterCodePattern = RegExp(r'^[A-Za-z0-9\-_/]+$');
|
||
static final RegExp _masterCodeCharPattern = RegExp(r'[A-Za-z0-9\-_/]');
|
||
|
||
static bool isMasterCodeFieldKey(String key) {
|
||
final normalizedKey = key.trim().toLowerCase();
|
||
return normalizedKey == 'code' || normalizedKey == 'item_code';
|
||
}
|
||
|
||
/// HSN/SAC codes are 4–8 digits.
|
||
static final RegExp _hsnCodePattern = RegExp(r'^\d{4,8}$');
|
||
|
||
static String? hsnCode(String? value, {String fieldName = 'HSN/SAC Code'}) {
|
||
final requiredError = required(value, fieldName: fieldName);
|
||
if (requiredError != null) return requiredError;
|
||
|
||
final trimmed = value!.trim();
|
||
if (!_hsnCodePattern.hasMatch(trimmed)) {
|
||
return '$fieldName must be 4–8 digits';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String? uniqueHsnCode(
|
||
String? value, {
|
||
required Iterable<Map<String, dynamic>> existingRecords,
|
||
String? currentRecordId,
|
||
String fieldName = 'HSN/SAC Code',
|
||
}) {
|
||
final formatError = hsnCode(value, fieldName: fieldName);
|
||
if (formatError != null) return formatError;
|
||
|
||
final normalized = value!.trim();
|
||
for (final record in existingRecords) {
|
||
final recordId = record['id']?.toString();
|
||
if (currentRecordId != null && recordId == currentRecordId) continue;
|
||
|
||
final existingCode = record['code']?.toString().trim();
|
||
if (existingCode != null &&
|
||
existingCode.isNotEmpty &&
|
||
existingCode == normalized) {
|
||
return '$fieldName must be unique';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static List<TextInputFormatter> get hsnCodeInput => [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(8),
|
||
];
|
||
|
||
/// Required master code — allowed: A-Z a-z 0-9 - _ /
|
||
static String? masterCode(String? value, {String fieldName = 'Code'}) {
|
||
final requiredError = required(value, fieldName: fieldName);
|
||
if (requiredError != null) return requiredError;
|
||
|
||
return _validateMasterCodeChars(value!.trim(), fieldName: fieldName);
|
||
}
|
||
|
||
static String? _validateMasterCodeChars(
|
||
String raw, {
|
||
String fieldName = 'Code',
|
||
}) {
|
||
if (!_masterCodePattern.hasMatch(raw)) {
|
||
return '$fieldName can only contain letters, numbers, and - _ /';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Validates master code format and uniqueness against existing records.
|
||
static String? uniqueMasterCode(
|
||
String? value, {
|
||
required String codeKey,
|
||
required Iterable<Map<String, dynamic>> existingRecords,
|
||
String? currentRecordId,
|
||
String fieldName = 'Code',
|
||
}) {
|
||
final formatError = masterCode(value, fieldName: fieldName);
|
||
if (formatError != null) return formatError;
|
||
|
||
final normalized = value!.trim().toLowerCase();
|
||
for (final record in existingRecords) {
|
||
final recordId = record['id']?.toString();
|
||
if (currentRecordId != null && recordId == currentRecordId) continue;
|
||
|
||
final existingCode = record[codeKey]?.toString().trim().toLowerCase();
|
||
if (existingCode != null && existingCode.isNotEmpty && existingCode == normalized) {
|
||
return '$fieldName must be unique';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static List<TextInputFormatter> get masterCodeInput => [
|
||
FilteringTextInputFormatter.allow(_masterCodeCharPattern),
|
||
];
|
||
|
||
/// Required role name — letters, numbers, and spaces only.
|
||
static String? roleName(String? value) {
|
||
final requiredError = required(value, fieldName: 'Role Name');
|
||
if (requiredError != null) return requiredError;
|
||
|
||
final trimmed = value!.trim();
|
||
if (!_roleNamePattern.hasMatch(trimmed)) {
|
||
return 'Role name must not contain special characters';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static List<TextInputFormatter> get roleNameInput => [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9 ]')),
|
||
];
|
||
|
||
/// Resolves validators for master-data and dynamic form fields by key.
|
||
static String? forFieldKey(
|
||
String key,
|
||
String? value, {
|
||
required bool required,
|
||
String fieldName = 'Field',
|
||
}) {
|
||
final normalizedKey = key.trim().toLowerCase();
|
||
|
||
if (normalizedKey == 'phone' || normalizedKey == 'mobile') {
|
||
return required ? mobile(value) : optionalMobile(value);
|
||
}
|
||
if (normalizedKey == 'email') {
|
||
return required ? email(value) : optionalEmail(value);
|
||
}
|
||
if (normalizedKey == 'gstin' ||
|
||
normalizedKey == 'gst_number' ||
|
||
normalizedKey == 'gst') {
|
||
return required ? gstin(value) : optionalGstin(value);
|
||
}
|
||
if (normalizedKey == 'pan') {
|
||
return required ? pan(value) : optionalPan(value);
|
||
}
|
||
if (normalizedKey == 'pincode' ||
|
||
normalizedKey == 'postal_code' ||
|
||
normalizedKey == 'zip') {
|
||
return required ? pincode(value) : optionalPincode(value);
|
||
}
|
||
if (normalizedKey == 'ifsc') {
|
||
return required ? ifsc(value) : optionalIfsc(value);
|
||
}
|
||
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
|
||
return required ? accountNumber(value) : optionalAccountNumber(value);
|
||
}
|
||
if (isMasterNameFieldKey(normalizedKey)) {
|
||
if (required) {
|
||
return masterName(value, fieldName: fieldName);
|
||
}
|
||
if (value != null && value.trim().isNotEmpty) {
|
||
return _validateMasterNameChars(value.trim(), fieldName: fieldName);
|
||
}
|
||
return null;
|
||
}
|
||
if (isMasterCodeFieldKey(normalizedKey)) {
|
||
if (required) {
|
||
return masterCode(value, fieldName: fieldName);
|
||
}
|
||
if (value != null && value.trim().isNotEmpty) {
|
||
return _validateMasterCodeChars(value.trim(), fieldName: fieldName);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (required) {
|
||
return Validators.required(value, fieldName: fieldName);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static List<TextInputFormatter> inputFormattersForFieldKey(String key) {
|
||
final normalizedKey = key.trim().toLowerCase();
|
||
|
||
if (normalizedKey == 'phone' || normalizedKey == 'mobile') {
|
||
return mobileInput;
|
||
}
|
||
if (normalizedKey == 'pincode' ||
|
||
normalizedKey == 'postal_code' ||
|
||
normalizedKey == 'zip') {
|
||
return pincodeInput;
|
||
}
|
||
if (normalizedKey == 'ifsc') {
|
||
return ifscInput;
|
||
}
|
||
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
|
||
return accountNumberInput;
|
||
}
|
||
if (normalizedKey == 'gstin' ||
|
||
normalizedKey == 'gst_number' ||
|
||
normalizedKey == 'gst') {
|
||
return gstinInput;
|
||
}
|
||
if (normalizedKey == 'pan') {
|
||
return panInput;
|
||
}
|
||
if (isMasterNameFieldKey(normalizedKey)) {
|
||
return masterNameInput;
|
||
}
|
||
if (isMasterCodeFieldKey(normalizedKey)) {
|
||
return masterCodeInput;
|
||
}
|
||
return const [];
|
||
}
|
||
|
||
static List<TextInputFormatter> get mobileInput => [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(10),
|
||
];
|
||
|
||
static List<TextInputFormatter> get pincodeInput => [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(6),
|
||
];
|
||
|
||
static List<TextInputFormatter> get accountNumberInput => [
|
||
FilteringTextInputFormatter.digitsOnly,
|
||
LengthLimitingTextInputFormatter(18),
|
||
];
|
||
|
||
static List<TextInputFormatter> get ifscInput => [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
|
||
LengthLimitingTextInputFormatter(11),
|
||
_upperCaseFormatter,
|
||
];
|
||
|
||
static List<TextInputFormatter> get gstinInput => [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
|
||
LengthLimitingTextInputFormatter(15),
|
||
_upperCaseFormatter,
|
||
];
|
||
|
||
static List<TextInputFormatter> get panInput => [
|
||
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
|
||
LengthLimitingTextInputFormatter(10),
|
||
_upperCaseFormatter,
|
||
];
|
||
|
||
static final TextInputFormatter _upperCaseFormatter =
|
||
TextInputFormatter.withFunction(
|
||
(oldValue, newValue) => TextEditingValue(
|
||
text: newValue.text.toUpperCase(),
|
||
selection: newValue.selection,
|
||
),
|
||
);
|
||
}
|