feat: implement searchable multi-select dropdown and format date display in agent profile edit screen

This commit is contained in:
pradeepkumar
2026-04-15 16:58:02 +05:30
parent 39231d5ca4
commit 3ccb582b38
3 changed files with 275 additions and 50 deletions

View File

@@ -996,17 +996,15 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
padding: const EdgeInsets.symmetric(horizontal: 36), padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column( child: Column(
children: [ children: [
// Availability card (always show) // Availability card (always show) — only schedule lines, no "Full-time" label
_buildInfoCard( _buildInfoCard(
icon: Icons.access_time_filled, icon: Icons.access_time_filled,
title: state.availabilityLabel, title: state.availabilityLabel,
subtitle: state.availabilityType.isNotEmpty subtitle: state.availabilitySchedule.isEmpty ? 'Not specified' : '',
? state.availabilityType
: 'Not specified',
items: state.availabilitySchedule.isNotEmpty items: state.availabilitySchedule.isNotEmpty
? state.availabilitySchedule ? state.availabilitySchedule
: null, : null,
isEmpty: state.availabilityType.isEmpty, isEmpty: state.availabilitySchedule.isEmpty,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Work environment (always show) // Work environment (always show)

View File

@@ -1160,7 +1160,14 @@ class _AgentEditProfileScreenState
String fieldName = 'This field', String fieldName = 'This field',
}) { }) {
final val = getValue(); final val = getValue();
final displayText = val is String && val.isNotEmpty ? val : placeholder; // Display as MM-dd-yyyy (US format) while keeping ISO (yyyy-MM-dd) on the wire.
String displayText = placeholder;
if (val is String && val.isNotEmpty) {
final parsed = DateTime.tryParse(val);
displayText = parsed != null
? DateFormat('MM-dd-yyyy').format(parsed)
: val;
}
// Parse admin-configured min/max dates for the picker; fall back to wide range. // Parse admin-configured min/max dates for the picker; fall back to wide range.
final minDateStr = validation?['minDate'] as String?; final minDateStr = validation?['minDate'] as String?;
@@ -1511,7 +1518,7 @@ class _AgentEditProfileScreenState
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MULTI_SELECT (chip group) // MULTI_SELECT (searchable dropdown — matches web's react-select pattern)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Widget _buildMultiSelect( Widget _buildMultiSelect(
@@ -1522,54 +1529,276 @@ class _AgentEditProfileScreenState
) { ) {
final selected = List<String>.from((getValue() ?? <String>[]) as List); final selected = List<String>.from((getValue() ?? <String>[]) as List);
return Wrap( String labelFor(String val) {
spacing: 8, final match = options.firstWhere(
runSpacing: 8, (o) => (o['value'] as String? ?? '') == val,
children: options.map((opt) { orElse: () => <String, dynamic>{},
final optVal = opt['value'] as String? ?? ''; );
final optLabel = opt['label'] as String? ?? optVal; return (match['label'] as String?) ?? val;
final isSelected = selected.contains(optVal); }
return GestureDetector( return Column(
onTap: () { crossAxisAlignment: CrossAxisAlignment.start,
final newList = List<String>.from(selected); children: [
if (isSelected) { // Tap target that opens the searchable picker
newList.remove(optVal); GestureDetector(
} else { onTap: () => _openMultiSelectPicker(
newList.add(optVal); title: slug,
} options: options,
setValue(newList); selected: selected,
}, onChanged: setValue,
),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: Colors.white,
? AppColors.accentOrange.withValues(alpha: 0.1) borderRadius: BorderRadius.circular(10),
: Colors.white, border: Border.all(color: const Color(0xFFD1D5DB)),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? AppColors.accentOrange
: const Color(0xFFD1D5DB),
),
), ),
child: Text( child: Row(
optLabel, children: [
style: TextStyle( Expanded(
fontFamily: 'SourceSerif4', child: Text(
fontSize: 13, selected.isEmpty
fontWeight: FontWeight.w400, ? 'Select...'
color: isSelected : '${selected.length} selected',
? AppColors.accentOrange style: TextStyle(
: AppColors.primaryDark, fontFamily: 'SourceSerif4',
), fontSize: 14,
fontWeight: FontWeight.w300,
color: selected.isEmpty
? AppColors.hintText
: AppColors.primaryDark,
),
),
),
const Icon(Icons.keyboard_arrow_down,
color: AppColors.hintText),
],
), ),
), ),
); ),
}).toList(), // Selected chips preview (tap × to remove)
if (selected.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: selected.map((val) {
return Container(
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),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
labelFor(val),
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.accentOrange,
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: () {
final newList = List<String>.from(selected)
..remove(val);
setValue(newList);
},
child: const Icon(
Icons.close_rounded,
size: 16,
color: AppColors.accentOrange,
),
),
],
),
);
}).toList(),
),
],
],
); );
} }
Future<void> _openMultiSelectPicker({
required String title,
required List<Map<String, dynamic>> options,
required List<String> selected,
required void Function(dynamic) onChanged,
}) async {
final searchController = TextEditingController();
final workingSelected = List<String>.from(selected);
String query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setSheetState) {
final filtered = options.where((o) {
if (query.isEmpty) return true;
final l = (o['label'] as String? ?? '').toLowerCase();
return l.contains(query.toLowerCase());
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: FractionallySizedBox(
heightFactor: 0.85,
child: Column(
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 14),
decoration: BoxDecoration(
color: const Color(0xFFD1D5DB),
borderRadius: BorderRadius.circular(2),
),
),
// Search
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: searchController,
onChanged: (v) =>
setSheetState(() => query = v),
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search,
color: AppColors.hintText),
filled: true,
fillColor: const Color(0xFFF5F5F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
vertical: 0, horizontal: 12),
),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) {
final opt = filtered[i];
final v =
opt['value'] as String? ?? '';
final l =
opt['label'] as String? ?? v;
final isSelected =
workingSelected.contains(v);
return CheckboxListTile(
value: isSelected,
onChanged: (_) {
setSheetState(() {
if (isSelected) {
workingSelected.remove(v);
} else {
workingSelected.add(v);
}
});
},
title: Text(
l,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
),
controlAffinity:
ListTileControlAffinity.leading,
activeColor: AppColors.accentOrange,
dense: true,
);
},
),
),
Padding(
padding:
const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () =>
Navigator.pop(ctx),
style: OutlinedButton.styleFrom(
padding:
const EdgeInsets.symmetric(
vertical: 14),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
onChanged(workingSelected);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor:
AppColors.accentOrange,
padding:
const EdgeInsets.symmetric(
vertical: 14),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
child: const Text(
'Done',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
],
),
),
),
);
},
);
},
);
searchController.dispose();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TAG_INPUT // TAG_INPUT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1053,13 +1053,11 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
_buildInfoCard( _buildInfoCard(
icon: Icons.access_time_filled, icon: Icons.access_time_filled,
title: state.availabilityLabel, title: state.availabilityLabel,
subtitle: state.availabilityType.isNotEmpty subtitle: state.availabilitySchedule.isEmpty ? 'Not specified' : '',
? state.availabilityType
: 'Not specified',
items: state.availabilitySchedule.isNotEmpty items: state.availabilitySchedule.isNotEmpty
? state.availabilitySchedule ? state.availabilitySchedule
: null, : null,
isEmpty: state.availabilityType.isEmpty, isEmpty: state.availabilitySchedule.isEmpty,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Work environment // Work environment