Vaibhav_Task-#729 #53

Merged
vaibhav.surve merged 10 commits from Vaibhav_Task-#729 into main 2025-07-15 09:52:02 +00:00
13 changed files with 1597 additions and 371 deletions

View File

@ -1,5 +1,3 @@
// Updated AddContactController to support multiple emails and phones
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
@ -103,7 +101,7 @@ class AddContactController extends GetxController {
.whereType<String>()
.toList();
// === Per-field Validation with Specific Messages ===
// === Required validations only for name, organization, and bucket ===
if (name.trim().isEmpty) {
showAppSnackbar(
title: "Missing Name",
@ -122,51 +120,6 @@ class AddContactController extends GetxController {
return;
}
if (emails.isEmpty) {
showAppSnackbar(
title: "Missing Email",
message: "Please add at least one email.",
type: SnackbarType.warning,
);
return;
}
if (phones.isEmpty) {
showAppSnackbar(
title: "Missing Phone Number",
message: "Please add at least one phone number.",
type: SnackbarType.warning,
);
return;
}
if (address.trim().isEmpty) {
showAppSnackbar(
title: "Missing Address",
message: "Please enter the address.",
type: SnackbarType.warning,
);
return;
}
if (description.trim().isEmpty) {
showAppSnackbar(
title: "Missing Description",
message: "Please enter a description.",
type: SnackbarType.warning,
);
return;
}
if (selectedCategory.value.trim().isEmpty || categoryId == null) {
showAppSnackbar(
title: "Missing Category",
message: "Please select a contact category.",
type: SnackbarType.warning,
);
return;
}
if (selectedBucket.value.trim().isEmpty || bucketId == null) {
showAppSnackbar(
title: "Missing Bucket",
@ -176,25 +129,7 @@ class AddContactController extends GetxController {
return;
}
if (selectedProjects.isEmpty || projectIds.isEmpty) {
showAppSnackbar(
title: "Missing Projects",
message: "Please select at least one project.",
type: SnackbarType.warning,
);
return;
}
if (enteredTags.isEmpty) {
showAppSnackbar(
title: "Missing Tags",
message: "Please enter at least one tag.",
type: SnackbarType.warning,
);
return;
}
// === Submit if all validations passed ===
// === Build body (include optional fields if available) ===
try {
final tagObjects = enteredTags.map((tagName) {
final tagId = tagsMap[tagName];
@ -207,14 +142,15 @@ class AddContactController extends GetxController {
if (id != null) "id": id,
"name": name.trim(),
"organization": organization.trim(),
"contactCategoryId": categoryId,
"projectIds": projectIds,
if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": [bucketId],
"tags": tagObjects,
"contactEmails": emails,
"contactPhones": phones,
"address": address.trim(),
"description": description.trim(),
if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones,
if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(),
};
logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

@ -0,0 +1,70 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class BucketController extends GetxController {
RxBool isCreating = false.obs;
final RxString name = ''.obs;
final RxString description = ''.obs;
Future<void> createBucket() async {
if (name.value.trim().isEmpty) {
showAppSnackbar(
title: "Missing Name",
message: "Bucket name is required.",
type: SnackbarType.warning,
);
return;
}
isCreating.value = true;
try {
logSafe("Creating bucket: ${name.value}");
final success = await ApiService.createBucket(
name: name.value.trim(),
description: description.value.trim(),
);
if (success) {
logSafe("Bucket created successfully");
Get.back(result: true); // Close bottom sheet/dialog
showAppSnackbar(
title: "Success",
message: "Bucket has been created successfully.",
type: SnackbarType.success,
);
} else {
logSafe("Bucket creation failed", level: LogLevel.error);
showAppSnackbar(
title: "Creation Failed",
message: "Unable to create bucket. Please try again later.",
type: SnackbarType.error,
);
}
} catch (e) {
logSafe("Error during bucket creation: $e", level: LogLevel.error);
showAppSnackbar(
title: "Unexpected Error",
message: "Something went wrong. Please try again.",
type: SnackbarType.error,
);
} finally {
isCreating.value = false;
}
}
void updateName(String value) {
name.value = value;
logSafe("Bucket name updated: ${value.trim()}");
}
void updateDescription(String value) {
description.value = value;
logSafe("Bucket description updated: ${value.trim()}");
}
}

View File

@ -0,0 +1,36 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/employee_model.dart';
class ManageBucketController extends GetxController {
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
RxBool isLoading = false.obs;
@override
void onInit() {
super.onInit();
fetchAllEmployees();
}
Future<void> fetchAllEmployees() async {
isLoading.value = true;
try {
final response = await ApiService.getAllEmployees();
if (response != null && response.isNotEmpty) {
allEmployees.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
logSafe("All Employees fetched for Manage Bucket: ${allEmployees.length}", level: LogLevel.info);
} else {
allEmployees.clear();
logSafe("No employees found for Manage Bucket.", level: LogLevel.warning);
}
} catch (e) {
allEmployees.clear();
logSafe("Error fetching employees in Manage Bucket", level: LogLevel.error, error: e);
}
isLoading.value = false;
update();
}
}

View File

@ -43,4 +43,5 @@ class ApiEndpoints {
static const String updateContact = "/directory";
static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket";
}

View File

@ -163,7 +163,7 @@ class ApiService {
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body",
);
);
try {
final response = await http
@ -243,6 +243,49 @@ class ApiService {
}
/// Directory calling the API
static Future<bool> createBucket({
required String name,
required String description,
}) async {
final payload = {
"name": name,
"description": description,
};
final endpoint = ApiEndpoints.createBucket;
logSafe("Creating bucket with payload: $payload");
try {
final response = await _postRequest(endpoint, payload);
if (response == null) {
logSafe("Create bucket failed: null response", level: LogLevel.error);
return false;
}
logSafe("Create bucket response status: ${response.statusCode}");
logSafe("Create bucket response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Bucket created successfully: ${json['data']}");
return true;
} else {
logSafe("Failed to create bucket: ${json['message']}",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception during createBucket API: ${e.toString()}",
level: LogLevel.error);
logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug);
}
return false;
}
static Future<Map<String, dynamic>?> getDirectoryNotes({
int pageSize = 1000,
int pageNumber = 1,

View File

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart';
class ContactPickerHelper {
static Future<String?> pickIndianPhoneNumber(BuildContext context) async {
final status = await Permission.contacts.request();
if (!status.isGranted) {
if (status.isPermanentlyDenied) {
await openAppSettings();
}
showAppSnackbar(
title: "Permission Required",
message:
"Please allow Contacts permission from settings to pick a contact.",
type: SnackbarType.warning,
);
return null;
}
try {
final picked = await FlutterContacts.openExternalPick();
if (picked == null) return null;
final contact =
await FlutterContacts.getContact(picked.id, withProperties: true);
if (contact == null || contact.phones.isEmpty) {
showAppSnackbar(
title: "No Phone Number",
message: "Selected contact has no phone number.",
type: SnackbarType.warning,
);
return null;
}
final indiaPhones = contact.phones.where((p) {
final normalized = p.number.replaceAll(RegExp(r'[^0-9+]'), '');
return normalized.startsWith('+91') || RegExp(r'^\d{10}$').hasMatch(normalized);
}).toList();
if (indiaPhones.isEmpty) {
showAppSnackbar(
title: "No Indian Number",
message: "Selected contact has no Indian (+91) phone number.",
type: SnackbarType.warning,
);
return null;
}
if (indiaPhones.length == 1) {
return _normalizeNumber(indiaPhones.first.number);
}
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Choose a number"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: indiaPhones
.map((p) => ListTile(
title: Text(p.number),
onTap: () => Navigator.of(ctx).pop(_normalizeNumber(p.number)),
))
.toList(),
),
),
);
} catch (e, st) {
logSafe("Error picking contact", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to fetch contact.",
type: SnackbarType.error,
);
return null;
}
}
static String _normalizeNumber(String raw) {
final normalized = raw.replaceAll(RegExp(r'[^0-9]'), '');
return normalized.length > 10
? normalized.substring(normalized.length - 10)
: normalized;
}
}

View File

@ -3,9 +3,14 @@ class Permissions {
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
static const String manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b";
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b";
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
static const String viewTask = "9fcc5f87-25e3-4846-90ac-67a71ab92e3c";
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
}

View File

@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart';
class TeamMembersBottomSheet {
static void show(
BuildContext context,
ContactBucket bucket,
List<dynamic> members, {
bool canEdit = false,
VoidCallback? onEdit,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
builder: (context) {
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Column(
children: [
const SizedBox(height: 6),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 10),
// Title at top center
MyText.titleMedium(
'Bucket Details',
fontWeight: 700,
),
const SizedBox(height: 12),
// Header with title and optional edit button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: MyText.titleMedium(
bucket.name,
fontWeight: 700,
),
),
if (canEdit)
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit, color: Colors.red),
tooltip: 'Edit Bucket',
),
],
),
),
// Bucket info
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (bucket.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: MyText.bodySmall(
bucket.description,
color: Colors.grey[700],
),
),
Row(
children: [
const Icon(Icons.contacts_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'${bucket.numberOfContacts} contact(s)',
fontWeight: 600,
color: Colors.red,
),
const SizedBox(width: 12),
const Icon(Icons.ios_share_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
'Shared with (${members.length})',
fontWeight: 600,
color: Colors.indigo,
),
],
),
// Can edit indicator
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.edit_outlined,
size: 14, color: Colors.grey),
const SizedBox(width: 4),
MyText.labelSmall(
canEdit
? 'Can be edited by you'
: 'You dont have edit access',
fontWeight: 600,
color: canEdit ? Colors.green : Colors.grey,
),
],
),
),
const SizedBox(height: 10),
// Created by
Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: Colors.grey.shade300,
backgroundImage:
bucket.createdBy.photo != null &&
bucket.createdBy.photo!.isNotEmpty
? NetworkImage(bucket.createdBy.photo!)
: null,
child: bucket.createdBy.photo == null
? Text(
bucket.createdBy.firstName.isNotEmpty
? bucket.createdBy.firstName[0]
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
)
: null,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: MyText.labelSmall(
'${bucket.createdBy.firstName} ${bucket.createdBy.lastName}',
fontWeight: 600,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius:
BorderRadius.circular(4),
),
child: MyText.labelSmall(
"Owner",
fontWeight: 600,
color: Colors.red,
),
),
],
),
MyText.bodySmall(
bucket.createdBy.jobRoleName,
color: Colors.grey[600],
),
],
),
),
],
),
const SizedBox(height: 12),
const Divider(thickness: 1),
const SizedBox(height: 6),
MyText.labelLarge(
'Shared with',
fontWeight: 700,
color: Colors.black,
),
],
),
),
const SizedBox(height: 4),
// Members list
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: members.isEmpty
? Center(
child: MyText.bodySmall(
"No team members found.",
fontWeight: 600,
color: Colors.grey,
),
)
: ListView.separated(
controller: scrollController,
itemCount: members.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 4),
itemBuilder: (context, index) {
final member = members[index];
final firstName = member.firstName ?? '';
final lastName = member.lastName ?? '';
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 32,
),
title: MyText.bodyMedium(
'${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}',
fontWeight: 600,
),
subtitle: MyText.bodySmall(
member.jobRole ?? '',
color: Colors.grey.shade600,
),
);
},
),
),
),
const SizedBox(height: 8),
],
);
},
),
),
);
},
);
}
}

View File

@ -7,6 +7,7 @@ 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';
class AddContactBottomSheet extends StatefulWidget {
final ContactModel? existingContact;
@ -25,7 +26,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final addressController = TextEditingController();
final descriptionController = TextEditingController();
final tagTextController = TextEditingController();
final RxBool showAdvanced = false.obs;
final RxList<TextEditingController> emailControllers =
<TextEditingController>[].obs;
final RxList<RxString> emailLabels = <RxString>[].obs;
@ -184,8 +185,23 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
inputFormatters: inputType == TextInputType.phone
? [FilteringTextInputFormatter.digitsOnly]
: [],
decoration: _inputDecoration("Enter $inputLabel")
.copyWith(counterText: ""),
decoration: _inputDecoration("Enter $inputLabel").copyWith(
counterText: "",
suffixIcon: inputType == TextInputType.phone
? IconButton(
icon: const Icon(Icons.contact_phone,
color: Colors.blue),
onPressed: () async {
final selectedPhone =
await ContactPickerHelper.pickIndianPhoneNumber(
context);
if (selectedPhone != null) {
controller.text = selectedPhone;
}
},
)
: null,
),
validator: (value) {
if (value == null || value.trim().isEmpty)
return "$inputLabel is required";
@ -195,7 +211,6 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
return "Enter valid phone number";
}
}
if (inputType == TextInputType.emailAddress &&
!RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(trimmed)) {
@ -506,188 +521,117 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: 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),
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("Basic Info"),
MySpacing.height(16),
_buildTextField("Name", nameController),
MySpacing.height(16),
_buildOrganizationField(),
MySpacing.height(24),
_sectionLabel("Contact Info"),
MySpacing.height(16),
Obx(() => _buildEmailList()),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
Obx(() => _buildPhoneList()),
TextButton.icon(
onPressed: () {
phoneControllers.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),
GestureDetector(
onTap: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Projects'),
content: Obx(() {
return 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(
unselectedWidgetColor: Colors
.black, // checkbox border when not selected
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty
.resolveWith<Color>((states) {
if (states.contains(
MaterialState.selected)) {
return Colors
.white; // fill when selected
}
return Colors.transparent;
}),
checkColor: MaterialStateProperty.all(
Colors.black), // check mark color
side: const BorderSide(
color: Colors.black,
width: 2), // border color
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(4),
),
),
),
child: CheckboxListTile(
dense: true,
title: Text(project),
value: isSelected,
onChanged: (bool? 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),
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),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: MyText.titleMedium(
widget.existingContact != null
? "Edit Contact"
: "Create New Contact",
fontWeight: 700,
),
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),
],
);
}),
),
),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
MySpacing.height(8),
_popupSelector(
hint: "Select Bucket",
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController, maxLines: 2),
MySpacing.height(16),
_buildTextField("Description", descriptionController,
maxLines: 2),
MySpacing.height(24),
_buildActionButtons(),
],
MySpacing.height(24),
_sectionLabel("Required Fields"),
MySpacing.height(12),
_buildTextField("Name", nameController),
MySpacing.height(16),
_buildOrganizationField(),
MySpacing.height(16),
MyText.labelMedium("Select Bucket"),
MySpacing.height(8),
_popupSelector(
hint: "Select Bucket",
selectedValue: controller.selectedBucket,
options: controller.buckets,
),
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),
_sectionLabel("Contact Info"),
MySpacing.height(16),
_buildEmailList(),
TextButton.icon(
onPressed: () {
emailControllers.add(TextEditingController());
emailLabels.add('Office'.obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_buildPhoneList(),
TextButton.icon(
onPressed: () {
phoneControllers.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(),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInputSection(),
MySpacing.height(16),
_buildTextField("Address", addressController,
maxLines: 2),
MySpacing.height(16),
_buildTextField(
"Description", descriptionController,
maxLines: 2),
],
)
: const SizedBox()),
MySpacing.height(24),
_buildActionButtons(),
],
),
),
),
),
@ -695,4 +639,95 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
);
});
}
Widget _projectSelectorUI() {
return GestureDetector(
onTap: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Projects'),
content: Obx(() {
return 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(
unselectedWidgetColor: Colors.black,
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.white;
}
return 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: (bool? 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),
],
);
}),
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/directory/create_bucket_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
class CreateBucketBottomSheet extends StatefulWidget {
const CreateBucketBottomSheet({super.key});
@override
State<CreateBucketBottomSheet> createState() => _CreateBucketBottomSheetState();
}
class _CreateBucketBottomSheetState extends State<CreateBucketBottomSheet> {
final BucketController _controller = Get.put(BucketController());
final _formKey = GlobalKey<FormState>();
InputDecoration _inputDecoration(String hint) {
return 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: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GetBuilder<BucketController>(
builder: (_) {
return SafeArea(
top: false,
child: SingleChildScrollView(
padding: MediaQuery.of(context).viewInsets,
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
Text("Create New Bucket", style: MyTextStyle.titleLarge(fontWeight: 700)),
MySpacing.height(24),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Bucket Name"),
MySpacing.height(8),
TextFormField(
initialValue: _controller.name.value,
onChanged: _controller.updateName,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Bucket name is required";
}
return null;
},
decoration: _inputDecoration("e.g., Project Docs"),
),
MySpacing.height(16),
MyText.labelMedium("Description"),
MySpacing.height(8),
TextFormField(
initialValue: _controller.description.value,
onChanged: _controller.updateDescription,
maxLines: 3,
decoration: _inputDecoration("Optional bucket description"),
),
MySpacing.height(24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context, false),
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(12)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
),
MySpacing.width(12),
Expanded(
child: Obx(() {
return ElevatedButton.icon(
onPressed: _controller.isCreating.value
? null
: () async {
if (_formKey.currentState!.validate()) {
await _controller.createBucket();
}
},
icon: _controller.isCreating.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
_controller.isCreating.value ? "Creating..." : "Create",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
),
);
}),
),
],
),
],
),
),
],
),
),
),
),
);
},
);
}
@override
void dispose() {
Get.delete<BucketController>();
super.dispose();
}
}

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/create_bucket_controller.dart';
import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
@ -10,11 +11,21 @@ import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/model/directory/directory_filter_bottom_sheet.dart';
import 'package:marco/model/directory/create_bucket_bottom_sheet.dart';
import 'package:marco/view/directory/contact_detail_screen.dart';
import 'package:marco/view/directory/manage_bucket_screen.dart';
import 'package:marco/controller/permission_controller.dart';
class DirectoryView extends StatelessWidget {
class DirectoryView extends StatefulWidget {
@override
State<DirectoryView> createState() => _DirectoryViewState();
}
class _DirectoryViewState extends State<DirectoryView> {
final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController();
final PermissionController permissionController =
Get.put(PermissionController());
Future<void> _refreshDirectory() async {
try {
@ -25,23 +36,121 @@ class DirectoryView extends StatelessWidget {
}
}
void _handleCreateContact() async {
await controller.fetchBuckets();
if (controller.contactBuckets.isEmpty) {
final shouldCreate = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _buildEmptyBucketPrompt(),
);
if (shouldCreate != true) return;
final created = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const CreateBucketBottomSheet(),
);
if (created == true) {
await controller.fetchBuckets();
} else {
return;
}
}
Get.delete<BucketController>();
final result = await Get.bottomSheet(
AddContactBottomSheet(),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
if (result == true) {
controller.fetchContacts();
}
}
void _handleManageBuckets() async {
await controller.fetchBuckets();
Get.to(
() => ManageBucketsScreen(permissionController: permissionController));
}
Widget _buildEmptyBucketPrompt() {
return SafeArea(
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 48, color: Colors.indigo),
MySpacing.height(12),
MyText.titleMedium("No Buckets Assigned",
fontWeight: 700, textAlign: TextAlign.center),
MySpacing.height(8),
MyText.bodyMedium(
"You dont have any buckets assigned. Please create a bucket before adding a contact.",
textAlign: TextAlign.center,
color: Colors.grey[700],
),
MySpacing.height(20),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.pop(context, false),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[300],
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text("Cancel"),
),
),
MySpacing.width(12),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text("Create Bucket"),
),
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
floatingActionButton: FloatingActionButton(
heroTag: 'createContact',
backgroundColor: Colors.red,
onPressed: () async {
final result = await Get.bottomSheet(
AddContactBottomSheet(),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
if (result == true) {
controller.fetchContacts();
}
},
child: const Icon(Icons.add, color: Colors.white),
onPressed: _handleCreateContact,
child: const Icon(Icons.person_add_alt_1, color: Colors.white),
),
body: Column(
children: [
@ -155,28 +264,86 @@ class DirectoryView extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
itemBuilder: (context) => [
PopupMenuItem<int>(
value: 0,
enabled: false,
child: Obx(() => Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodySmall('Show Inactive',
fontWeight: 600),
Switch.adaptive(
value: !controller.isActive.value,
activeColor: Colors.indigo,
onChanged: (val) {
controller.isActive.value = !val;
controller.fetchContacts(active: !val);
Navigator.pop(context);
},
),
],
)),
),
],
itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = [];
// Section: Actions (Always visible now)
menuItems.add(
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Actions",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
);
menuItems.add(
PopupMenuItem<int>(
value: 1,
child: Row(
children: const [
Icon(Icons.label_outline,
size: 20, color: Colors.black87),
SizedBox(width: 10),
Expanded(child: Text("Manage Buckets")),
Icon(Icons.chevron_right,
size: 20, color: Colors.red),
],
),
onTap: () {
Future.delayed(Duration.zero, () {
_handleManageBuckets();
});
},
),
);
// Section: Preferences
menuItems.add(
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
);
menuItems.add(
PopupMenuItem<int>(
value: 0,
enabled: false,
child: Obx(() => Row(
children: [
const Icon(Icons.visibility_off_outlined,
size: 20, color: Colors.black87),
const SizedBox(width: 10),
const Expanded(child: Text('Show Inactive')),
Switch.adaptive(
value: !controller.isActive.value,
activeColor: Colors.indigo,
onChanged: (val) {
controller.isActive.value = !val;
controller.fetchContacts(active: !val);
Navigator.pop(context);
},
),
],
)),
),
);
return menuItems;
},
),
),
],
@ -242,34 +409,62 @@ class DirectoryView extends StatelessWidget {
color: Colors.grey[700],
overflow: TextOverflow.ellipsis),
MySpacing.height(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...contact.contactEmails.map((e) =>
GestureDetector(
onTap: () =>
LauncherUtils.launchEmail(
e.emailAddress),
onLongPress: () =>
LauncherUtils.copyToClipboard(
e.emailAddress,
typeLabel: 'Email'),
child: Padding(
padding: const EdgeInsets.only(
bottom: 4),
...contact.contactEmails.map((e) =>
GestureDetector(
onTap: () => LauncherUtils.launchEmail(
e.emailAddress),
onLongPress: () =>
LauncherUtils.copyToClipboard(
e.emailAddress,
typeLabel: 'Email'),
child: Padding(
padding:
const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(Icons.email_outlined,
size: 16, color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 180),
child: MyText.labelSmall(
e.emailAddress,
overflow: TextOverflow.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration.underline,
),
),
],
),
),
)),
...contact.contactPhones.map((p) => Padding(
padding: const EdgeInsets.only(
bottom: 8, top: 4),
child: Row(
children: [
GestureDetector(
onTap: () =>
LauncherUtils.launchPhone(
p.phoneNumber),
onLongPress: () =>
LauncherUtils.copyToClipboard(
p.phoneNumber,
typeLabel: 'Phone'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.email_outlined,
const Icon(Icons.phone_outlined,
size: 16,
color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints:
const BoxConstraints(
maxWidth: 180),
maxWidth: 140),
child: MyText.labelSmall(
e.emailAddress,
p.phoneNumber,
overflow:
TextOverflow.ellipsis,
color: Colors.indigo,
@ -280,63 +475,19 @@ class DirectoryView extends StatelessWidget {
],
),
),
)),
...contact.contactPhones.map((p) => Padding(
padding:
const EdgeInsets.only(bottom: 8,top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () =>
LauncherUtils.launchPhone(
p.phoneNumber),
onLongPress: () => LauncherUtils
.copyToClipboard(
p.phoneNumber,
typeLabel:
'Phone number'),
child: Row(
mainAxisSize:
MainAxisSize.min,
children: [
const Icon(
Icons.phone_outlined,
size: 16,
color: Colors.indigo),
MySpacing.width(4),
ConstrainedBox(
constraints:
const BoxConstraints(
maxWidth: 140),
child: MyText.labelSmall(
p.phoneNumber,
overflow: TextOverflow
.ellipsis,
color: Colors.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
),
MySpacing.width(8),
GestureDetector(
onTap: () => LauncherUtils
.launchWhatsApp(
p.phoneNumber),
child: const FaIcon(
FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 16),
),
],
MySpacing.width(8),
GestureDetector(
onTap: () =>
LauncherUtils.launchWhatsApp(
p.phoneNumber),
child: const FaIcon(
FontAwesomeIcons.whatsapp,
color: Colors.green,
size: 16),
),
))
],
),
],
),
)),
if (tags.isNotEmpty) ...[
MySpacing.height(2),
MyText.labelSmall(tags.join(', '),
@ -348,7 +499,6 @@ class DirectoryView extends StatelessWidget {
),
),
Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.arrow_forward_ios,
color: Colors.grey, size: 16),

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class EditBucketBottomSheet extends StatelessWidget {
const EditBucketBottomSheet({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: MediaQuery.of(context).viewInsets,
child: Container(
padding: MySpacing.xy(20, 16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Wrap(
children: [
Center(
child: MyText.titleMedium(
"Edit Bucket",
fontWeight: 700,
),
),
MySpacing.height(16),
MyText.bodyMedium("Bucket Name", fontWeight: 600),
MySpacing.height(6),
const TextField(
decoration: InputDecoration(
hintText: 'Sample Bucket Name',
border: OutlineInputBorder(),
),
),
MySpacing.height(16),
MyText.bodyMedium("Description", fontWeight: 600),
MySpacing.height(6),
const TextField(
maxLines: 3,
decoration: InputDecoration(
hintText: 'Sample description...',
border: OutlineInputBorder(),
),
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () {
print("Save clicked (static)");
Navigator.pop(context); // Dismiss the sheet
},
child: const Text(
"Save",
style:
TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,356 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/manage_bucket_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/team_members_bottom_sheet.dart';
class ManageBucketsScreen extends StatefulWidget {
final PermissionController permissionController;
const ManageBucketsScreen({super.key, required this.permissionController});
@override
State<ManageBucketsScreen> createState() => _ManageBucketsScreenState();
}
class _ManageBucketsScreenState extends State<ManageBucketsScreen> {
final DirectoryController directoryController = Get.find();
final ManageBucketController manageBucketController =
Get.put(ManageBucketController());
final ProjectController projectController = Get.find();
final Map<String, bool> _expandedMap = {};
final TextEditingController searchController = TextEditingController();
String searchText = '';
void _clearSearch() {
searchController.clear();
setState(() {
searchText = '';
});
}
@override
Widget build(BuildContext context) {
final currentUserId = widget.permissionController.employeeInfo.value?.id;
return Scaffold(
backgroundColor: Colors.white,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Manage Buckets',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
),
body: Column(
children: [
Padding(
padding: MySpacing.xy(16, 12),
child: SizedBox(
height: 38,
child: TextField(
controller: searchController,
onChanged: (value) {
setState(() {
searchText = value.toLowerCase();
});
},
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon:
const Icon(Icons.search, size: 18, color: Colors.grey),
suffixIcon: searchText.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: _clearSearch,
)
: null,
hintText: 'Search buckets...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
Expanded(
child: Obx(() {
final buckets =
directoryController.contactBuckets.where((bucket) {
return bucket.name.toLowerCase().contains(searchText) ||
bucket.description.toLowerCase().contains(searchText) ||
bucket.numberOfContacts.toString().contains(searchText);
}).toList();
if (directoryController.isLoading.value ||
manageBucketController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (buckets.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_off,
size: 48, color: Colors.grey),
MySpacing.height(12),
MyText.bodyMedium("No buckets available.",
fontWeight: 600, color: Colors.grey[700]),
],
),
);
}
return ListView.separated(
padding: MySpacing.fromLTRB(16, 0, 16, 24),
itemCount: buckets.length,
separatorBuilder: (_, __) => MySpacing.height(16),
itemBuilder: (context, index) {
final bucket = buckets[index];
final isOwner = currentUserId != null &&
bucket.createdBy.id == currentUserId;
final canEdit = isOwner ||
widget.permissionController
.hasPermission(Permissions.directoryAdmin) ||
widget.permissionController
.hasPermission(Permissions.directoryManager);
final isExpanded = _expandedMap[bucket.id] ?? false;
final matchedEmployees = manageBucketController.allEmployees
.where((emp) => bucket.employeeIds.contains(emp.id))
.toList();
return GestureDetector(
onTap: () {
TeamMembersBottomSheet.show(
context,
bucket,
matchedEmployees,
canEdit: canEdit,
onEdit: () {
Get.defaultDialog(
title: '',
contentPadding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 16),
content: Column(
children: [
const Icon(Icons.warning_amber,
size: 48, color: Colors.amber),
const SizedBox(height: 16),
Text(
'Coming Soon',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'This feature is under development and will be available soon.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
vertical: 12),
),
onPressed: () => Get.back(),
child: const Text('OK'),
),
),
],
),
radius: 12,
);
},
);
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 4),
child: Icon(Icons.label_outline,
size: 26, color: Colors.indigo),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(
bucket.name,
fontWeight: 700,
overflow: TextOverflow.ellipsis,
),
MySpacing.height(4),
Row(
children: [
const Icon(Icons.contacts_outlined,
size: 14, color: Colors.grey),
MySpacing.width(4),
MyText.labelSmall(
'${bucket.numberOfContacts} contact(s)',
color: Colors.red,
fontWeight: 600,
),
MySpacing.width(12),
Row(
children: [
const Icon(Icons.ios_share_outlined,
size: 14, color: Colors.grey),
MySpacing.width(4),
MyText.labelSmall(
'Shared with (${matchedEmployees.length})',
color: Colors.indigo,
fontWeight: 600,
),
],
),
],
),
if (bucket.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: LayoutBuilder(
builder: (context, constraints) {
final span = TextSpan(
text: bucket.description,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey[700]),
);
final tp = TextPainter(
text: span,
maxLines: 2,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
final hasOverflow = tp.didExceedMaxLines;
return Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.bodySmall(
bucket.description,
color: Colors.grey[700],
maxLines: isExpanded ? null : 2,
overflow: isExpanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
if (hasOverflow)
GestureDetector(
onTap: () {
setState(() {
_expandedMap[bucket.id] =
!isExpanded;
});
},
child: Padding(
padding: const EdgeInsets.only(
top: 4),
child: MyText.labelSmall(
isExpanded
? "Show less"
: "Show more",
fontWeight: 600,
color: Colors.red,
),
),
),
],
);
},
),
),
],
),
),
],
),
);
},
);
}),
),
],
),
);
}
}