marco.pms.mobileapp/lib/model/directory/add_contact_bottom_sheet.dart

564 lines
21 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) {
final projectIds = c.projectIds;
final bucketId = c.bucketIds.firstOrNull;
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
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 {
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(),
)),
],
);
}
void _handleSubmit() {
bool valid = formKey.currentState?.validate() ?? false;
if (controller.selectedBucket.value.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("Bucket", required: true),
MySpacing.height(8),
Stack(
children: [
_popupSelector(controller.selectedBucket, controller.buckets,
"Choose Bucket"),
Positioned(
left: 0,
right: 0,
top: 56,
child: Obx(() => bucketError.value.isEmpty
? const SizedBox.shrink()
: Padding(
padding:
const EdgeInsets.symmetric(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),
_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),
],
)
: const SizedBox.shrink()),
],
)
: const SizedBox.shrink()),
],
),
),
);
});
}
}