2025-08-10 01:20:45 +05:30
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
|
|
|
|
/// Comprehensive input validation service
|
|
|
|
|
class InputValidationService {
|
|
|
|
|
// Email regex pattern
|
|
|
|
|
static final RegExp _emailRegex = RegExp(
|
|
|
|
|
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Strong password regex (min 8 chars, 1 upper, 1 lower, 1 number, 1 special)
|
|
|
|
|
static final RegExp _strongPasswordRegex = RegExp(
|
|
|
|
|
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Validate email address
|
|
|
|
|
static String? validateEmail(String? value) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Email is required';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final trimmed = value.trim();
|
|
|
|
|
if (!_emailRegex.hasMatch(trimmed)) {
|
|
|
|
|
return 'Please enter a valid email address';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
/// Validate URL (enhanced version for server addresses)
|
2025-08-10 01:20:45 +05:30
|
|
|
static String? validateUrl(String? value, {bool required = true}) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
2025-08-16 15:51:27 +05:30
|
|
|
return required ? 'Server address is required' : null;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final trimmed = value.trim();
|
|
|
|
|
|
|
|
|
|
// Add protocol if missing
|
|
|
|
|
String urlToValidate = trimmed;
|
|
|
|
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
2025-08-16 15:51:27 +05:30
|
|
|
urlToValidate = 'http://$trimmed';
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final uri = Uri.parse(urlToValidate);
|
2025-09-24 12:00:49 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
// Validate scheme
|
|
|
|
|
if (!uri.hasScheme || (uri.scheme != 'http' && uri.scheme != 'https')) {
|
|
|
|
|
return 'Use http:// or https:// only';
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
2025-09-24 12:00:49 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
// Validate host
|
|
|
|
|
if (!uri.hasAuthority || uri.host.isEmpty) {
|
|
|
|
|
return 'Please enter a server address (e.g., 192.168.1.10:3000)';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate port if specified
|
|
|
|
|
if (uri.hasPort) {
|
|
|
|
|
if (uri.port < 1 || uri.port > 65535) {
|
|
|
|
|
return 'Port must be between 1 and 65535';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate IP address format if it looks like an IP
|
|
|
|
|
if (_isIPAddress(uri.host) && !_isValidIPAddress(uri.host)) {
|
|
|
|
|
return 'Invalid IP address format (use 192.168.1.10)';
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
} catch (e) {
|
2025-08-16 15:51:27 +05:30
|
|
|
return 'Invalid server address format';
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
/// Check if a string looks like an IP address
|
|
|
|
|
static bool _isIPAddress(String host) {
|
|
|
|
|
return RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate IP address format
|
|
|
|
|
static bool _isValidIPAddress(String ip) {
|
|
|
|
|
final parts = ip.split('.');
|
|
|
|
|
if (parts.length != 4) return false;
|
2025-09-24 12:00:49 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
for (final part in parts) {
|
|
|
|
|
final num = int.tryParse(part);
|
|
|
|
|
if (num == null || num < 0 || num > 255) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
/// Validate password strength
|
|
|
|
|
static String? validatePassword(String? value, {bool checkStrength = true}) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Password is required';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value.length < 8) {
|
|
|
|
|
return 'Password must be at least 8 characters';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (checkStrength && !_strongPasswordRegex.hasMatch(value)) {
|
|
|
|
|
return 'Password must contain uppercase, lowercase, number, and special character';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate confirm password
|
|
|
|
|
static String? validateConfirmPassword(String? value, String password) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Please confirm your password';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value != password) {
|
|
|
|
|
return 'Passwords do not match';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate required field
|
|
|
|
|
static String? validateRequired(
|
|
|
|
|
String? value, {
|
|
|
|
|
String fieldName = 'This field',
|
|
|
|
|
}) {
|
|
|
|
|
if (value == null || value.trim().isEmpty) {
|
|
|
|
|
return '$fieldName is required';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate minimum length
|
|
|
|
|
static String? validateMinLength(
|
|
|
|
|
String? value,
|
|
|
|
|
int minLength, {
|
|
|
|
|
String fieldName = 'This field',
|
|
|
|
|
}) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return '$fieldName is required';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value.length < minLength) {
|
|
|
|
|
return '$fieldName must be at least $minLength characters';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate maximum length
|
|
|
|
|
static String? validateMaxLength(
|
|
|
|
|
String? value,
|
|
|
|
|
int maxLength, {
|
|
|
|
|
String fieldName = 'This field',
|
|
|
|
|
}) {
|
|
|
|
|
if (value != null && value.length > maxLength) {
|
|
|
|
|
return '$fieldName must be at most $maxLength characters';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate numeric input
|
|
|
|
|
static String? validateNumber(
|
|
|
|
|
String? value, {
|
|
|
|
|
double? min,
|
|
|
|
|
double? max,
|
|
|
|
|
bool allowDecimal = true,
|
|
|
|
|
bool required = true,
|
|
|
|
|
}) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return required ? 'Number is required' : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final number = allowDecimal ? double.tryParse(value) : int.tryParse(value);
|
|
|
|
|
|
|
|
|
|
if (number == null) {
|
|
|
|
|
return allowDecimal
|
|
|
|
|
? 'Please enter a valid number'
|
|
|
|
|
: 'Please enter a whole number';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (min != null && number < min) {
|
|
|
|
|
return 'Value must be at least $min';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (max != null && number > max) {
|
|
|
|
|
return 'Value must be at most $max';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate phone number
|
|
|
|
|
static String? validatePhoneNumber(String? value, {bool required = true}) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return required ? 'Phone number is required' : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove all non-digits
|
|
|
|
|
final digitsOnly = value.replaceAll(RegExp(r'\D'), '');
|
|
|
|
|
|
|
|
|
|
if (digitsOnly.length < 10) {
|
|
|
|
|
return 'Please enter a valid phone number';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate alphanumeric input
|
|
|
|
|
static String? validateAlphanumeric(
|
|
|
|
|
String? value, {
|
|
|
|
|
bool allowSpaces = false,
|
|
|
|
|
bool required = true,
|
|
|
|
|
String fieldName = 'This field',
|
|
|
|
|
}) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return required ? '$fieldName is required' : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final pattern = allowSpaces ? r'^[a-zA-Z0-9\s]+$' : r'^[a-zA-Z0-9]+$';
|
|
|
|
|
if (!RegExp(pattern).hasMatch(value)) {
|
|
|
|
|
return allowSpaces
|
|
|
|
|
? '$fieldName can only contain letters, numbers, and spaces'
|
|
|
|
|
: '$fieldName can only contain letters and numbers';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate username
|
|
|
|
|
static String? validateUsername(String? value) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Username is required';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value.length < 3) {
|
|
|
|
|
return 'Username must be at least 3 characters';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value.length > 20) {
|
|
|
|
|
return 'Username must be at most 20 characters';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
|
|
|
|
|
return 'Username can only contain letters, numbers, and underscores';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate email or username (flexible login)
|
|
|
|
|
static String? validateEmailOrUsername(String? value) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Email or username is required';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final trimmed = value.trim();
|
|
|
|
|
|
|
|
|
|
// If it contains @ symbol, validate as email
|
|
|
|
|
if (trimmed.contains('@')) {
|
|
|
|
|
return validateEmail(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise validate as username
|
|
|
|
|
return validateUsername(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Sanitize input to prevent XSS
|
|
|
|
|
static String sanitizeInput(String input) {
|
|
|
|
|
return input
|
|
|
|
|
.replaceAll('<', '<')
|
|
|
|
|
.replaceAll('>', '>')
|
|
|
|
|
.replaceAll('"', '"')
|
|
|
|
|
.replaceAll("'", ''')
|
|
|
|
|
.replaceAll('/', '/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create input formatter for numeric input
|
|
|
|
|
static List<TextInputFormatter> numericInputFormatters({
|
|
|
|
|
bool allowDecimal = true,
|
|
|
|
|
bool allowNegative = false,
|
|
|
|
|
}) {
|
|
|
|
|
return [
|
|
|
|
|
FilteringTextInputFormatter.allow(
|
|
|
|
|
RegExp(
|
|
|
|
|
allowDecimal
|
|
|
|
|
? (allowNegative ? r'[0-9.-]' : r'[0-9.]')
|
|
|
|
|
: (allowNegative ? r'[0-9-]' : r'[0-9]'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create input formatter for alphanumeric input
|
|
|
|
|
static List<TextInputFormatter> alphanumericInputFormatters({
|
|
|
|
|
bool allowSpaces = false,
|
|
|
|
|
}) {
|
|
|
|
|
return [
|
|
|
|
|
FilteringTextInputFormatter.allow(
|
|
|
|
|
RegExp(allowSpaces ? r'[a-zA-Z0-9\s]' : r'[a-zA-Z0-9]'),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create input formatter for phone number
|
|
|
|
|
static List<TextInputFormatter> phoneNumberFormatters() {
|
|
|
|
|
return [
|
|
|
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
|
|
|
LengthLimitingTextInputFormatter(15),
|
|
|
|
|
_PhoneNumberFormatter(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate file size
|
|
|
|
|
static String? validateFileSize(int sizeInBytes, {int maxSizeInMB = 10}) {
|
|
|
|
|
final maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
|
|
|
|
if (sizeInBytes > maxSizeInBytes) {
|
|
|
|
|
return 'File size must be less than ${maxSizeInMB}MB';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate file extension
|
|
|
|
|
static String? validateFileExtension(
|
|
|
|
|
String fileName,
|
|
|
|
|
List<String> allowedExtensions,
|
|
|
|
|
) {
|
|
|
|
|
final extension = fileName.split('.').last.toLowerCase();
|
|
|
|
|
if (!allowedExtensions.contains(extension)) {
|
|
|
|
|
return 'File type not allowed. Allowed types: ${allowedExtensions.join(', ')}';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Composite validator that runs multiple validators
|
|
|
|
|
static String? Function(String?) combine(
|
|
|
|
|
List<String? Function(String?)> validators,
|
|
|
|
|
) {
|
|
|
|
|
return (String? value) {
|
|
|
|
|
for (final validator in validators) {
|
|
|
|
|
final result = validator(value);
|
|
|
|
|
if (result != null) {
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Custom phone number formatter
|
|
|
|
|
class _PhoneNumberFormatter extends TextInputFormatter {
|
|
|
|
|
@override
|
|
|
|
|
TextEditingValue formatEditUpdate(
|
|
|
|
|
TextEditingValue oldValue,
|
|
|
|
|
TextEditingValue newValue,
|
|
|
|
|
) {
|
|
|
|
|
final text = newValue.text;
|
|
|
|
|
|
|
|
|
|
if (text.length <= 3) {
|
|
|
|
|
return newValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (text.length <= 6) {
|
|
|
|
|
final newText = '(${text.substring(0, 3)}) ${text.substring(3)}';
|
|
|
|
|
return TextEditingValue(
|
|
|
|
|
text: newText,
|
|
|
|
|
selection: TextSelection.collapsed(offset: newText.length),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (text.length <= 10) {
|
|
|
|
|
final newText =
|
|
|
|
|
'(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6)}';
|
|
|
|
|
return TextEditingValue(
|
|
|
|
|
text: newText,
|
|
|
|
|
selection: TextSelection.collapsed(offset: newText.length),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final newText =
|
|
|
|
|
'(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6, 10)}';
|
|
|
|
|
return TextEditingValue(
|
|
|
|
|
text: newText,
|
|
|
|
|
selection: TextSelection.collapsed(offset: newText.length),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Form field wrapper with validation
|
|
|
|
|
class ValidatedFormField extends StatefulWidget {
|
|
|
|
|
final String label;
|
|
|
|
|
final String? hint;
|
|
|
|
|
final TextEditingController controller;
|
|
|
|
|
final String? Function(String?) validator;
|
|
|
|
|
final List<TextInputFormatter>? inputFormatters;
|
|
|
|
|
final TextInputType? keyboardType;
|
|
|
|
|
final bool obscureText;
|
|
|
|
|
final Widget? suffixIcon;
|
|
|
|
|
final bool autofocus;
|
|
|
|
|
final void Function(String)? onChanged;
|
|
|
|
|
final void Function(String)? onFieldSubmitted;
|
|
|
|
|
final FocusNode? focusNode;
|
|
|
|
|
final int? maxLines;
|
|
|
|
|
final bool enabled;
|
|
|
|
|
|
|
|
|
|
const ValidatedFormField({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.label,
|
|
|
|
|
this.hint,
|
|
|
|
|
required this.controller,
|
|
|
|
|
required this.validator,
|
|
|
|
|
this.inputFormatters,
|
|
|
|
|
this.keyboardType,
|
|
|
|
|
this.obscureText = false,
|
|
|
|
|
this.suffixIcon,
|
|
|
|
|
this.autofocus = false,
|
|
|
|
|
this.onChanged,
|
|
|
|
|
this.onFieldSubmitted,
|
|
|
|
|
this.focusNode,
|
|
|
|
|
this.maxLines = 1,
|
|
|
|
|
this.enabled = true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<ValidatedFormField> createState() => _ValidatedFormFieldState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ValidatedFormFieldState extends State<ValidatedFormField> {
|
|
|
|
|
String? _errorText;
|
|
|
|
|
bool _hasInteracted = false;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
widget.controller.addListener(_validate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
widget.controller.removeListener(_validate);
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _validate() {
|
|
|
|
|
if (!_hasInteracted) return;
|
|
|
|
|
|
|
|
|
|
final error = widget.validator(widget.controller.text);
|
|
|
|
|
if (error != _errorText) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_errorText = error;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return TextFormField(
|
|
|
|
|
controller: widget.controller,
|
|
|
|
|
focusNode: widget.focusNode,
|
|
|
|
|
validator: (value) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_hasInteracted = true;
|
|
|
|
|
});
|
|
|
|
|
return widget.validator(value);
|
|
|
|
|
},
|
|
|
|
|
inputFormatters: widget.inputFormatters,
|
|
|
|
|
keyboardType: widget.keyboardType,
|
|
|
|
|
obscureText: widget.obscureText,
|
|
|
|
|
autofocus: widget.autofocus,
|
|
|
|
|
maxLines: widget.maxLines,
|
|
|
|
|
enabled: widget.enabled,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
if (!_hasInteracted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_hasInteracted = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
_validate();
|
|
|
|
|
widget.onChanged?.call(value);
|
|
|
|
|
},
|
|
|
|
|
onFieldSubmitted: widget.onFieldSubmitted,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: widget.label,
|
|
|
|
|
hintText: widget.hint,
|
|
|
|
|
errorText: _errorText,
|
|
|
|
|
suffixIcon: widget.suffixIcon,
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|