From df32a836928ec9c22d85208b51ddeec767507d59 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Wed, 15 Apr 2026 15:45:05 +0530 Subject: [PATCH] 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. --- .../screens/agent_edit_profile_screen.dart | 270 ++++++++++++++---- .../presentation/providers/auth_provider.dart | 4 + .../presentation/widgets/home_header.dart | 18 +- 3 files changed, 225 insertions(+), 67 deletions(-) diff --git a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart index f005473..e91fe22 100644 --- a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart +++ b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart @@ -253,6 +253,82 @@ class _AgentEditProfileScreenState } } + // --------------------------------------------------------------------------- + // Validation helpers (mirrors web `validateFieldValue`) + // --------------------------------------------------------------------------- + + String? _validateText({ + required String? value, + required bool isRequired, + required String fieldName, + Map? 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? 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? 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? 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? 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? 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( + 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', + ), + ), + ), ], - ), - ), + ); + }, ); } diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index 28c5d8d..d023aa7 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -137,6 +137,10 @@ class AuthNotifier extends StateNotifier { 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, diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 607488d..258cc2d 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -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, ), ), ],