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

View File

@ -137,7 +137,6 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
onCancel: () => Navigator.of(context).pop(),
onSubmit: _submitComment,
isSubmitting: controller.isLoading.value,
submitText: 'Comment',
bottomContent: _buildCommentsSection(),
child: Form(
// 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_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
void showCreateTaskBottomSheet({
required String workArea,
@ -27,147 +26,15 @@ void showCreateTaskBottomSheet({
Get.bottomSheet(
StatefulBuilder(
builder: (context, setState) {
return LayoutBuilder(
builder: (context, constraints) {
final isLarge = constraints.maxWidth > 600;
final horizontalPadding =
isLarge ? constraints.maxWidth * 0.2 : 16.0;
return BaseBottomSheet(
title: "Create Task",
onCancel: () => Get.back(),
onSubmit: () async {
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) {
showAppSnackbar(
title: "error",
@ -188,8 +55,7 @@ void showCreateTaskBottomSheet({
if (success) {
Get.back();
Future.delayed(
const Duration(milliseconds: 300), () {
Future.delayed(const Duration(milliseconds: 300), () {
onSubmit();
showAppSnackbar(
title: "Success",
@ -199,25 +65,81 @@ void showCreateTaskBottomSheet({
});
}
},
icon: const Icon(Icons.check, size: 18),
label: MyText.bodyMedium("Submit",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
submitText: "Submit",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_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),
],
),
],
),
),
),
),
);
},
}),
],
),
);
},
),

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.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';
@ -8,6 +8,7 @@ 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;
@ -18,25 +19,24 @@ class AddContactBottomSheet extends StatefulWidget {
}
class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
// Controllers and state
final AddContactController controller = Get.put(AddContactController());
final 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 nameCtrl = TextEditingController();
final orgCtrl = TextEditingController();
final addrCtrl = TextEditingController();
final descCtrl = TextEditingController();
final tagCtrl = TextEditingController();
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 emailCtrls = <TextEditingController>[].obs;
final emailLabels = <RxString>[].obs;
final phoneCtrls = <TextEditingController>[].obs;
final phoneLabels = <RxString>[].obs;
@override
void initState() {
super.initState();
@ -47,34 +47,40 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
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
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)));
: 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
phoneCtrls.assignAll(c.contactPhones.isEmpty
? [TextEditingController()]
: c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber)));
: 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));
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 categoryName = c.contactCategory?.name;
if (categoryName != null) controller.selectedCategory.value = categoryName;
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
if (projectIds != null) {
controller.selectedProjects.assignAll(
projectIds //
projectIds
.map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.key)
@ -82,6 +88,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
.toList(),
);
}
if (bucketId != null) {
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
@ -91,32 +98,26 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
}
});
} else {
emailControllers.add(TextEditingController());
emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs);
phoneControllers.add(TextEditingController());
phoneCtrls.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();
}
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();
}
// --- COMMON WIDGETS ---
InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -134,167 +135,43 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
borderRadius: BorderRadius.circular(12),
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,
);
// 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(
Widget _textField(String label, TextEditingController ctrl,
{bool required = false, int maxLines = 1}) {
return 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,
),
controller: ctrl,
maxLines: maxLines,
decoration: _inputDecoration("Enter $label"),
validator: required
? (v) =>
(v == null || v.trim().isEmpty) ? "$label is required" : null
: null,
),
],
);
}
// 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(
Widget _popupSelector(RxString selected, List<String> options, String hint) =>
Obx(() {
return GestureDetector(
onTap: () async {
final selected = await showMenu<String>(
final selectedItem = await showMenu<String>(
context: context,
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(
height: 48,
@ -308,38 +185,119 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedValue.value.isNotEmpty ? selectedValue.value : hint,
style: const TextStyle(fontSize: 14),
),
Text(selected.value.isNotEmpty ? selected.value : hint,
style: const TextStyle(fontSize: 14)),
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,
children: [
MyText.labelLarge(title, fontWeight: 600),
MySpacing.height(4),
Divider(thickness: 1, color: Colors.grey.shade200),
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);
},
),
),
],
),
);
}),
);
});
}
// CHIP list for tags
Widget _tagInputSection() {
Widget _tagInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 48,
child: TextField(
controller: tagTextController,
controller: tagCtrl,
onChanged: controller.filterSuggestions,
onSubmitted: (value) {
controller.addEnteredTag(value);
tagTextController.clear();
onSubmitted: (v) {
controller.addEnteredTag(v);
tagCtrl.clear();
controller.clearSuggestions();
},
decoration: _inputDecoration("Start typing to add tags"),
@ -353,19 +311,21 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)],
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];
itemBuilder: (_, i) {
final suggestion = controller.filteredSuggestions[i];
return ListTile(
dense: true,
title: Text(suggestion),
onTap: () {
controller.addEnteredTag(suggestion);
tagTextController.clear();
tagCtrl.clear();
controller.clearSuggestions();
},
);
@ -386,279 +346,91 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
);
}
// ---- 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,
),
],
);
void _handleSubmit() {
bool valid = formKey.currentState?.validate() ?? false;
// -- 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
if (!valid) return;
final emails = emailCtrls
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": emailLabels[entry.key].value,
"emailAddress": entry.value.text.trim(),
.where((e) => e.value.text.trim().isNotEmpty)
.map((e) => {
"label": emailLabels[e.key].value,
"emailAddress": e.value.text.trim()
})
.toList();
final phones = phoneControllers
final phones = phoneCtrls
.asMap()
.entries
.where((entry) => entry.value.text.trim().isNotEmpty)
.map((entry) => {
"label": phoneLabels[entry.key].value,
"phoneNumber": entry.value.text.trim(),
.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: nameController.text.trim(),
organization: orgController.text.trim(),
name: nameCtrl.text.trim(),
organization: orgCtrl.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),
],
);
}),
),
address: addrCtrl.text.trim(),
description: descCtrl.text.trim(),
);
}
// --- 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),
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: [
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),
_textField("Name", nameCtrl, required: true),
MySpacing.height(16),
_buildOrganizationField(),
_textField("Organization", orgCtrl, required: true),
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
_popupSelector(controller.selectedBucket, controller.buckets,
"Select Bucket"),
Positioned(
left: 0,
right: 0,
top: 56,
child: Obx(
() => bucketError.value.isEmpty
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),
),
),
),
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(bucketError.value,
style: const TextStyle(
color: Colors.red, fontSize: 12)),
)),
),
],
),
@ -668,8 +440,11 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600),
Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more),
MyText.labelLarge("Advanced Details (Optional)",
fontWeight: 600),
Icon(showAdvanced.value
? Icons.expand_less
: Icons.expand_more),
],
),
)),
@ -678,59 +453,49 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
_sectionLabel("Contact Info"),
MySpacing.height(16),
_buildEmailList(),
_dynamicList(
emailCtrls,
emailLabels,
"Email",
["Office", "Personal", "Other"],
TextInputType.emailAddress),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
emailCtrls.add(TextEditingController());
emailLabels.add("Office".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_buildPhoneList(),
_dynamicList(phoneCtrls, phoneLabels, "Phone",
["Work", "Mobile", "Other"], TextInputType.phone),
TextButton.icon(
onPressed: () {
phoneControllers.add(TextEditingController());
phoneLabels.add('Work'.obs);
phoneCtrls.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(),
_popupSelector(controller.selectedCategory,
controller.categories, "Select Category"),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
_tagInput(),
MySpacing.height(16),
_buildTextField("Address", addressController, maxLines: 2, required: false),
_textField("Address", addrCtrl),
MySpacing.height(16),
_buildTextField("Description", descriptionController, maxLines: 2, required: false),
_textField("Description", descCtrl),
],
)
: const SizedBox.shrink()),
MySpacing.height(24),
_buildActionButtons(),
],
),
),
),
),
),
);
});
}