feat: implement form validation helpers and integrate field-level validation in agent profile screen, reset auth error suppression on login, and clean up home header UI.

This commit is contained in:
pradeepkumar
2026-04-15 15:45:05 +05:30
parent 347cb6a810
commit df32a83692
3 changed files with 225 additions and 67 deletions

View File

@@ -253,6 +253,82 @@ class _AgentEditProfileScreenState
} }
} }
// ---------------------------------------------------------------------------
// Validation helpers (mirrors web `validateFieldValue`)
// ---------------------------------------------------------------------------
String? _validateText({
required String? value,
required bool isRequired,
required String fieldName,
Map<String, dynamic>? validation,
}) {
final str = (value ?? '').trim();
if (isRequired && str.isEmpty) return '$fieldName is required';
if (str.isEmpty) return null;
final v = validation ?? const {};
final minLen = v['minLength'];
final maxLen = v['maxLength'];
final pattern = v['pattern'];
if (minLen is int && str.length < minLen) {
return '$fieldName must be at least $minLen characters';
}
if (maxLen is int && str.length > maxLen) {
return '$fieldName must be no more than $maxLen characters';
}
if (pattern is String && pattern.isNotEmpty) {
try {
if (!RegExp(pattern).hasMatch(str)) {
return '$fieldName has an invalid format';
}
} catch (_) {}
}
return null;
}
String? _validateNumber({
required String? value,
required bool isRequired,
required String fieldName,
Map<String, dynamic>? validation,
}) {
final str = (value ?? '').trim();
if (isRequired && str.isEmpty) return '$fieldName is required';
if (str.isEmpty) return null;
final n = num.tryParse(str);
if (n == null) return '$fieldName must be a number';
final v = validation ?? const {};
final min = v['min'];
final max = v['max'];
if (min is num && n < min) return '$fieldName must be at least $min';
if (max is num && n > max) return '$fieldName must be no more than $max';
return null;
}
String? _validateDate({
required String? value,
required bool isRequired,
required String fieldName,
Map<String, dynamic>? validation,
}) {
final str = (value ?? '').trim();
if (isRequired && str.isEmpty) return '$fieldName is required';
if (str.isEmpty) return null;
if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(str)) {
return '$fieldName must be a valid date';
}
final v = validation ?? const {};
final minDate = v['minDate'];
final maxDate = v['maxDate'];
if (minDate is String && minDate.isNotEmpty && str.compareTo(minDate) < 0) {
return '$fieldName must be on or after $minDate';
}
if (maxDate is String && maxDate.isNotEmpty && str.compareTo(maxDate) > 0) {
return '$fieldName must be on or before $maxDate';
}
return null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Save // Save
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -803,6 +879,8 @@ class _AgentEditProfileScreenState
validation, validation,
false, false,
controllerKeySuffix: controllerKeySuffix, controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
); );
break; break;
case 'TEXTAREA': case 'TEXTAREA':
@@ -814,6 +892,8 @@ class _AgentEditProfileScreenState
validation, validation,
true, true,
controllerKeySuffix: controllerKeySuffix, controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
); );
break; break;
case 'NUMBER': case 'NUMBER':
@@ -824,6 +904,8 @@ class _AgentEditProfileScreenState
placeholder, placeholder,
validation, validation,
controllerKeySuffix: controllerKeySuffix, controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
); );
break; break;
case 'DATE': case 'DATE':
@@ -832,6 +914,9 @@ class _AgentEditProfileScreenState
currentValue, currentValue,
updateValue, updateValue,
placeholder, placeholder,
validation: validation,
isRequired: isRequired,
fieldName: label,
); );
break; break;
case 'CHECKBOX': case 'CHECKBOX':
@@ -976,6 +1061,8 @@ class _AgentEditProfileScreenState
Map<String, dynamic>? validation, Map<String, dynamic>? validation,
bool multiline, { bool multiline, {
String? controllerKeySuffix, String? controllerKeySuffix,
bool isRequired = false,
String fieldName = 'This field',
}) { }) {
final key = controllerKeySuffix != null final key = controllerKeySuffix != null
? '${slug}_${controllerKeySuffix}_text' ? '${slug}_${controllerKeySuffix}_text'
@@ -1002,6 +1089,13 @@ class _AgentEditProfileScreenState
), ),
decoration: _inputDecoration(placeholder), decoration: _inputDecoration(placeholder),
onChanged: (val) => setValue(val), onChanged: (val) => setValue(val),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (val) => _validateText(
value: val,
isRequired: isRequired,
fieldName: fieldName,
validation: validation,
),
); );
} }
@@ -1016,6 +1110,8 @@ class _AgentEditProfileScreenState
String placeholder, String placeholder,
Map<String, dynamic>? validation, { Map<String, dynamic>? validation, {
String? controllerKeySuffix, String? controllerKeySuffix,
bool isRequired = false,
String fieldName = 'This field',
}) { }) {
final key = controllerKeySuffix != null final key = controllerKeySuffix != null
? '${slug}_${controllerKeySuffix}_number' ? '${slug}_${controllerKeySuffix}_number'
@@ -1040,6 +1136,13 @@ class _AgentEditProfileScreenState
), ),
decoration: _inputDecoration(placeholder), decoration: _inputDecoration(placeholder),
onChanged: (val) => setValue(num.tryParse(val)), onChanged: (val) => setValue(num.tryParse(val)),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (val) => _validateNumber(
value: val,
isRequired: isRequired,
fieldName: fieldName,
validation: validation,
),
); );
} }
@@ -1051,70 +1154,125 @@ class _AgentEditProfileScreenState
String slug, String slug,
dynamic Function() getValue, dynamic Function() getValue,
void Function(dynamic) setValue, void Function(dynamic) setValue,
String placeholder, String placeholder, {
) { Map<String, dynamic>? validation,
bool isRequired = false,
String fieldName = 'This field',
}) {
final val = getValue(); final val = getValue();
final displayText = val is String && val.isNotEmpty ? val : placeholder; final displayText = val is String && val.isNotEmpty ? val : placeholder;
return GestureDetector( // Parse admin-configured min/max dates for the picker; fall back to wide range.
onTap: () async { final minDateStr = validation?['minDate'] as String?;
DateTime initial = DateTime.now(); final maxDateStr = validation?['maxDate'] as String?;
if (val is String && val.isNotEmpty) { DateTime firstDate = DateTime(1900);
try { DateTime lastDate = DateTime(2100);
initial = DateTime.parse(val); if (minDateStr != null && minDateStr.isNotEmpty) {
} catch (_) {} final parsed = DateTime.tryParse(minDateStr);
} if (parsed != null) firstDate = parsed;
final picked = await showDatePicker( }
context: context, if (maxDateStr != null && maxDateStr.isNotEmpty) {
initialDate: initial, final parsed = DateTime.tryParse(maxDateStr);
firstDate: DateTime(1900), if (parsed != null) lastDate = parsed;
lastDate: DateTime(2100), }
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith( return FormField<String>(
colorScheme: const ColorScheme.light( initialValue: val is String ? val : '',
primary: AppColors.accentOrange, autovalidateMode: AutovalidateMode.onUserInteraction,
onPrimary: Colors.white, validator: (fieldVal) => _validateDate(
surface: Colors.white, value: fieldVal,
onSurface: AppColors.primaryDark, isRequired: isRequired,
), fieldName: fieldName,
), validation: validation,
child: child!, ),
), builder: (state) {
); return Column(
if (picked != null) { crossAxisAlignment: CrossAxisAlignment.start,
setValue(DateFormat('yyyy-MM-dd').format(picked));
}
},
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E7EB)),
borderRadius: BorderRadius.circular(15),
),
child: Row(
children: [ children: [
Expanded( GestureDetector(
child: Text( onTap: () async {
displayText.isEmpty ? 'Select date' : displayText, DateTime initial = DateTime.now();
style: TextStyle( if (val is String && val.isNotEmpty) {
fontFamily: 'SourceSerif4', try {
fontSize: 14, initial = DateTime.parse(val);
fontWeight: FontWeight.w300, } catch (_) {}
color: val is String && val.isNotEmpty }
? AppColors.primaryDark // Clamp initial into valid range so picker doesn't assert
: AppColors.hintText, if (initial.isBefore(firstDate)) initial = firstDate;
if (initial.isAfter(lastDate)) initial = lastDate;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: firstDate,
lastDate: lastDate,
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: const ColorScheme.light(
primary: AppColors.accentOrange,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.primaryDark,
),
),
child: child!,
),
);
if (picked != null) {
final formatted = DateFormat('yyyy-MM-dd').format(picked);
setValue(formatted);
state.didChange(formatted);
}
},
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(
color: state.hasError
? Colors.red
: const Color(0xFFE5E7EB),
),
borderRadius: BorderRadius.circular(15),
),
child: Row(
children: [
Expanded(
child: Text(
displayText.isEmpty ? 'Select date' : displayText,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w300,
color: val is String && val.isNotEmpty
? AppColors.primaryDark
: AppColors.hintText,
),
),
),
const Icon(
Icons.calendar_today,
size: 18,
color: AppColors.hintText,
),
],
), ),
), ),
), ),
const Icon( if (state.hasError)
Icons.calendar_today, Padding(
size: 18, padding: const EdgeInsets.only(top: 6, left: 4),
color: AppColors.hintText, child: Text(
), state.errorText ?? '',
style: const TextStyle(
color: Colors.red,
fontSize: 12,
fontFamily: 'SourceSerif4',
),
),
),
], ],
), );
), },
); );
} }

View File

@@ -137,6 +137,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
required String password, required String password,
String? loginRole, String? loginRole,
}) async { }) async {
// Reset suppressAuthErrors in case a prior logout left it enabled —
// otherwise the 401 interceptor rejects errors unwrapped and we lose
// the backend's message (e.g. role-mismatch), falling back to generic.
ApiClient.suppressAuthErrors = false;
state = state.copyWith( state = state.copyWith(
status: AuthStatus.loading, status: AuthStatus.loading,
errorMessage: null, errorMessage: null,

View File

@@ -401,17 +401,13 @@ class _MenuDrawer extends ConsumerWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Padding( const Text(
padding: EdgeInsets.only(top: 1), 'Log In',
child: Text( style: TextStyle(
'Log In', fontFamily: 'Fractul',
style: TextStyle( fontSize: 16,
fontFamily: 'Fractul', fontWeight: FontWeight.w700,
fontSize: 16, color: Colors.white,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1.0,
),
), ),
), ),
], ],