import 'dart:convert'; 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: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}; default: return value?.toString() ?? ''; } } // --------------------------------------------------------------------------- // 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 Scaffold( backgroundColor: const Color(0xFFF5F7FA), appBar: AppBar( backgroundColor: Colors.white, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back, color: AppColors.primaryDark), onPressed: () => context.pop(), ), title: const Text( 'Edit Profile', style: TextStyle( fontFamily: 'Fractul', fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.primaryDark, ), ), centerTitle: true, ), body: 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.symmetric(horizontal: 16, vertical: 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 : () => context.pop(), 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); }); }, 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; }); }, ); }), ], 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, }) { 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); break; case 'TEXTAREA': fieldWidget = _buildTextField(slug, currentValue, updateValue, placeholder, validation, true); break; case 'NUMBER': fieldWidget = _buildNumberField( slug, currentValue, updateValue, placeholder, validation); break; case 'DATE': fieldWidget = _buildDateField(slug, currentValue, updateValue, placeholder); 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); break; case 'RANGE': fieldWidget = _buildRangeField(slug, currentValue, updateValue, field); break; case 'FILE_UPLOAD': fieldWidget = _buildFileUpload(slug, currentValue, updateValue); break; case 'REPEATER': return const SizedBox.shrink(); default: fieldWidget = _buildTextField(slug, currentValue, updateValue, placeholder, validation, false); } 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, ) { final key = '${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), ); } // --------------------------------------------------------------------------- // NUMBER // --------------------------------------------------------------------------- Widget _buildNumberField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, Map? validation, ) { final key = '${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)), ); } // --------------------------------------------------------------------------- // DATE // --------------------------------------------------------------------------- Widget _buildDateField( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, ) { 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( 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), ], ), ), ); } // --------------------------------------------------------------------------- // 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 (chip group) // --------------------------------------------------------------------------- Widget _buildMultiSelect( String slug, dynamic Function() getValue, void Function(dynamic) setValue, List> options, ) { final selected = List.from((getValue() ?? []) as List); return Wrap( spacing: 8, runSpacing: 8, children: options.map((opt) { final optVal = opt['value'] as String? ?? ''; final optLabel = opt['label'] as String? ?? optVal; final isSelected = selected.contains(optVal); return GestureDetector( onTap: () { final newList = List.from(selected); if (isSelected) { newList.remove(optVal); } else { newList.add(optVal); } setValue(newList); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? AppColors.accentOrange.withValues(alpha: 0.1) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? AppColors.accentOrange : const Color(0xFFD1D5DB), ), ), child: Text( optLabel, style: TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, fontWeight: FontWeight.w400, color: isSelected ? AppColors.accentOrange : AppColors.primaryDark, ), ), ), ); }).toList(), ); } // --------------------------------------------------------------------------- // TAG_INPUT // --------------------------------------------------------------------------- Widget _buildTagInput( String slug, dynamic Function() getValue, void Function(dynamic) setValue, String placeholder, ) { final tags = List.from((getValue() ?? []) as List); if (!_tagControllers.containsKey(slug)) { final controller = TextEditingController(); _tagControllers[slug] = controller; _controllers.add(controller); } final controller = _tagControllers[slug]!; 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 // --------------------------------------------------------------------------- Widget _buildFileUpload( String slug, dynamic Function() getValue, void Function(dynamic) setValue, ) { final files = getValue(); final fileList = files is List ? List.from(files.map((e) => '$e')) : []; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ OutlinedButton.icon( onPressed: () { // Placeholder: actual file picking would use image_picker or file_picker ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('File upload is not yet available on mobile'), backgroundColor: AppColors.hintText, ), ); }, icon: const Icon(Icons.upload_file, size: 18), label: const Text('Upload File'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.accentOrange, side: const BorderSide(color: AppColors.accentOrange), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), ), if (fileList.isNotEmpty) ...[ const SizedBox(height: 8), ...fileList.map((f) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ const Icon(Icons.attach_file, size: 16, color: AppColors.hintText), const SizedBox(width: 4), Expanded( child: Text( f, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 13, color: AppColors.primaryDark, ), ), ), GestureDetector( onTap: () { final newList = List.from(fileList)..remove(f); setValue(newList); }, child: const Icon(Icons.close, size: 16, color: AppColors.error), ), ], ), )), ], ], ); } // --------------------------------------------------------------------------- // 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; } }