marco.pms.mobileapp/lib/model/directory/add_contact_bottom_sheet.dart
Vaibhav Surve ddbc1ec1e5 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.
2025-07-29 18:05:37 +05:30

738 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart';
import 'package:collection/collection.dart';
import 'package:marco/controller/directory/add_contact_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/directory/contact_model.dart';
import 'package:marco/helpers/utils/contact_picker_helper.dart';
class AddContactBottomSheet extends StatefulWidget {
final ContactModel? existingContact;
const AddContactBottomSheet({super.key, this.existingContact});
@override
State<AddContactBottomSheet> createState() => _AddContactBottomSheetState();
}
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
// 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();
// 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();
}
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 = c.projectIds;
final bucketId = c.bucketIds.firstOrNull;
final categoryName = c.contactCategory?.name;
if (categoryName != null) controller.selectedCategory.value = categoryName;
if (projectIds != null) {
controller.selectedProjects.assignAll(
projectIds //
.map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.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;
}
}
});
} else {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
}
tagTextController.clear();
}
@override
void dispose() {
nameController.dispose();
orgController.dispose();
tagTextController.dispose();
addressController.dispose();
descriptionController.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),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true,
);
// 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: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
_popupSelector(
hint: "Label",
selectedValue: selectedLabel,
options: options,
),
],
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(inputLabel),
MySpacing.height(8),
TextFormField(
controller: controller,
keyboardType: inputType,
maxLength: inputType == TextInputType.phone ? 10 : null,
inputFormatters: inputType == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly]
: [],
decoration: _inputDecoration("Enter $inputLabel").copyWith(
counterText: "",
suffixIcon: suffixIcon,
),
validator: (value) {
if (value == null || value.trim().isEmpty) return null;
final trimmed = value.trim();
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)) {
return "Enter valid email";
}
return null;
},
),
],
),
),
if (onRemove != null)
Padding(
padding: const EdgeInsets.only(top: 24),
child: IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.red),
onPressed: onRemove,
),
),
],
);
}
// 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(
label: label,
selectedLabel: labels[index],
options: labelOptions,
inputLabel: inputLabel,
controller: ctrls[index],
inputType: inputType,
onRemove: ctrls.length > 1
? () {
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 _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,
}) =>
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),
],
),
),
));
Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelLarge(title, fontWeight: 600),
MySpacing.height(4),
Divider(thickness: 1, color: Colors.grey.shade200),
],
);
// CHIP list for tags
Widget _tagInputSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 48,
child: TextField(
controller: tagTextController,
onChanged: controller.filterSuggestions,
onSubmitted: (value) {
controller.addEnteredTag(value);
tagTextController.clear();
controller.clearSuggestions();
},
decoration: _inputDecoration("Start typing to add tags"),
),
),
Obx(() => controller.filteredSuggestions.isEmpty
? 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)],
),
child: ListView.builder(
shrinkWrap: true,
itemCount: controller.filteredSuggestions.length,
itemBuilder: (context, index) {
final suggestion = controller.filteredSuggestions[index];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
controller.addEnteredTag(suggestion);
tagTextController.clear();
controller.clearSuggestions();
},
);
},
),
)),
MySpacing.height(8),
Obx(() => Wrap(
spacing: 8,
children: controller.enteredTags
.map((tag) => Chip(
label: Text(tag),
onDeleted: () => controller.removeEnteredTag(tag),
))
.toList(),
)),
],
);
}
// ---- 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,
),
],
);
// -- 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(),
);
}
},
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),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: MyText.titleMedium(
widget.existingContact != null ? "Edit Contact" : "Create New Contact",
fontWeight: 700,
),
),
MySpacing.height(24),
_sectionLabel("Required Fields"),
MySpacing.height(12),
_buildTextField("Name", nameController, required: true),
MySpacing.height(16),
_buildOrganizationField(),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
MySpacing.height(8),
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(
onTap: () => showAdvanced.toggle(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600),
Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more),
],
),
)),
Obx(() => showAdvanced.value
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
_sectionLabel("Contact Info"),
MySpacing.height(16),
_buildEmailList(),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_buildPhoneList(),
TextButton.icon(
onPressed: () {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(
hint: "Select Category",
selectedValue: controller.selectedCategory,
options: controller.categories,
),
MySpacing.height(16),
MyText.labelMedium("Select Projects"),
MySpacing.height(8),
_projectSelectorUI(),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController, maxLines: 2, required: false),
MySpacing.height(16),
_buildTextField("Description", descriptionController, maxLines: 2, required: false),
],
)
: const SizedBox.shrink()),
MySpacing.height(24),
_buildActionButtons(),
],
),
),
),
),
),
);
});
}
}