2026-03-09 06:04:54 +05:30
|
|
|
|
import 'dart:convert';
|
2026-03-09 06:33:55 +05:30
|
|
|
|
import 'dart:io';
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
2026-03-09 06:33:55 +05:30
|
|
|
|
import 'package:image_picker/image_picker.dart';
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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.
|
2026-03-09 06:33:55 +05:30
|
|
|
|
final _editProfileDataProvider = FutureProvider.autoDispose<_EditProfileData>((
|
|
|
|
|
|
ref,
|
|
|
|
|
|
) async {
|
2026-03-09 06:04:54 +05:30
|
|
|
|
final repo = ProfileRepository();
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Fetch agent profile (need agentTypeId + pre-fill values)
|
|
|
|
|
|
final profile = await repo.getMyProfile('AGENT');
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Resolve agentTypeId
|
2026-03-09 06:33:55 +05:30
|
|
|
|
final agentTypeId =
|
|
|
|
|
|
profile['agentTypeId'] as String? ??
|
2026-03-09 06:04:54 +05:30
|
|
|
|
(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
|
2026-03-09 06:33:55 +05:30
|
|
|
|
.map(
|
|
|
|
|
|
(e) => e is Map<String, dynamic>
|
|
|
|
|
|
? Map<String, dynamic>.from(e)
|
|
|
|
|
|
: <String, dynamic>{},
|
|
|
|
|
|
)
|
2026-03-09 06:04:54 +05:30
|
|
|
|
.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(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
dynamic value,
|
|
|
|
|
|
String fieldType,
|
|
|
|
|
|
Map<String, dynamic> field,
|
|
|
|
|
|
) {
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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};
|
2026-03-09 06:35:32 +05:30
|
|
|
|
case 'FILE':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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>>[];
|
2026-03-09 06:04:54 +05:30
|
|
|
|
default:
|
|
|
|
|
|
return value?.toString() ?? '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 15:45:05 +05:30
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 06:04:54 +05:30
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldValuesToSave.add({'fieldSlug': entry.key, 'value': val});
|
2026-03-09 06:04:54 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-03-15 00:57:14 +05:30
|
|
|
|
return Column(
|
|
|
|
|
|
children: [
|
2026-03-26 15:05:06 +05:30
|
|
|
|
// Header with back button
|
2026-03-15 00:57:14 +05:30
|
|
|
|
Padding(
|
2026-03-26 15:05:06 +05:30
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
2026-03-15 00:57:14 +05:30
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
GestureDetector(
|
2026-03-26 15:05:06 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
),
|
2026-03-15 00:57:14 +05:30
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
|
const Text(
|
|
|
|
|
|
'Edit Profile',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
2026-03-15 00:57:14 +05:30
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-09 06:33:55 +05:30
|
|
|
|
),
|
2026-03-09 07:40:57 +05:30
|
|
|
|
),
|
2026-03-15 00:57:14 +05:30
|
|
|
|
Expanded(child: _buildBody(asyncData)),
|
|
|
|
|
|
],
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
),
|
|
|
|
|
|
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),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
child: const Text(
|
|
|
|
|
|
'Retry',
|
|
|
|
|
|
style: TextStyle(color: Colors.white),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
data: (data) {
|
|
|
|
|
|
_initializeFormData(data);
|
|
|
|
|
|
return _buildForm(data);
|
|
|
|
|
|
},
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildForm(_EditProfileData data) {
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
|
|
|
|
|
return Form(
|
|
|
|
|
|
key: _formKey,
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: SingleChildScrollView(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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(
|
2026-03-30 15:12:09 +05:30
|
|
|
|
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
|
|
|
|
|
|
},
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
disabledBackgroundColor: AppColors.accentOrange.withValues(
|
|
|
|
|
|
alpha: 0.5,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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? ?? '';
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
String sectionSlug,
|
|
|
|
|
|
List<Map<String, dynamic>> fields,
|
|
|
|
|
|
) {
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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);
|
2026-03-15 00:57:14 +05:30
|
|
|
|
// 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')));
|
2026-03-09 06:04:54 +05:30
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-03-09 06:33:55 +05:30
|
|
|
|
child: const Icon(
|
|
|
|
|
|
Icons.remove_circle_outline,
|
|
|
|
|
|
size: 20,
|
|
|
|
|
|
color: AppColors.error,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-03-15 00:57:14 +05:30
|
|
|
|
controllerKeySuffix: 'entry$i',
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
entries.add({});
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
|
children: const [
|
2026-03-09 06:33:55 +05:30
|
|
|
|
Icon(
|
|
|
|
|
|
Icons.add_circle_outline,
|
|
|
|
|
|
size: 20,
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
String? controllerKeySuffix,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
}) {
|
|
|
|
|
|
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>?;
|
2026-03-09 06:33:55 +05:30
|
|
|
|
final options =
|
|
|
|
|
|
(field['options'] as List<dynamic>?)
|
2026-03-09 06:04:54 +05:30
|
|
|
|
?.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':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildTextField(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
validation,
|
|
|
|
|
|
false,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
controllerKeySuffix: controllerKeySuffix,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
isRequired: isRequired,
|
|
|
|
|
|
fieldName: label,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'TEXTAREA':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildTextField(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
validation,
|
|
|
|
|
|
true,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
controllerKeySuffix: controllerKeySuffix,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
isRequired: isRequired,
|
|
|
|
|
|
fieldName: label,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'NUMBER':
|
|
|
|
|
|
fieldWidget = _buildNumberField(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
validation,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
controllerKeySuffix: controllerKeySuffix,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
isRequired: isRequired,
|
|
|
|
|
|
fieldName: label,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'DATE':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildDateField(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
validation: validation,
|
|
|
|
|
|
isRequired: isRequired,
|
|
|
|
|
|
fieldName: label,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'CHECKBOX':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildCheckboxField(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
label,
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
// Checkbox renders its own label, so skip the standard label wrapper.
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
|
|
|
|
child: fieldWidget,
|
|
|
|
|
|
);
|
|
|
|
|
|
case 'CHECKBOX_GROUP':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildCheckboxGroup(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
options,
|
|
|
|
|
|
uiConfig,
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'RADIO':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildRadioGroup(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
options,
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'SELECT':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildSelectField(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
options,
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'MULTI_SELECT':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildMultiSelect(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
options,
|
|
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'TAG_INPUT':
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildTagInput(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
controllerKeySuffix: controllerKeySuffix,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
break;
|
|
|
|
|
|
case 'RANGE':
|
|
|
|
|
|
fieldWidget = _buildRangeField(slug, currentValue, updateValue, field);
|
|
|
|
|
|
break;
|
2026-03-09 06:35:32 +05:30
|
|
|
|
case 'FILE':
|
2026-03-09 06:04:54 +05:30
|
|
|
|
case 'FILE_UPLOAD':
|
|
|
|
|
|
fieldWidget = _buildFileUpload(slug, currentValue, updateValue);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'REPEATER':
|
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
|
default:
|
2026-03-09 06:33:55 +05:30
|
|
|
|
fieldWidget = _buildTextField(
|
|
|
|
|
|
slug,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
updateValue,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
validation,
|
|
|
|
|
|
false,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
controllerKeySuffix: controllerKeySuffix,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
bool multiline, {
|
|
|
|
|
|
String? controllerKeySuffix,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
bool isRequired = false,
|
|
|
|
|
|
String fieldName = 'This field',
|
2026-03-15 00:57:14 +05:30
|
|
|
|
}) {
|
|
|
|
|
|
final key = controllerKeySuffix != null
|
|
|
|
|
|
? '${slug}_${controllerKeySuffix}_text'
|
|
|
|
|
|
: '${slug}_text';
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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),
|
2026-04-15 15:45:05 +05:30
|
|
|
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
|
|
|
|
|
validator: (val) => _validateText(
|
|
|
|
|
|
value: val,
|
|
|
|
|
|
isRequired: isRequired,
|
|
|
|
|
|
fieldName: fieldName,
|
|
|
|
|
|
validation: validation,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// NUMBER
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildNumberField(
|
|
|
|
|
|
String slug,
|
|
|
|
|
|
dynamic Function() getValue,
|
|
|
|
|
|
void Function(dynamic) setValue,
|
|
|
|
|
|
String placeholder,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
Map<String, dynamic>? validation, {
|
|
|
|
|
|
String? controllerKeySuffix,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
bool isRequired = false,
|
|
|
|
|
|
String fieldName = 'This field',
|
2026-03-15 00:57:14 +05:30
|
|
|
|
}) {
|
|
|
|
|
|
final key = controllerKeySuffix != null
|
|
|
|
|
|
? '${slug}_${controllerKeySuffix}_number'
|
|
|
|
|
|
: '${slug}_number';
|
2026-03-09 06:04:54 +05:30
|
|
|
|
if (!_tagControllers.containsKey(key)) {
|
|
|
|
|
|
final val = getValue();
|
2026-03-09 06:33:55 +05:30
|
|
|
|
final controller = TextEditingController(text: val != null ? '$val' : '');
|
2026-03-09 06:04:54 +05:30
|
|
|
|
_tagControllers[key] = controller;
|
|
|
|
|
|
_controllers.add(controller);
|
|
|
|
|
|
}
|
|
|
|
|
|
final controller = _tagControllers[key]!;
|
|
|
|
|
|
|
|
|
|
|
|
return TextFormField(
|
|
|
|
|
|
controller: controller,
|
|
|
|
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
|
2026-03-09 06:04:54 +05:30
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w300,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
decoration: _inputDecoration(placeholder),
|
|
|
|
|
|
onChanged: (val) => setValue(num.tryParse(val)),
|
2026-04-15 15:45:05 +05:30
|
|
|
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
|
|
|
|
|
validator: (val) => _validateNumber(
|
|
|
|
|
|
value: val,
|
|
|
|
|
|
isRequired: isRequired,
|
|
|
|
|
|
fieldName: fieldName,
|
|
|
|
|
|
validation: validation,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// DATE
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildDateField(
|
|
|
|
|
|
String slug,
|
|
|
|
|
|
dynamic Function() getValue,
|
|
|
|
|
|
void Function(dynamic) setValue,
|
2026-04-15 15:45:05 +05:30
|
|
|
|
String placeholder, {
|
|
|
|
|
|
Map<String, dynamic>? validation,
|
|
|
|
|
|
bool isRequired = false,
|
|
|
|
|
|
String fieldName = 'This field',
|
|
|
|
|
|
}) {
|
2026-03-09 06:04:54 +05:30
|
|
|
|
final val = getValue();
|
2026-04-15 16:58:02 +05:30
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
2026-04-15 15:45:05 +05:30
|
|
|
|
// 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,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
children: [
|
2026-04-15 15:45:05 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-04-15 15:45:05 +05:30
|
|
|
|
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',
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
],
|
2026-04-15 15:45:05 +05:30
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
color: checked
|
|
|
|
|
|
? AppColors.accentOrange
|
|
|
|
|
|
: const Color(0xFFD1D5DB),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
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(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
color: isChecked ? AppColors.accentOrange : Colors.white,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
borderRadius: BorderRadius.circular(5),
|
|
|
|
|
|
border: Border.all(
|
|
|
|
|
|
color: isChecked
|
|
|
|
|
|
? AppColors.accentOrange
|
|
|
|
|
|
: const Color(0xFFD1D5DB),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: isChecked
|
2026-03-09 06:33:55 +05:30
|
|
|
|
? const Icon(Icons.check, size: 13, color: Colors.white)
|
2026-03-09 06:04:54 +05:30
|
|
|
|
: 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);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-15 16:58:02 +05:30
|
|
|
|
// MULTI_SELECT (searchable dropdown — matches web's react-select pattern)
|
2026-03-09 06:04:54 +05:30
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-04-15 16:58:02 +05:30
|
|
|
|
String labelFor(String val) {
|
|
|
|
|
|
final match = options.firstWhere(
|
|
|
|
|
|
(o) => (o['value'] as String? ?? '') == val,
|
|
|
|
|
|
orElse: () => <String, dynamic>{},
|
|
|
|
|
|
);
|
|
|
|
|
|
return (match['label'] as String?) ?? val;
|
|
|
|
|
|
}
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
2026-04-15 16:58:02 +05:30
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Tap target that opens the searchable picker
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => _openMultiSelectPicker(
|
|
|
|
|
|
title: slug,
|
|
|
|
|
|
options: options,
|
|
|
|
|
|
selected: selected,
|
|
|
|
|
|
onChanged: setValue,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
child: Container(
|
2026-04-15 16:58:02 +05:30
|
|
|
|
width: double.infinity,
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
decoration: BoxDecoration(
|
2026-04-15 16:58:02 +05:30
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
|
|
border: Border.all(color: const Color(0xFFD1D5DB)),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
2026-04-15 16:58:02 +05:30
|
|
|
|
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),
|
|
|
|
|
|
],
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-04-15 16:58:02 +05:30
|
|
|
|
),
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
2026-04-15 16:58:02 +05:30
|
|
|
|
},
|
2026-03-09 06:04:54 +05:30
|
|
|
|
);
|
2026-04-15 16:58:02 +05:30
|
|
|
|
searchController.dispose();
|
2026-03-09 06:04:54 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// TAG_INPUT
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildTagInput(
|
|
|
|
|
|
String slug,
|
|
|
|
|
|
dynamic Function() getValue,
|
|
|
|
|
|
void Function(dynamic) setValue,
|
2026-03-15 00:57:14 +05:30
|
|
|
|
String placeholder, {
|
|
|
|
|
|
String? controllerKeySuffix,
|
|
|
|
|
|
}) {
|
2026-03-09 06:04:54 +05:30
|
|
|
|
final tags = List<String>.from((getValue() ?? <String>[]) as List);
|
|
|
|
|
|
|
2026-03-15 00:57:14 +05:30
|
|
|
|
final tagKey = controllerKeySuffix != null
|
|
|
|
|
|
? '${slug}_${controllerKeySuffix}'
|
|
|
|
|
|
: slug;
|
|
|
|
|
|
if (!_tagControllers.containsKey(tagKey)) {
|
2026-03-09 06:04:54 +05:30
|
|
|
|
final controller = TextEditingController();
|
2026-03-15 00:57:14 +05:30
|
|
|
|
_tagControllers[tagKey] = controller;
|
2026-03-09 06:04:54 +05:30
|
|
|
|
_controllers.add(controller);
|
|
|
|
|
|
}
|
2026-03-15 00:57:14 +05:30
|
|
|
|
final controller = _tagControllers[tagKey]!;
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: 12,
|
|
|
|
|
|
vertical: 6,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: AppColors.accentOrange.withValues(alpha: 0.1),
|
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
border: Border.all(
|
|
|
|
|
|
color: AppColors.accentOrange.withValues(alpha: 0.3),
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
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);
|
|
|
|
|
|
},
|
2026-03-09 06:33:55 +05:30
|
|
|
|
child: const Icon(
|
|
|
|
|
|
Icons.close,
|
|
|
|
|
|
size: 14,
|
|
|
|
|
|
color: AppColors.hintText,
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}).toList(),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
TextFormField(
|
|
|
|
|
|
controller: controller,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w300,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
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,
|
2026-03-09 06:33:55 +05:30
|
|
|
|
divisions: step > 0
|
|
|
|
|
|
? ((configMax - configMin) / step).round()
|
|
|
|
|
|
: null,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
onChanged: (values) {
|
2026-03-09 06:33:55 +05:30
|
|
|
|
setValue({'min': values.start, 'max': values.end});
|
2026-03-09 06:04:54 +05:30
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// FILE_UPLOAD
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 06:04:54 +05:30
|
|
|
|
Widget _buildFileUpload(
|
|
|
|
|
|
String slug,
|
|
|
|
|
|
dynamic Function() getValue,
|
|
|
|
|
|
void Function(dynamic) setValue,
|
|
|
|
|
|
) {
|
2026-03-09 06:33:55 +05:30
|
|
|
|
final fileList = _parseFileList(getValue());
|
2026-03-09 06:04:54 +05:30
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
2026-03-09 06:33:55 +05:30
|
|
|
|
// 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(
|
2026-03-09 06:04:54 +05:30
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
// Existing files list
|
2026-03-09 06:04:54 +05:30
|
|
|
|
if (fileList.isNotEmpty) ...[
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
const SizedBox(height: 8),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
...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)),
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(
|
2026-03-09 06:33:55 +05:30
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () {
|
2026-03-09 06:33:55 +05:30
|
|
|
|
final newList = List<Map<String, dynamic>>.from(
|
|
|
|
|
|
fileList,
|
|
|
|
|
|
);
|
|
|
|
|
|
newList.remove(file);
|
2026-03-09 06:04:54 +05:30
|
|
|
|
setValue(newList);
|
|
|
|
|
|
},
|
2026-03-09 06:33:55 +05:30
|
|
|
|
child: const Padding(
|
|
|
|
|
|
padding: EdgeInsets.all(4),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
Icons.close,
|
|
|
|
|
|
size: 14,
|
|
|
|
|
|
color: AppColors.hintText,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-03-09 06:33:55 +05:30
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}),
|
2026-03-09 06:04:54 +05:30
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|