Files
mobile-app/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart

1600 lines
51 KiB
Dart

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<String, dynamic>?)?['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<dynamic>?) ?? [];
// getFieldValues returns { agentProfileId: "...", fieldValues: [...] }
final fieldValuesResponse = results[1];
final fieldValues =
(fieldValuesResponse['fieldValues'] as List<dynamic>?) ?? [];
return _EditProfileData(
profile: profile,
sections: sections,
fieldValues: fieldValues,
);
});
class _EditProfileData {
final Map<String, dynamic> profile;
final List<dynamic> sections;
final List<dynamic> fieldValues;
const _EditProfileData({
required this.profile,
required this.sections,
required this.fieldValues,
});
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
class AgentEditProfileScreen extends ConsumerStatefulWidget {
const AgentEditProfileScreen({super.key});
@override
ConsumerState<AgentEditProfileScreen> createState() =>
_AgentEditProfileScreenState();
}
class _AgentEditProfileScreenState
extends ConsumerState<AgentEditProfileScreen> {
/// Master form data keyed by field slug.
final Map<String, dynamic> _formData = {};
/// Repeatable section entries keyed by section slug.
final Map<String, List<Map<String, dynamic>>> _repeatableEntries = {};
/// Tag-input controllers keyed by field slug.
final Map<String, TextEditingController> _tagControllers = {};
/// All text controllers for cleanup.
final List<TextEditingController> _controllers = [];
bool _saving = false;
bool _initialized = false;
final _formKey = GlobalKey<FormState>();
// Pre-fill slug mapping to profile keys
static const _profileSlugMap = <String, String>{
'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 = <String, dynamic>{};
for (final fv in data.fieldValues) {
if (fv is Map<String, dynamic>) {
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<String, dynamic>) continue;
final isRepeatable = section['isRepeatable'] == true;
final sectionSlug = section['slug'] as String? ?? '';
final fields = section['fields'] as List<dynamic>? ?? [];
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<String, dynamic>
? Map<String, dynamic>.from(e)
: <String, dynamic>{})
.toList();
} else {
_repeatableEntries[sectionSlug] = [{}];
}
}
for (final field in fields) {
if (field is! Map<String, dynamic>) 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<String, dynamic> 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<String>.from(value.map((e) => '$e'));
if (value is String && value.isNotEmpty) {
try {
final decoded = jsonDecode(value);
if (decoded is List) {
return List<String>.from(decoded.map((e) => '$e'));
}
} catch (_) {}
return [value];
}
return <String>[];
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<String, dynamic>?;
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<void> _save(_EditProfileData data) async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
final repo = ProfileRepository();
final fieldValuesToSave = <Map<String, dynamic>>[];
// 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<Map<String, dynamic>>()
.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<String, dynamic> 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<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.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<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic>?;
final options = (field['options'] as List<dynamic>?)
?.whereType<Map<String, dynamic>>()
.toList() ??
[];
final uiConfig = field['uiConfig'] as Map<String, dynamic>?;
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<String, dynamic>? 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<String, dynamic>? 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<Map<String, dynamic>> options,
Map<String, dynamic>? uiConfig,
) {
final selected = List<String>.from((getValue() ?? <String>[]) 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<String>.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<Map<String, dynamic>> 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<Map<String, dynamic>> options,
) {
final currentVal = '${getValue() ?? ''}';
final hasMatch = options.any((o) => o['value'] == currentVal);
return DropdownButtonFormField<String>(
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<String>(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<Map<String, dynamic>> options,
) {
final selected = List<String>.from((getValue() ?? <String>[]) 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<String>.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<String>.from((getValue() ?? <String>[]) 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<String>.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<String>.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<String, dynamic> field,
) {
final rangeConfig = field['rangeConfig'] as Map<String, dynamic>? ?? {};
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<String>.from(files.map((e) => '$e')) : <String>[];
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<String>.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 = <String, IconData>{
'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;
}
}