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
// ---------------------------------------------------------------------------
@@ -803,6 +879,8 @@ class _AgentEditProfileScreenState
validation,
false,
controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
);
break;
case 'TEXTAREA':
@@ -814,6 +892,8 @@ class _AgentEditProfileScreenState
validation,
true,
controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
);
break;
case 'NUMBER':
@@ -824,6 +904,8 @@ class _AgentEditProfileScreenState
placeholder,
validation,
controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
);
break;
case 'DATE':
@@ -832,6 +914,9 @@ class _AgentEditProfileScreenState
currentValue,
updateValue,
placeholder,
validation: validation,
isRequired: isRequired,
fieldName: label,
);
break;
case 'CHECKBOX':
@@ -976,6 +1061,8 @@ class _AgentEditProfileScreenState
Map<String, dynamic>? validation,
bool multiline, {
String? controllerKeySuffix,
bool isRequired = false,
String fieldName = 'This field',
}) {
final key = controllerKeySuffix != null
? '${slug}_${controllerKeySuffix}_text'
@@ -1002,6 +1089,13 @@ class _AgentEditProfileScreenState
),
decoration: _inputDecoration(placeholder),
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,
Map<String, dynamic>? validation, {
String? controllerKeySuffix,
bool isRequired = false,
String fieldName = 'This field',
}) {
final key = controllerKeySuffix != null
? '${slug}_${controllerKeySuffix}_number'
@@ -1040,6 +1136,13 @@ class _AgentEditProfileScreenState
),
decoration: _inputDecoration(placeholder),
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,
dynamic Function() getValue,
void Function(dynamic) setValue,
String placeholder,
) {
String placeholder, {
Map<String, dynamic>? validation,
bool isRequired = false,
String fieldName = 'This field',
}) {
final val = getValue();
final displayText = val is String && val.isNotEmpty ? val : placeholder;
return GestureDetector(
onTap: () async {
DateTime initial = DateTime.now();
if (val is String && val.isNotEmpty) {
try {
initial = DateTime.parse(val);
} catch (_) {}
}
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(1900),
lastDate: DateTime(2100),
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) {
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(
// 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: [
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,
GestureDetector(
onTap: () async {
DateTime initial = DateTime.now();
if (val is String && val.isNotEmpty) {
try {
initial = DateTime.parse(val);
} 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(
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(
Icons.calendar_today,
size: 18,
color: AppColors.hintText,
),
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,
String? loginRole,
}) 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(
status: AuthStatus.loading,
errorMessage: null,

View File

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