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:
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user