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,12 +1154,42 @@ 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.
final minDateStr = validation?['minDate'] as String?;
final maxDateStr = validation?['maxDate'] as String?;
DateTime firstDate = DateTime(1900);
DateTime lastDate = DateTime(2100);
if (minDateStr != null && minDateStr.isNotEmpty) {
final parsed = DateTime.tryParse(minDateStr);
if (parsed != null) firstDate = parsed;
}
if (maxDateStr != null && maxDateStr.isNotEmpty) {
final parsed = DateTime.tryParse(maxDateStr);
if (parsed != null) lastDate = parsed;
}
return FormField<String>(
initialValue: val is String ? val : '',
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (fieldVal) => _validateDate(
value: fieldVal,
isRequired: isRequired,
fieldName: fieldName,
validation: validation,
),
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () async { onTap: () async {
DateTime initial = DateTime.now(); DateTime initial = DateTime.now();
if (val is String && val.isNotEmpty) { if (val is String && val.isNotEmpty) {
@@ -1064,11 +1197,14 @@ class _AgentEditProfileScreenState
initial = DateTime.parse(val); initial = DateTime.parse(val);
} catch (_) {} } catch (_) {}
} }
// Clamp initial into valid range so picker doesn't assert
if (initial.isBefore(firstDate)) initial = firstDate;
if (initial.isAfter(lastDate)) initial = lastDate;
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
initialDate: initial, initialDate: initial,
firstDate: DateTime(1900), firstDate: firstDate,
lastDate: DateTime(2100), lastDate: lastDate,
builder: (ctx, child) => Theme( builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith( data: Theme.of(ctx).copyWith(
colorScheme: const ColorScheme.light( colorScheme: const ColorScheme.light(
@@ -1082,14 +1218,20 @@ class _AgentEditProfileScreenState
), ),
); );
if (picked != null) { if (picked != null) {
setValue(DateFormat('yyyy-MM-dd').format(picked)); final formatted = DateFormat('yyyy-MM-dd').format(picked);
setValue(formatted);
state.didChange(formatted);
} }
}, },
child: Container( child: Container(
height: 44, height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E7EB)), border: Border.all(
color: state.hasError
? Colors.red
: const Color(0xFFE5E7EB),
),
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
child: Row( child: Row(
@@ -1115,6 +1257,22 @@ class _AgentEditProfileScreenState
], ],
), ),
), ),
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
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),
child: Text(
'Log In', 'Log In',
style: TextStyle( style: TextStyle(
fontFamily: 'Fractul', fontFamily: 'Fractul',
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: Colors.white,
height: 1.0,
),
), ),
), ),
], ],