feat: Implement document upload functionality and update agent profile edit screen UI with a new back icon.

This commit is contained in:
pradeepkumar
2026-03-09 06:33:55 +05:30
parent f1003be757
commit d800b14280
4 changed files with 532 additions and 180 deletions

View File

@@ -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,
),
),
),
],
),
)),
),
);
}),
],
],
);

View File

@@ -242,6 +242,7 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
_buildCoverAndAvatar(agent),
const SizedBox(height: 16),
_buildStatusSection(agent),

View File

@@ -96,6 +96,30 @@ class ProfileRepository {
}
}
/// Get presigned upload URL for documents.
/// Returns { uploadUrl: String, key: String, publicUrl: String }
Future<Map<String, String>> getDocumentUploadUrl(
String fileName, String contentType) async {
try {
final response = await _dio.post('/upload/presigned-url', data: {
'filename': fileName,
'contentType': contentType,
});
final data = response.data['data'] as Map<String, dynamic>;
return {
'uploadUrl': data['uploadUrl'] as String,
'key': data['key'] as String,
'publicUrl': (data['publicUrl'] ?? data['key'] ?? '') as String,
};
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to get document upload URL',
statusCode: e.response?.statusCode,
);
}
}
/// Delete avatar from S3: DELETE /upload/{encodedKey}
Future<void> deleteAvatar(String key) async {
try {