feat: Implement document upload functionality and update agent profile edit screen UI with a new back icon.
This commit is contained in:
3
assets/icons/back_circle_icon.svg
Normal file
3
assets/icons/back_circle_icon.svg
Normal 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 |
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user