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

@@ -0,0 +1,3 @@
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 17.5081L14.25 15.7573L12.25 13.7564H17.5V11.2552H12.25L14.25 9.2543L12.5 7.50349L7.5 12.5058L12.5 17.5081ZM12.5 25.0116C10.7708 25.0116 9.14583 24.6833 7.625 24.0268C6.10417 23.3702 4.78125 22.4792 3.65625 21.3537C2.53125 20.2282 1.64062 18.9046 0.984375 17.3831C0.328125 15.8615 0 14.2358 0 12.5058C0 10.7758 0.328125 9.15009 0.984375 7.62855C1.64062 6.10701 2.53125 4.78347 3.65625 3.65795C4.78125 2.53243 6.10417 1.64139 7.625 0.984833C9.14583 0.328278 10.7708 0 12.5 0C14.2292 0 15.8542 0.328278 17.375 0.984833C18.8958 1.64139 20.2188 2.53243 21.3438 3.65795C22.4688 4.78347 23.3594 6.10701 24.0156 7.62855C24.6719 9.15009 25 10.7758 25 12.5058C25 14.2358 24.6719 15.8615 24.0156 17.3831C23.3594 18.9046 22.4688 20.2282 21.3438 21.3537C20.2188 22.4792 18.8958 23.3702 17.375 24.0268C15.8542 24.6833 14.2292 25.0116 12.5 25.0116ZM12.5 22.5105C15.2917 22.5105 17.6562 21.5413 19.5938 19.6029C21.5312 17.6645 22.5 15.2988 22.5 12.5058C22.5 9.71285 21.5312 7.34717 19.5938 5.40876C17.6562 3.47036 15.2917 2.50116 12.5 2.50116C9.70833 2.50116 7.34375 3.47036 5.40625 5.40876C3.46875 7.34717 2.5 9.71285 2.5 12.5058C2.5 15.2988 3.46875 17.6645 5.40625 19.6029C7.34375 21.5413 9.70833 22.5105 12.5 22.5105Z" fill="#E58625"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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 {