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