diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index bcc1abc..3cfcc57 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -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() .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"); diff --git a/lib/controller/directory/create_bucket_controller.dart b/lib/controller/directory/create_bucket_controller.dart new file mode 100644 index 0000000..80338d5 --- /dev/null +++ b/lib/controller/directory/create_bucket_controller.dart @@ -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 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()}"); + } +} diff --git a/lib/controller/directory/manage_bucket_controller.dart b/lib/controller/directory/manage_bucket_controller.dart new file mode 100644 index 0000000..7d2678f --- /dev/null +++ b/lib/controller/directory/manage_bucket_controller.dart @@ -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 allEmployees = [].obs; + RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + fetchAllEmployees(); + } + + Future 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(); + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 4dbeec6..bb7c500 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -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"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 75a3abf..411c594 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -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 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?> getDirectoryNotes({ int pageSize = 1000, int pageNumber = 1, diff --git a/lib/helpers/utils/contact_picker_helper.dart b/lib/helpers/utils/contact_picker_helper.dart new file mode 100644 index 0000000..73c2840 --- /dev/null +++ b/lib/helpers/utils/contact_picker_helper.dart @@ -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 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( + 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; + } +} diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index 230f161..8160964 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -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"; } diff --git a/lib/helpers/widgets/team_members_bottom_sheet.dart b/lib/helpers/widgets/team_members_bottom_sheet.dart new file mode 100644 index 0000000..be09523 --- /dev/null +++ b/lib/helpers/widgets/team_members_bottom_sheet.dart @@ -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 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 don’t 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), + ], + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 97d2e95..31e0f4a 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -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 { final addressController = TextEditingController(); final descriptionController = TextEditingController(); final tagTextController = TextEditingController(); - + final RxBool showAdvanced = false.obs; final RxList emailControllers = [].obs; final RxList emailLabels = [].obs; @@ -184,8 +185,23 @@ class _AddContactBottomSheetState extends State { 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 { 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 { 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((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 { ); }); } + + 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( + (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), + ], + ); + }), + ), + ); + } } diff --git a/lib/model/directory/create_bucket_bottom_sheet.dart b/lib/model/directory/create_bucket_bottom_sheet.dart new file mode 100644 index 0000000..51495c3 --- /dev/null +++ b/lib/model/directory/create_bucket_bottom_sheet.dart @@ -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 createState() => _CreateBucketBottomSheetState(); +} + +class _CreateBucketBottomSheetState extends State { + final BucketController _controller = Get.put(BucketController()); + final _formKey = GlobalKey(); + + 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( + 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(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(); + super.dispose(); + } +} diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 135da9f..5e1bcd3 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -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 createState() => _DirectoryViewState(); +} + +class _DirectoryViewState extends State { final DirectoryController controller = Get.find(); final TextEditingController searchController = TextEditingController(); + final PermissionController permissionController = + Get.put(PermissionController()); Future _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( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _buildEmptyBucketPrompt(), + ); + if (shouldCreate != true) return; + + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const CreateBucketBottomSheet(), + ); + if (created == true) { + await controller.fetchBuckets(); + } else { + return; + } + } + + Get.delete(); + + 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 don’t 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( - 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> menuItems = []; + + // Section: Actions (Always visible now) + menuItems.add( + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Actions", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + ); + + menuItems.add( + PopupMenuItem( + 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( + enabled: false, + height: 30, + child: Text( + "Preferences", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + ); + + menuItems.add( + PopupMenuItem( + 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), diff --git a/lib/view/directory/edit_bucket_model.dart b/lib/view/directory/edit_bucket_model.dart new file mode 100644 index 0000000..b4a331f --- /dev/null +++ b/lib/view/directory/edit_bucket_model.dart @@ -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), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/directory/manage_bucket_screen.dart b/lib/view/directory/manage_bucket_screen.dart new file mode 100644 index 0000000..a5a0ce7 --- /dev/null +++ b/lib/view/directory/manage_bucket_screen.dart @@ -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 createState() => _ManageBucketsScreenState(); +} + +class _ManageBucketsScreenState extends State { + final DirectoryController directoryController = Get.find(); + final ManageBucketController manageBucketController = + Get.put(ManageBucketController()); + final ProjectController projectController = Get.find(); + + final Map _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(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, + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }), + ), + ], + ), + ); + } +}