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> { class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final controller = Get.put(AddContactController()); // Controllers and state
final AddContactController controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final orgController = TextEditingController(); final orgController = TextEditingController();
final addressController = TextEditingController(); final addressController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
final tagTextController = 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 = // Use Rx for advanced toggle and dynamic fields
<TextEditingController>[].obs; final showAdvanced = false.obs;
final RxList<RxString> phoneLabels = <RxString>[].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 @override
void initState() { void initState() {
super.initState(); super.initState();
controller.resetForm(); controller.resetForm();
_initFields();
}
nameController.text = widget.existingContact?.name ?? ''; void _initFields() {
orgController.text = widget.existingContact?.organization ?? ''; final c = widget.existingContact;
addressController.text = widget.existingContact?.address ?? ''; if (c != null) {
descriptionController.text = widget.existingContact?.description ?? ''; nameController.text = c.name;
tagTextController.clear(); orgController.text = c.organization;
addressController.text = c.address;
if (widget.existingContact != null) { descriptionController.text = c.description ;
emailControllers.clear(); }
emailLabels.clear(); if (c != null) {
for (var email in widget.existingContact!.contactEmails) { emailControllers.assignAll(c.contactEmails.isEmpty
emailControllers.add(TextEditingController(text: email.emailAddress)); ? [TextEditingController()]
emailLabels.add((email.label).obs); : c.contactEmails.map((e) => TextEditingController(text: e.emailAddress)));
} emailLabels.assignAll(c.contactEmails.isEmpty
if (emailControllers.isEmpty) { ? ['Office'.obs]
emailControllers.add(TextEditingController()); : c.contactEmails.map((e) => e.label.obs));
emailLabels.add('Office'.obs); phoneControllers.assignAll(c.contactPhones.isEmpty
} ? [TextEditingController()]
: c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber)));
phoneControllers.clear(); phoneLabels.assignAll(c.contactPhones.isEmpty
phoneLabels.clear(); ? ['Work'.obs]
for (var phone in widget.existingContact!.contactPhones) { : c.contactPhones.map((p) => p.label.obs));
phoneControllers.add(TextEditingController(text: phone.phoneNumber)); controller.enteredTags.assignAll(c.tags.map((tag) => tag.name));
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(),
);
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
final projectIds = widget.existingContact!.projectIds; final projectIds = c.projectIds;
final bucketId = widget.existingContact!.bucketIds.firstOrNull; final bucketId = c.bucketIds.firstOrNull;
final categoryName = widget.existingContact!.contactCategory?.name; final categoryName = c.contactCategory?.name;
if (categoryName != null) controller.selectedCategory.value = categoryName;
if (categoryName != null) {
controller.selectedCategory.value = categoryName;
}
if (projectIds != null) { if (projectIds != null) {
final names = projectIds controller.selectedProjects.assignAll(
.map((id) { projectIds //
return controller.projectsMap.entries .map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id) .firstWhereOrNull((e) => e.value == id)
?.key; ?.key)
}) .whereType<String>()
.whereType<String>() .toList(),
.toList(); );
controller.selectedProjects.assignAll(names);
} }
if (bucketId != null) { if (bucketId != null) {
final name = controller.bucketsMap.entries final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId) .firstWhereOrNull((e) => e.value == bucketId)
?.key; ?.key;
if (name != null) { if (name != null) controller.selectedBucket.value = name;
controller.selectedBucket.value = name;
}
} }
} }
}); });
@ -110,6 +96,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
phoneControllers.add(TextEditingController()); phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs); phoneLabels.add('Work'.obs);
} }
tagTextController.clear();
} }
@override @override
@ -119,12 +106,17 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
tagTextController.dispose(); tagTextController.dispose();
addressController.dispose(); addressController.dispose();
descriptionController.dispose(); descriptionController.dispose();
emailControllers.forEach((e) => e.dispose()); for (final c in emailControllers) {
phoneControllers.forEach((p) => p.dispose()); c.dispose();
}
for (final c in phoneControllers) {
c.dispose();
}
Get.delete<AddContactController>(); Get.delete<AddContactController>();
super.dispose(); super.dispose();
} }
// --- COMMON WIDGETS ---
InputDecoration _inputDecoration(String hint) => InputDecoration( InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint, hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true), hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -142,19 +134,21 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
), ),
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true, isDense: true,
); );
Widget _buildLabeledRow( // DRY'd: LABELED FIELD ROW (used for phone/email)
String label, Widget _buildLabeledRow({
RxString selectedLabel, required String label,
List<String> options, required RxString selectedLabel,
String inputLabel, required List<String> options,
TextEditingController controller, required String inputLabel,
TextInputType inputType, required TextEditingController controller,
{VoidCallback? onRemove}) { required TextInputType inputType,
VoidCallback? onRemove,
Widget? suffixIcon,
}) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -165,9 +159,10 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MyText.labelMedium(label), MyText.labelMedium(label),
MySpacing.height(8), MySpacing.height(8),
_popupSelector( _popupSelector(
hint: "Label", hint: "Label",
selectedValue: selectedLabel, selectedValue: selectedLabel,
options: options), options: options,
),
], ],
), ),
), ),
@ -187,33 +182,17 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
: [], : [],
decoration: _inputDecoration("Enter $inputLabel").copyWith( decoration: _inputDecoration("Enter $inputLabel").copyWith(
counterText: "", counterText: "",
suffixIcon: inputType == TextInputType.phone suffixIcon: suffixIcon,
? 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,
), ),
validator: (value) { validator: (value) {
if (value == null || value.trim().isEmpty) if (value == null || value.trim().isEmpty) return null;
return "$inputLabel is required";
final trimmed = value.trim(); final trimmed = value.trim();
if (inputType == TextInputType.phone) { if (inputType == TextInputType.phone &&
if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { !RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
return "Enter valid phone number"; return "Enter valid phone number";
}
} }
if (inputType == TextInputType.emailAddress && if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) {
.hasMatch(trimmed)) {
return "Enter valid email"; return "Enter valid email";
} }
return null; return null;
@ -234,94 +213,110 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
); );
} }
Widget _buildEmailList() => Column( // DRY: List builder for email/phone fields
children: List.generate(emailControllers.length, (index) { 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( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: _buildLabeledRow( child: _buildLabeledRow(
"Email Label", label: label,
emailLabels[index], selectedLabel: labels[index],
["Office", "Personal", "Other"], options: labelOptions,
"Email", inputLabel: inputLabel,
emailControllers[index], controller: ctrls[index],
TextInputType.emailAddress, inputType: inputType,
onRemove: emailControllers.length > 1 onRemove: ctrls.length > 1
? () { ? () {
emailControllers.removeAt(index); ctrls.removeAt(index);
emailLabels.removeAt(index); labels.removeAt(index);
} }
: null, : 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( Widget _buildEmailList() => _buildDynamicList(
children: List.generate(phoneControllers.length, (index) { ctrls: emailControllers,
return Padding( labels: emailLabels,
padding: const EdgeInsets.only(bottom: 12), labelOptions: ["Office", "Personal", "Other"],
child: _buildLabeledRow( label: "Email Label",
"Phone Label", inputLabel: "Email",
phoneLabels[index], inputType: TextInputType.emailAddress,
["Work", "Mobile", "Other"], listToRemoveFrom: emailControllers,
"Phone", );
phoneControllers[index],
TextInputType.phone, Widget _buildPhoneList() => _buildDynamicList(
onRemove: phoneControllers.length > 1 ctrls: phoneControllers,
? () { labels: phoneLabels,
phoneControllers.removeAt(index); labelOptions: ["Work", "Mobile", "Other"],
phoneLabels.removeAt(index); label: "Phone Label",
} inputLabel: "Phone",
: null, inputType: TextInputType.phone,
), listToRemoveFrom: phoneControllers,
); phoneSuffixIcon: const Icon(Icons.contact_phone, color: Colors.blue),
}),
); );
Widget _popupSelector({ Widget _popupSelector({
required String hint, required String hint,
required RxString selectedValue, required RxString selectedValue,
required List<String> options, required List<String> options,
}) { }) =>
return Obx(() => GestureDetector( Obx(() => GestureDetector(
onTap: () async { onTap: () async {
final selected = await showMenu<String>( final selected = await showMenu<String>(
context: context, context: context,
position: RelativeRect.fromLTRB(100, 300, 100, 0), position: RelativeRect.fromLTRB(100, 300, 100, 0),
items: options.map((option) { items: options.map((option) => PopupMenuItem<String>(value: option, child: Text(option))).toList(),
return PopupMenuItem<String>( );
value: option, if (selected != null) selectedValue.value = selected;
child: Text(option), },
); child: Container(
}).toList(), height: 48,
); padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
if (selected != null) { color: Colors.grey.shade100,
selectedValue.value = selected; borderRadius: BorderRadius.circular(12),
} border: Border.all(color: Colors.grey.shade300),
}, ),
child: Container( alignment: Alignment.centerLeft,
height: 48, child: Row(
padding: const EdgeInsets.symmetric(horizontal: 16), mainAxisAlignment: MainAxisAlignment.spaceBetween,
decoration: BoxDecoration( children: [
color: Colors.grey.shade100, Text(
borderRadius: BorderRadius.circular(12), selectedValue.value.isNotEmpty ? selectedValue.value : hint,
border: Border.all(color: Colors.grey.shade300), 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( Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -332,6 +327,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
], ],
); );
// CHIP list for tags
Widget _tagInputSection() { Widget _tagInputSection() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -350,16 +346,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
), ),
), ),
Obx(() => controller.filteredSuggestions.isEmpty Obx(() => controller.filteredSuggestions.isEmpty
? const SizedBox() ? const SizedBox.shrink()
: Container( : Container(
margin: const EdgeInsets.only(top: 4), margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
boxShadow: const [ boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)],
BoxShadow(color: Colors.black12, blurRadius: 4)
],
), ),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
@ -392,145 +386,233 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
); );
} }
Widget _buildTextField(String label, TextEditingController controller, // ---- REQUIRED FIELD (reusable)
{int maxLines = 1}) { Widget _buildTextField(
return Column( String label,
crossAxisAlignment: CrossAxisAlignment.start, TextEditingController controller, {
children: [ int maxLines = 1,
MyText.labelMedium(label), bool required = false,
MySpacing.height(8), }) =>
TextFormField( Column(
controller: controller, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: maxLines, children: [
decoration: _inputDecoration("Enter $label"), MyText.labelMedium(label),
validator: (value) => value == null || value.trim().isEmpty MySpacing.height(8),
? "$label is required" TextFormField(
: null, controller: controller,
), maxLines: maxLines,
], decoration: _inputDecoration("Enter $label"),
); validator: required
} ? (value) =>
value == null || value.trim().isEmpty ? "$label is required" : null
: null,
),
],
);
Widget _buildOrganizationField() { // -- Organization as required TextFormField
return Column( Widget _buildOrganizationField() => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.labelMedium("Organization"), MyText.labelMedium("Organization"),
MySpacing.height(8), MySpacing.height(8),
TextField( TextFormField(
controller: orgController, controller: orgController,
onChanged: controller.filterOrganizationSuggestions, onChanged: controller.filterOrganizationSuggestions,
decoration: _inputDecoration("Enter organization"), decoration: _inputDecoration("Enter organization"),
), validator: (value) =>
Obx(() => controller.filteredOrgSuggestions.isEmpty value == null || value.trim().isEmpty ? "Organization is required" : null,
? const SizedBox() ),
: ListView.builder( Obx(() => controller.filteredOrgSuggestions.isEmpty
shrinkWrap: true, ? const SizedBox.shrink()
itemCount: controller.filteredOrgSuggestions.length, : ListView.builder(
itemBuilder: (context, index) { shrinkWrap: true,
final suggestion = controller.filteredOrgSuggestions[index]; itemCount: controller.filteredOrgSuggestions.length,
return ListTile( itemBuilder: (context, index) {
dense: true, final suggestion = controller.filteredOrgSuggestions[index];
title: Text(suggestion), return ListTile(
onTap: () { dense: true,
orgController.text = suggestion; title: Text(suggestion),
controller.filteredOrgSuggestions.clear(); 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(),
); );
}, }
)), },
], 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,
Widget _buildActionButtons() { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
return Row( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
children: [ ),
Expanded( ),
child: OutlinedButton.icon( ),
onPressed: () { ],
Get.back(); );
Get.delete<AddContactController>();
}, // Projects multi-select section
icon: const Icon(Icons.close, color: Colors.red), Widget _projectSelectorUI() {
label: return GestureDetector(
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), onTap: () async {
style: OutlinedButton.styleFrom( await showDialog(
side: const BorderSide(color: Colors.red), context: context,
shape: RoundedRectangleBorder( builder: (_) {
borderRadius: BorderRadius.circular(10)), return AlertDialog(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), title: const Text('Select Projects'),
), content: Obx(() => SizedBox(
), width: double.maxFinite,
), child: ListView(
MySpacing.width(12), shrinkWrap: true,
Expanded( children: controller.globalProjects.map((project) {
child: ElevatedButton.icon( final isSelected = controller.selectedProjects.contains(project);
onPressed: () { return Theme(
if (formKey.currentState!.validate()) { data: Theme.of(context).copyWith(
final emails = emailControllers checkboxTheme: CheckboxThemeData(
.asMap() fillColor: MaterialStateProperty.resolveWith<Color>(
.entries (states) => states.contains(MaterialState.selected)
.where((entry) => entry.value.text.trim().isNotEmpty) ? Colors.white
.map((entry) => { : Colors.transparent),
"label": emailLabels[entry.key].value, checkColor: MaterialStateProperty.all(Colors.black),
"emailAddress": entry.value.text.trim(), side: const BorderSide(color: Colors.black, width: 2),
}) shape: RoundedRectangleBorder(
.toList(); borderRadius: BorderRadius.circular(4),
),
final phones = phoneControllers ),
.asMap() ),
.entries child: CheckboxListTile(
.where((entry) => entry.value.text.trim().isNotEmpty) dense: true,
.map((entry) => { title: Text(project),
"label": phoneLabels[entry.key].value, value: isSelected,
"phoneNumber": entry.value.text.trim(), onChanged: (selected) {
}) if (selected == true) {
.toList(); controller.selectedProjects.add(project);
} else {
controller.submitContact( controller.selectedProjects.remove(project);
id: widget.existingContact?.id, }
name: nameController.text.trim(), },
organization: orgController.text.trim(), ),
emails: emails, );
phones: phones, }).toList(),
address: addressController.text.trim(), ),
description: descriptionController.text.trim(), )),
); actions: [
} TextButton(
}, onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.check_circle_outline, color: Colors.white), child: const Text('Done'),
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), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
if (!controller.isInitialized.value) { if (!controller.isInitialized.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return SafeArea( return SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.only( padding: EdgeInsets.only(top: 32).add(MediaQuery.of(context).viewInsets),
top: 32,
).add(MediaQuery.of(context).viewInsets),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
borderRadius: borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
const BorderRadius.vertical(top: Radius.circular(24)),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
@ -541,25 +623,44 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
children: [ children: [
Center( Center(
child: MyText.titleMedium( child: MyText.titleMedium(
widget.existingContact != null widget.existingContact != null ? "Edit Contact" : "Create New Contact",
? "Edit Contact"
: "Create New Contact",
fontWeight: 700, fontWeight: 700,
), ),
), ),
MySpacing.height(24), MySpacing.height(24),
_sectionLabel("Required Fields"), _sectionLabel("Required Fields"),
MySpacing.height(12), MySpacing.height(12),
_buildTextField("Name", nameController), _buildTextField("Name", nameController, required: true),
MySpacing.height(16), MySpacing.height(16),
_buildOrganizationField(), _buildOrganizationField(),
MySpacing.height(16), MySpacing.height(16),
MyText.labelMedium("Select Bucket"), MyText.labelMedium("Select Bucket"),
MySpacing.height(8), MySpacing.height(8),
_popupSelector( Stack(
hint: "Select Bucket", children: [
selectedValue: controller.selectedBucket, _popupSelector(
options: controller.buckets, 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), MySpacing.height(24),
Obx(() => GestureDetector( Obx(() => GestureDetector(
@ -567,11 +668,8 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
MyText.labelLarge("Advanced Details (Optional)", MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600),
fontWeight: 600), Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more),
Icon(showAdvanced.value
? Icons.expand_less
: Icons.expand_more),
], ],
), ),
)), )),
@ -619,15 +717,12 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(8), MySpacing.height(8),
_tagInputSection(), _tagInputSection(),
MySpacing.height(16), MySpacing.height(16),
_buildTextField("Address", addressController, _buildTextField("Address", addressController, maxLines: 2, required: false),
maxLines: 2),
MySpacing.height(16), MySpacing.height(16),
_buildTextField( _buildTextField("Description", descriptionController, maxLines: 2, required: false),
"Description", descriptionController,
maxLines: 2),
], ],
) )
: const SizedBox()), : const SizedBox.shrink()),
MySpacing.height(24), MySpacing.height(24),
_buildActionButtons(), _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/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
class ContactDetailScreen extends StatefulWidget { // HELPER: Delta to HTML conversion
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
String _convertDeltaToHtml(dynamic delta) { String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer(); final buffer = StringBuffer();
bool inList = false; bool inList = false;
for (var op in delta.toList()) { for (var op in delta.toList()) {
final data = op.data?.toString() ?? ''; final String data = op.data?.toString() ?? '';
final attr = op.attributes ?? {}; final attr = op.attributes ?? {};
final bool isListItem = attr.containsKey('list');
final isListItem = attr.containsKey('list');
// Start list
if (isListItem && !inList) { if (isListItem && !inList) {
buffer.write('<ul>'); buffer.write('<ul>');
inList = true; inList = true;
} }
// Close list if we are not in list mode anymore
if (!isListItem && inList) { if (!isListItem && inList) {
buffer.write('</ul>'); buffer.write('</ul>');
inList = false; inList = false;
@ -49,15 +37,12 @@ String _convertDeltaToHtml(dynamic delta) {
if (isListItem) buffer.write('<li>'); if (isListItem) buffer.write('<li>');
// Apply inline styles
if (attr.containsKey('bold')) buffer.write('<strong>'); if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>'); if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>'); if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>'); if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">'); if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(data.replaceAll('\n', '')); buffer.write(data.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>'); if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>'); if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>'); if (attr.containsKey('underline')) buffer.write('</u>');
@ -66,14 +51,21 @@ String _convertDeltaToHtml(dynamic delta) {
if (isListItem) if (isListItem)
buffer.write('</li>'); buffer.write('</li>');
else if (data.contains('\n')) buffer.write('<br>'); else if (data.contains('\n')) {
buffer.write('<br>');
}
} }
if (inList) buffer.write('</ul>'); if (inList) buffer.write('</ul>');
return buffer.toString(); 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> { class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
@ -85,7 +77,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
directoryController = Get.find<DirectoryController>(); directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>(); projectController = Get.find<ProjectController>();
contact = widget.contact; contact = widget.contact;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
directoryController.fetchCommentsForContact(contact.id); directoryController.fetchCommentsForContact(contact.id);
}); });
@ -103,13 +94,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSubHeader(), _buildSubHeader(),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded( Expanded(
child: TabBarView( child: TabBarView(children: [
children: [ _buildDetailsTab(),
_buildDetailsTab(), _buildCommentsTab(context),
_buildCommentsTab(context), ]),
],
),
), ),
], ],
), ),
@ -130,10 +120,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20),
color: Colors.black, size: 20), onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
onPressed: () =>
Get.offAllNamed('/dashboard/directory-main-page'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
@ -141,30 +129,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge('Contact Profile', MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black),
fontWeight: 700, color: Colors.black),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(
builder: (projectController) { builder: (p) => ProjectLabel(p.selectedProject?.name),
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],
),
),
],
);
},
), ),
], ],
), ),
@ -176,38 +144,30 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
} }
Widget _buildSubHeader() { Widget _buildSubHeader() {
final firstName = contact.name.split(" ").first;
final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
return Padding( return Padding(
padding: MySpacing.xy(16, 12), padding: MySpacing.xy(16, 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(children: [
children: [ Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo),
Avatar( MySpacing.width(12),
firstName: contact.name.split(" ").first, Column(
lastName: contact.name.split(" ").length > 1 crossAxisAlignment: CrossAxisAlignment.start,
? contact.name.split(" ").last children: [
: "", MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black),
size: 35, MySpacing.height(2),
backgroundColor: Colors.indigo, MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]),
), ],
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( TabBar(
labelColor: Colors.red, labelColor: Colors.red,
unselectedLabelColor: Colors.black, unselectedLabelColor: Colors.black,
indicator: MaterialIndicator( indicator: MaterialIndicator(
color: Colors.red, color: Colors.red,
height: 4, height: 4,
topLeftRadius: 8, topLeftRadius: 8,
@ -226,33 +186,37 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
} }
Widget _buildDetailsTab() { 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 tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds final bucketNames = contact.bucketIds
.map((id) => directoryController.contactBuckets .map((id) => directoryController.contactBuckets
.firstWhereOrNull((b) => b.id == id) .firstWhereOrNull((b) => b.id == id)
?.name) ?.name)
.whereType<String>() .whereType<String>()
.join(", "); .join(", ");
final projectNames = contact.projectIds?.map((id) =>
final projectNames = contact.projectIds projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType<String>().join(", ") ?? "-";
?.map((id) => projectController.projects
.firstWhereOrNull((p) => p.id == id)
?.name)
.whereType<String>()
.join(", ") ??
"-";
final category = contact.contactCategory?.name ?? "-"; 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( return Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
@ -261,28 +225,38 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(12), MySpacing.height(12),
// BASIC INFO CARD
_infoCard("Basic Info", [ _infoCard("Basic Info", [
_iconInfoRow(Icons.email, "Email", email, multiRows(
onTap: () => LauncherUtils.launchEmail(email), items: contact.contactEmails.map((e) => e.emailAddress).toList(),
onLongPress: () => LauncherUtils.copyToClipboard(email, icon: Icons.email,
typeLabel: "Email")), label: "Email",
_iconInfoRow(Icons.phone, "Phone", phone, typeLabel: "Email",
onTap: () => LauncherUtils.launchPhone(phone), onTap: (email) => LauncherUtils.launchEmail(email),
onLongPress: () => LauncherUtils.copyToClipboard(phone, onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"),
typeLabel: "Phone")), ),
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), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
// ORGANIZATION CARD
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow( _iconInfoRow(Icons.business, "Organization", contact.organization),
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
// META INFO CARD
_infoCard("Meta Info", [ _infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets", _iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"),
bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.work_outline, "Projects", projectNames), _iconInfoRow(Icons.work_outline, "Projects", projectNames),
]), ]),
// DESCRIPTION CARD
_infoCard("Description", [ _infoCard("Description", [
MySpacing.height(6), MySpacing.height(6),
Align( Align(
@ -294,7 +268,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
textAlign: TextAlign.left, textAlign: TextAlign.left,
), ),
), ),
]) ]),
], ],
), ),
), ),
@ -309,25 +283,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
); );
if (result == true) { if (result == true) {
await directoryController.fetchContacts(); await directoryController.fetchContacts();
final updated = final updated =
directoryController.allContacts.firstWhereOrNull( directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id);
(c) => c.id == contact.id,
);
if (updated != null) { if (updated != null) {
setState(() { setState(() => contact = updated);
contact = updated;
});
} }
} }
}, },
icon: const Icon(Icons.edit, color: Colors.white), icon: const Icon(Icons.edit, color: Colors.white),
label: const Text( label: const Text("Edit Contact", style: TextStyle(color: Colors.white)),
"Edit Contact",
style: TextStyle(color: Colors.white),
),
), ),
), ),
], ],
@ -337,24 +303,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildCommentsTab(BuildContext context) { Widget _buildCommentsTab(BuildContext context) {
return Obx(() { return Obx(() {
final contactId = contact.id; final contactId = contact.id;
if (!directoryController.contactCommentsMap.containsKey(contactId)) { if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final comments = directoryController.getCommentsForContact(contactId).reversed.toList();
final comments = directoryController
.getCommentsForContact(contactId)
.reversed
.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
return Stack( return Stack(
children: [ children: [
comments.isEmpty comments.isEmpty
? Center( ? Center(
child: child: MyText.bodyLarge("No comments yet.", color: Colors.grey),
MyText.bodyLarge("No comments yet.", color: Colors.grey),
) )
: Padding( : Padding(
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
@ -362,137 +321,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
padding: const EdgeInsets.only(bottom: 100), padding: const EdgeInsets.only(bottom: 100),
itemCount: comments.length, itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14), separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) { itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id),
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,
),
},
),
],
),
);
},
), ),
), ),
if (editingId == null)
// Floating Action Button
if (directoryController.editingCommentId.value == null)
Positioned( Positioned(
bottom: 20, bottom: 20,
right: 20, right: 20,
@ -503,17 +335,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
AddCommentBottomSheet(contactId: contactId), AddCommentBottomSheet(contactId: contactId),
isScrollControlled: true, isScrollControlled: true,
); );
if (result == true) { if (result == true) {
await directoryController await directoryController.fetchCommentsForContact(contactId);
.fetchCommentsForContact(contactId);
} }
}, },
icon: const Icon(Icons.add_comment, color: Colors.white), icon: const Icon(Icons.add_comment, color: Colors.white),
label: const Text( label: const Text("Add Comment", style: TextStyle(color: Colors.white)),
"Add Comment",
style: TextStyle(color: Colors.white),
),
), ),
), ),
], ],
@ -521,25 +348,127 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}); });
} }
Widget _iconInfoRow(IconData icon, String label, String value, Widget _buildCommentItem(comment, editingId, contactId) {
{VoidCallback? onTap, VoidCallback? onLongPress}) { 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( return Padding(
padding: MySpacing.y(8), padding: MySpacing.y(2),
child: GestureDetector( child: GestureDetector(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(icon, size: 22, color: Colors.indigo), if (icon != null) ...[
MySpacing.width(12), Icon(icon, size: 22, color: Colors.indigo),
MySpacing.width(12),
] else
const SizedBox(width: 34),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodySmall(label, if (label.isNotEmpty)
fontWeight: 600, color: Colors.black87), MyText.bodySmall(label, fontWeight: 600, color: Colors.black87),
MySpacing.height(2), if (label.isNotEmpty) MySpacing.height(2),
MyText.bodyMedium(value, color: Colors.grey[800]), MyText.bodyMedium(value, color: Colors.grey[800]),
], ],
), ),
@ -560,8 +489,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.titleSmall(title, MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]),
fontWeight: 700, color: Colors.indigo[700]),
MySpacing.height(8), MySpacing.height(8),
...children, ...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> { class _DirectoryViewState extends State<DirectoryView> {
final DirectoryController controller = Get.find(); final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final PermissionController permissionController = final PermissionController permissionController = Get.put(PermissionController());
Get.put(PermissionController());
Future<void> _refreshDirectory() async { Future<void> _refreshDirectory() async {
try { try {
@ -304,7 +303,6 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (_) => const CreateBucketBottomSheet(), builder: (_) => const CreateBucketBottomSheet(),
); );
if (created == true) { if (created == true) {
await controller.fetchBuckets(); await controller.fetchBuckets();
} }
@ -442,62 +440,69 @@ class _DirectoryViewState extends State<DirectoryView> {
color: Colors.grey[700], color: Colors.grey[700],
overflow: TextOverflow.ellipsis), overflow: TextOverflow.ellipsis),
MySpacing.height(8), MySpacing.height(8),
...contact.contactEmails.map((e) =>
GestureDetector( // Show only the first email (if present)
onTap: () => LauncherUtils.launchEmail( if (contact.contactEmails.isNotEmpty)
e.emailAddress), GestureDetector(
onLongPress: () => onTap: () => LauncherUtils.launchEmail(
LauncherUtils.copyToClipboard( contact.contactEmails.first.emailAddress),
e.emailAddress, onLongPress: () =>
typeLabel: 'Email'), LauncherUtils.copyToClipboard(
child: Padding( contact.contactEmails.first.emailAddress,
padding: typeLabel: 'Email',
const EdgeInsets.only(bottom: 4), ),
child: Row( child: Padding(
children: [ padding: const EdgeInsets.only(bottom: 4),
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),
child: Row( child: Row(
children: [ children: [
GestureDetector( const Icon(Icons.email_outlined,
onTap: () => size: 16, color: Colors.indigo),
LauncherUtils.launchPhone( MySpacing.width(4),
p.phoneNumber), 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: () => onLongPress: () =>
LauncherUtils.copyToClipboard( LauncherUtils.copyToClipboard(
p.phoneNumber, contact.contactPhones.first
typeLabel: 'Phone'), .phoneNumber,
typeLabel: 'Phone',
),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.phone_outlined, const Icon(
Icons.phone_outlined,
size: 16, size: 16,
color: Colors.indigo), color: Colors.indigo),
MySpacing.width(4), MySpacing.width(4),
ConstrainedBox( Expanded(
constraints:
const BoxConstraints(
maxWidth: 140),
child: MyText.labelSmall( child: MyText.labelSmall(
p.phoneNumber, contact.contactPhones.first
.phoneNumber,
overflow: overflow:
TextOverflow.ellipsis, TextOverflow.ellipsis,
color: Colors.indigo, color: Colors.indigo,
@ -508,19 +513,22 @@ class _DirectoryViewState extends State<DirectoryView> {
], ],
), ),
), ),
MySpacing.width(8), ),
GestureDetector( MySpacing.width(8),
onTap: () => GestureDetector(
LauncherUtils.launchWhatsApp( onTap: () =>
p.phoneNumber), LauncherUtils.launchWhatsApp(
child: const FaIcon( contact.contactPhones.first
FontAwesomeIcons.whatsapp, .phoneNumber),
color: Colors.green, child: const FaIcon(
size: 16), FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 16,
), ),
], ),
), ],
)), ),
),
if (tags.isNotEmpty) ...[ if (tags.isNotEmpty) ...[
MySpacing.height(2), MySpacing.height(2),
MyText.labelSmall(tags.join(', '), MyText.labelSmall(tags.join(', '),