chore: initial release
This commit is contained in:
457
lib/core/services/input_validation_service.dart
Normal file
457
lib/core/services/input_validation_service.dart
Normal file
@@ -0,0 +1,457 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/// Validate URL
|
||||
static String? validateUrl(String? value, {bool required = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'URL is required' : null;
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
|
||||
// Add protocol if missing
|
||||
String urlToValidate = trimmed;
|
||||
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||
urlToValidate = 'https://$trimmed';
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(urlToValidate);
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user