From d800b14280dc75fded0228b7474024a2ca2efecd Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Mon, 9 Mar 2026 06:33:55 +0530 Subject: [PATCH] feat: Implement document upload functionality and update agent profile edit screen UI with a new back icon. --- assets/icons/back_circle_icon.svg | 3 + .../screens/agent_edit_profile_screen.dart | 684 +++++++++++++----- .../screens/agent_home_screen.dart | 1 + .../profile/data/profile_repository.dart | 24 + 4 files changed, 532 insertions(+), 180 deletions(-) create mode 100644 assets/icons/back_circle_icon.svg diff --git a/assets/icons/back_circle_icon.svg b/assets/icons/back_circle_icon.svg new file mode 100644 index 0000000..452ca2d --- /dev/null +++ b/assets/icons/back_circle_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart index 6229aff..620e5f7 100644 --- a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart +++ b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart @@ -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?)?['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 - ? Map.from(e) - : {}) + .map( + (e) => e is Map + ? Map.from(e) + : {}, + ) .toList(); } else { _repeatableEntries[sectionSlug] = [{}]; @@ -193,7 +200,10 @@ class _AgentEditProfileScreenState } dynamic _normalizeValue( - dynamic value, String fieldType, Map field) { + dynamic value, + String fieldType, + Map 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 >[]; 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>() - .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>() + .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? ?? []) - .whereType>() - .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? ?? []) + .whereType>() + .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> fields) { + String sectionSlug, + List> 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?; - final options = (field['options'] as List?) + final options = + (field['options'] as List?) ?.whereType>() .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.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> _parseFileList(dynamic value) { + if (value is! List) return []; + return value.map>((e) { + if (e is Map) return e; + if (e is Map) return Map.from(e); + return {'name': '$e', 'id': '$e'}; + }).toList(); + } + + /// Extract a human-readable filename from a file entry. + String _extractFileName(Map 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 _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.from(files.map((e) => '$e')) : []; + 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.from(fileList)..remove(f); + final newList = List>.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, + ), + ), ), ], ), - )), + ), + ); + }), ], ], ); diff --git a/lib/features/agents/presentation/screens/agent_home_screen.dart b/lib/features/agents/presentation/screens/agent_home_screen.dart index 192003f..8834b66 100644 --- a/lib/features/agents/presentation/screens/agent_home_screen.dart +++ b/lib/features/agents/presentation/screens/agent_home_screen.dart @@ -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), diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 9ebc777..8affce2 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -96,6 +96,30 @@ class ProfileRepository { } } + /// Get presigned upload URL for documents. + /// Returns { uploadUrl: String, key: String, publicUrl: String } + Future> 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; + 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 deleteAvatar(String key) async { try {