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

2363 lines
77 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
// ---------------------------------------------------------------------------
// Data loading provider
// ---------------------------------------------------------------------------
/// Bundles all three API calls needed to render the edit-profile form.
final _editProfileDataProvider = FutureProvider.autoDispose<_EditProfileData>((
ref,
) async {
final repo = ProfileRepository();
// 1. Fetch agent profile (need agentTypeId + pre-fill values)
final profile = await repo.getMyProfile('AGENT');
// 2. Resolve agentTypeId
final agentTypeId =
profile['agentTypeId'] as String? ??
(profile['agentType'] as Map<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() ?? '';
}
}
// ---------------------------------------------------------------------------
// Validation helpers (mirrors web `validateFieldValue`)
// ---------------------------------------------------------------------------
String? _validateText({
required String? value,
required bool isRequired,
required String fieldName,
Map<String, dynamic>? validation,
}) {
final str = (value ?? '').trim();
if (isRequired && str.isEmpty) return '$fieldName is required';
if (str.isEmpty) return null;
final v = validation ?? const {};
final minLen = v['minLength'];
final maxLen = v['maxLength'];
final pattern = v['pattern'];
if (minLen is int && str.length < minLen) {
return '$fieldName must be at least $minLen characters';
}
if (maxLen is int && str.length > maxLen) {
return '$fieldName must be no more than $maxLen characters';
}
if (pattern is String && pattern.isNotEmpty) {
try {
if (!RegExp(pattern).hasMatch(str)) {
return '$fieldName has an invalid format';
}
} catch (_) {}
}
return null;
}
String? _validateNumber({
required String? value,
required bool isRequired,
required String fieldName,
Map<String, dynamic>? validation,
}) {
final str = (value ?? '').trim();
if (isRequired && str.isEmpty) return '$fieldName is required';
if (str.isEmpty) return null;
final n = num.tryParse(str);
if (n == null) return '$fieldName must be a number';
final v = validation ?? const {};
final min = v['min'];
final max = v['max'];
if (min is num && n < min) return '$fieldName must be at least $min';
if (max is num && n > max) return '$fieldName must be no more than $max';
return null;
}
String? _validateDate({
required String? value,
required bool isRequired,
required String fieldName,
Map<String, dynamic>? validation,
}) {
final str = (value ?? '').trim();
if (isRequired && str.isEmpty) return '$fieldName is required';
if (str.isEmpty) return null;
if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(str)) {
return '$fieldName must be a valid date';
}
final v = validation ?? const {};
final minDate = v['minDate'];
final maxDate = v['maxDate'];
if (minDate is String && minDate.isNotEmpty && str.compareTo(minDate) < 0) {
return '$fieldName must be on or after $minDate';
}
if (maxDate is String && maxDate.isNotEmpty && str.compareTo(maxDate) > 0) {
return '$fieldName must be on or before $maxDate';
}
return null;
}
// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
Future<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 back button
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
GestureDetector(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 18,
),
),
),
const SizedBox(width: 12),
const Text(
'Edit Profile',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
],
),
),
Expanded(child: _buildBody(asyncData)),
],
);
}
Widget _buildBody(AsyncValue<_EditProfileData> asyncData) {
return asyncData.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2,
),
),
error: (err, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text(
'Failed to load profile data',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
Text(
'$err',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.hintText,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => ref.invalidate(_editProfileDataProvider),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: const Text(
'Retry',
style: TextStyle(color: Colors.white),
),
),
],
),
),
),
data: (data) {
_initializeFormData(data);
return _buildForm(data);
},
);
}
Widget _buildForm(_EditProfileData data) {
final sections =
data.sections
.whereType<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 : () {
// Reset form to original values (reload from provider)
setState(() {
_formData.clear();
_repeatableEntries.clear();
_initialized = false;
for (final c in _controllers) {
c.dispose();
}
_controllers.clear();
for (final c in _tagControllers.values) {
c.dispose();
}
_tagControllers.clear();
});
// Re-initialize will happen on next build via _initializeFormData
},
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.primaryDark),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text(
'Cancel',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _saving ? null : () => _save(data),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
disabledBackgroundColor: AppColors.accentOrange.withValues(
alpha: 0.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Save',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Section card
// ---------------------------------------------------------------------------
Widget _buildSection(Map<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,
isRequired: isRequired,
fieldName: label,
);
break;
case 'TEXTAREA':
fieldWidget = _buildTextField(
slug,
currentValue,
updateValue,
placeholder,
validation,
true,
controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
);
break;
case 'NUMBER':
fieldWidget = _buildNumberField(
slug,
currentValue,
updateValue,
placeholder,
validation,
controllerKeySuffix: controllerKeySuffix,
isRequired: isRequired,
fieldName: label,
);
break;
case 'DATE':
fieldWidget = _buildDateField(
slug,
currentValue,
updateValue,
placeholder,
validation: validation,
isRequired: isRequired,
fieldName: label,
);
break;
case 'CHECKBOX':
fieldWidget = _buildCheckboxField(
slug,
currentValue,
updateValue,
label,
);
// Checkbox renders its own label, so skip the standard label wrapper.
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: fieldWidget,
);
case 'CHECKBOX_GROUP':
fieldWidget = _buildCheckboxGroup(
slug,
currentValue,
updateValue,
options,
uiConfig,
);
break;
case 'RADIO':
fieldWidget = _buildRadioGroup(
slug,
currentValue,
updateValue,
options,
);
break;
case 'SELECT':
fieldWidget = _buildSelectField(
slug,
currentValue,
updateValue,
placeholder,
options,
);
break;
case 'MULTI_SELECT':
fieldWidget = _buildMultiSelect(
slug,
currentValue,
updateValue,
options,
);
break;
case 'TAG_INPUT':
fieldWidget = _buildTagInput(
slug,
currentValue,
updateValue,
placeholder,
controllerKeySuffix: controllerKeySuffix,
);
break;
case 'RANGE':
fieldWidget = _buildRangeField(slug, currentValue, updateValue, field);
break;
case 'FILE':
case 'FILE_UPLOAD':
fieldWidget = _buildFileUpload(slug, currentValue, updateValue);
break;
case 'REPEATER':
return const SizedBox.shrink();
default:
fieldWidget = _buildTextField(
slug,
currentValue,
updateValue,
placeholder,
validation,
false,
controllerKeySuffix: controllerKeySuffix,
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Label
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
children: isRequired
? [
const TextSpan(
text: ' *',
style: TextStyle(color: AppColors.error),
),
]
: null,
),
),
if (description != null && description.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
color: AppColors.hintText,
),
),
],
if (helpText != null && helpText.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
helpText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
color: AppColors.hintText,
),
),
],
const SizedBox(height: 6),
fieldWidget,
],
),
);
}
// ---------------------------------------------------------------------------
// TEXT / TEXTAREA
// ---------------------------------------------------------------------------
Widget _buildTextField(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
String placeholder,
Map<String, dynamic>? validation,
bool multiline, {
String? controllerKeySuffix,
bool isRequired = false,
String fieldName = 'This field',
}) {
final key = controllerKeySuffix != null
? '${slug}_${controllerKeySuffix}_text'
: '${slug}_text';
if (!_tagControllers.containsKey(key)) {
final controller = TextEditingController(text: '${getValue() ?? ''}');
_tagControllers[key] = controller;
_controllers.add(controller);
}
final controller = _tagControllers[key]!;
final maxLength = validation?['maxLength'] as int?;
return TextFormField(
controller: controller,
maxLines: multiline ? 5 : 1,
minLines: multiline ? 3 : 1,
maxLength: maxLength != null && maxLength > 0 ? maxLength : null,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w300,
color: AppColors.primaryDark,
),
decoration: _inputDecoration(placeholder),
onChanged: (val) => setValue(val),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (val) => _validateText(
value: val,
isRequired: isRequired,
fieldName: fieldName,
validation: validation,
),
);
}
// ---------------------------------------------------------------------------
// NUMBER
// ---------------------------------------------------------------------------
Widget _buildNumberField(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
String placeholder,
Map<String, dynamic>? validation, {
String? controllerKeySuffix,
bool isRequired = false,
String fieldName = 'This field',
}) {
final key = controllerKeySuffix != null
? '${slug}_${controllerKeySuffix}_number'
: '${slug}_number';
if (!_tagControllers.containsKey(key)) {
final val = getValue();
final controller = TextEditingController(text: val != null ? '$val' : '');
_tagControllers[key] = controller;
_controllers.add(controller);
}
final controller = _tagControllers[key]!;
return TextFormField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w300,
color: AppColors.primaryDark,
),
decoration: _inputDecoration(placeholder),
onChanged: (val) => setValue(num.tryParse(val)),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (val) => _validateNumber(
value: val,
isRequired: isRequired,
fieldName: fieldName,
validation: validation,
),
);
}
// ---------------------------------------------------------------------------
// DATE
// ---------------------------------------------------------------------------
Widget _buildDateField(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
String placeholder, {
Map<String, dynamic>? validation,
bool isRequired = false,
String fieldName = 'This field',
}) {
final val = getValue();
// Display as MM-dd-yyyy (US format) while keeping ISO (yyyy-MM-dd) on the wire.
String displayText = placeholder;
if (val is String && val.isNotEmpty) {
final parsed = DateTime.tryParse(val);
displayText = parsed != null
? DateFormat('MM-dd-yyyy').format(parsed)
: val;
}
// Parse admin-configured min/max dates for the picker; fall back to wide range.
final minDateStr = validation?['minDate'] as String?;
final maxDateStr = validation?['maxDate'] as String?;
DateTime firstDate = DateTime(1900);
DateTime lastDate = DateTime(2100);
if (minDateStr != null && minDateStr.isNotEmpty) {
final parsed = DateTime.tryParse(minDateStr);
if (parsed != null) firstDate = parsed;
}
if (maxDateStr != null && maxDateStr.isNotEmpty) {
final parsed = DateTime.tryParse(maxDateStr);
if (parsed != null) lastDate = parsed;
}
return FormField<String>(
initialValue: val is String ? val : '',
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (fieldVal) => _validateDate(
value: fieldVal,
isRequired: isRequired,
fieldName: fieldName,
validation: validation,
),
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () async {
DateTime initial = DateTime.now();
if (val is String && val.isNotEmpty) {
try {
initial = DateTime.parse(val);
} catch (_) {}
}
// Clamp initial into valid range so picker doesn't assert
if (initial.isBefore(firstDate)) initial = firstDate;
if (initial.isAfter(lastDate)) initial = lastDate;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: firstDate,
lastDate: lastDate,
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: const ColorScheme.light(
primary: AppColors.accentOrange,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.primaryDark,
),
),
child: child!,
),
);
if (picked != null) {
final formatted = DateFormat('yyyy-MM-dd').format(picked);
setValue(formatted);
state.didChange(formatted);
}
},
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(
color: state.hasError
? Colors.red
: const Color(0xFFE5E7EB),
),
borderRadius: BorderRadius.circular(15),
),
child: Row(
children: [
Expanded(
child: Text(
displayText.isEmpty ? 'Select date' : displayText,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w300,
color: val is String && val.isNotEmpty
? AppColors.primaryDark
: AppColors.hintText,
),
),
),
const Icon(
Icons.calendar_today,
size: 18,
color: AppColors.hintText,
),
],
),
),
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Text(
state.errorText ?? '',
style: const TextStyle(
color: Colors.red,
fontSize: 12,
fontFamily: 'SourceSerif4',
),
),
),
],
);
},
);
}
// ---------------------------------------------------------------------------
// CHECKBOX (single toggle)
// ---------------------------------------------------------------------------
Widget _buildCheckboxField(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
String label,
) {
final checked = getValue() == true;
return GestureDetector(
onTap: () => setValue(!checked),
child: Row(
children: [
Container(
width: 17,
height: 17,
decoration: BoxDecoration(
color: checked ? AppColors.accentOrange : Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: checked
? AppColors.accentOrange
: const Color(0xFFD1D5DB),
),
),
child: checked
? const Icon(Icons.check, size: 13, color: Colors.white)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
),
],
),
);
}
// ---------------------------------------------------------------------------
// CHECKBOX_GROUP
// ---------------------------------------------------------------------------
Widget _buildCheckboxGroup(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
List<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 (searchable dropdown — matches web's react-select pattern)
// ---------------------------------------------------------------------------
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);
String labelFor(String val) {
final match = options.firstWhere(
(o) => (o['value'] as String? ?? '') == val,
orElse: () => <String, dynamic>{},
);
return (match['label'] as String?) ?? val;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tap target that opens the searchable picker
GestureDetector(
onTap: () => _openMultiSelectPicker(
title: slug,
options: options,
selected: selected,
onChanged: setValue,
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFD1D5DB)),
),
child: Row(
children: [
Expanded(
child: Text(
selected.isEmpty
? 'Select...'
: '${selected.length} selected',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w300,
color: selected.isEmpty
? AppColors.hintText
: AppColors.primaryDark,
),
),
),
const Icon(Icons.keyboard_arrow_down,
color: AppColors.hintText),
],
),
),
),
// Selected chips preview (tap × to remove)
if (selected.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: selected.map((val) {
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.accentOrange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.accentOrange),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
labelFor(val),
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.accentOrange,
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: () {
final newList = List<String>.from(selected)
..remove(val);
setValue(newList);
},
child: const Icon(
Icons.close_rounded,
size: 16,
color: AppColors.accentOrange,
),
),
],
),
);
}).toList(),
),
],
],
);
}
Future<void> _openMultiSelectPicker({
required String title,
required List<Map<String, dynamic>> options,
required List<String> selected,
required void Function(dynamic) onChanged,
}) async {
final searchController = TextEditingController();
final workingSelected = List<String>.from(selected);
String query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setSheetState) {
final filtered = options.where((o) {
if (query.isEmpty) return true;
final l = (o['label'] as String? ?? '').toLowerCase();
return l.contains(query.toLowerCase());
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: FractionallySizedBox(
heightFactor: 0.85,
child: Column(
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 14),
decoration: BoxDecoration(
color: const Color(0xFFD1D5DB),
borderRadius: BorderRadius.circular(2),
),
),
// Search
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: searchController,
onChanged: (v) =>
setSheetState(() => query = v),
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search,
color: AppColors.hintText),
filled: true,
fillColor: const Color(0xFFF5F5F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
vertical: 0, horizontal: 12),
),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) {
final opt = filtered[i];
final v =
opt['value'] as String? ?? '';
final l =
opt['label'] as String? ?? v;
final isSelected =
workingSelected.contains(v);
return CheckboxListTile(
value: isSelected,
onChanged: (_) {
setSheetState(() {
if (isSelected) {
workingSelected.remove(v);
} else {
workingSelected.add(v);
}
});
},
title: Text(
l,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
),
controlAffinity:
ListTileControlAffinity.leading,
activeColor: AppColors.accentOrange,
dense: true,
);
},
),
),
Padding(
padding:
const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () =>
Navigator.pop(ctx),
style: OutlinedButton.styleFrom(
padding:
const EdgeInsets.symmetric(
vertical: 14),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
onChanged(workingSelected);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor:
AppColors.accentOrange,
padding:
const EdgeInsets.symmetric(
vertical: 14),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
child: const Text(
'Done',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
],
),
),
),
);
},
);
},
);
searchController.dispose();
}
// ---------------------------------------------------------------------------
// TAG_INPUT
// ---------------------------------------------------------------------------
Widget _buildTagInput(
String slug,
dynamic Function() getValue,
void Function(dynamic) setValue,
String placeholder, {
String? controllerKeySuffix,
}) {
final tags = List<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;
}
}