2363 lines
77 KiB
Dart
2363 lines
77 KiB
Dart
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;
|
||
}
|
||
}
|