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

1947 lines
61 KiB
Dart
Raw Normal View History

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:flutter_svg/flutter_svg.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};
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 <Map<String, dynamic>>[];
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 Column(
children: [
// Header with circular back button (SVG) matching Figma
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Row(
children: [
GestureDetector(
onTap: () => context.pop(),
child: SvgPicture.asset(
'assets/icons/back_circle_icon.svg',
width: 32,
height: 32,
),
),
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<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.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 : () => 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);
// 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<String, dynamic> 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<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,
controllerKeySuffix: controllerKeySuffix,
);
break;
case 'TEXTAREA':
fieldWidget = _buildTextField(
slug,
currentValue,
updateValue,
placeholder,
validation,
true,
controllerKeySuffix: controllerKeySuffix,
);
break;
case 'NUMBER':
fieldWidget = _buildNumberField(
slug,
currentValue,
updateValue,
placeholder,
validation,
controllerKeySuffix: controllerKeySuffix,
);
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,
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<String, dynamic>? validation,
bool multiline, {
String? controllerKeySuffix,
}) {
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),
);
}
// ---------------------------------------------------------------------------
// NUMBER
// ---------------------------------------------------------------------------
Widget _buildNumberField(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
String placeholder,
Map<String, dynamic>? validation, {
String? controllerKeySuffix,
}) {
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)),
);
}
// ---------------------------------------------------------------------------
// 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, {
String? controllerKeySuffix,
}) {
final tags = List<String>.from((getValue() ?? <String>[]) 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<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
// ---------------------------------------------------------------------------
bool _uploading = false;
/// Parse file entries from the stored value (list of maps or strings).
List<Map<String, dynamic>> _parseFileList(dynamic value) {
if (value is! List) return [];
return value.map<Map<String, dynamic>>((e) {
if (e is Map<String, dynamic>) return e;
if (e is Map) return Map<String, dynamic>.from(e);
return {'name': '$e', 'id': '$e'};
}).toList();
}
/// Extract a human-readable filename from a file entry.
String _extractFileName(Map<String, dynamic> 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<void> _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<Map<String, dynamic>>.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 = <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;
}
}