Refactor ContactDetailScreen and DirectoryView for improved readability and performance

- Moved the Delta to HTML conversion logic outside of the ContactDetailScreen class for better separation of concerns.
- Simplified the handling of email and phone display in DirectoryView to show only the first entry, reducing redundancy.
- Enhanced the layout and structure of the ContactDetailScreen for better maintainability.
- Introduced a new ProjectLabel widget to encapsulate project display logic in the ContactDetailScreen.
- Cleaned up unnecessary comments and improved code formatting for consistency.
This commit is contained in:
Vaibhav Surve 2025-07-29 18:05:37 +05:30
parent 5bc811f91f
commit ddbc1ec1e5
3 changed files with 705 additions and 742 deletions

View File

@ -18,89 +18,75 @@ class AddContactBottomSheet extends StatefulWidget {
}
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final controller = Get.put(AddContactController());
// Controllers and state
final AddContactController controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>();
final nameController = TextEditingController();
final orgController = TextEditingController();
final addressController = TextEditingController();
final descriptionController = TextEditingController();
final tagTextController = TextEditingController();
final RxBool showAdvanced = false.obs;
final RxList<TextEditingController> emailControllers =
<TextEditingController>[].obs;
final RxList<RxString> emailLabels = <RxString>[].obs;
final RxList<TextEditingController> phoneControllers =
<TextEditingController>[].obs;
final RxList<RxString> phoneLabels = <RxString>[].obs;
// Use Rx for advanced toggle and dynamic fields
final showAdvanced = false.obs;
final emailControllers = <TextEditingController>[].obs;
final emailLabels = <RxString>[].obs;
final phoneControllers = <TextEditingController>[].obs;
final phoneLabels = <RxString>[].obs;
// For required bucket validation (new)
final bucketError = ''.obs;
@override
void initState() {
super.initState();
controller.resetForm();
_initFields();
}
nameController.text = widget.existingContact?.name ?? '';
orgController.text = widget.existingContact?.organization ?? '';
addressController.text = widget.existingContact?.address ?? '';
descriptionController.text = widget.existingContact?.description ?? '';
tagTextController.clear();
if (widget.existingContact != null) {
emailControllers.clear();
emailLabels.clear();
for (var email in widget.existingContact!.contactEmails) {
emailControllers.add(TextEditingController(text: email.emailAddress));
emailLabels.add((email.label).obs);
}
if (emailControllers.isEmpty) {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
}
phoneControllers.clear();
phoneLabels.clear();
for (var phone in widget.existingContact!.contactPhones) {
phoneControllers.add(TextEditingController(text: phone.phoneNumber));
phoneLabels.add((phone.label).obs);
}
if (phoneControllers.isEmpty) {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
controller.enteredTags.assignAll(
widget.existingContact!.tags.map((tag) => tag.name).toList(),
);
void _initFields() {
final c = widget.existingContact;
if (c != null) {
nameController.text = c.name;
orgController.text = c.organization;
addressController.text = c.address;
descriptionController.text = c.description ;
}
if (c != null) {
emailControllers.assignAll(c.contactEmails.isEmpty
? [TextEditingController()]
: c.contactEmails.map((e) => TextEditingController(text: e.emailAddress)));
emailLabels.assignAll(c.contactEmails.isEmpty
? ['Office'.obs]
: c.contactEmails.map((e) => e.label.obs));
phoneControllers.assignAll(c.contactPhones.isEmpty
? [TextEditingController()]
: c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber)));
phoneLabels.assignAll(c.contactPhones.isEmpty
? ['Work'.obs]
: c.contactPhones.map((p) => p.label.obs));
controller.enteredTags.assignAll(c.tags.map((tag) => tag.name));
ever(controller.isInitialized, (bool ready) {
if (ready) {
final projectIds = widget.existingContact!.projectIds;
final bucketId = widget.existingContact!.bucketIds.firstOrNull;
final categoryName = widget.existingContact!.contactCategory?.name;
if (categoryName != null) {
controller.selectedCategory.value = categoryName;
}
final projectIds = c.projectIds;
final bucketId = c.bucketIds.firstOrNull;
final categoryName = c.contactCategory?.name;
if (categoryName != null) controller.selectedCategory.value = categoryName;
if (projectIds != null) {
final names = projectIds
.map((id) {
return controller.projectsMap.entries
controller.selectedProjects.assignAll(
projectIds //
.map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.key;
})
.whereType<String>()
.toList();
controller.selectedProjects.assignAll(names);
?.key)
.whereType<String>()
.toList(),
);
}
if (bucketId != null) {
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key;
if (name != null) {
controller.selectedBucket.value = name;
}
if (name != null) controller.selectedBucket.value = name;
}
}
});
@ -110,6 +96,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
tagTextController.clear();
}
@override
@ -119,12 +106,17 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
tagTextController.dispose();
addressController.dispose();
descriptionController.dispose();
emailControllers.forEach((e) => e.dispose());
phoneControllers.forEach((p) => p.dispose());
for (final c in emailControllers) {
c.dispose();
}
for (final c in phoneControllers) {
c.dispose();
}
Get.delete<AddContactController>();
super.dispose();
}
// --- COMMON WIDGETS ---
InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -142,19 +134,21 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true,
);
Widget _buildLabeledRow(
String label,
RxString selectedLabel,
List<String> options,
String inputLabel,
TextEditingController controller,
TextInputType inputType,
{VoidCallback? onRemove}) {
// DRY'd: LABELED FIELD ROW (used for phone/email)
Widget _buildLabeledRow({
required String label,
required RxString selectedLabel,
required List<String> options,
required String inputLabel,
required TextEditingController controller,
required TextInputType inputType,
VoidCallback? onRemove,
Widget? suffixIcon,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -165,9 +159,10 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MyText.labelMedium(label),
MySpacing.height(8),
_popupSelector(
hint: "Label",
selectedValue: selectedLabel,
options: options),
hint: "Label",
selectedValue: selectedLabel,
options: options,
),
],
),
),
@ -187,33 +182,17 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
: [],
decoration: _inputDecoration("Enter $inputLabel").copyWith(
counterText: "",
suffixIcon: inputType == TextInputType.phone
? IconButton(
icon: const Icon(Icons.contact_phone,
color: Colors.blue),
onPressed: () async {
final selectedPhone =
await ContactPickerHelper.pickIndianPhoneNumber(
context);
if (selectedPhone != null) {
controller.text = selectedPhone;
}
},
)
: null,
suffixIcon: suffixIcon,
),
validator: (value) {
if (value == null || value.trim().isEmpty)
return "$inputLabel is required";
if (value == null || value.trim().isEmpty) return null;
final trimmed = value.trim();
if (inputType == TextInputType.phone) {
if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter valid phone number";
}
if (inputType == TextInputType.phone &&
!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter valid phone number";
}
if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) {
return "Enter valid email";
}
return null;
@ -234,94 +213,110 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
);
}
Widget _buildEmailList() => Column(
children: List.generate(emailControllers.length, (index) {
// DRY: List builder for email/phone fields
Widget _buildDynamicList({
required RxList<TextEditingController> ctrls,
required RxList<RxString> labels,
required List<String> labelOptions,
required String label,
required String inputLabel,
required TextInputType inputType,
required RxList listToRemoveFrom,
Widget? phoneSuffixIcon,
}) {
return Obx(() {
return Column(
children: List.generate(ctrls.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Email Label",
emailLabels[index],
["Office", "Personal", "Other"],
"Email",
emailControllers[index],
TextInputType.emailAddress,
onRemove: emailControllers.length > 1
label: label,
selectedLabel: labels[index],
options: labelOptions,
inputLabel: inputLabel,
controller: ctrls[index],
inputType: inputType,
onRemove: ctrls.length > 1
? () {
emailControllers.removeAt(index);
emailLabels.removeAt(index);
ctrls.removeAt(index);
labels.removeAt(index);
}
: null,
suffixIcon: phoneSuffixIcon != null && inputType == TextInputType.phone
? IconButton(
icon: const Icon(Icons.contact_phone, color: Colors.blue),
onPressed: () async {
final selectedPhone =
await ContactPickerHelper.pickIndianPhoneNumber(context);
if (selectedPhone != null) {
ctrls[index].text = selectedPhone;
}
},
)
: null,
),
);
}),
);
});
}
Widget _buildPhoneList() => Column(
children: List.generate(phoneControllers.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow(
"Phone Label",
phoneLabels[index],
["Work", "Mobile", "Other"],
"Phone",
phoneControllers[index],
TextInputType.phone,
onRemove: phoneControllers.length > 1
? () {
phoneControllers.removeAt(index);
phoneLabels.removeAt(index);
}
: null,
),
);
}),
Widget _buildEmailList() => _buildDynamicList(
ctrls: emailControllers,
labels: emailLabels,
labelOptions: ["Office", "Personal", "Other"],
label: "Email Label",
inputLabel: "Email",
inputType: TextInputType.emailAddress,
listToRemoveFrom: emailControllers,
);
Widget _buildPhoneList() => _buildDynamicList(
ctrls: phoneControllers,
labels: phoneLabels,
labelOptions: ["Work", "Mobile", "Other"],
label: "Phone Label",
inputLabel: "Phone",
inputType: TextInputType.phone,
listToRemoveFrom: phoneControllers,
phoneSuffixIcon: const Icon(Icons.contact_phone, color: Colors.blue),
);
Widget _popupSelector({
required String hint,
required RxString selectedValue,
required List<String> options,
}) {
return Obx(() => GestureDetector(
onTap: () async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(100, 300, 100, 0),
items: options.map((option) {
return PopupMenuItem<String>(
value: option,
child: Text(option),
);
}).toList(),
);
if (selected != null) {
selectedValue.value = selected;
}
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
}) =>
Obx(() => GestureDetector(
onTap: () async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(100, 300, 100, 0),
items: options.map((option) => PopupMenuItem<String>(value: option, child: Text(option))).toList(),
);
if (selected != null) selectedValue.value = selected;
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
alignment: Alignment.centerLeft,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedValue.value.isNotEmpty ? selectedValue.value : hint,
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.expand_more, size: 20),
],
),
),
alignment: Alignment.centerLeft,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedValue.value.isNotEmpty ? selectedValue.value : hint,
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.expand_more, size: 20),
],
),
),
));
}
));
Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -332,6 +327,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
],
);
// CHIP list for tags
Widget _tagInputSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -350,16 +346,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
),
),
Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox()
? const SizedBox.shrink()
: Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 4)
],
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)],
),
child: ListView.builder(
shrinkWrap: true,
@ -392,145 +386,233 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
);
}
Widget _buildTextField(String label, TextEditingController controller,
{int maxLines = 1}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
TextFormField(
controller: controller,
maxLines: maxLines,
decoration: _inputDecoration("Enter $label"),
validator: (value) => value == null || value.trim().isEmpty
? "$label is required"
: null,
),
],
);
}
// ---- REQUIRED FIELD (reusable)
Widget _buildTextField(
String label,
TextEditingController controller, {
int maxLines = 1,
bool required = false,
}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
TextFormField(
controller: controller,
maxLines: maxLines,
decoration: _inputDecoration("Enter $label"),
validator: required
? (value) =>
value == null || value.trim().isEmpty ? "$label is required" : null
: null,
),
],
);
Widget _buildOrganizationField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Organization"),
MySpacing.height(8),
TextField(
controller: orgController,
onChanged: controller.filterOrganizationSuggestions,
decoration: _inputDecoration("Enter organization"),
),
Obx(() => controller.filteredOrgSuggestions.isEmpty
? const SizedBox()
: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredOrgSuggestions.length,
itemBuilder: (context, index) {
final suggestion = controller.filteredOrgSuggestions[index];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
orgController.text = suggestion;
controller.filteredOrgSuggestions.clear();
},
// -- Organization as required TextFormField
Widget _buildOrganizationField() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Organization"),
MySpacing.height(8),
TextFormField(
controller: orgController,
onChanged: controller.filterOrganizationSuggestions,
decoration: _inputDecoration("Enter organization"),
validator: (value) =>
value == null || value.trim().isEmpty ? "Organization is required" : null,
),
Obx(() => controller.filteredOrgSuggestions.isEmpty
? const SizedBox.shrink()
: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredOrgSuggestions.length,
itemBuilder: (context, index) {
final suggestion = controller.filteredOrgSuggestions[index];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
orgController.text = suggestion;
controller.filteredOrgSuggestions.clear();
},
);
},
)),
],
);
// Action button row
Widget _buildActionButtons() => Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Get.back();
Get.delete<AddContactController>();
},
icon: const Icon(Icons.close, color: Colors.red),
label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
MySpacing.width(12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
// Validate bucket first in UI and show error under dropdown if empty
bool valid = formKey.currentState!.validate();
if (controller.selectedBucket.value.isEmpty) {
bucketError.value = "Bucket is required";
valid = false;
} else {
bucketError.value = "";
}
if (valid) {
final emails = emailControllers
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": emailLabels[entry.key].value,
"emailAddress": entry.value.text.trim(),
})
.toList();
final phones = phoneControllers
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": phoneLabels[entry.key].value,
"phoneNumber": entry.value.text.trim(),
})
.toList();
controller.submitContact(
id: widget.existingContact?.id,
name: nameController.text.trim(),
organization: orgController.text.trim(),
emails: emails,
phones: phones,
address: addressController.text.trim(),
description: descriptionController.text.trim(),
);
},
)),
],
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Get.back();
Get.delete<AddContactController>();
},
icon: const Icon(Icons.close, color: Colors.red),
label:
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
MySpacing.width(12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
if (formKey.currentState!.validate()) {
final emails = emailControllers
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": emailLabels[entry.key].value,
"emailAddress": entry.value.text.trim(),
})
.toList();
final phones = phoneControllers
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": phoneLabels[entry.key].value,
"phoneNumber": entry.value.text.trim(),
})
.toList();
controller.submitContact(
id: widget.existingContact?.id,
name: nameController.text.trim(),
organization: orgController.text.trim(),
emails: emails,
phones: phones,
address: addressController.text.trim(),
description: descriptionController.text.trim(),
);
}
},
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label:
MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
],
}
},
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
),
),
],
);
// Projects multi-select section
Widget _projectSelectorUI() {
return GestureDetector(
onTap: () async {
await showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: const Text('Select Projects'),
content: Obx(() => SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: controller.globalProjects.map((project) {
final isSelected = controller.selectedProjects.contains(project);
return Theme(
data: Theme.of(context).copyWith(
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.white
: Colors.transparent),
checkColor: MaterialStateProperty.all(Colors.black),
side: const BorderSide(color: Colors.black, width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
child: CheckboxListTile(
dense: true,
title: Text(project),
value: isSelected,
onChanged: (selected) {
if (selected == true) {
controller.selectedProjects.add(project);
} else {
controller.selectedProjects.remove(project);
}
},
),
);
}).toList(),
),
)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
],
);
},
);
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
alignment: Alignment.centerLeft,
child: Obx(() {
final selected = controller.selectedProjects;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
selected.isEmpty ? "Select Projects" : selected.join(', '),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
const Icon(Icons.expand_more, size: 20),
],
);
}),
),
);
}
// --- MAIN BUILD ---
@override
Widget build(BuildContext context) {
return Obx(() {
if (!controller.isInitialized.value) {
return const Center(child: CircularProgressIndicator());
}
return SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
top: 32,
).add(MediaQuery.of(context).viewInsets),
padding: EdgeInsets.only(top: 32).add(MediaQuery.of(context).viewInsets),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
@ -541,25 +623,44 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
children: [
Center(
child: MyText.titleMedium(
widget.existingContact != null
? "Edit Contact"
: "Create New Contact",
widget.existingContact != null ? "Edit Contact" : "Create New Contact",
fontWeight: 700,
),
),
MySpacing.height(24),
_sectionLabel("Required Fields"),
MySpacing.height(12),
_buildTextField("Name", nameController),
_buildTextField("Name", nameController, required: true),
MySpacing.height(16),
_buildOrganizationField(),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
MySpacing.height(8),
_popupSelector(
hint: "Select Bucket",
selectedValue: controller.selectedBucket,
options: controller.buckets,
Stack(
children: [
_popupSelector(
hint: "Select Bucket",
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
// Validation message for bucket
Positioned(
left: 0,
right: 0,
top: 56,
child: Obx(
() => bucketError.value.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Text(
bucketError.value,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
),
),
],
),
MySpacing.height(24),
Obx(() => GestureDetector(
@ -567,11 +668,8 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.labelLarge("Advanced Details (Optional)",
fontWeight: 600),
Icon(showAdvanced.value
? Icons.expand_less
: Icons.expand_more),
MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600),
Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more),
],
),
)),
@ -619,15 +717,12 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController,
maxLines: 2),
_buildTextField("Address", addressController, maxLines: 2, required: false),
MySpacing.height(16),
_buildTextField(
"Description", descriptionController,
maxLines: 2),
_buildTextField("Description", descriptionController, maxLines: 2, required: false),
],
)
: const SizedBox()),
: const SizedBox.shrink()),
MySpacing.height(24),
_buildActionButtons(),
],
@ -639,95 +734,4 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
);
});
}
Widget _projectSelectorUI() {
return GestureDetector(
onTap: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Projects'),
content: Obx(() {
return SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: controller.globalProjects.map((project) {
final isSelected =
controller.selectedProjects.contains(project);
return Theme(
data: Theme.of(context).copyWith(
unselectedWidgetColor: Colors.black,
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.white;
}
return Colors.transparent;
}),
checkColor: MaterialStateProperty.all(Colors.black),
side:
const BorderSide(color: Colors.black, width: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
child: CheckboxListTile(
dense: true,
title: Text(project),
value: isSelected,
onChanged: (bool? selected) {
if (selected == true) {
controller.selectedProjects.add(project);
} else {
controller.selectedProjects.remove(project);
}
},
),
);
}).toList(),
),
);
}),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
],
);
},
);
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
alignment: Alignment.centerLeft,
child: Obx(() {
final selected = controller.selectedProjects;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
selected.isEmpty ? "Select Projects" : selected.join(', '),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
const Icon(Icons.expand_more, size: 20),
],
);
}),
),
);
}
}

View File

@ -16,32 +16,20 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
// HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer();
bool inList = false;
for (var op in delta.toList()) {
final data = op.data?.toString() ?? '';
final String data = op.data?.toString() ?? '';
final attr = op.attributes ?? {};
final bool isListItem = attr.containsKey('list');
final isListItem = attr.containsKey('list');
// Start list
if (isListItem && !inList) {
buffer.write('<ul>');
inList = true;
}
// Close list if we are not in list mode anymore
if (!isListItem && inList) {
buffer.write('</ul>');
inList = false;
@ -49,15 +37,12 @@ String _convertDeltaToHtml(dynamic delta) {
if (isListItem) buffer.write('<li>');
// Apply inline styles
if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(data.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>');
@ -66,14 +51,21 @@ String _convertDeltaToHtml(dynamic delta) {
if (isListItem)
buffer.write('</li>');
else if (data.contains('\n')) buffer.write('<br>');
else if (data.contains('\n')) {
buffer.write('<br>');
}
}
if (inList) buffer.write('</ul>');
return buffer.toString();
}
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController;
late final ProjectController projectController;
@ -85,7 +77,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>();
contact = widget.contact;
WidgetsBinding.instance.addPostFrameCallback((_) {
directoryController.fetchCommentsForContact(contact.id);
});
@ -103,13 +94,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSubHeader(),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded(
child: TabBarView(
children: [
_buildDetailsTab(),
_buildCommentsTab(context),
],
),
child: TabBarView(children: [
_buildDetailsTab(),
_buildCommentsTab(context),
]),
),
],
),
@ -130,10 +120,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () =>
Get.offAllNamed('/dashboard/directory-main-page'),
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20),
onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
),
MySpacing.width(8),
Expanded(
@ -141,30 +129,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
builder: (p) => ProjectLabel(p.selectedProject?.name),
),
],
),
@ -176,38 +144,30 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}
Widget _buildSubHeader() {
final firstName = contact.name.split(" ").first;
final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
return Padding(
padding: MySpacing.xy(16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: contact.name.split(" ").first,
lastName: contact.name.split(" ").length > 1
? contact.name.split(" ").last
: "",
size: 35,
backgroundColor: Colors.indigo,
),
MySpacing.width(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(contact.name,
fontWeight: 600, color: Colors.black),
MySpacing.height(2),
MyText.bodySmall(contact.organization,
fontWeight: 500, color: Colors.grey[700]),
],
),
],
),
Row(children: [
Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo),
MySpacing.width(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black),
MySpacing.height(2),
MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]),
],
),
]),
TabBar(
labelColor: Colors.red,
unselectedLabelColor: Colors.black,
indicator: MaterialIndicator(
indicator: MaterialIndicator(
color: Colors.red,
height: 4,
topLeftRadius: 8,
@ -226,33 +186,37 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}
Widget _buildDetailsTab() {
final email = contact.contactEmails.isNotEmpty
? contact.contactEmails.first.emailAddress
: "-";
final phone = contact.contactPhones.isNotEmpty
? contact.contactPhones.first.phoneNumber
: "-";
final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds
.map((id) => directoryController.contactBuckets
.firstWhereOrNull((b) => b.id == id)
?.name)
.whereType<String>()
.join(", ");
final projectNames = contact.projectIds
?.map((id) => projectController.projects
.firstWhereOrNull((p) => p.id == id)
?.name)
.whereType<String>()
.join(", ") ??
"-";
final projectNames = contact.projectIds?.map((id) =>
projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType<String>().join(", ") ?? "-";
final category = contact.contactCategory?.name ?? "-";
Widget multiRows({required List<dynamic> items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) {
return items.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)),
...items.skip(1).map(
(val) => _iconInfoRow(
null,
'',
val,
onTap: () => onTap?.call(val),
onLongPress: () => onLongPress?.call(val),
),
),
],
)
: _iconInfoRow(icon, label, "-");
}
return Stack(
children: [
SingleChildScrollView(
@ -261,28 +225,38 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(12),
// BASIC INFO CARD
_infoCard("Basic Info", [
_iconInfoRow(Icons.email, "Email", email,
onTap: () => LauncherUtils.launchEmail(email),
onLongPress: () => LauncherUtils.copyToClipboard(email,
typeLabel: "Email")),
_iconInfoRow(Icons.phone, "Phone", phone,
onTap: () => LauncherUtils.launchPhone(phone),
onLongPress: () => LauncherUtils.copyToClipboard(phone,
typeLabel: "Phone")),
multiRows(
items: contact.contactEmails.map((e) => e.emailAddress).toList(),
icon: Icons.email,
label: "Email",
typeLabel: "Email",
onTap: (email) => LauncherUtils.launchEmail(email),
onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"),
),
multiRows(
items: contact.contactPhones.map((p) => p.phoneNumber).toList(),
icon: Icons.phone,
label: "Phone",
typeLabel: "Phone",
onTap: (phone) => LauncherUtils.launchPhone(phone),
onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"),
),
_iconInfoRow(Icons.location_on, "Address", contact.address),
]),
// ORGANIZATION CARD
_infoCard("Organization", [
_iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category),
]),
// META INFO CARD
_infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets",
bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.work_outline, "Projects", projectNames),
]),
// DESCRIPTION CARD
_infoCard("Description", [
MySpacing.height(6),
Align(
@ -294,7 +268,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
textAlign: TextAlign.left,
),
),
])
]),
],
),
),
@ -309,25 +283,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
if (result == true) {
await directoryController.fetchContacts();
final updated =
directoryController.allContacts.firstWhereOrNull(
(c) => c.id == contact.id,
);
directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id);
if (updated != null) {
setState(() {
contact = updated;
});
setState(() => contact = updated);
}
}
},
icon: const Icon(Icons.edit, color: Colors.white),
label: const Text(
"Edit Contact",
style: TextStyle(color: Colors.white),
),
label: const Text("Edit Contact", style: TextStyle(color: Colors.white)),
),
),
],
@ -337,24 +303,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildCommentsTab(BuildContext context) {
return Obx(() {
final contactId = contact.id;
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator());
}
final comments = directoryController
.getCommentsForContact(contactId)
.reversed
.toList();
final comments = directoryController.getCommentsForContact(contactId).reversed.toList();
final editingId = directoryController.editingCommentId.value;
return Stack(
children: [
comments.isEmpty
? Center(
child:
MyText.bodyLarge("No comments yet.", color: Colors.grey),
? Center(
child: MyText.bodyLarge("No comments yet.", color: Colors.grey),
)
: Padding(
padding: MySpacing.xy(12, 12),
@ -362,137 +321,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
padding: const EdgeInsets.only(bottom: 100),
itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) {
final comment = comments[index];
final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase()
: "?";
final decodedDelta = HtmlToDelta().convert(comment.note);
final quillController = isEditing
? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed(
offset: decodedDelta.length),
)
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: MySpacing.xy(8, 7),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isEditing
? Colors.indigo
: Colors.grey.shade300,
width: 1.2,
),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2),
)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: initials,
lastName: '',
size: 36),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
"By: ${comment.createdBy.firstName}",
fontWeight: 600,
color: Colors.indigo[800],
),
MySpacing.height(4),
MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
format: 'dd MMM yyyy, hh:mm a',
),
color: Colors.grey[600],
),
],
),
),
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
size: 20,
color: Colors.indigo,
),
onPressed: () {
directoryController.editingCommentId.value =
isEditing ? null : comment.id;
},
),
],
),
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () {
directoryController.editingCommentId.value =
null;
},
onSave: (controller) async {
final delta = controller.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated =
comment.copyWith(note: htmlOutput);
await directoryController
.updateComment(updated);
// Re-fetch comments to get updated list
await directoryController
.fetchCommentsForContact(contactId);
// Exit editing mode
directoryController.editingCommentId.value =
null;
},
)
else
html.Html(
data: comment.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
),
},
),
],
),
);
},
itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id),
),
),
// Floating Action Button
if (directoryController.editingCommentId.value == null)
if (editingId == null)
Positioned(
bottom: 20,
right: 20,
@ -503,17 +335,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
AddCommentBottomSheet(contactId: contactId),
isScrollControlled: true,
);
if (result == true) {
await directoryController
.fetchCommentsForContact(contactId);
await directoryController.fetchCommentsForContact(contactId);
}
},
icon: const Icon(Icons.add_comment, color: Colors.white),
label: const Text(
"Add Comment",
style: TextStyle(color: Colors.white),
),
label: const Text("Add Comment", style: TextStyle(color: Colors.white)),
),
),
],
@ -521,25 +348,127 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
});
}
Widget _iconInfoRow(IconData icon, String label, String value,
{VoidCallback? onTap, VoidCallback? onLongPress}) {
Widget _buildCommentItem(comment, editingId, contactId) {
final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase()
: "?";
final decodedDelta = HtmlToDelta().convert(comment.note);
final quillController = isEditing
? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed(offset: decodedDelta.length),
)
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: MySpacing.xy(8, 7),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isEditing ? Colors.indigo : Colors.grey.shade300,
width: 1.2,
),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: initials, lastName: '', size: 36),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium("By: ${comment.createdBy.firstName}",
fontWeight: 600, color: Colors.indigo[800]),
MySpacing.height(4),
MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
format: 'dd MMM yyyy, hh:mm a',
),
color: Colors.grey[600],
),
],
),
),
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
size: 20,
color: Colors.indigo,
),
onPressed: () {
directoryController.editingCommentId.value = isEditing ? null : comment.id;
},
),
],
),
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () => directoryController.editingCommentId.value = null,
onSave: (ctrl) async {
final delta = ctrl.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated = comment.copyWith(note: htmlOutput);
await directoryController.updateComment(updated);
await directoryController.fetchCommentsForContact(contactId);
directoryController.editingCommentId.value = null;
},
)
else
html.Html(
data: comment.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
),
},
),
],
),
);
}
Widget _iconInfoRow(
IconData? icon,
String label,
String value, {
VoidCallback? onTap,
VoidCallback? onLongPress,
}) {
return Padding(
padding: MySpacing.y(8),
padding: MySpacing.y(2),
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 22, color: Colors.indigo),
MySpacing.width(12),
if (icon != null) ...[
Icon(icon, size: 22, color: Colors.indigo),
MySpacing.width(12),
] else
const SizedBox(width: 34),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(label,
fontWeight: 600, color: Colors.black87),
MySpacing.height(2),
if (label.isNotEmpty)
MyText.bodySmall(label, fontWeight: 600, color: Colors.black87),
if (label.isNotEmpty) MySpacing.height(2),
MyText.bodyMedium(value, color: Colors.grey[800]),
],
),
@ -560,8 +489,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(title,
fontWeight: 700, color: Colors.indigo[700]),
MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]),
MySpacing.height(8),
...children,
],
@ -570,3 +498,26 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
);
}
}
// Helper widget for Project label in AppBar
class ProjectLabel extends StatelessWidget {
final String? projectName;
const ProjectLabel(this.projectName, {super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName ?? 'Select Project',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}
}

View File

@ -24,8 +24,7 @@ class DirectoryView extends StatefulWidget {
class _DirectoryViewState extends State<DirectoryView> {
final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController();
final PermissionController permissionController =
Get.put(PermissionController());
final PermissionController permissionController = Get.put(PermissionController());
Future<void> _refreshDirectory() async {
try {
@ -304,7 +303,6 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.transparent,
builder: (_) => const CreateBucketBottomSheet(),
);
if (created == true) {
await controller.fetchBuckets();
}
@ -442,62 +440,69 @@ class _DirectoryViewState extends State<DirectoryView> {
color: Colors.grey[700],
overflow: TextOverflow.ellipsis),
MySpacing.height(8),
...contact.contactEmails.map((e) =>
GestureDetector(
onTap: () => LauncherUtils.launchEmail(
e.emailAddress),
onLongPress: () =>
LauncherUtils.copyToClipboard(
e.emailAddress,
typeLabel: 'Email'),
child: Padding(
padding:
const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(Icons.email_outlined,
size: 16, color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 180),
child: MyText.labelSmall(
e.emailAddress,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration.underline,
),
),
],
),
),
)),
...contact.contactPhones.map((p) => Padding(
padding: const EdgeInsets.only(
bottom: 8, top: 4),
// Show only the first email (if present)
if (contact.contactEmails.isNotEmpty)
GestureDetector(
onTap: () => LauncherUtils.launchEmail(
contact.contactEmails.first.emailAddress),
onLongPress: () =>
LauncherUtils.copyToClipboard(
contact.contactEmails.first.emailAddress,
typeLabel: 'Email',
),
child: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
GestureDetector(
onTap: () =>
LauncherUtils.launchPhone(
p.phoneNumber),
const Icon(Icons.email_outlined,
size: 16, color: Colors.indigo),
MySpacing.width(4),
Expanded(
child: MyText.labelSmall(
contact.contactEmails.first.emailAddress,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration.underline,
),
),
],
),
),
),
// Show only the first phone (if present)
if (contact.contactPhones.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: 8, top: 4),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => LauncherUtils
.launchPhone(contact
.contactPhones
.first
.phoneNumber),
onLongPress: () =>
LauncherUtils.copyToClipboard(
p.phoneNumber,
typeLabel: 'Phone'),
contact.contactPhones.first
.phoneNumber,
typeLabel: 'Phone',
),
child: Row(
children: [
const Icon(Icons.phone_outlined,
const Icon(
Icons.phone_outlined,
size: 16,
color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints:
const BoxConstraints(
maxWidth: 140),
Expanded(
child: MyText.labelSmall(
p.phoneNumber,
contact.contactPhones.first
.phoneNumber,
overflow:
TextOverflow.ellipsis,
color: Colors.indigo,
@ -508,19 +513,22 @@ class _DirectoryViewState extends State<DirectoryView> {
],
),
),
MySpacing.width(8),
GestureDetector(
onTap: () =>
LauncherUtils.launchWhatsApp(
p.phoneNumber),
child: const FaIcon(
FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 16),
),
MySpacing.width(8),
GestureDetector(
onTap: () =>
LauncherUtils.launchWhatsApp(
contact.contactPhones.first
.phoneNumber),
child: const FaIcon(
FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 16,
),
],
),
)),
),
],
),
),
if (tags.isNotEmpty) ...[
MySpacing.height(2),
MyText.labelSmall(tags.join(', '),