feat: Implement document upload functionality and update agent profile edit screen UI with a new back icon.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
||||
|
||||
@@ -13,15 +16,17 @@ import 'package:real_estate_mobile/features/profile/data/profile_repository.dart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bundles all three API calls needed to render the edit-profile form.
|
||||
final _editProfileDataProvider =
|
||||
FutureProvider.autoDispose<_EditProfileData>((ref) async {
|
||||
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? ??
|
||||
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');
|
||||
@@ -153,9 +158,11 @@ class _AgentEditProfileScreenState
|
||||
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>{})
|
||||
.map(
|
||||
(e) => e is Map<String, dynamic>
|
||||
? Map<String, dynamic>.from(e)
|
||||
: <String, dynamic>{},
|
||||
)
|
||||
.toList();
|
||||
} else {
|
||||
_repeatableEntries[sectionSlug] = [{}];
|
||||
@@ -193,7 +200,10 @@ class _AgentEditProfileScreenState
|
||||
}
|
||||
|
||||
dynamic _normalizeValue(
|
||||
dynamic value, String fieldType, Map<String, dynamic> field) {
|
||||
dynamic value,
|
||||
String fieldType,
|
||||
Map<String, dynamic> field,
|
||||
) {
|
||||
switch (fieldType) {
|
||||
case 'CHECKBOX':
|
||||
if (value is bool) return value;
|
||||
@@ -228,6 +238,16 @@ class _AgentEditProfileScreenState
|
||||
final min = (rangeConfig?['min'] as num?)?.toDouble() ?? 0;
|
||||
final max = (rangeConfig?['max'] as num?)?.toDouble() ?? 100;
|
||||
return {'min': min, 'max': max};
|
||||
case 'FILE_UPLOAD':
|
||||
// Keep as list of maps — don't stringify
|
||||
if (value is List) return value;
|
||||
if (value is String && value.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(value);
|
||||
if (decoded is List) return decoded;
|
||||
} catch (_) {}
|
||||
}
|
||||
return <Map<String, dynamic>>[];
|
||||
default:
|
||||
return value?.toString() ?? '';
|
||||
}
|
||||
@@ -255,10 +275,7 @@ class _AgentEditProfileScreenState
|
||||
} else if (val is Map) {
|
||||
val = val;
|
||||
}
|
||||
fieldValuesToSave.add({
|
||||
'fieldSlug': entry.key,
|
||||
'value': val,
|
||||
});
|
||||
fieldValuesToSave.add({'fieldSlug': entry.key, 'value': val});
|
||||
}
|
||||
|
||||
// Add repeatable section entries
|
||||
@@ -325,91 +342,111 @@ class _AgentEditProfileScreenState
|
||||
|
||||
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,
|
||||
body: Column(
|
||||
children: [
|
||||
// Header with circular back button (SVG) matching Figma
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 48, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load profile data',
|
||||
style: const TextStyle(
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/back_circle_icon.svg',
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Edit Profile',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
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);
|
||||
},
|
||||
Expanded(child: _buildBody(asyncData)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(AsyncValue<_EditProfileData> asyncData) {
|
||||
return asyncData.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accentOrange,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
error: (err, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load profile data',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$err',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.invalidate(_editProfileDataProvider),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accentOrange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (data) {
|
||||
_initializeFormData(data);
|
||||
return _buildForm(data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(_EditProfileData data) {
|
||||
final sections = data.sections
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.where((s) => s['isActive'] != false)
|
||||
.toList()
|
||||
..sort((a, b) =>
|
||||
((a['sortOrder'] as num?) ?? 0).compareTo((b['sortOrder'] as num?) ?? 0));
|
||||
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,
|
||||
@@ -417,7 +454,7 @@ class _AgentEditProfileScreenState
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final section in sections) ...[
|
||||
@@ -481,7 +518,9 @@ class _AgentEditProfileScreenState
|
||||
onPressed: _saving ? null : () => _save(data),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accentOrange,
|
||||
disabledBackgroundColor: AppColors.accentOrange.withValues(alpha: 0.5),
|
||||
disabledBackgroundColor: AppColors.accentOrange.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
@@ -521,12 +560,18 @@ class _AgentEditProfileScreenState
|
||||
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));
|
||||
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();
|
||||
|
||||
@@ -588,7 +633,9 @@ class _AgentEditProfileScreenState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildRepeatableFields(
|
||||
String sectionSlug, List<Map<String, dynamic>> fields) {
|
||||
String sectionSlug,
|
||||
List<Map<String, dynamic>> fields,
|
||||
) {
|
||||
final entries = _repeatableEntries[sectionSlug] ?? [{}];
|
||||
|
||||
return Column(
|
||||
@@ -618,8 +665,11 @@ class _AgentEditProfileScreenState
|
||||
entries.removeAt(i);
|
||||
});
|
||||
},
|
||||
child: const Icon(Icons.remove_circle_outline,
|
||||
size: 20, color: AppColors.error),
|
||||
child: const Icon(
|
||||
Icons.remove_circle_outline,
|
||||
size: 20,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -647,8 +697,11 @@ class _AgentEditProfileScreenState
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.add_circle_outline,
|
||||
size: 20, color: AppColors.accentOrange),
|
||||
Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 20,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Add Entry',
|
||||
@@ -682,7 +735,8 @@ class _AgentEditProfileScreenState
|
||||
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>?)
|
||||
final options =
|
||||
(field['options'] as List<dynamic>?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
[];
|
||||
@@ -705,43 +759,95 @@ class _AgentEditProfileScreenState
|
||||
|
||||
switch (fieldType) {
|
||||
case 'TEXT':
|
||||
fieldWidget =
|
||||
_buildTextField(slug, currentValue, updateValue, placeholder, validation, false);
|
||||
fieldWidget = _buildTextField(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
validation,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case 'TEXTAREA':
|
||||
fieldWidget =
|
||||
_buildTextField(slug, currentValue, updateValue, placeholder, validation, true);
|
||||
fieldWidget = _buildTextField(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
validation,
|
||||
true,
|
||||
);
|
||||
break;
|
||||
case 'NUMBER':
|
||||
fieldWidget = _buildNumberField(
|
||||
slug, currentValue, updateValue, placeholder, validation);
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
validation,
|
||||
);
|
||||
break;
|
||||
case 'DATE':
|
||||
fieldWidget = _buildDateField(slug, currentValue, updateValue, placeholder);
|
||||
fieldWidget = _buildDateField(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
);
|
||||
break;
|
||||
case 'CHECKBOX':
|
||||
fieldWidget = _buildCheckboxField(slug, currentValue, updateValue, label);
|
||||
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);
|
||||
fieldWidget = _buildCheckboxGroup(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
options,
|
||||
uiConfig,
|
||||
);
|
||||
break;
|
||||
case 'RADIO':
|
||||
fieldWidget = _buildRadioGroup(slug, currentValue, updateValue, options);
|
||||
fieldWidget = _buildRadioGroup(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case 'SELECT':
|
||||
fieldWidget =
|
||||
_buildSelectField(slug, currentValue, updateValue, placeholder, options);
|
||||
fieldWidget = _buildSelectField(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case 'MULTI_SELECT':
|
||||
fieldWidget =
|
||||
_buildMultiSelect(slug, currentValue, updateValue, options);
|
||||
fieldWidget = _buildMultiSelect(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case 'TAG_INPUT':
|
||||
fieldWidget = _buildTagInput(slug, currentValue, updateValue, placeholder);
|
||||
fieldWidget = _buildTagInput(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
);
|
||||
break;
|
||||
case 'RANGE':
|
||||
fieldWidget = _buildRangeField(slug, currentValue, updateValue, field);
|
||||
@@ -752,8 +858,14 @@ class _AgentEditProfileScreenState
|
||||
case 'REPEATER':
|
||||
return const SizedBox.shrink();
|
||||
default:
|
||||
fieldWidget =
|
||||
_buildTextField(slug, currentValue, updateValue, placeholder, validation, false);
|
||||
fieldWidget = _buildTextField(
|
||||
slug,
|
||||
currentValue,
|
||||
updateValue,
|
||||
placeholder,
|
||||
validation,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -862,8 +974,7 @@ class _AgentEditProfileScreenState
|
||||
final key = '${slug}_number';
|
||||
if (!_tagControllers.containsKey(key)) {
|
||||
final val = getValue();
|
||||
final controller =
|
||||
TextEditingController(text: val != null ? '$val' : '');
|
||||
final controller = TextEditingController(text: val != null ? '$val' : '');
|
||||
_tagControllers[key] = controller;
|
||||
_controllers.add(controller);
|
||||
}
|
||||
@@ -872,9 +983,7 @@ class _AgentEditProfileScreenState
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
|
||||
],
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
@@ -950,8 +1059,11 @@ class _AgentEditProfileScreenState
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.calendar_today,
|
||||
size: 18, color: AppColors.hintText),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 18,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -980,7 +1092,9 @@ class _AgentEditProfileScreenState
|
||||
color: checked ? AppColors.accentOrange : Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
color: checked ? AppColors.accentOrange : const Color(0xFFD1D5DB),
|
||||
color: checked
|
||||
? AppColors.accentOrange
|
||||
: const Color(0xFFD1D5DB),
|
||||
),
|
||||
),
|
||||
child: checked
|
||||
@@ -1047,8 +1161,7 @@ class _AgentEditProfileScreenState
|
||||
width: 17,
|
||||
height: 17,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isChecked ? AppColors.accentOrange : Colors.white,
|
||||
color: isChecked ? AppColors.accentOrange : Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
color: isChecked
|
||||
@@ -1057,8 +1170,7 @@ class _AgentEditProfileScreenState
|
||||
),
|
||||
),
|
||||
child: isChecked
|
||||
? const Icon(Icons.check,
|
||||
size: 13, color: Colors.white)
|
||||
? const Icon(Icons.check, size: 13, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -1290,12 +1402,16 @@ class _AgentEditProfileScreenState
|
||||
runSpacing: 6,
|
||||
children: tags.map((tag) {
|
||||
return Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
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)),
|
||||
border: Border.all(
|
||||
color: AppColors.accentOrange.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1315,8 +1431,11 @@ class _AgentEditProfileScreenState
|
||||
final newList = List<String>.from(tags)..remove(tag);
|
||||
setValue(newList);
|
||||
},
|
||||
child: const Icon(Icons.close,
|
||||
size: 14, color: AppColors.hintText),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1332,15 +1451,18 @@ class _AgentEditProfileScreenState
|
||||
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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
@@ -1409,13 +1531,11 @@ class _AgentEditProfileScreenState
|
||||
values: RangeValues(rangeMin, rangeMax),
|
||||
min: configMin,
|
||||
max: configMax,
|
||||
divisions:
|
||||
step > 0 ? ((configMax - configMin) / step).round() : null,
|
||||
divisions: step > 0
|
||||
? ((configMax - configMin) / step).round()
|
||||
: null,
|
||||
onChanged: (values) {
|
||||
setValue({
|
||||
'min': values.start,
|
||||
'max': values.end,
|
||||
});
|
||||
setValue({'min': values.start, 'max': values.end});
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1427,69 +1547,273 @@ class _AgentEditProfileScreenState
|
||||
// FILE_UPLOAD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool _uploading = false;
|
||||
|
||||
/// Parse file entries from the stored value (list of maps or strings).
|
||||
List<Map<String, dynamic>> _parseFileList(dynamic value) {
|
||||
if (value is! List) return [];
|
||||
return value.map<Map<String, dynamic>>((e) {
|
||||
if (e is Map<String, dynamic>) return e;
|
||||
if (e is Map) return Map<String, dynamic>.from(e);
|
||||
return {'name': '$e', 'id': '$e'};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Extract a human-readable filename from a file entry.
|
||||
String _extractFileName(Map<String, dynamic> file) {
|
||||
// Prefer 'name' field
|
||||
if (file['name'] != null && (file['name'] as String).isNotEmpty) {
|
||||
return file['name'] as String;
|
||||
}
|
||||
// Fall back to extracting from key/id/url
|
||||
final key = (file['key'] ?? file['id'] ?? file['url'] ?? '') as String;
|
||||
if (key.isEmpty) return 'Unknown file';
|
||||
// Extract just the filename from the S3 key path
|
||||
final parts = key.split('/');
|
||||
return parts.last;
|
||||
}
|
||||
|
||||
/// Format file size in human-readable form.
|
||||
String _formatFileSize(dynamic size) {
|
||||
if (size == null) return '';
|
||||
final bytes = size is num ? size.toInt() : int.tryParse('$size') ?? 0;
|
||||
if (bytes <= 0) return '';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile(
|
||||
String slug,
|
||||
dynamic Function() getValue,
|
||||
void Function(dynamic) setValue,
|
||||
) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) return;
|
||||
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final repo = ProfileRepository();
|
||||
final fileName = picked.path.split('/').last;
|
||||
final contentType = _guessContentType(fileName);
|
||||
final fileBytes = await File(picked.path).readAsBytes();
|
||||
|
||||
// Get presigned URL
|
||||
final urlData = await repo.getDocumentUploadUrl(fileName, contentType);
|
||||
|
||||
// Upload to S3
|
||||
await repo.uploadToS3(urlData['uploadUrl']!, fileBytes, contentType);
|
||||
|
||||
// Build new file entry
|
||||
final newFile = {
|
||||
'id': urlData['key']!,
|
||||
'key': urlData['key']!,
|
||||
'name': fileName,
|
||||
'size': fileBytes.length,
|
||||
'type': contentType,
|
||||
'url': urlData['publicUrl'] ?? urlData['key']!,
|
||||
'uploadedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
// Add to existing list
|
||||
final currentFiles = _parseFileList(getValue());
|
||||
currentFiles.add(newFile);
|
||||
setValue(currentFiles);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('File uploaded successfully'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Upload failed: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _guessContentType(String fileName) {
|
||||
final ext = (fileName.contains('.') ? '.${fileName.split('.').last}' : '')
|
||||
.toLowerCase();
|
||||
switch (ext) {
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
case '.doc':
|
||||
return 'application/msword';
|
||||
case '.docx':
|
||||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFileUpload(
|
||||
String slug,
|
||||
dynamic Function() getValue,
|
||||
void Function(dynamic) setValue,
|
||||
) {
|
||||
final files = getValue();
|
||||
final fileList = files is List ? List<String>.from(files.map((e) => '$e')) : <String>[];
|
||||
final fileList = _parseFileList(getValue());
|
||||
|
||||
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(
|
||||
// Dashed upload area (matches Figma design)
|
||||
GestureDetector(
|
||||
onTap: _uploading
|
||||
? null
|
||||
: () => _pickAndUploadFile(slug, getValue, setValue),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFCCD5DA),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_upload_outlined,
|
||||
size: 28,
|
||||
color: _uploading
|
||||
? AppColors.hintText
|
||||
: AppColors.primaryDark.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_uploading ? 'Uploading...' : 'Click to upload',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'PDF, DOCX, JPG, PNG (max 10MB)',
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 12,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
if (_uploading) ...[
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accentOrange,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
),
|
||||
// Existing files list
|
||||
if (fileList.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.link, size: 16, color: AppColors.primaryDark),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Attached Documents',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...fileList.map((f) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
...fileList.map((file) {
|
||||
final name = _extractFileName(file);
|
||||
final sizeStr = _formatFileSize(file['size']);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 13,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
if (sizeStr.isNotEmpty)
|
||||
Text(
|
||||
sizeStr,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 11,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final newList = List<String>.from(fileList)..remove(f);
|
||||
final newList = List<Map<String, dynamic>>.from(
|
||||
fileList,
|
||||
);
|
||||
newList.remove(file);
|
||||
setValue(newList);
|
||||
},
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.error),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: AppColors.hintText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user