1600 lines
51 KiB
Dart
1600 lines
51 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
|
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data loading provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Bundles all three API calls needed to render the edit-profile form.
|
|
final _editProfileDataProvider =
|
|
FutureProvider.autoDispose<_EditProfileData>((ref) async {
|
|
final repo = ProfileRepository();
|
|
|
|
// 1. Fetch agent profile (need agentTypeId + pre-fill values)
|
|
final profile = await repo.getMyProfile('AGENT');
|
|
|
|
// 2. Resolve agentTypeId
|
|
final agentTypeId = profile['agentTypeId'] as String? ??
|
|
(profile['agentType'] as Map<String, dynamic>?)?['id'] as String?;
|
|
if (agentTypeId == null || agentTypeId.isEmpty) {
|
|
throw Exception('Agent type not found on profile');
|
|
}
|
|
|
|
// 3 + 4. Fetch sections & field-values in parallel
|
|
final results = await Future.wait([
|
|
repo.getSectionsForAgentType(agentTypeId),
|
|
repo.getFieldValues(),
|
|
]);
|
|
|
|
// getSectionsForAgentType returns { agentType: {...}, sections: [...] }
|
|
final sectionsResponse = results[0];
|
|
final sections = (sectionsResponse['sections'] as List<dynamic>?) ?? [];
|
|
|
|
// getFieldValues returns { agentProfileId: "...", fieldValues: [...] }
|
|
final fieldValuesResponse = results[1];
|
|
final fieldValues =
|
|
(fieldValuesResponse['fieldValues'] as List<dynamic>?) ?? [];
|
|
|
|
return _EditProfileData(
|
|
profile: profile,
|
|
sections: sections,
|
|
fieldValues: fieldValues,
|
|
);
|
|
});
|
|
|
|
class _EditProfileData {
|
|
final Map<String, dynamic> profile;
|
|
final List<dynamic> sections;
|
|
final List<dynamic> fieldValues;
|
|
const _EditProfileData({
|
|
required this.profile,
|
|
required this.sections,
|
|
required this.fieldValues,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Screen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class AgentEditProfileScreen extends ConsumerStatefulWidget {
|
|
const AgentEditProfileScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<AgentEditProfileScreen> createState() =>
|
|
_AgentEditProfileScreenState();
|
|
}
|
|
|
|
class _AgentEditProfileScreenState
|
|
extends ConsumerState<AgentEditProfileScreen> {
|
|
/// Master form data keyed by field slug.
|
|
final Map<String, dynamic> _formData = {};
|
|
|
|
/// Repeatable section entries keyed by section slug.
|
|
final Map<String, List<Map<String, dynamic>>> _repeatableEntries = {};
|
|
|
|
/// Tag-input controllers keyed by field slug.
|
|
final Map<String, TextEditingController> _tagControllers = {};
|
|
|
|
/// All text controllers for cleanup.
|
|
final List<TextEditingController> _controllers = [];
|
|
|
|
bool _saving = false;
|
|
bool _initialized = false;
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Pre-fill slug mapping to profile keys
|
|
static const _profileSlugMap = <String, String>{
|
|
'first-name': 'firstName',
|
|
'first_name': 'firstName',
|
|
'last-name': 'lastName',
|
|
'last_name': 'lastName',
|
|
'email': 'email',
|
|
'bio': 'bio',
|
|
'license-number': 'licenseNumber',
|
|
'license_number': 'licenseNumber',
|
|
'experience': 'experience',
|
|
'specializations': 'specializations',
|
|
'languages': 'languages',
|
|
'service-areas': 'serviceAreas',
|
|
'service_areas': 'serviceAreas',
|
|
};
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final c in _controllers) {
|
|
c.dispose();
|
|
}
|
|
for (final c in _tagControllers.values) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initialization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void _initializeFormData(_EditProfileData data) {
|
|
if (_initialized) return;
|
|
_initialized = true;
|
|
|
|
final profile = data.profile;
|
|
|
|
// Build field-value lookup by slug
|
|
final fvMap = <String, dynamic>{};
|
|
for (final fv in data.fieldValues) {
|
|
if (fv is Map<String, dynamic>) {
|
|
final slug = fv['fieldSlug'] as String? ?? '';
|
|
if (slug.isNotEmpty) {
|
|
fvMap[slug] = fv['value'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Walk sections & fields
|
|
for (final section in data.sections) {
|
|
if (section is! Map<String, dynamic>) continue;
|
|
final isRepeatable = section['isRepeatable'] == true;
|
|
final sectionSlug = section['slug'] as String? ?? '';
|
|
final fields = section['fields'] as List<dynamic>? ?? [];
|
|
|
|
if (isRepeatable) {
|
|
// Load existing repeatable entries from field values
|
|
final entriesKey = '${sectionSlug}_entries';
|
|
final existingEntries = fvMap[entriesKey];
|
|
if (existingEntries is List && existingEntries.isNotEmpty) {
|
|
_repeatableEntries[sectionSlug] = existingEntries
|
|
.map((e) => e is Map<String, dynamic>
|
|
? Map<String, dynamic>.from(e)
|
|
: <String, dynamic>{})
|
|
.toList();
|
|
} else {
|
|
_repeatableEntries[sectionSlug] = [{}];
|
|
}
|
|
}
|
|
|
|
for (final field in fields) {
|
|
if (field is! Map<String, dynamic>) continue;
|
|
final slug = field['slug'] as String? ?? '';
|
|
final fieldType = field['fieldType'] as String? ?? 'TEXT';
|
|
if (slug.isEmpty) continue;
|
|
|
|
// Priority: fieldValues API > profile pre-fill > defaultValue
|
|
dynamic value;
|
|
|
|
// 1. Field values from API
|
|
if (fvMap.containsKey(slug)) {
|
|
value = fvMap[slug];
|
|
}
|
|
// 2. Profile pre-fill
|
|
else if (_profileSlugMap.containsKey(slug)) {
|
|
value = profile[_profileSlugMap[slug]!];
|
|
}
|
|
// 3. Default value
|
|
else {
|
|
value = field['defaultValue'];
|
|
}
|
|
|
|
// Normalize value based on field type
|
|
if (!isRepeatable) {
|
|
_formData[slug] = _normalizeValue(value, fieldType, field);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dynamic _normalizeValue(
|
|
dynamic value, String fieldType, Map<String, dynamic> field) {
|
|
switch (fieldType) {
|
|
case 'CHECKBOX':
|
|
if (value is bool) return value;
|
|
if (value is String) return value.toLowerCase() == 'true';
|
|
return false;
|
|
case 'CHECKBOX_GROUP':
|
|
case 'MULTI_SELECT':
|
|
case 'TAG_INPUT':
|
|
if (value is List) return List<String>.from(value.map((e) => '$e'));
|
|
if (value is String && value.isNotEmpty) {
|
|
try {
|
|
final decoded = jsonDecode(value);
|
|
if (decoded is List) {
|
|
return List<String>.from(decoded.map((e) => '$e'));
|
|
}
|
|
} catch (_) {}
|
|
return [value];
|
|
}
|
|
return <String>[];
|
|
case 'NUMBER':
|
|
if (value is num) return value;
|
|
if (value is String) return num.tryParse(value);
|
|
return null;
|
|
case 'RANGE':
|
|
if (value is Map) return value;
|
|
if (value is String && value.isNotEmpty) {
|
|
try {
|
|
return jsonDecode(value);
|
|
} catch (_) {}
|
|
}
|
|
final rangeConfig = field['rangeConfig'] as Map<String, dynamic>?;
|
|
final min = (rangeConfig?['min'] as num?)?.toDouble() ?? 0;
|
|
final max = (rangeConfig?['max'] as num?)?.toDouble() ?? 100;
|
|
return {'min': min, 'max': max};
|
|
default:
|
|
return value?.toString() ?? '';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Save
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Future<void> _save(_EditProfileData data) async {
|
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
|
|
|
setState(() => _saving = true);
|
|
|
|
try {
|
|
final repo = ProfileRepository();
|
|
final fieldValuesToSave = <Map<String, dynamic>>[];
|
|
|
|
// Collect all form data
|
|
for (final entry in _formData.entries) {
|
|
dynamic val = entry.value;
|
|
// Convert lists to JSON-compatible format
|
|
if (val is List) {
|
|
val = val;
|
|
} else if (val is Map) {
|
|
val = val;
|
|
}
|
|
fieldValuesToSave.add({
|
|
'fieldSlug': entry.key,
|
|
'value': val,
|
|
});
|
|
}
|
|
|
|
// Add repeatable section entries
|
|
for (final entry in _repeatableEntries.entries) {
|
|
fieldValuesToSave.add({
|
|
'fieldSlug': '${entry.key}_entries',
|
|
'value': entry.value,
|
|
});
|
|
}
|
|
|
|
// Save field values
|
|
await repo.saveFieldValues(fieldValuesToSave);
|
|
|
|
// Sync phone to main profile if present
|
|
const phoneSlugs = [
|
|
'phone_number',
|
|
'phone-number',
|
|
'cell_number',
|
|
'cell-number',
|
|
'office_number',
|
|
'office-number',
|
|
'phone',
|
|
];
|
|
for (final slug in phoneSlugs) {
|
|
if (_formData.containsKey(slug)) {
|
|
final phoneVal = _formData[slug];
|
|
if (phoneVal is String && phoneVal.isNotEmpty) {
|
|
await repo.updateProfile('AGENT', {'phone': phoneVal});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Profile updated successfully'),
|
|
backgroundColor: AppColors.success,
|
|
),
|
|
);
|
|
context.pop();
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to save: $e'),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final asyncData = ref.watch(_editProfileDataProvider);
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFF5F7FA),
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.white,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: AppColors.primaryDark),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
title: const Text(
|
|
'Edit Profile',
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: asyncData.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.accentOrange,
|
|
strokeWidth: 2,
|
|
),
|
|
),
|
|
error: (err, _) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline,
|
|
size: 48, color: AppColors.error),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load profile data',
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'$err',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
color: AppColors.hintText,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: () =>
|
|
ref.invalidate(_editProfileDataProvider),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.accentOrange,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
),
|
|
child: const Text('Retry',
|
|
style: TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
data: (data) {
|
|
_initializeFormData(data);
|
|
return _buildForm(data);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildForm(_EditProfileData data) {
|
|
final sections = data.sections
|
|
.whereType<Map<String, dynamic>>()
|
|
.where((s) => s['isActive'] != false)
|
|
.toList()
|
|
..sort((a, b) =>
|
|
((a['sortOrder'] as num?) ?? 0).compareTo((b['sortOrder'] as num?) ?? 0));
|
|
|
|
return Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
child: Column(
|
|
children: [
|
|
for (final section in sections) ...[
|
|
_buildSection(section),
|
|
const SizedBox(height: 16),
|
|
],
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
_buildBottomButtons(data),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bottom buttons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildBottomButtons(_EditProfileData data) {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: _saving ? null : () => context.pop(),
|
|
style: OutlinedButton.styleFrom(
|
|
side: const BorderSide(color: AppColors.primaryDark),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
child: const Text(
|
|
'Cancel',
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: ElevatedButton(
|
|
onPressed: _saving ? null : () => _save(data),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.accentOrange,
|
|
disabledBackgroundColor: AppColors.accentOrange.withValues(alpha: 0.5),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
child: _saving
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Text(
|
|
'Save',
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildSection(Map<String, dynamic> section) {
|
|
final name = section['name'] as String? ?? '';
|
|
final iconName = section['icon'] as String?;
|
|
final isRepeatable = section['isRepeatable'] == true;
|
|
final sectionSlug = section['slug'] as String? ?? '';
|
|
final fields = (section['fields'] as List<dynamic>? ?? [])
|
|
.whereType<Map<String, dynamic>>()
|
|
.where((f) => f['isActive'] != false && f['isSearchableOnly'] != true)
|
|
.toList()
|
|
..sort((a, b) => ((a['sortOrder'] as num?) ?? 0)
|
|
.compareTo((b['sortOrder'] as num?) ?? 0));
|
|
|
|
if (fields.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(15),
|
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x80D9D9D9),
|
|
blurRadius: 20,
|
|
offset: Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
if (iconName != null && iconName.isNotEmpty) ...[
|
|
Icon(
|
|
_resolveIcon(iconName),
|
|
size: 20,
|
|
color: AppColors.accentOrange,
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
Expanded(
|
|
child: Text(
|
|
name,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Fields
|
|
if (isRepeatable)
|
|
_buildRepeatableFields(sectionSlug, fields)
|
|
else
|
|
...fields.map((f) => _buildFieldWidget(f)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repeatable section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildRepeatableFields(
|
|
String sectionSlug, List<Map<String, dynamic>> fields) {
|
|
final entries = _repeatableEntries[sectionSlug] ?? [{}];
|
|
|
|
return Column(
|
|
children: [
|
|
for (int i = 0; i < entries.length; i++) ...[
|
|
if (i > 0)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12),
|
|
child: Divider(height: 1, color: Color(0xFFE5E7EB)),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Entry ${i + 1}',
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.hintText,
|
|
),
|
|
),
|
|
if (entries.length > 1)
|
|
GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
entries.removeAt(i);
|
|
});
|
|
},
|
|
child: const Icon(Icons.remove_circle_outline,
|
|
size: 20, color: AppColors.error),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
...fields.map((f) {
|
|
final slug = f['slug'] as String? ?? '';
|
|
return _buildFieldWidget(
|
|
f,
|
|
getValue: () => entries[i][slug],
|
|
setValue: (val) {
|
|
setState(() {
|
|
entries[i][slug] = val;
|
|
});
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
const SizedBox(height: 12),
|
|
GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
entries.add({});
|
|
});
|
|
},
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: const [
|
|
Icon(Icons.add_circle_outline,
|
|
size: 20, color: AppColors.accentOrange),
|
|
SizedBox(width: 6),
|
|
Text(
|
|
'Add Entry',
|
|
style: TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.accentOrange,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Field dispatcher
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildFieldWidget(
|
|
Map<String, dynamic> field, {
|
|
dynamic Function()? getValue,
|
|
void Function(dynamic)? setValue,
|
|
}) {
|
|
final slug = field['slug'] as String? ?? '';
|
|
final fieldType = field['fieldType'] as String? ?? 'TEXT';
|
|
final label = field['name'] as String? ?? '';
|
|
final placeholder = field['placeholder'] as String? ?? '';
|
|
final description = field['description'] as String?;
|
|
final isRequired = field['isRequired'] == true;
|
|
final validation = field['validation'] as Map<String, dynamic>?;
|
|
final options = (field['options'] as List<dynamic>?)
|
|
?.whereType<Map<String, dynamic>>()
|
|
.toList() ??
|
|
[];
|
|
final uiConfig = field['uiConfig'] as Map<String, dynamic>?;
|
|
final helpText = uiConfig?['helpText'] as String?;
|
|
|
|
// Getter / setter — either use repeatable entry or global formData
|
|
dynamic currentValue() => getValue != null ? getValue() : _formData[slug];
|
|
void updateValue(dynamic val) {
|
|
if (setValue != null) {
|
|
setValue(val);
|
|
} else {
|
|
setState(() {
|
|
_formData[slug] = val;
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget fieldWidget;
|
|
|
|
switch (fieldType) {
|
|
case 'TEXT':
|
|
fieldWidget =
|
|
_buildTextField(slug, currentValue, updateValue, placeholder, validation, false);
|
|
break;
|
|
case 'TEXTAREA':
|
|
fieldWidget =
|
|
_buildTextField(slug, currentValue, updateValue, placeholder, validation, true);
|
|
break;
|
|
case 'NUMBER':
|
|
fieldWidget = _buildNumberField(
|
|
slug, currentValue, updateValue, placeholder, validation);
|
|
break;
|
|
case 'DATE':
|
|
fieldWidget = _buildDateField(slug, currentValue, updateValue, placeholder);
|
|
break;
|
|
case 'CHECKBOX':
|
|
fieldWidget = _buildCheckboxField(slug, currentValue, updateValue, label);
|
|
// Checkbox renders its own label, so skip the standard label wrapper.
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: fieldWidget,
|
|
);
|
|
case 'CHECKBOX_GROUP':
|
|
fieldWidget = _buildCheckboxGroup(slug, currentValue, updateValue, options, uiConfig);
|
|
break;
|
|
case 'RADIO':
|
|
fieldWidget = _buildRadioGroup(slug, currentValue, updateValue, options);
|
|
break;
|
|
case 'SELECT':
|
|
fieldWidget =
|
|
_buildSelectField(slug, currentValue, updateValue, placeholder, options);
|
|
break;
|
|
case 'MULTI_SELECT':
|
|
fieldWidget =
|
|
_buildMultiSelect(slug, currentValue, updateValue, options);
|
|
break;
|
|
case 'TAG_INPUT':
|
|
fieldWidget = _buildTagInput(slug, currentValue, updateValue, placeholder);
|
|
break;
|
|
case 'RANGE':
|
|
fieldWidget = _buildRangeField(slug, currentValue, updateValue, field);
|
|
break;
|
|
case 'FILE_UPLOAD':
|
|
fieldWidget = _buildFileUpload(slug, currentValue, updateValue);
|
|
break;
|
|
case 'REPEATER':
|
|
return const SizedBox.shrink();
|
|
default:
|
|
fieldWidget =
|
|
_buildTextField(slug, currentValue, updateValue, placeholder, validation, false);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Label
|
|
RichText(
|
|
text: TextSpan(
|
|
text: label,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
children: isRequired
|
|
? [
|
|
const TextSpan(
|
|
text: ' *',
|
|
style: TextStyle(color: AppColors.error),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
),
|
|
if (description != null && description.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
description,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 12,
|
|
color: AppColors.hintText,
|
|
),
|
|
),
|
|
],
|
|
if (helpText != null && helpText.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
helpText,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 12,
|
|
color: AppColors.hintText,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 6),
|
|
fieldWidget,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TEXT / TEXTAREA
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildTextField(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
String placeholder,
|
|
Map<String, dynamic>? validation,
|
|
bool multiline,
|
|
) {
|
|
final key = '${slug}_text';
|
|
if (!_tagControllers.containsKey(key)) {
|
|
final controller = TextEditingController(text: '${getValue() ?? ''}');
|
|
_tagControllers[key] = controller;
|
|
_controllers.add(controller);
|
|
}
|
|
final controller = _tagControllers[key]!;
|
|
|
|
final maxLength = validation?['maxLength'] as int?;
|
|
|
|
return TextFormField(
|
|
controller: controller,
|
|
maxLines: multiline ? 5 : 1,
|
|
minLines: multiline ? 3 : 1,
|
|
maxLength: maxLength != null && maxLength > 0 ? maxLength : null,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
decoration: _inputDecoration(placeholder),
|
|
onChanged: (val) => setValue(val),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NUMBER
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildNumberField(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
String placeholder,
|
|
Map<String, dynamic>? validation,
|
|
) {
|
|
final key = '${slug}_number';
|
|
if (!_tagControllers.containsKey(key)) {
|
|
final val = getValue();
|
|
final controller =
|
|
TextEditingController(text: val != null ? '$val' : '');
|
|
_tagControllers[key] = controller;
|
|
_controllers.add(controller);
|
|
}
|
|
final controller = _tagControllers[key]!;
|
|
|
|
return TextFormField(
|
|
controller: controller,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
|
|
],
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
decoration: _inputDecoration(placeholder),
|
|
onChanged: (val) => setValue(num.tryParse(val)),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DATE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildDateField(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
String placeholder,
|
|
) {
|
|
final val = getValue();
|
|
final displayText = val is String && val.isNotEmpty ? val : placeholder;
|
|
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
DateTime initial = DateTime.now();
|
|
if (val is String && val.isNotEmpty) {
|
|
try {
|
|
initial = DateTime.parse(val);
|
|
} catch (_) {}
|
|
}
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: initial,
|
|
firstDate: DateTime(1900),
|
|
lastDate: DateTime(2100),
|
|
builder: (ctx, child) => Theme(
|
|
data: Theme.of(ctx).copyWith(
|
|
colorScheme: const ColorScheme.light(
|
|
primary: AppColors.accentOrange,
|
|
onPrimary: Colors.white,
|
|
surface: Colors.white,
|
|
onSurface: AppColors.primaryDark,
|
|
),
|
|
),
|
|
child: child!,
|
|
),
|
|
);
|
|
if (picked != null) {
|
|
setValue(DateFormat('yyyy-MM-dd').format(picked));
|
|
}
|
|
},
|
|
child: Container(
|
|
height: 44,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
displayText.isEmpty ? 'Select date' : displayText,
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: val is String && val.isNotEmpty
|
|
? AppColors.primaryDark
|
|
: AppColors.hintText,
|
|
),
|
|
),
|
|
),
|
|
const Icon(Icons.calendar_today,
|
|
size: 18, color: AppColors.hintText),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CHECKBOX (single toggle)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildCheckboxField(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
String label,
|
|
) {
|
|
final checked = getValue() == true;
|
|
return GestureDetector(
|
|
onTap: () => setValue(!checked),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 17,
|
|
height: 17,
|
|
decoration: BoxDecoration(
|
|
color: checked ? AppColors.accentOrange : Colors.white,
|
|
borderRadius: BorderRadius.circular(5),
|
|
border: Border.all(
|
|
color: checked ? AppColors.accentOrange : const Color(0xFFD1D5DB),
|
|
),
|
|
),
|
|
child: checked
|
|
? const Icon(Icons.check, size: 13, color: Colors.white)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CHECKBOX_GROUP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildCheckboxGroup(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
List<Map<String, dynamic>> options,
|
|
Map<String, dynamic>? uiConfig,
|
|
) {
|
|
final selected = List<String>.from((getValue() ?? <String>[]) as List);
|
|
final columns = (uiConfig?['columns'] as num?)?.toInt() ?? 2;
|
|
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: options.map((opt) {
|
|
final optVal = opt['value'] as String? ?? '';
|
|
final optLabel = opt['label'] as String? ?? optVal;
|
|
final isChecked = selected.contains(optVal);
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
final newList = List<String>.from(selected);
|
|
if (isChecked) {
|
|
newList.remove(optVal);
|
|
} else {
|
|
newList.add(optVal);
|
|
}
|
|
setValue(newList);
|
|
},
|
|
child: SizedBox(
|
|
width: columns > 1
|
|
? (MediaQuery.of(context).size.width - 72) / columns
|
|
: null,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 17,
|
|
height: 17,
|
|
decoration: BoxDecoration(
|
|
color:
|
|
isChecked ? AppColors.accentOrange : Colors.white,
|
|
borderRadius: BorderRadius.circular(5),
|
|
border: Border.all(
|
|
color: isChecked
|
|
? AppColors.accentOrange
|
|
: const Color(0xFFD1D5DB),
|
|
),
|
|
),
|
|
child: isChecked
|
|
? const Icon(Icons.check,
|
|
size: 13, color: Colors.white)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
optLabel,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RADIO
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildRadioGroup(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
List<Map<String, dynamic>> options,
|
|
) {
|
|
final currentVal = '${getValue() ?? ''}';
|
|
return Column(
|
|
children: options.map((opt) {
|
|
final optVal = opt['value'] as String? ?? '';
|
|
final optLabel = opt['label'] as String? ?? optVal;
|
|
return GestureDetector(
|
|
onTap: () => setValue(optVal),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 20,
|
|
height: 20,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: currentVal == optVal
|
|
? AppColors.accentOrange
|
|
: const Color(0xFFD1D5DB),
|
|
width: 2,
|
|
),
|
|
),
|
|
child: currentVal == optVal
|
|
? Center(
|
|
child: Container(
|
|
width: 10,
|
|
height: 10,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: AppColors.accentOrange,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
optLabel,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SELECT (dropdown)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildSelectField(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
String placeholder,
|
|
List<Map<String, dynamic>> options,
|
|
) {
|
|
final currentVal = '${getValue() ?? ''}';
|
|
final hasMatch = options.any((o) => o['value'] == currentVal);
|
|
|
|
return DropdownButtonFormField<String>(
|
|
initialValue: hasMatch ? currentVal : null,
|
|
isExpanded: true,
|
|
decoration: _inputDecoration(placeholder),
|
|
icon: const Icon(Icons.keyboard_arrow_down, color: AppColors.hintText),
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
hint: Text(
|
|
placeholder.isEmpty ? 'Select...' : placeholder,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.hintText,
|
|
),
|
|
),
|
|
items: options.map((opt) {
|
|
final v = opt['value'] as String? ?? '';
|
|
final l = opt['label'] as String? ?? v;
|
|
return DropdownMenuItem<String>(value: v, child: Text(l));
|
|
}).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) setValue(val);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MULTI_SELECT (chip group)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildMultiSelect(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
List<Map<String, dynamic>> options,
|
|
) {
|
|
final selected = List<String>.from((getValue() ?? <String>[]) as List);
|
|
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: options.map((opt) {
|
|
final optVal = opt['value'] as String? ?? '';
|
|
final optLabel = opt['label'] as String? ?? optVal;
|
|
final isSelected = selected.contains(optVal);
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
final newList = List<String>.from(selected);
|
|
if (isSelected) {
|
|
newList.remove(optVal);
|
|
} else {
|
|
newList.add(optVal);
|
|
}
|
|
setValue(newList);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? AppColors.accentOrange.withValues(alpha: 0.1)
|
|
: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? AppColors.accentOrange
|
|
: const Color(0xFFD1D5DB),
|
|
),
|
|
),
|
|
child: Text(
|
|
optLabel,
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w400,
|
|
color: isSelected
|
|
? AppColors.accentOrange
|
|
: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TAG_INPUT
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildTagInput(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
String placeholder,
|
|
) {
|
|
final tags = List<String>.from((getValue() ?? <String>[]) as List);
|
|
|
|
if (!_tagControllers.containsKey(slug)) {
|
|
final controller = TextEditingController();
|
|
_tagControllers[slug] = controller;
|
|
_controllers.add(controller);
|
|
}
|
|
final controller = _tagControllers[slug]!;
|
|
|
|
void addTag(String text) {
|
|
final trimmed = text.trim();
|
|
if (trimmed.isEmpty || tags.contains(trimmed)) return;
|
|
final newList = List<String>.from(tags)..add(trimmed);
|
|
setValue(newList);
|
|
controller.clear();
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (tags.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: tags.map((tag) {
|
|
return Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.accentOrange.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: AppColors.accentOrange.withValues(alpha: 0.3)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
tag,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
GestureDetector(
|
|
onTap: () {
|
|
final newList = List<String>.from(tags)..remove(tag);
|
|
setValue(newList);
|
|
},
|
|
child: const Icon(Icons.close,
|
|
size: 14, color: AppColors.hintText),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
TextFormField(
|
|
controller: controller,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
decoration: _inputDecoration(
|
|
placeholder.isEmpty ? 'Type and press Enter' : placeholder,
|
|
).copyWith(
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.add_circle_outline,
|
|
color: AppColors.accentOrange),
|
|
onPressed: () => addTag(controller.text),
|
|
),
|
|
),
|
|
onFieldSubmitted: addTag,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RANGE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildRangeField(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
Map<String, dynamic> field,
|
|
) {
|
|
final rangeConfig = field['rangeConfig'] as Map<String, dynamic>? ?? {};
|
|
final configMin = (rangeConfig['min'] as num?)?.toDouble() ?? 0;
|
|
final configMax = (rangeConfig['max'] as num?)?.toDouble() ?? 100;
|
|
final step = (rangeConfig['step'] as num?)?.toDouble() ?? 1;
|
|
final unit = rangeConfig['unit'] as String? ?? '';
|
|
|
|
final currentRange = getValue();
|
|
double rangeMin = configMin;
|
|
double rangeMax = configMax;
|
|
if (currentRange is Map) {
|
|
rangeMin = (currentRange['min'] as num?)?.toDouble() ?? configMin;
|
|
rangeMax = (currentRange['max'] as num?)?.toDouble() ?? configMax;
|
|
}
|
|
// Clamp to valid range
|
|
rangeMin = rangeMin.clamp(configMin, configMax);
|
|
rangeMax = rangeMax.clamp(configMin, configMax);
|
|
if (rangeMin > rangeMax) rangeMin = rangeMax;
|
|
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'${rangeMin.toInt()} $unit',
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
Text(
|
|
'${rangeMax.toInt()} $unit',
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
activeTrackColor: AppColors.accentOrange,
|
|
inactiveTrackColor: AppColors.accentOrange.withValues(alpha: 0.2),
|
|
thumbColor: AppColors.accentOrange,
|
|
overlayColor: AppColors.accentOrange.withValues(alpha: 0.1),
|
|
),
|
|
child: RangeSlider(
|
|
values: RangeValues(rangeMin, rangeMax),
|
|
min: configMin,
|
|
max: configMax,
|
|
divisions:
|
|
step > 0 ? ((configMax - configMin) / step).round() : null,
|
|
onChanged: (values) {
|
|
setValue({
|
|
'min': values.start,
|
|
'max': values.end,
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FILE_UPLOAD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildFileUpload(
|
|
String slug,
|
|
dynamic Function() getValue,
|
|
void Function(dynamic) setValue,
|
|
) {
|
|
final files = getValue();
|
|
final fileList = files is List ? List<String>.from(files.map((e) => '$e')) : <String>[];
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
// Placeholder: actual file picking would use image_picker or file_picker
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('File upload is not yet available on mobile'),
|
|
backgroundColor: AppColors.hintText,
|
|
),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.upload_file, size: 18),
|
|
label: const Text('Upload File'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppColors.accentOrange,
|
|
side: const BorderSide(color: AppColors.accentOrange),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
),
|
|
),
|
|
if (fileList.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
...fileList.map((f) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.attach_file,
|
|
size: 16, color: AppColors.hintText),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
f,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 13,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: () {
|
|
final newList = List<String>.from(fileList)..remove(f);
|
|
setValue(newList);
|
|
},
|
|
child: const Icon(Icons.close,
|
|
size: 16, color: AppColors.error),
|
|
),
|
|
],
|
|
),
|
|
)),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared input decoration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
InputDecoration _inputDecoration(String hint) {
|
|
return InputDecoration(
|
|
hintText: hint,
|
|
hintStyle: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppColors.hintText,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
borderSide: const BorderSide(color: AppColors.accentOrange),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
borderSide: const BorderSide(color: AppColors.error),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(15),
|
|
borderSide: const BorderSide(color: AppColors.error),
|
|
),
|
|
isDense: true,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Icon resolver
|
|
// ---------------------------------------------------------------------------
|
|
|
|
IconData _resolveIcon(String name) {
|
|
const iconMap = <String, IconData>{
|
|
'user': Icons.person_outline,
|
|
'person': Icons.person_outline,
|
|
'profile': Icons.person_outline,
|
|
'briefcase': Icons.work_outline,
|
|
'work': Icons.work_outline,
|
|
'business': Icons.business_outlined,
|
|
'building': Icons.business_outlined,
|
|
'office': Icons.business_outlined,
|
|
'education': Icons.school_outlined,
|
|
'school': Icons.school_outlined,
|
|
'award': Icons.emoji_events_outlined,
|
|
'trophy': Icons.emoji_events_outlined,
|
|
'certificate': Icons.card_membership_outlined,
|
|
'star': Icons.star_outline,
|
|
'heart': Icons.favorite_outline,
|
|
'home': Icons.home_outlined,
|
|
'house': Icons.home_outlined,
|
|
'location': Icons.location_on_outlined,
|
|
'map': Icons.map_outlined,
|
|
'phone': Icons.phone_outlined,
|
|
'call': Icons.phone_outlined,
|
|
'email': Icons.email_outlined,
|
|
'mail': Icons.email_outlined,
|
|
'globe': Icons.language,
|
|
'world': Icons.language,
|
|
'link': Icons.link,
|
|
'settings': Icons.settings_outlined,
|
|
'gear': Icons.settings_outlined,
|
|
'info': Icons.info_outline,
|
|
'dollar': Icons.attach_money,
|
|
'money': Icons.attach_money,
|
|
'finance': Icons.attach_money,
|
|
'shield': Icons.shield_outlined,
|
|
'lock': Icons.lock_outline,
|
|
'clock': Icons.access_time,
|
|
'time': Icons.access_time,
|
|
'calendar': Icons.calendar_today_outlined,
|
|
'file': Icons.description_outlined,
|
|
'document': Icons.description_outlined,
|
|
'camera': Icons.camera_alt_outlined,
|
|
'photo': Icons.photo_outlined,
|
|
'image': Icons.image_outlined,
|
|
'chat': Icons.chat_outlined,
|
|
'message': Icons.message_outlined,
|
|
'list': Icons.list,
|
|
'check': Icons.check_circle_outline,
|
|
'target': Icons.gps_fixed,
|
|
'flag': Icons.flag_outlined,
|
|
'tag': Icons.label_outline,
|
|
};
|
|
|
|
final lower = name.toLowerCase().replaceAll(RegExp(r'[^a-z]'), '');
|
|
for (final entry in iconMap.entries) {
|
|
if (lower.contains(entry.key)) return entry.value;
|
|
}
|
|
return Icons.article_outlined;
|
|
}
|
|
}
|