679 lines
25 KiB
Dart
679 lines
25 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get/get.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';
|
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
|
|
|
class AddContactBottomSheet extends StatefulWidget {
|
|
final ContactModel? existingContact;
|
|
const AddContactBottomSheet({super.key, this.existingContact});
|
|
|
|
@override
|
|
State<AddContactBottomSheet> createState() => _AddContactBottomSheetState();
|
|
}
|
|
|
|
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|
final controller = Get.put(AddContactController());
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
final nameCtrl = TextEditingController();
|
|
final orgCtrl = TextEditingController();
|
|
final designationCtrl = TextEditingController();
|
|
final addrCtrl = TextEditingController();
|
|
final descCtrl = TextEditingController();
|
|
final tagCtrl = TextEditingController();
|
|
|
|
final showAdvanced = false.obs;
|
|
final bucketError = ''.obs;
|
|
|
|
final emailCtrls = <TextEditingController>[].obs;
|
|
final emailLabels = <RxString>[].obs;
|
|
|
|
final phoneCtrls = <TextEditingController>[].obs;
|
|
final phoneLabels = <RxString>[].obs;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller.resetForm();
|
|
_initFields();
|
|
}
|
|
|
|
void _initFields() {
|
|
final c = widget.existingContact;
|
|
if (c != null) {
|
|
nameCtrl.text = c.name;
|
|
orgCtrl.text = c.organization;
|
|
designationCtrl.text = c.designation ?? '';
|
|
addrCtrl.text = c.address;
|
|
descCtrl.text = c.description;
|
|
|
|
emailCtrls.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));
|
|
|
|
phoneCtrls.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((e) => e.name));
|
|
|
|
ever(controller.isInitialized, (bool ready) {
|
|
if (ready) {
|
|
// Buckets - map all
|
|
if (c.bucketIds.isNotEmpty) {
|
|
final names = c.bucketIds
|
|
.map((id) {
|
|
return controller.bucketsMap.entries
|
|
.firstWhereOrNull((e) => e.value == id)
|
|
?.key;
|
|
})
|
|
.whereType<String>()
|
|
.toList();
|
|
controller.selectedBuckets.assignAll(names);
|
|
}
|
|
// Projects and Category mapping - as before
|
|
final projectIds = c.projectIds;
|
|
if (projectIds != null) {
|
|
controller.selectedProjects.assignAll(
|
|
projectIds
|
|
.map((id) => controller.projectsMap.entries
|
|
.firstWhereOrNull((e) => e.value == id)
|
|
?.key)
|
|
.whereType<String>()
|
|
.toList(),
|
|
);
|
|
}
|
|
final category = c.contactCategory?.name;
|
|
if (category != null) controller.selectedCategory.value = category;
|
|
}
|
|
});
|
|
} else {
|
|
showAdvanced.value = false; // Optional
|
|
emailCtrls.add(TextEditingController());
|
|
emailLabels.add('Office'.obs);
|
|
phoneCtrls.add(TextEditingController());
|
|
phoneLabels.add('Work'.obs);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
nameCtrl.dispose();
|
|
orgCtrl.dispose();
|
|
designationCtrl.dispose();
|
|
addrCtrl.dispose();
|
|
descCtrl.dispose();
|
|
tagCtrl.dispose();
|
|
emailCtrls.forEach((c) => c.dispose());
|
|
phoneCtrls.forEach((c) => c.dispose());
|
|
Get.delete<AddContactController>();
|
|
super.dispose();
|
|
}
|
|
|
|
Widget _labelWithStar(String label, {bool required = false}) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
MyText.labelMedium(label),
|
|
if (required)
|
|
const Text(
|
|
" *",
|
|
style: TextStyle(color: Colors.red, fontSize: 14),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
Widget _textField(String label, TextEditingController ctrl,
|
|
{bool required = false, int maxLines = 1}) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_labelWithStar(label, required: required),
|
|
MySpacing.height(8),
|
|
TextFormField(
|
|
controller: ctrl,
|
|
maxLines: maxLines,
|
|
decoration: _inputDecoration("Enter $label"),
|
|
validator: required
|
|
? (v) =>
|
|
(v == null || v.trim().isEmpty) ? "$label is required" : null
|
|
: null,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _popupSelector(RxString selected, List<String> options, String hint) =>
|
|
Obx(() {
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final selectedItem = await showMenu<String>(
|
|
context: context,
|
|
position: RelativeRect.fromLTRB(100, 300, 100, 0),
|
|
items: options
|
|
.map((e) => PopupMenuItem<String>(value: e, child: Text(e)))
|
|
.toList(),
|
|
);
|
|
if (selectedItem != null) selected.value = selectedItem;
|
|
},
|
|
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(selected.value.isNotEmpty ? selected.value : hint,
|
|
style: const TextStyle(fontSize: 14)),
|
|
const Icon(Icons.expand_more, size: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
Widget _dynamicList(
|
|
RxList<TextEditingController> ctrls,
|
|
RxList<RxString> labels,
|
|
String labelType,
|
|
List<String> labelOptions,
|
|
TextInputType type) {
|
|
return Obx(() {
|
|
return Column(
|
|
children: List.generate(ctrls.length, (i) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.labelMedium("$labelType Label"),
|
|
MySpacing.height(8),
|
|
_popupSelector(labels[i], labelOptions, "Label"),
|
|
],
|
|
),
|
|
),
|
|
MySpacing.width(12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.labelMedium(labelType),
|
|
MySpacing.height(8),
|
|
TextFormField(
|
|
controller: ctrls[i],
|
|
keyboardType: type,
|
|
maxLength: type == TextInputType.phone ? 10 : null,
|
|
inputFormatters: type == TextInputType.phone
|
|
? [FilteringTextInputFormatter.digitsOnly]
|
|
: [],
|
|
decoration:
|
|
_inputDecoration("Enter $labelType").copyWith(
|
|
counterText: "",
|
|
suffixIcon: type == TextInputType.phone
|
|
? IconButton(
|
|
icon: const Icon(Icons.contact_phone,
|
|
color: Colors.blue),
|
|
onPressed: () async {
|
|
final phone = await ContactPickerHelper
|
|
.pickIndianPhoneNumber(context);
|
|
if (phone != null) ctrls[i].text = phone;
|
|
},
|
|
)
|
|
: null,
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty)
|
|
return null;
|
|
final trimmed = value.trim();
|
|
if (type == TextInputType.phone &&
|
|
!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) {
|
|
return "Enter valid phone number";
|
|
}
|
|
if (type == TextInputType.emailAddress &&
|
|
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
|
|
.hasMatch(trimmed)) {
|
|
return "Enter valid email";
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (ctrls.length > 1)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 24),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.remove_circle_outline,
|
|
color: Colors.red),
|
|
onPressed: () {
|
|
ctrls.removeAt(i);
|
|
labels.removeAt(i);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _tagInput() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
height: 48,
|
|
child: TextField(
|
|
controller: tagCtrl,
|
|
onChanged: controller.filterSuggestions,
|
|
onSubmitted: (v) {
|
|
controller.addEnteredTag(v);
|
|
tagCtrl.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: (_, i) {
|
|
final suggestion = controller.filteredSuggestions[i];
|
|
return ListTile(
|
|
dense: true,
|
|
title: Text(suggestion),
|
|
onTap: () {
|
|
controller.addEnteredTag(suggestion);
|
|
tagCtrl.clear();
|
|
controller.clearSuggestions();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
)),
|
|
MySpacing.height(8),
|
|
Obx(() => Wrap(
|
|
spacing: 8,
|
|
children: controller.enteredTags
|
|
.map((tag) => Chip(
|
|
label: Text(tag),
|
|
onDeleted: () => controller.removeEnteredTag(tag),
|
|
))
|
|
.toList(),
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _bucketMultiSelectField() {
|
|
return _multiSelectField(
|
|
items: controller.buckets
|
|
.map((name) => FilterItem(id: name, name: name))
|
|
.toList(),
|
|
fallback: "Choose Buckets",
|
|
selectedValues: controller.selectedBuckets,
|
|
);
|
|
}
|
|
|
|
Widget _multiSelectField({
|
|
required List<FilterItem> items,
|
|
required String fallback,
|
|
required RxList<String> selectedValues,
|
|
}) {
|
|
if (items.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Obx(() {
|
|
final selectedNames = items
|
|
.where((f) => selectedValues.contains(f.id))
|
|
.map((f) => f.name)
|
|
.join(", ");
|
|
final displayText = selectedNames.isNotEmpty ? selectedNames : fallback;
|
|
|
|
return Builder(
|
|
builder: (context) {
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final RenderBox button = context.findRenderObject() as RenderBox;
|
|
final RenderBox overlay =
|
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
final position = button.localToGlobal(Offset.zero);
|
|
|
|
await showMenu(
|
|
context: context,
|
|
position: RelativeRect.fromLTRB(
|
|
position.dx,
|
|
position.dy + button.size.height,
|
|
overlay.size.width - position.dx - button.size.width,
|
|
0,
|
|
),
|
|
items: [
|
|
PopupMenuItem(
|
|
enabled: false,
|
|
child: StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return SizedBox(
|
|
width: 250,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: items.map((f) {
|
|
final isChecked = selectedValues.contains(f.id);
|
|
return CheckboxListTile(
|
|
dense: true,
|
|
title: Text(f.name),
|
|
value: isChecked,
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity:
|
|
ListTileControlAffinity.leading,
|
|
side: const BorderSide(
|
|
color: Colors.black, width: 1.5),
|
|
fillColor:
|
|
MaterialStateProperty.resolveWith<Color>(
|
|
(states) {
|
|
if (states
|
|
.contains(MaterialState.selected)) {
|
|
return Colors.indigo; // selected color
|
|
}
|
|
return Colors
|
|
.white; // unselected background
|
|
}),
|
|
checkColor: Colors.white, // tick color
|
|
onChanged: (val) {
|
|
if (val == true) {
|
|
selectedValues.add(f.id);
|
|
} else {
|
|
selectedValues.remove(f.id);
|
|
}
|
|
setState(() {});
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
child: Container(
|
|
padding: MySpacing.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade100,
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: MyText(
|
|
displayText,
|
|
style: const TextStyle(color: Colors.black87),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
void _handleSubmit() {
|
|
bool valid = formKey.currentState?.validate() ?? false;
|
|
|
|
if (controller.selectedBuckets.isEmpty) {
|
|
bucketError.value = "Bucket is required";
|
|
valid = false;
|
|
} else {
|
|
bucketError.value = "";
|
|
}
|
|
|
|
if (!valid) return;
|
|
|
|
final emails = emailCtrls
|
|
.asMap()
|
|
.entries
|
|
.where((e) => e.value.text.trim().isNotEmpty)
|
|
.map((e) => {
|
|
"label": emailLabels[e.key].value,
|
|
"emailAddress": e.value.text.trim()
|
|
})
|
|
.toList();
|
|
|
|
final phones = phoneCtrls
|
|
.asMap()
|
|
.entries
|
|
.where((e) => e.value.text.trim().isNotEmpty)
|
|
.map((e) => {
|
|
"label": phoneLabels[e.key].value,
|
|
"phoneNumber": e.value.text.trim()
|
|
})
|
|
.toList();
|
|
|
|
controller.submitContact(
|
|
id: widget.existingContact?.id,
|
|
name: nameCtrl.text.trim(),
|
|
organization: orgCtrl.text.trim(),
|
|
emails: emails,
|
|
phones: phones,
|
|
address: addrCtrl.text.trim(),
|
|
description: descCtrl.text.trim(),
|
|
designation: designationCtrl.text.trim(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() {
|
|
if (!controller.isInitialized.value) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return BaseBottomSheet(
|
|
title: widget.existingContact != null
|
|
? "Edit Contact"
|
|
: "Create New Contact",
|
|
onCancel: () => Get.back(),
|
|
onSubmit: _handleSubmit,
|
|
isSubmitting: controller.isSubmitting.value,
|
|
child: Form(
|
|
key: formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_textField("Name", nameCtrl, required: true),
|
|
MySpacing.height(16),
|
|
_textField("Organization", orgCtrl, required: true),
|
|
MySpacing.height(16),
|
|
_labelWithStar("Buckets", required: true),
|
|
MySpacing.height(8),
|
|
Stack(
|
|
children: [
|
|
_bucketMultiSelectField(),
|
|
],
|
|
),
|
|
MySpacing.height(12),
|
|
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),
|
|
_dynamicList(
|
|
emailCtrls,
|
|
emailLabels,
|
|
"Email",
|
|
["Office", "Personal", "Other"],
|
|
TextInputType.emailAddress),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
emailCtrls.add(TextEditingController());
|
|
emailLabels.add("Office".obs);
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
label: const Text("Add Email"),
|
|
),
|
|
_dynamicList(phoneCtrls, phoneLabels, "Phone",
|
|
["Work", "Mobile", "Other"], TextInputType.phone),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
phoneCtrls.add(TextEditingController());
|
|
phoneLabels.add("Work".obs);
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
label: const Text("Add Phone"),
|
|
),
|
|
Obx(() => showAdvanced.value
|
|
? Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ✅ Move Designation field here
|
|
_textField("Designation", designationCtrl),
|
|
MySpacing.height(16),
|
|
|
|
_dynamicList(
|
|
emailCtrls,
|
|
emailLabels,
|
|
"Email",
|
|
["Office", "Personal", "Other"],
|
|
TextInputType.emailAddress,
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
emailCtrls.add(TextEditingController());
|
|
emailLabels.add("Office".obs);
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
label: const Text("Add Email"),
|
|
),
|
|
_dynamicList(
|
|
phoneCtrls,
|
|
phoneLabels,
|
|
"Phone",
|
|
["Work", "Mobile", "Other"],
|
|
TextInputType.phone,
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
phoneCtrls.add(TextEditingController());
|
|
phoneLabels.add("Work".obs);
|
|
},
|
|
icon: const Icon(Icons.add),
|
|
label: const Text("Add Phone"),
|
|
),
|
|
MyText.labelMedium("Category"),
|
|
MySpacing.height(8),
|
|
_popupSelector(
|
|
controller.selectedCategory,
|
|
controller.categories,
|
|
"Choose Category",
|
|
),
|
|
MySpacing.height(16),
|
|
MyText.labelMedium("Tags"),
|
|
MySpacing.height(8),
|
|
_tagInput(),
|
|
MySpacing.height(16),
|
|
_textField("Address", addrCtrl),
|
|
MySpacing.height(16),
|
|
_textField("Description", descCtrl,
|
|
maxLines: 3),
|
|
],
|
|
)
|
|
: const SizedBox.shrink()),
|
|
],
|
|
)
|
|
: const SizedBox.shrink()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
class FilterItem {
|
|
final String id;
|
|
final String name;
|
|
FilterItem({required this.id, required this.name});
|
|
}
|