503 lines
18 KiB
Dart
503 lines
18 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 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;
|
|
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();
|
|
addrCtrl.dispose();
|
|
descCtrl.dispose();
|
|
tagCtrl.dispose();
|
|
emailCtrls.forEach((c) => c.dispose());
|
|
phoneCtrls.forEach((c) => c.dispose());
|
|
Get.delete<AddContactController>();
|
|
super.dispose();
|
|
}
|
|
|
|
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: [
|
|
MyText.labelMedium(label),
|
|
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(),
|
|
);
|
|
}
|
|
|
|
@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),
|
|
MyText.labelMedium(" Bucket"),
|
|
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"),
|
|
),
|
|
MySpacing.height(16),
|
|
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()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|