import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/features/profile/data/profile_repository.dart'; // --------------------------------------------------------------------------- // Data loading provider // --------------------------------------------------------------------------- /// Bundles all three API calls needed to render the edit-profile form. final _editProfileDataProvider = FutureProvider.autoDispose<_EditProfileData>(( ref, ) async { final repo = ProfileRepository(); // 1. Fetch agent profile (need agentTypeId + pre-fill values) final profile = await repo.getMyProfile('AGENT'); // 2. Resolve agentTypeId final agentTypeId = profile['agentTypeId'] as String? ?? (profile['agentType'] as Map?)?['id'] as String?; if (agentTypeId == null || agentTypeId.isEmpty) { throw Exception('Agent type not found on profile'); } // 3 + 4. Fetch sections & field-values in parallel final results = await Future.wait([ repo.getSectionsForAgentType(agentTypeId), repo.getFieldValues(), ]); // getSectionsForAgentType returns { agentType: {...}, sections: [...] } final sectionsResponse = results[0]; final sections = (sectionsResponse['sections'] as List?) ?? []; // getFieldValues returns { agentProfileId: "...", fieldValues: [...] } final fieldValuesResponse = results[1]; final fieldValues = (fieldValuesResponse['fieldValues'] as List?) ?? []; return _EditProfileData( profile: profile, sections: sections, fieldValues: fieldValues, ); }); class _EditProfileData { final Map profile; final List sections; final List fieldValues; const _EditProfileData({ required this.profile, required this.sections, required this.fieldValues, }); } // --------------------------------------------------------------------------- // Screen // --------------------------------------------------------------------------- class AgentEditProfileScreen extends ConsumerStatefulWidget { const AgentEditProfileScreen({super.key}); @override ConsumerState createState() => _AgentEditProfileScreenState(); } class _AgentEditProfileScreenState extends ConsumerState { /// Master form data keyed by field slug. final Map _formData = {}; /// Repeatable section entries keyed by section slug. final Map>> _repeatableEntries = {}; /// Tag-input controllers keyed by field slug. final Map _tagControllers = {}; /// All text controllers for cleanup. final List _controllers = []; bool _saving = false; bool _initialized = false; final _formKey = GlobalKey(); // Pre-fill slug mapping to profile keys static const _profileSlugMap = { 'first-name': 'firstName', 'first_name': 'firstName', 'last-name': 'lastName', 'last_name': 'lastName', 'email': 'email', 'bio': 'bio', 'license-number': 'licenseNumber', 'license_number': 'licenseNumber', 'experience': 'experience', 'specializations': 'specializations', 'languages': 'languages', 'service-areas': 'serviceAreas', 'service_areas': 'serviceAreas', }; @override void dispose() { for (final c in _controllers) { c.dispose(); } for (final c in _tagControllers.values) { c.dispose(); } super.dispose(); } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- void _initializeFormData(_EditProfileData data) { if (_initialized) return; _initialized = true; final profile = data.profile; // Build field-value lookup by slug final fvMap = {}; for (final fv in data.fieldValues) { if (fv is Map) { final slug = fv['fieldSlug'] as String? ?? ''; if (slug.isNotEmpty) { fvMap[slug] = fv['value']; } } } // Walk sections & fields for (final section in data.sections) { if (section is! Map) continue; final isRepeatable = section['isRepeatable'] == true; final sectionSlug = section['slug'] as String? ?? ''; final fields = section['fields'] as List? ?? []; if (isRepeatable) { // Load existing repeatable entries from field values final entriesKey = '${sectionSlug}_entries'; final existingEntries = fvMap[entriesKey]; if (existingEntries is List && existingEntries.isNotEmpty) { _repeatableEntries[sectionSlug] = existingEntries .map( (e) => e is Map ? Map.from(e) : {}, ) .toList(); } else { _repeatableEntries[sectionSlug] = [{}]; } } for (final field in fields) { if (field is! Map) continue; final slug = field['slug'] as String? ?? ''; final fieldType = field['fieldType'] as String? ?? 'TEXT'; if (slug.isEmpty) continue; // Priority: fieldValues API > profile pre-fill > defaultValue dynamic value; // 1. Field values from API if (fvMap.containsKey(slug)) { value = fvMap[slug]; } // 2. Profile pre-fill else if (_profileSlugMap.containsKey(slug)) { value = profile[_profileSlugMap[slug]!]; } // 3. Default value else { value = field['defaultValue']; } // Normalize value based on field type if (!isRepeatable) { _formData[slug] = _normalizeValue(value, fieldType, field); } } } } dynamic _normalizeValue( dynamic value, String fieldType, Map field, ) { switch (fieldType) { case 'CHECKBOX': if (value is bool) return value; if (value is String) return value.toLowerCase() == 'true'; return false; case 'CHECKBOX_GROUP': case 'MULTI_SELECT': case 'TAG_INPUT': if (value is List) return List.from(value.map((e) => '$e')); if (value is String && value.isNotEmpty) { try { final decoded = jsonDecode(value); if (decoded is List) { return List.from(decoded.map((e) => '$e')); } } catch (_) {} return [value]; } return []; case 'NUMBER': if (value is num) return value; if (value is String) return num.tryParse(value); return null; case 'RANGE': if (value is Map) return value; if (value is String && value.isNotEmpty) { try { return jsonDecode(value); } catch (_) {} } final rangeConfig = field['rangeConfig'] as Map?; final min = (rangeConfig?['min'] as num?)?.toDouble() ?? 0; final max = (rangeConfig?['max'] as num?)?.toDouble() ?? 100; return {'min': min, 'max': max}; case 'FILE': case 'FILE_UPLOAD': // Keep as list of maps — don't stringify if (value is List) return value; if (value is String && value.isNotEmpty) { try { final decoded = jsonDecode(value); if (decoded is List) return decoded; } catch (_) {} } return >[]; default: return value?.toString() ?? ''; } } // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- Future _save(_EditProfileData data) async { if (!(_formKey.currentState?.validate() ?? false)) return; setState(() => _saving = true); try { final repo = ProfileRepository(); final fieldValuesToSave = >[]; // Collect all form data for (final entry in _formData.entries) { dynamic val = entry.value; // Convert lists to JSON-compatible format if (val is List) { val = val; } else if (val is Map) { val = val; } fieldValuesToSave.add({'fieldSlug': entry.key, 'value': val}); } // Add repeatable section entries for (final entry in _repeatableEntries.entries) { fieldValuesToSave.add({ 'fieldSlug': '${entry.key}_entries', 'value': entry.value, }); } // Save field values await repo.saveFieldValues(fieldValuesToSave); // Sync phone to main profile if present const phoneSlugs = [ 'phone_number', 'phone-number', 'cell_number', 'cell-number', 'office_number', 'office-number', 'phone', ]; for (final slug in phoneSlugs) { if (_formData.containsKey(slug)) { final phoneVal = _formData[slug]; if (phoneVal is String && phoneVal.isNotEmpty) { await repo.updateProfile('AGENT', {'phone': phoneVal}); break; } } } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Profile updated successfully'), backgroundColor: AppColors.success, ), ); context.pop(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to save: $e'), backgroundColor: AppColors.error, ), ); } } finally { if (mounted) setState(() => _saving = false); } } // --------------------------------------------------------------------------- // Build // --------------------------------------------------------------------------- @override Widget build(BuildContext context) { final asyncData = ref.watch(_editProfileDataProvider); return Column( children: [ // Header with back button Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ GestureDetector( onTap: () { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } else { context.go('/home'); } }, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: AppColors.primaryDark.withValues(alpha: 0.08), shape: BoxShape.circle, ), child: const Icon( Icons.arrow_back_ios_new_rounded, color: AppColors.primaryDark, size: 18, ), ), ), const SizedBox(width: 12), const Text( 'Edit Profile', style: TextStyle( fontFamily: 'Fractul', fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.primaryDark, ), ), ], ), ), Expanded(child: _buildBody(asyncData)), ], ); } Widget _buildBody(AsyncValue<_EditProfileData> asyncData) { return asyncData.when( loading: () => const Center( child: CircularProgressIndicator( color: AppColors.accentOrange, strokeWidth: 2, ), ), error: (err, _) => Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: AppColors.error), const SizedBox(height: 16), Text( 'Failed to load profile data', style: const TextStyle( fontFamily: 'Fractul', fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primaryDark, ), ), const SizedBox(height: 8), Text( '$err', textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, color: AppColors.hintText, ), ), const SizedBox(height: 24), ElevatedButton( onPressed: () => ref.invalidate(_editProfileDataProvider), style: ElevatedButton.styleFrom( backgroundColor: AppColors.accentOrange, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), ), child: const Text( 'Retry', style: TextStyle(color: Colors.white), ), ), ], ), ), ), data: (data) { _initializeFormData(data); return _buildForm(data); }, ); } Widget _buildForm(_EditProfileData data) { final sections = data.sections .whereType>() .where((s) => s['isActive'] != false) .toList() ..sort( (a, b) => ((a['sortOrder'] as num?) ?? 0).compareTo( (b['sortOrder'] as num?) ?? 0, ), ); return Form( key: _formKey, child: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( children: [ for (final section in sections) ...[ _buildSection(section), const SizedBox(height: 16), ], const SizedBox(height: 24), ], ), ), ), _buildBottomButtons(data), ], ), ); } // --------------------------------------------------------------------------- // Bottom buttons // --------------------------------------------------------------------------- Widget _buildBottomButtons(_EditProfileData data) { return Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, -2), ), ], ), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: _saving ? null : () { // Reset form to original values (reload from provider) setState(() { _formData.clear(); _repeatableEntries.clear(); _initialized = false; for (final c in _controllers) { c.dispose(); } _controllers.clear(); for (final c in _tagControllers.values) { c.dispose(); } _tagControllers.clear(); }); // Re-initialize will happen on next build via _initializeFormData }, style: OutlinedButton.styleFrom( side: const BorderSide(color: AppColors.primaryDark), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), padding: const EdgeInsets.symmetric(vertical: 14), ), child: const Text( 'Cancel', style: TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: _saving ? null : () => _save(data), style: ElevatedButton.styleFrom( backgroundColor: AppColors.accentOrange, disabledBackgroundColor: AppColors.accentOrange.withValues( alpha: 0.5, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), padding: const EdgeInsets.symmetric(vertical: 14), ), child: _saving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text( 'Save', style: TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white, ), ), ), ), ], ), ); } // --------------------------------------------------------------------------- // Section card // --------------------------------------------------------------------------- Widget _buildSection(Map section) { final name = section['name'] as String? ?? ''; final iconName = section['icon'] as String?; final isRepeatable = section['isRepeatable'] == true; final sectionSlug = section['slug'] as String? ?? ''; final fields = (section['fields'] as List? ?? []) .whereType>() .where( (f) => f['isActive'] != false && f['isSearchableOnly'] != true, ) .toList() ..sort( (a, b) => ((a['sortOrder'] as num?) ?? 0).compareTo( (b['sortOrder'] as num?) ?? 0, ), ); if (fields.isEmpty) return const SizedBox.shrink(); return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(15), border: Border.all(color: const Color(0xFFE5E7EB)), boxShadow: const [ BoxShadow( color: Color(0x80D9D9D9), blurRadius: 20, offset: Offset(0, 10), ), ], ), padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ if (iconName != null && iconName.isNotEmpty) ...[ Icon( _resolveIcon(iconName), size: 20, color: AppColors.accentOrange, ), const SizedBox(width: 8), ], Expanded( child: Text( name, style: const TextStyle( fontFamily: 'Fractul', fontSize: 15, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), ), ), ], ), const SizedBox(height: 16), // Fields if (isRepeatable) _buildRepeatableFields(sectionSlug, fields) else ...fields.map((f) => _buildFieldWidget(f)), ], ), ); } // --------------------------------------------------------------------------- // Repeatable section // --------------------------------------------------------------------------- Widget _buildRepeatableFields( String sectionSlug, List> fields, ) { final entries = _repeatableEntries[sectionSlug] ?? [{}]; return Column( children: [ for (int i = 0; i < entries.length; i++) ...[ if (i > 0) const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Divider(height: 1, color: Color(0xFFE5E7EB)), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Entry ${i + 1}', style: const TextStyle( fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.hintText, ), ), if (entries.length > 1) GestureDetector( onTap: () { setState(() { entries.removeAt(i); // Clear cached controllers for this section's entries // so they get recreated with correct values after // re-indexing final fieldSlugs = fields.map((f) => f['slug'] as String? ?? ''); _tagControllers.removeWhere((key, _) => fieldSlugs.any((s) => key.startsWith('${s}_entry'))); }); }, child: const Icon( Icons.remove_circle_outline, size: 20, color: AppColors.error, ), ), ], ), const SizedBox(height: 8), ...fields.map((f) { final slug = f['slug'] as String? ?? ''; return _buildFieldWidget( f, getValue: () => entries[i][slug], setValue: (val) { setState(() { entries[i][slug] = val; }); }, controllerKeySuffix: 'entry$i', ); }), ], const SizedBox(height: 12), GestureDetector( onTap: () { setState(() { entries.add({}); }); }, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon( Icons.add_circle_outline, size: 20, color: AppColors.accentOrange, ), SizedBox(width: 6), Text( 'Add Entry', style: TextStyle( fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.accentOrange, ), ), ], ), ), ], ); } // --------------------------------------------------------------------------- // Field dispatcher // --------------------------------------------------------------------------- Widget _buildFieldWidget( Map field, { dynamic Function()? getValue, void Function(dynamic)? setValue, String? controllerKeySuffix, }) { final slug = field['slug'] as String? ?? ''; final fieldType = field['fieldType'] as String? ?? 'TEXT'; final label = field['name'] as String? ?? ''; final placeholder = field['placeholder'] as String? ?? ''; final description = field['description'] as String?; final isRequired = field['isRequired'] == true; final validation = field['validation'] as Map?; final options = (field['options'] as List?) ?.whereType>() .toList() ?? []; final uiConfig = field['uiConfig'] as Map?; final helpText = uiConfig?['helpText'] as String?; // Getter / setter — either use repeatable entry or global formData dynamic currentValue() => getValue != null ? getValue() : _formData[slug]; void updateValue(dynamic val) { if (setValue != null) { setValue(val); } else { setState(() { _formData[slug] = val; }); } } Widget fieldWidget; switch (fieldType) { case 'TEXT': fieldWidget = _buildTextField( slug, currentValue, updateValue, placeholder, validation, false, controllerKeySuffix: controllerKeySuffix, isRequired: isRequired, fieldName: label, ); break; case 'TEXTAREA': fieldWidget = _buildTextField( slug, currentValue, updateValue, placeholder, validation, true, controllerKeySuffix: controllerKeySuffix, isRequired: isRequired, fieldName: label, ); break; case 'NUMBER': fieldWidget = _buildNumberField( slug, currentValue, updateValue, placeholder, validation, controllerKeySuffix: controllerKeySuffix, isRequired: isRequired, fieldName: label, ); break; case 'DATE': fieldWidget = _buildDateField( slug, currentValue, updateValue, placeholder, validation: validation, isRequired: isRequired, fieldName: label, ); break; case 'CHECKBOX': fieldWidget = _buildCheckboxField( slug, currentValue, updateValue, label, ); // Checkbox renders its own label, so skip the standard label wrapper. return Padding( padding: const EdgeInsets.only(bottom: 16), child: fieldWidget, ); case 'CHECKBOX_GROUP': fieldWidget = _buildCheckboxGroup( slug, currentValue, updateValue, options, uiConfig, ); break; case 'RADIO': fieldWidget = _buildRadioGroup( slug, currentValue, updateValue, options, ); break; case 'SELECT': fieldWidget = _buildSelectField( slug, currentValue, updateValue, placeholder, options, ); break; case 'MULTI_SELECT': fieldWidget = _buildMultiSelect( slug, currentValue, updateValue, options, ); break; case 'TAG_INPUT': fieldWidget = _buildTagInput( slug, currentValue, updateValue, placeholder, controllerKeySuffix: controllerKeySuffix, ); break; case 'RANGE': fieldWidget = _buildRangeField(slug, currentValue, updateValue, field); break; case 'FILE': case 'FILE_UPLOAD': fieldWidget = _buildFileUpload(slug, currentValue, updateValue); break; case 'REPEATER': return const SizedBox.shrink(); default: fieldWidget = _buildTextField( slug, currentValue, updateValue, placeholder, validation, false, controllerKeySuffix: controllerKeySuffix, ); } return Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Label RichText( text: TextSpan( text: label, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), children: isRequired ? [ const TextSpan( text: ' *', style: TextStyle(color: AppColors.error), ), ] : null, ), ), if (description != null && description.isNotEmpty) ...[ const SizedBox(height: 2), Text( description, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 12, color: AppColors.hintText, ), ), ], if (helpText != null && helpText.isNotEmpty) ...[ const SizedBox(height: 2), Text( helpText, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 12, color: AppColors.hintText, ), ), ], const SizedBox(height: 6), fieldWidget, ], ), ); } // --------------------------------------------------------------------------- // TEXT / TEXTAREA // --------------------------------------------------------------------------- Widget _buildTextField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, Map? validation, bool multiline, { String? controllerKeySuffix, bool isRequired = false, String fieldName = 'This field', }) { final key = controllerKeySuffix != null ? '${slug}_${controllerKeySuffix}_text' : '${slug}_text'; if (!_tagControllers.containsKey(key)) { final controller = TextEditingController(text: '${getValue() ?? ''}'); _tagControllers[key] = controller; _controllers.add(controller); } final controller = _tagControllers[key]!; final maxLength = validation?['maxLength'] as int?; return TextFormField( controller: controller, maxLines: multiline ? 5 : 1, minLines: multiline ? 3 : 1, maxLength: maxLength != null && maxLength > 0 ? maxLength : null, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.primaryDark, ), decoration: _inputDecoration(placeholder), onChanged: (val) => setValue(val), autovalidateMode: AutovalidateMode.onUserInteraction, validator: (val) => _validateText( value: val, isRequired: isRequired, fieldName: fieldName, validation: validation, ), ); } // --------------------------------------------------------------------------- // NUMBER // --------------------------------------------------------------------------- Widget _buildNumberField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, Map? validation, { String? controllerKeySuffix, bool isRequired = false, String fieldName = 'This field', }) { final key = controllerKeySuffix != null ? '${slug}_${controllerKeySuffix}_number' : '${slug}_number'; if (!_tagControllers.containsKey(key)) { final val = getValue(); final controller = TextEditingController(text: val != null ? '$val' : ''); _tagControllers[key] = controller; _controllers.add(controller); } final controller = _tagControllers[key]!; return TextFormField( controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))], style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.primaryDark, ), decoration: _inputDecoration(placeholder), onChanged: (val) => setValue(num.tryParse(val)), autovalidateMode: AutovalidateMode.onUserInteraction, validator: (val) => _validateNumber( value: val, isRequired: isRequired, fieldName: fieldName, validation: validation, ), ); } // --------------------------------------------------------------------------- // DATE // --------------------------------------------------------------------------- Widget _buildDateField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, { Map? validation, bool isRequired = false, String fieldName = 'This field', }) { final val = getValue(); // Display as MM-dd-yyyy (US format) while keeping ISO (yyyy-MM-dd) on the wire. String displayText = placeholder; if (val is String && val.isNotEmpty) { final parsed = DateTime.tryParse(val); displayText = parsed != null ? DateFormat('MM-dd-yyyy').format(parsed) : val; } // 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: [ 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, ), ], ), ), ), 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', ), ), ), ], ); }, ); } // --------------------------------------------------------------------------- // CHECKBOX (single toggle) // --------------------------------------------------------------------------- Widget _buildCheckboxField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String label, ) { final checked = getValue() == true; return GestureDetector( onTap: () => setValue(!checked), child: Row( children: [ Container( width: 17, height: 17, decoration: BoxDecoration( color: checked ? AppColors.accentOrange : Colors.white, borderRadius: BorderRadius.circular(5), border: Border.all( color: checked ? AppColors.accentOrange : const Color(0xFFD1D5DB), ), ), child: checked ? const Icon(Icons.check, size: 13, color: Colors.white) : null, ), const SizedBox(width: 10), Expanded( child: Text( label, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), ), ), ], ), ); } // --------------------------------------------------------------------------- // CHECKBOX_GROUP // --------------------------------------------------------------------------- Widget _buildCheckboxGroup( String slug, dynamic Function() getValue, void Function(dynamic) setValue, List> options, Map? uiConfig, ) { final selected = List.from((getValue() ?? []) as List); final columns = (uiConfig?['columns'] as num?)?.toInt() ?? 2; return Wrap( spacing: 8, runSpacing: 8, children: options.map((opt) { final optVal = opt['value'] as String? ?? ''; final optLabel = opt['label'] as String? ?? optVal; final isChecked = selected.contains(optVal); return GestureDetector( onTap: () { final newList = List.from(selected); if (isChecked) { newList.remove(optVal); } else { newList.add(optVal); } setValue(newList); }, child: SizedBox( width: columns > 1 ? (MediaQuery.of(context).size.width - 72) / columns : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 17, height: 17, decoration: BoxDecoration( color: isChecked ? AppColors.accentOrange : Colors.white, borderRadius: BorderRadius.circular(5), border: Border.all( color: isChecked ? AppColors.accentOrange : const Color(0xFFD1D5DB), ), ), child: isChecked ? const Icon(Icons.check, size: 13, color: Colors.white) : null, ), const SizedBox(width: 8), Flexible( child: Text( optLabel, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, fontWeight: FontWeight.w300, color: AppColors.primaryDark, ), ), ), ], ), ), ); }).toList(), ); } // --------------------------------------------------------------------------- // RADIO // --------------------------------------------------------------------------- Widget _buildRadioGroup( String slug, dynamic Function() getValue, void Function(dynamic) setValue, List> options, ) { final currentVal = '${getValue() ?? ''}'; return Column( children: options.map((opt) { final optVal = opt['value'] as String? ?? ''; final optLabel = opt['label'] as String? ?? optVal; return GestureDetector( onTap: () => setValue(optVal), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Container( width: 20, height: 20, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: currentVal == optVal ? AppColors.accentOrange : const Color(0xFFD1D5DB), width: 2, ), ), child: currentVal == optVal ? Center( child: Container( width: 10, height: 10, decoration: const BoxDecoration( shape: BoxShape.circle, color: AppColors.accentOrange, ), ), ) : null, ), const SizedBox(width: 10), Expanded( child: Text( optLabel, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.primaryDark, ), ), ), ], ), ), ); }).toList(), ); } // --------------------------------------------------------------------------- // SELECT (dropdown) // --------------------------------------------------------------------------- Widget _buildSelectField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, List> options, ) { final currentVal = '${getValue() ?? ''}'; final hasMatch = options.any((o) => o['value'] == currentVal); return DropdownButtonFormField( initialValue: hasMatch ? currentVal : null, isExpanded: true, decoration: _inputDecoration(placeholder), icon: const Icon(Icons.keyboard_arrow_down, color: AppColors.hintText), style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.primaryDark, ), hint: Text( placeholder.isEmpty ? 'Select...' : placeholder, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.hintText, ), ), items: options.map((opt) { final v = opt['value'] as String? ?? ''; final l = opt['label'] as String? ?? v; return DropdownMenuItem(value: v, child: Text(l)); }).toList(), onChanged: (val) { if (val != null) setValue(val); }, ); } // --------------------------------------------------------------------------- // MULTI_SELECT (searchable dropdown — matches web's react-select pattern) // --------------------------------------------------------------------------- Widget _buildMultiSelect( String slug, dynamic Function() getValue, void Function(dynamic) setValue, List> options, ) { final selected = List.from((getValue() ?? []) as List); String labelFor(String val) { final match = options.firstWhere( (o) => (o['value'] as String? ?? '') == val, orElse: () => {}, ); return (match['label'] as String?) ?? val; } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Tap target that opens the searchable picker GestureDetector( onTap: () => _openMultiSelectPicker( title: slug, options: options, selected: selected, onChanged: setValue, ), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFD1D5DB)), ), child: Row( children: [ Expanded( child: Text( selected.isEmpty ? 'Select...' : '${selected.length} selected', style: TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: selected.isEmpty ? AppColors.hintText : AppColors.primaryDark, ), ), ), const Icon(Icons.keyboard_arrow_down, color: AppColors.hintText), ], ), ), ), // Selected chips preview (tap × to remove) if (selected.isNotEmpty) ...[ const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: selected.map((val) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppColors.accentOrange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.accentOrange), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( labelFor(val), style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, fontWeight: FontWeight.w400, color: AppColors.accentOrange, ), ), const SizedBox(width: 6), GestureDetector( onTap: () { final newList = List.from(selected) ..remove(val); setValue(newList); }, child: const Icon( Icons.close_rounded, size: 16, color: AppColors.accentOrange, ), ), ], ), ); }).toList(), ), ], ], ); } Future _openMultiSelectPicker({ required String title, required List> options, required List selected, required void Function(dynamic) onChanged, }) async { final searchController = TextEditingController(); final workingSelected = List.from(selected); String query = ''; await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) { return StatefulBuilder( builder: (ctx, setSheetState) { final filtered = options.where((o) { if (query.isEmpty) return true; final l = (o['label'] as String? ?? '').toLowerCase(); return l.contains(query.toLowerCase()); }).toList(); return SafeArea( child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(ctx).viewInsets.bottom, ), child: FractionallySizedBox( heightFactor: 0.85, child: Column( children: [ // Drag handle Container( width: 40, height: 4, margin: const EdgeInsets.only(top: 10, bottom: 14), decoration: BoxDecoration( color: const Color(0xFFD1D5DB), borderRadius: BorderRadius.circular(2), ), ), // Search Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TextField( controller: searchController, onChanged: (v) => setSheetState(() => query = v), decoration: InputDecoration( hintText: 'Search...', prefixIcon: const Icon(Icons.search, color: AppColors.hintText), filled: true, fillColor: const Color(0xFFF5F5F5), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( vertical: 0, horizontal: 12), ), ), ), const SizedBox(height: 8), Expanded( child: ListView.builder( itemCount: filtered.length, itemBuilder: (_, i) { final opt = filtered[i]; final v = opt['value'] as String? ?? ''; final l = opt['label'] as String? ?? v; final isSelected = workingSelected.contains(v); return CheckboxListTile( value: isSelected, onChanged: (_) { setSheetState(() { if (isSelected) { workingSelected.remove(v); } else { workingSelected.add(v); } }); }, title: Text( l, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, color: AppColors.primaryDark, ), ), controlAffinity: ListTileControlAffinity.leading, activeColor: AppColors.accentOrange, dense: true, ); }, ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: () => Navigator.pop(ctx), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric( vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: const Text('Cancel'), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: () { onChanged(workingSelected); Navigator.pop(ctx); }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.accentOrange, padding: const EdgeInsets.symmetric( vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: const Text( 'Done', style: TextStyle( color: Colors.white, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), ], ), ), ), ); }, ); }, ); searchController.dispose(); } // --------------------------------------------------------------------------- // TAG_INPUT // --------------------------------------------------------------------------- Widget _buildTagInput( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, { String? controllerKeySuffix, }) { final tags = List.from((getValue() ?? []) as List); final tagKey = controllerKeySuffix != null ? '${slug}_${controllerKeySuffix}' : slug; if (!_tagControllers.containsKey(tagKey)) { final controller = TextEditingController(); _tagControllers[tagKey] = controller; _controllers.add(controller); } final controller = _tagControllers[tagKey]!; void addTag(String text) { final trimmed = text.trim(); if (trimmed.isEmpty || tags.contains(trimmed)) return; final newList = List.from(tags)..add(trimmed); setValue(newList); controller.clear(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (tags.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Wrap( spacing: 6, runSpacing: 6, children: tags.map((tag) { return Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: AppColors.accentOrange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), border: Border.all( color: AppColors.accentOrange.withValues(alpha: 0.3), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( tag, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), ), const SizedBox(width: 4), GestureDetector( onTap: () { final newList = List.from(tags)..remove(tag); setValue(newList); }, child: const Icon( Icons.close, size: 14, color: AppColors.hintText, ), ), ], ), ); }).toList(), ), ), TextFormField( controller: controller, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.primaryDark, ), decoration: _inputDecoration( placeholder.isEmpty ? 'Type and press Enter' : placeholder, ).copyWith( suffixIcon: IconButton( icon: const Icon( Icons.add_circle_outline, color: AppColors.accentOrange, ), onPressed: () => addTag(controller.text), ), ), onFieldSubmitted: addTag, ), ], ); } // --------------------------------------------------------------------------- // RANGE // --------------------------------------------------------------------------- Widget _buildRangeField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, Map field, ) { final rangeConfig = field['rangeConfig'] as Map? ?? {}; final configMin = (rangeConfig['min'] as num?)?.toDouble() ?? 0; final configMax = (rangeConfig['max'] as num?)?.toDouble() ?? 100; final step = (rangeConfig['step'] as num?)?.toDouble() ?? 1; final unit = rangeConfig['unit'] as String? ?? ''; final currentRange = getValue(); double rangeMin = configMin; double rangeMax = configMax; if (currentRange is Map) { rangeMin = (currentRange['min'] as num?)?.toDouble() ?? configMin; rangeMax = (currentRange['max'] as num?)?.toDouble() ?? configMax; } // Clamp to valid range rangeMin = rangeMin.clamp(configMin, configMax); rangeMax = rangeMax.clamp(configMin, configMax); if (rangeMin > rangeMax) rangeMin = rangeMax; return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${rangeMin.toInt()} $unit', style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, color: AppColors.primaryDark, ), ), Text( '${rangeMax.toInt()} $unit', style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, color: AppColors.primaryDark, ), ), ], ), SliderTheme( data: SliderTheme.of(context).copyWith( activeTrackColor: AppColors.accentOrange, inactiveTrackColor: AppColors.accentOrange.withValues(alpha: 0.2), thumbColor: AppColors.accentOrange, overlayColor: AppColors.accentOrange.withValues(alpha: 0.1), ), child: RangeSlider( values: RangeValues(rangeMin, rangeMax), min: configMin, max: configMax, divisions: step > 0 ? ((configMax - configMin) / step).round() : null, onChanged: (values) { setValue({'min': values.start, 'max': values.end}); }, ), ), ], ); } // --------------------------------------------------------------------------- // FILE_UPLOAD // --------------------------------------------------------------------------- bool _uploading = false; /// Parse file entries from the stored value (list of maps or strings). List> _parseFileList(dynamic value) { if (value is! List) return []; return value.map>((e) { if (e is Map) return e; if (e is Map) return Map.from(e); return {'name': '$e', 'id': '$e'}; }).toList(); } /// Extract a human-readable filename from a file entry. String _extractFileName(Map file) { // Prefer 'name' field if (file['name'] != null && (file['name'] as String).isNotEmpty) { return file['name'] as String; } // Fall back to extracting from key/id/url final key = (file['key'] ?? file['id'] ?? file['url'] ?? '') as String; if (key.isEmpty) return 'Unknown file'; // Extract just the filename from the S3 key path final parts = key.split('/'); return parts.last; } /// Format file size in human-readable form. String _formatFileSize(dynamic size) { if (size == null) return ''; final bytes = size is num ? size.toInt() : int.tryParse('$size') ?? 0; if (bytes <= 0) return ''; if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } Future _pickAndUploadFile( String slug, dynamic Function() getValue, void Function(dynamic) setValue, ) async { final picker = ImagePicker(); final picked = await picker.pickImage(source: ImageSource.gallery); if (picked == null) return; setState(() => _uploading = true); try { final repo = ProfileRepository(); final fileName = picked.path.split('/').last; final contentType = _guessContentType(fileName); final fileBytes = await File(picked.path).readAsBytes(); // Get presigned URL final urlData = await repo.getDocumentUploadUrl(fileName, contentType); // Upload to S3 await repo.uploadToS3(urlData['uploadUrl']!, fileBytes, contentType); // Build new file entry final newFile = { 'id': urlData['key']!, 'key': urlData['key']!, 'name': fileName, 'size': fileBytes.length, 'type': contentType, 'url': urlData['publicUrl'] ?? urlData['key']!, 'uploadedAt': DateTime.now().toIso8601String(), }; // Add to existing list final currentFiles = _parseFileList(getValue()); currentFiles.add(newFile); setValue(currentFiles); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('File uploaded successfully'), backgroundColor: AppColors.success, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Upload failed: $e'), backgroundColor: AppColors.error, ), ); } } finally { if (mounted) setState(() => _uploading = false); } } String _guessContentType(String fileName) { final ext = (fileName.contains('.') ? '.${fileName.split('.').last}' : '') .toLowerCase(); switch (ext) { case '.pdf': return 'application/pdf'; case '.doc': return 'application/msword'; case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.gif': return 'image/gif'; default: return 'application/octet-stream'; } } Widget _buildFileUpload( String slug, dynamic Function() getValue, void Function(dynamic) setValue, ) { final fileList = _parseFileList(getValue()); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Dashed upload area (matches Figma design) GestureDetector( onTap: _uploading ? null : () => _pickAndUploadFile(slug, getValue, setValue), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 24), decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all( color: const Color(0xFFCCD5DA), style: BorderStyle.solid, ), ), child: Column( children: [ Icon( Icons.cloud_upload_outlined, size: 28, color: _uploading ? AppColors.hintText : AppColors.primaryDark.withValues(alpha: 0.6), ), const SizedBox(height: 8), Text( _uploading ? 'Uploading...' : 'Click to upload', style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, color: AppColors.primaryDark, ), ), const SizedBox(height: 4), const Text( 'PDF, DOCX, JPG, PNG (max 10MB)', style: TextStyle( fontFamily: 'SourceSerif4', fontSize: 12, color: AppColors.hintText, ), ), if (_uploading) ...[ const SizedBox(height: 12), const SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.accentOrange, ), ), ], ], ), ), ), // Existing files list if (fileList.isNotEmpty) ...[ const SizedBox(height: 12), const Row( children: [ Icon(Icons.link, size: 16, color: AppColors.primaryDark), SizedBox(width: 6), Text( 'Attached Documents', style: TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), ), ], ), const SizedBox(height: 8), ...fileList.map((file) { final name = _extractFileName(file); final sizeStr = _formatFileSize(file['size']); return Padding( padding: const EdgeInsets.only(bottom: 6), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all(color: const Color(0xFFE5E7EB)), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, color: AppColors.primaryDark, ), ), if (sizeStr.isNotEmpty) Text( sizeStr, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 11, color: AppColors.hintText, ), ), ], ), ), GestureDetector( onTap: () { final newList = List>.from( fileList, ); newList.remove(file); setValue(newList); }, child: const Padding( padding: EdgeInsets.all(4), child: Icon( Icons.close, size: 14, color: AppColors.hintText, ), ), ), ], ), ), ); }), ], ], ); } // --------------------------------------------------------------------------- // Shared input decoration // --------------------------------------------------------------------------- InputDecoration _inputDecoration(String hint) { return InputDecoration( hintText: hint, hintStyle: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w300, color: AppColors.hintText, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(15), borderSide: const BorderSide(color: Color(0xFFE5E7EB)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(15), borderSide: const BorderSide(color: Color(0xFFE5E7EB)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(15), borderSide: const BorderSide(color: AppColors.accentOrange), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(15), borderSide: const BorderSide(color: AppColors.error), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(15), borderSide: const BorderSide(color: AppColors.error), ), isDense: true, ); } // --------------------------------------------------------------------------- // Icon resolver // --------------------------------------------------------------------------- IconData _resolveIcon(String name) { const iconMap = { 'user': Icons.person_outline, 'person': Icons.person_outline, 'profile': Icons.person_outline, 'briefcase': Icons.work_outline, 'work': Icons.work_outline, 'business': Icons.business_outlined, 'building': Icons.business_outlined, 'office': Icons.business_outlined, 'education': Icons.school_outlined, 'school': Icons.school_outlined, 'award': Icons.emoji_events_outlined, 'trophy': Icons.emoji_events_outlined, 'certificate': Icons.card_membership_outlined, 'star': Icons.star_outline, 'heart': Icons.favorite_outline, 'home': Icons.home_outlined, 'house': Icons.home_outlined, 'location': Icons.location_on_outlined, 'map': Icons.map_outlined, 'phone': Icons.phone_outlined, 'call': Icons.phone_outlined, 'email': Icons.email_outlined, 'mail': Icons.email_outlined, 'globe': Icons.language, 'world': Icons.language, 'link': Icons.link, 'settings': Icons.settings_outlined, 'gear': Icons.settings_outlined, 'info': Icons.info_outline, 'dollar': Icons.attach_money, 'money': Icons.attach_money, 'finance': Icons.attach_money, 'shield': Icons.shield_outlined, 'lock': Icons.lock_outline, 'clock': Icons.access_time, 'time': Icons.access_time, 'calendar': Icons.calendar_today_outlined, 'file': Icons.description_outlined, 'document': Icons.description_outlined, 'camera': Icons.camera_alt_outlined, 'photo': Icons.photo_outlined, 'image': Icons.image_outlined, 'chat': Icons.chat_outlined, 'message': Icons.message_outlined, 'list': Icons.list, 'check': Icons.check_circle_outline, 'target': Icons.gps_fixed, 'flag': Icons.flag_outlined, 'tag': Icons.label_outline, }; final lower = name.toLowerCase().replaceAll(RegExp(r'[^a-z]'), ''); for (final entry in iconMap.entries) { if (lower.contains(entry.key)) return entry.value; } return Icons.article_outlined; } }