Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
4 changed files with 435 additions and 742 deletions
Showing only changes of commit 7ce07c9b47 - Show all commits

View File

@ -24,6 +24,7 @@ class AddContactController extends GetxController {
final RxMap<String, String> tagsMap = <String, String>{}.obs; final RxMap<String, String> tagsMap = <String, String>{}.obs;
final RxBool isInitialized = false.obs; final RxBool isInitialized = false.obs;
final RxList<String> selectedProjects = <String>[].obs; final RxList<String> selectedProjects = <String>[].obs;
final RxBool isSubmitting = false.obs;
@override @override
void onInit() { void onInit() {
@ -94,6 +95,9 @@ class AddContactController extends GetxController {
required String address, required String address,
required String description, required String description,
}) async { }) async {
if (isSubmitting.value) return;
isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value]; final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value]; final bucketId = bucketsMap[selectedBucket.value];
final projectIds = selectedProjects final projectIds = selectedProjects
@ -101,13 +105,13 @@ class AddContactController extends GetxController {
.whereType<String>() .whereType<String>()
.toList(); .toList();
// === Required validations only for name, organization, and bucket ===
if (name.trim().isEmpty) { if (name.trim().isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Name", title: "Missing Name",
message: "Please enter the contact name.", message: "Please enter the contact name.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
@ -117,6 +121,7 @@ class AddContactController extends GetxController {
message: "Please enter the organization name.", message: "Please enter the organization name.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
@ -126,10 +131,10 @@ class AddContactController extends GetxController {
message: "Please select a bucket.", message: "Please select a bucket.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false;
return; return;
} }
// === Build body (include optional fields if available) ===
try { try {
final tagObjects = enteredTags.map((tagName) { final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName]; final tagId = tagsMap[tagName];
@ -182,6 +187,8 @@ class AddContactController extends GetxController {
message: "Something went wrong", message: "Something went wrong",
type: SnackbarType.error, type: SnackbarType.error,
); );
} finally {
isSubmitting.value = false;
} }
} }

View File

@ -137,7 +137,6 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
onSubmit: _submitComment, onSubmit: _submitComment,
isSubmitting: controller.isLoading.value, isSubmitting: controller.isLoading.value,
submitText: 'Comment',
bottomContent: _buildCommentsSection(), bottomContent: _buildCommentsSection(),
child: Form( child: Form(
// moved to last // moved to last

View File

@ -4,8 +4,7 @@ import 'package:marco/controller/task_planing/add_task_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
void showCreateTaskBottomSheet({ void showCreateTaskBottomSheet({
required String workArea, required String workArea,
@ -27,147 +26,15 @@ void showCreateTaskBottomSheet({
Get.bottomSheet( Get.bottomSheet(
StatefulBuilder( StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
return LayoutBuilder( return BaseBottomSheet(
builder: (context, constraints) { title: "Create Task",
final isLarge = constraints.maxWidth > 600; onCancel: () => Get.back(),
final horizontalPadding = onSubmit: () async {
isLarge ? constraints.maxWidth * 0.2 : 16.0; final plannedValue =
int.tryParse(plannedTaskController.text.trim()) ?? 0;
final comment = descriptionController.text.trim();
final selectedCategoryId = controller.selectedCategoryId.value;
return // Inside showManageTaskBottomSheet...
SafeArea(
child: Material(
color: Colors.white,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(20)),
child: Container(
constraints: const BoxConstraints(maxHeight: 760),
padding: EdgeInsets.fromLTRB(
horizontalPadding, 12, horizontalPadding, 24),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: MyText.titleLarge(
"Create Task",
fontWeight: 700,
),
),
const SizedBox(height: 20),
_infoCardSection([
_infoRowWithIcon(
Icons.workspaces, "Selected Work Area", workArea),
_infoRowWithIcon(
Icons.list_alt, "Selected Activity", activity),
_infoRowWithIcon(Icons.check_circle_outline,
"Completed Work", completedWork),
]),
const SizedBox(height: 16),
_sectionTitle(Icons.edit_calendar, "Planned Work"),
const SizedBox(height: 6),
_customTextField(
controller: plannedTaskController,
hint: "Enter planned work",
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_sectionTitle(Icons.description_outlined, "Comment"),
const SizedBox(height: 6),
_customTextField(
controller: descriptionController,
hint: "Enter task description",
maxLines: 3,
),
const SizedBox(height: 16),
_sectionTitle(
Icons.category_outlined, "Selected Work Category"),
const SizedBox(height: 6),
Obx(() {
final categoryMap = controller.categoryIdNameMap;
final String selectedName =
controller.selectedCategoryId.value != null
? (categoryMap[controller
.selectedCategoryId.value!] ??
'Select Category')
: 'Select Category';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (val) {
controller.selectCategory(val);
onCategoryChanged(val);
},
itemBuilder: (context) => categoryMap.entries
.map(
(entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
selectedName,
style: const TextStyle(
fontSize: 14, color: Colors.black87),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close, size: 18),
label: MyText.bodyMedium("Cancel",
fontWeight: 600),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final plannedValue = int.tryParse(
plannedTaskController.text.trim()) ??
0;
final comment =
descriptionController.text.trim();
final selectedCategoryId =
controller.selectedCategoryId.value;
if (selectedCategoryId == null) { if (selectedCategoryId == null) {
showAppSnackbar( showAppSnackbar(
title: "error", title: "error",
@ -188,8 +55,7 @@ void showCreateTaskBottomSheet({
if (success) { if (success) {
Get.back(); Get.back();
Future.delayed( Future.delayed(const Duration(milliseconds: 300), () {
const Duration(milliseconds: 300), () {
onSubmit(); onSubmit();
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
@ -199,25 +65,81 @@ void showCreateTaskBottomSheet({
}); });
} }
}, },
icon: const Icon(Icons.check, size: 18), submitText: "Submit",
label: MyText.bodyMedium("Submit", child: Column(
color: Colors.white, fontWeight: 600), crossAxisAlignment: CrossAxisAlignment.start,
style: ElevatedButton.styleFrom( children: [
backgroundColor: Colors.blueAccent, _infoCardSection([
shape: RoundedRectangleBorder( _infoRowWithIcon(
borderRadius: BorderRadius.circular(10)), Icons.workspaces, "Selected Work Area", workArea),
_infoRowWithIcon(Icons.list_alt, "Selected Activity", activity),
_infoRowWithIcon(Icons.check_circle_outline, "Completed Work",
completedWork),
]),
const SizedBox(height: 16),
_sectionTitle(Icons.edit_calendar, "Planned Work"),
const SizedBox(height: 6),
_customTextField(
controller: plannedTaskController,
hint: "Enter planned work",
keyboardType: TextInputType.number,
), ),
const SizedBox(height: 16),
_sectionTitle(Icons.description_outlined, "Comment"),
const SizedBox(height: 6),
_customTextField(
controller: descriptionController,
hint: "Enter task description",
maxLines: 3,
), ),
const SizedBox(height: 16),
_sectionTitle(Icons.category_outlined, "Selected Work Category"),
const SizedBox(height: 6),
Obx(() {
final categoryMap = controller.categoryIdNameMap;
final String selectedName =
controller.selectedCategoryId.value != null
? (categoryMap[controller.selectedCategoryId.value!] ??
'Select Category')
: 'Select Category';
return Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
), ),
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (val) {
controller.selectCategory(val);
onCategoryChanged(val);
},
itemBuilder: (context) => categoryMap.entries
.map((entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
))
.toList(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedName,
style: const TextStyle(
fontSize: 14, color: Colors.black87),
),
const Icon(Icons.arrow_drop_down),
], ],
), ),
],
),
),
),
), ),
); );
}, }),
],
),
); );
}, },
), ),

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:marco/controller/directory/add_contact_controller.dart'; import 'package:marco/controller/directory/add_contact_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -8,6 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/model/directory/contact_model.dart';
import 'package:marco/helpers/utils/contact_picker_helper.dart'; import 'package:marco/helpers/utils/contact_picker_helper.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class AddContactBottomSheet extends StatefulWidget { class AddContactBottomSheet extends StatefulWidget {
final ContactModel? existingContact; final ContactModel? existingContact;
@ -18,25 +19,24 @@ class AddContactBottomSheet extends StatefulWidget {
} }
class _AddContactBottomSheetState extends State<AddContactBottomSheet> { class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
// Controllers and state final controller = Get.put(AddContactController());
final AddContactController controller = Get.put(AddContactController());
final formKey = GlobalKey<FormState>(); 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 nameCtrl = TextEditingController();
final orgCtrl = TextEditingController();
final addrCtrl = TextEditingController();
final descCtrl = TextEditingController();
final tagCtrl = TextEditingController();
final showAdvanced = false.obs; 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; final bucketError = ''.obs;
final emailCtrls = <TextEditingController>[].obs;
final emailLabels = <RxString>[].obs;
final phoneCtrls = <TextEditingController>[].obs;
final phoneLabels = <RxString>[].obs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -47,34 +47,40 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
void _initFields() { void _initFields() {
final c = widget.existingContact; final c = widget.existingContact;
if (c != null) { if (c != null) {
nameController.text = c.name; nameCtrl.text = c.name;
orgController.text = c.organization; orgCtrl.text = c.organization;
addressController.text = c.address; addrCtrl.text = c.address;
descriptionController.text = c.description ; descCtrl.text = c.description;
}
if (c != null) { emailCtrls.assignAll(c.contactEmails.isEmpty
emailControllers.assignAll(c.contactEmails.isEmpty
? [TextEditingController()] ? [TextEditingController()]
: c.contactEmails.map((e) => TextEditingController(text: e.emailAddress))); : c.contactEmails
.map((e) => TextEditingController(text: e.emailAddress)));
emailLabels.assignAll(c.contactEmails.isEmpty emailLabels.assignAll(c.contactEmails.isEmpty
? ['Office'.obs] ? ['Office'.obs]
: c.contactEmails.map((e) => e.label.obs)); : c.contactEmails.map((e) => e.label.obs));
phoneControllers.assignAll(c.contactPhones.isEmpty
phoneCtrls.assignAll(c.contactPhones.isEmpty
? [TextEditingController()] ? [TextEditingController()]
: c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber))); : c.contactPhones
.map((p) => TextEditingController(text: p.phoneNumber)));
phoneLabels.assignAll(c.contactPhones.isEmpty phoneLabels.assignAll(c.contactPhones.isEmpty
? ['Work'.obs] ? ['Work'.obs]
: c.contactPhones.map((p) => p.label.obs)); : c.contactPhones.map((p) => p.label.obs));
controller.enteredTags.assignAll(c.tags.map((tag) => tag.name));
controller.enteredTags.assignAll(c.tags.map((e) => e.name));
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
final projectIds = c.projectIds; final projectIds = c.projectIds;
final bucketId = c.bucketIds.firstOrNull; final bucketId = c.bucketIds.firstOrNull;
final categoryName = c.contactCategory?.name; final category = c.contactCategory?.name;
if (categoryName != null) controller.selectedCategory.value = categoryName;
if (category != null) controller.selectedCategory.value = category;
if (projectIds != null) { if (projectIds != null) {
controller.selectedProjects.assignAll( controller.selectedProjects.assignAll(
projectIds // projectIds
.map((id) => controller.projectsMap.entries .map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id) .firstWhereOrNull((e) => e.value == id)
?.key) ?.key)
@ -82,6 +88,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
.toList(), .toList(),
); );
} }
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)
@ -91,32 +98,26 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
} }
}); });
} else { } else {
emailControllers.add(TextEditingController()); emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs); emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController()); phoneCtrls.add(TextEditingController());
phoneLabels.add('Work'.obs); phoneLabels.add('Work'.obs);
} }
tagTextController.clear();
} }
@override @override
void dispose() { void dispose() {
nameController.dispose(); nameCtrl.dispose();
orgController.dispose(); orgCtrl.dispose();
tagTextController.dispose(); addrCtrl.dispose();
addressController.dispose(); descCtrl.dispose();
descriptionController.dispose(); tagCtrl.dispose();
for (final c in emailControllers) { emailCtrls.forEach((c) => c.dispose());
c.dispose(); phoneCtrls.forEach((c) => 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),
@ -134,167 +135,43 @@ 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: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true, isDense: true,
); );
// DRY'd: LABELED FIELD ROW (used for phone/email) Widget _textField(String label, TextEditingController ctrl,
Widget _buildLabeledRow({ {bool required = false, int maxLines = 1}) {
required String label, return Column(
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.labelMedium(label), MyText.labelMedium(label),
MySpacing.height(8), 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( TextFormField(
controller: controller, controller: ctrl,
keyboardType: inputType, maxLines: maxLines,
maxLength: inputType == TextInputType.phone ? 10 : null, decoration: _inputDecoration("Enter $label"),
inputFormatters: inputType == TextInputType.phone validator: required
? [FilteringTextInputFormatter.digitsOnly] ? (v) =>
: [], (v == null || v.trim().isEmpty) ? "$label is required" : null
decoration: _inputDecoration("Enter $inputLabel").copyWith( : null,
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 _popupSelector(RxString selected, List<String> options, String hint) =>
Widget _buildDynamicList({ Obx(() {
required RxList<TextEditingController> ctrls, return GestureDetector(
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 { onTap: () async {
final selected = await showMenu<String>( final selectedItem = 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) => PopupMenuItem<String>(value: option, child: Text(option))).toList(), items: options
.map((e) => PopupMenuItem<String>(value: e, child: Text(e)))
.toList(),
); );
if (selected != null) selectedValue.value = selected; if (selectedItem != null) selected.value = selectedItem;
}, },
child: Container( child: Container(
height: 48, height: 48,
@ -308,38 +185,119 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(selected.value.isNotEmpty ? selected.value : hint,
selectedValue.value.isNotEmpty ? selectedValue.value : hint, style: const TextStyle(fontSize: 14)),
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.expand_more, size: 20), const Icon(Icons.expand_more, size: 20),
], ],
), ),
), ),
)); );
});
Widget _sectionLabel(String title) => Column( 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.labelLarge(title, fontWeight: 600), Expanded(
MySpacing.height(4), child: Column(
Divider(thickness: 1, color: Colors.grey.shade200), 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);
},
),
),
],
),
); );
}),
);
});
}
// CHIP list for tags Widget _tagInput() {
Widget _tagInputSection() {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
height: 48, height: 48,
child: TextField( child: TextField(
controller: tagTextController, controller: tagCtrl,
onChanged: controller.filterSuggestions, onChanged: controller.filterSuggestions,
onSubmitted: (value) { onSubmitted: (v) {
controller.addEnteredTag(value); controller.addEnteredTag(v);
tagTextController.clear(); tagCtrl.clear();
controller.clearSuggestions(); controller.clearSuggestions();
}, },
decoration: _inputDecoration("Start typing to add tags"), decoration: _inputDecoration("Start typing to add tags"),
@ -353,19 +311,21 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
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(color: Colors.black12, blurRadius: 4)], boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 4)
],
), ),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: controller.filteredSuggestions.length, itemCount: controller.filteredSuggestions.length,
itemBuilder: (context, index) { itemBuilder: (_, i) {
final suggestion = controller.filteredSuggestions[index]; final suggestion = controller.filteredSuggestions[i];
return ListTile( return ListTile(
dense: true, dense: true,
title: Text(suggestion), title: Text(suggestion),
onTap: () { onTap: () {
controller.addEnteredTag(suggestion); controller.addEnteredTag(suggestion);
tagTextController.clear(); tagCtrl.clear();
controller.clearSuggestions(); controller.clearSuggestions();
}, },
); );
@ -386,279 +346,91 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
); );
} }
// ---- REQUIRED FIELD (reusable) void _handleSubmit() {
Widget _buildTextField( bool valid = formKey.currentState?.validate() ?? false;
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) { if (controller.selectedBucket.value.isEmpty) {
bucketError.value = "Bucket is required"; bucketError.value = "Bucket is required";
valid = false; valid = false;
} else { } else {
bucketError.value = ""; bucketError.value = "";
} }
if (valid) {
final emails = emailControllers if (!valid) return;
final emails = emailCtrls
.asMap() .asMap()
.entries .entries
.where((entry) => entry.value.text.trim().isNotEmpty) .where((e) => e.value.text.trim().isNotEmpty)
.map((entry) => { .map((e) => {
"label": emailLabels[entry.key].value, "label": emailLabels[e.key].value,
"emailAddress": entry.value.text.trim(), "emailAddress": e.value.text.trim()
}) })
.toList(); .toList();
final phones = phoneControllers
final phones = phoneCtrls
.asMap() .asMap()
.entries .entries
.where((entry) => entry.value.text.trim().isNotEmpty) .where((e) => e.value.text.trim().isNotEmpty)
.map((entry) => { .map((e) => {
"label": phoneLabels[entry.key].value, "label": phoneLabels[e.key].value,
"phoneNumber": entry.value.text.trim(), "phoneNumber": e.value.text.trim()
}) })
.toList(); .toList();
controller.submitContact( controller.submitContact(
id: widget.existingContact?.id, id: widget.existingContact?.id,
name: nameController.text.trim(), name: nameCtrl.text.trim(),
organization: orgController.text.trim(), organization: orgCtrl.text.trim(),
emails: emails, emails: emails,
phones: phones, phones: phones,
address: addressController.text.trim(), address: addrCtrl.text.trim(),
description: descriptionController.text.trim(), description: descCtrl.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 @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(
child: SingleChildScrollView( return BaseBottomSheet(
padding: EdgeInsets.only(top: 32).add(MediaQuery.of(context).viewInsets), title: widget.existingContact != null
child: Container( ? "Edit Contact"
decoration: BoxDecoration( : "Create New Contact",
color: Theme.of(context).cardColor, onCancel: () => Get.back(),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), onSubmit: _handleSubmit,
), isSubmitting: controller.isSubmitting.value,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Form( child: Form(
key: formKey, key: formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( _textField("Name", nameCtrl, required: true),
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), MySpacing.height(16),
_buildOrganizationField(), _textField("Organization", orgCtrl, required: true),
MySpacing.height(16), MySpacing.height(16),
MyText.labelMedium("Select Bucket"), MyText.labelMedium("Select Bucket"),
MySpacing.height(8), MySpacing.height(8),
Stack( Stack(
children: [ children: [
_popupSelector( _popupSelector(controller.selectedBucket, controller.buckets,
hint: "Select Bucket", "Select Bucket"),
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
// Validation message for bucket
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
top: 56, top: 56,
child: Obx( child: Obx(() => bucketError.value.isEmpty
() => bucketError.value.isEmpty
? const SizedBox.shrink() ? const SizedBox.shrink()
: Padding( : Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), padding:
child: Text( const EdgeInsets.symmetric(horizontal: 8.0),
bucketError.value, child: Text(bucketError.value,
style: const TextStyle(color: Colors.red, fontSize: 12), style: const TextStyle(
), color: Colors.red, fontSize: 12)),
), )),
),
), ),
], ],
), ),
@ -668,8 +440,11 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600), MyText.labelLarge("Advanced Details (Optional)",
Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more), fontWeight: 600),
Icon(showAdvanced.value
? Icons.expand_less
: Icons.expand_more),
], ],
), ),
)), )),
@ -678,59 +453,49 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(24), MySpacing.height(24),
_sectionLabel("Contact Info"), _dynamicList(
MySpacing.height(16), emailCtrls,
_buildEmailList(), emailLabels,
"Email",
["Office", "Personal", "Other"],
TextInputType.emailAddress),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
emailControllers.add(TextEditingController()); emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs); emailLabels.add("Office".obs);
}, },
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text("Add Email"), label: const Text("Add Email"),
), ),
_buildPhoneList(), _dynamicList(phoneCtrls, phoneLabels, "Phone",
["Work", "Mobile", "Other"], TextInputType.phone),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
phoneControllers.add(TextEditingController()); phoneCtrls.add(TextEditingController());
phoneLabels.add('Work'.obs); phoneLabels.add("Work".obs);
}, },
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text("Add Phone"), label: const Text("Add Phone"),
), ),
MySpacing.height(24),
_sectionLabel("Other Details"),
MySpacing.height(16), MySpacing.height(16),
MyText.labelMedium("Category"), MyText.labelMedium("Category"),
MySpacing.height(8), MySpacing.height(8),
_popupSelector( _popupSelector(controller.selectedCategory,
hint: "Select Category", controller.categories, "Select Category"),
selectedValue: controller.selectedCategory,
options: controller.categories,
),
MySpacing.height(16),
MyText.labelMedium("Select Projects"),
MySpacing.height(8),
_projectSelectorUI(),
MySpacing.height(16), MySpacing.height(16),
MyText.labelMedium("Tags"), MyText.labelMedium("Tags"),
MySpacing.height(8), MySpacing.height(8),
_tagInputSection(), _tagInput(),
MySpacing.height(16), MySpacing.height(16),
_buildTextField("Address", addressController, maxLines: 2, required: false), _textField("Address", addrCtrl),
MySpacing.height(16), MySpacing.height(16),
_buildTextField("Description", descriptionController, maxLines: 2, required: false), _textField("Description", descCtrl),
], ],
) )
: const SizedBox.shrink()), : const SizedBox.shrink()),
MySpacing.height(24),
_buildActionButtons(),
], ],
), ),
), ),
),
),
),
); );
}); });
} }