diff --git a/lib/controller/directory/manage_bucket_controller.dart b/lib/controller/directory/manage_bucket_controller.dart index 7d2678f..b4dd29e 100644 --- a/lib/controller/directory/manage_bucket_controller.dart +++ b/lib/controller/directory/manage_bucket_controller.dart @@ -2,35 +2,151 @@ 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'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; class ManageBucketController extends GetxController { RxList allEmployees = [].obs; RxBool isLoading = false.obs; + final DirectoryController directoryController = Get.find(); + @override void onInit() { super.onInit(); fetchAllEmployees(); } + Future updateBucket({ + required String id, + required String name, + required String description, + required List employeeIds, + required List originalEmployeeIds, + }) async { + isLoading(true); + update(); + + try { + final updated = await ApiService.updateBucket( + id: id, + name: name, + description: description, + ); + + if (!updated) { + showAppSnackbar( + title: "Update Failed", + message: "Unable to update bucket details.", + type: SnackbarType.error, + ); + isLoading(false); + update(); + return false; + } + + final allInvolvedIds = {...originalEmployeeIds, ...employeeIds}.toList(); + + final assignPayload = allInvolvedIds.map((empId) { + return { + "employeeId": empId, + "isActive": employeeIds.contains(empId), + }; + }).toList(); + + final assigned = await ApiService.assignEmployeesToBucket( + bucketId: id, + employees: assignPayload, + ); + + if (!assigned) { + showAppSnackbar( + title: "Assignment Failed", + message: "Employees couldn't be updated.", + type: SnackbarType.warning, + ); + } else { + showAppSnackbar( + title: "Success", + message: "Bucket updated successfully.", + type: SnackbarType.success, + ); + } + + return true; + } catch (e, stack) { + logSafe("Error in updateBucket: $e", level: LogLevel.error); + logSafe("Stack: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Unexpected Error", + message: "Please try again later.", + type: SnackbarType.error, + ); + return false; + } finally { + isLoading(false); + update(); + } + } + 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); + 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); + 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); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); } isLoading.value = false; update(); } + + Future deleteBucket(String bucketId) async { + isLoading.value = true; + update(); + + try { + final deleted = await ApiService.deleteBucket(bucketId); + if (deleted) { + directoryController.contactBuckets.removeWhere((b) => b.id == bucketId); + showAppSnackbar( + title: "Deleted", + message: "Bucket deleted successfully.", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Delete Failed", + message: "Unable to delete bucket.", + type: SnackbarType.error, + ); + } + } catch (e, stack) { + logSafe("Error deleting bucket: $e", level: LogLevel.error); + logSafe("Stack: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Unexpected Error", + message: "Failed to delete bucket.", + type: SnackbarType.error, + ); + } finally { + isLoading.value = false; + update(); + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index bb7c500..17328a6 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -44,4 +44,6 @@ class ApiEndpoints { static const String getDirectoryNotes = "/directory/notes"; static const String updateDirectoryNotes = "/directory/note"; static const String createBucket = "/directory/bucket"; + static const String updateBucket = "/directory/bucket"; + static const String assignBucket = "/directory/assign-bucket"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 411c594..260e2b0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -162,7 +162,8 @@ class ApiService { if (token == null) return null; final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); - logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body", + logSafe( + "POST $uri\nHeaders: ${_headers(token)}\nBody: $body", ); try { @@ -203,7 +204,9 @@ class ApiService { if (additionalHeaders != null) ...additionalHeaders, }; - logSafe("PUT $uri\nHeaders: $headers\nBody: $body", ); + logSafe( + "PUT $uri\nHeaders: $headers\nBody: $body", + ); try { final response = await http @@ -244,6 +247,124 @@ class ApiService { /// Directory calling the API + static Future deleteBucket(String id) async { + final endpoint = "${ApiEndpoints.updateBucket}/$id"; + + try { + final token = await _getToken(); + if (token == null) { + logSafe("Token is null. Cannot proceed with DELETE request.", + level: LogLevel.error); + return false; + } + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + + logSafe("Sending DELETE request to $uri", level: LogLevel.debug); + + final response = + await http.delete(uri, headers: _headers(token)).timeout(timeout); + + logSafe("DELETE bucket response status: ${response.statusCode}"); + logSafe("DELETE bucket response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (response.statusCode == 200 && json['success'] == true) { + logSafe("Bucket deleted successfully."); + return true; + } else { + logSafe( + "Failed to delete bucket: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during deleteBucket API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + static Future updateBucket({ + required String id, + required String name, + required String description, + }) async { + final payload = { + "id": id, + "name": name, + "description": description, + }; + + final endpoint = "${ApiEndpoints.updateBucket}/$id"; + + logSafe("Updating bucket with payload: $payload"); + + try { + final response = await _putRequest(endpoint, payload); + + if (response == null) { + logSafe("Update bucket failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Update bucket response status: ${response.statusCode}"); + logSafe("Update bucket response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Bucket updated successfully: ${json['data']}"); + return true; + } else { + logSafe("Failed to update bucket: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during updateBucket API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Assign employees to a bucket + static Future assignEmployeesToBucket({ + required String bucketId, + required List> employees, + }) async { + final endpoint = "${ApiEndpoints.assignBucket}/$bucketId"; + + logSafe("Assigning employees to bucket $bucketId: $employees"); + + try { + final response = await _postRequest(endpoint, employees); + + if (response == null) { + logSafe("Assign employees failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Assign employees response status: ${response.statusCode}"); + logSafe("Assign employees response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Employees assigned successfully"); + return true; + } else { + logSafe("Failed to assign employees: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during assignEmployeesToBucket API: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + static Future createBucket({ required String name, required String description, diff --git a/lib/helpers/widgets/team_members_bottom_sheet.dart b/lib/helpers/widgets/team_members_bottom_sheet.dart index be09523..6926df5 100644 --- a/lib/helpers/widgets/team_members_bottom_sheet.dart +++ b/lib/helpers/widgets/team_members_bottom_sheet.dart @@ -11,6 +11,14 @@ class TeamMembersBottomSheet { bool canEdit = false, VoidCallback? onEdit, }) { + // Ensure the owner is at the top of the list + final ownerId = bucket.createdBy.id; + members.sort((a, b) { + if (a.id == ownerId) return -1; + if (b.id == ownerId) return 1; + return 0; + }); + showModalBottomSheet( context: context, isScrollControlled: true, @@ -42,7 +50,7 @@ class TeamMembersBottomSheet { ), ), const SizedBox(height: 10), - // Title at top center + MyText.titleMedium( 'Bucket Details', fontWeight: 700, @@ -50,7 +58,7 @@ class TeamMembersBottomSheet { const SizedBox(height: 12), - // Header with title and optional edit button + // Header with title and edit Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -71,7 +79,7 @@ class TeamMembersBottomSheet { ), ), - // Bucket info + // Info Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( @@ -106,8 +114,6 @@ class TeamMembersBottomSheet { ), ], ), - - // Can edit indicator Padding( padding: const EdgeInsets.only(top: 8), child: Row( @@ -125,73 +131,7 @@ class TeamMembersBottomSheet { ], ), ), - - 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 SizedBox(height: 8), const Divider(thickness: 1), const SizedBox(height: 6), MyText.labelLarge( @@ -205,7 +145,6 @@ class TeamMembersBottomSheet { const SizedBox(height: 4), - // Members list Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -226,6 +165,8 @@ class TeamMembersBottomSheet { final member = members[index]; final firstName = member.firstName ?? ''; final lastName = member.lastName ?? ''; + final isOwner = + member.id == bucket.createdBy.id; return ListTile( dense: true, @@ -235,9 +176,32 @@ class TeamMembersBottomSheet { lastName: lastName, size: 32, ), - title: MyText.bodyMedium( - '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', - fontWeight: 600, + title: Row( + children: [ + Expanded( + child: MyText.bodyMedium( + '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', + fontWeight: 600, + ), + ), + if (isOwner) + Container( + margin: + const EdgeInsets.only(left: 6), + 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, + ), + ), + ], ), subtitle: MyText.bodySmall( member.jobRole ?? '', @@ -248,6 +212,7 @@ class TeamMembersBottomSheet { ), ), ), + const SizedBox(height: 8), ], ); diff --git a/lib/model/directory/edit_bucket_bottom_sheet.dart b/lib/model/directory/edit_bucket_bottom_sheet.dart new file mode 100644 index 0000000..30bbc3c --- /dev/null +++ b/lib/model/directory/edit_bucket_bottom_sheet.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/directory/manage_bucket_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/model/directory/contact_bucket_list_model.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:collection/collection.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class EditBucketBottomSheet { + static void show(BuildContext context, ContactBucket bucket, + List allEmployees) { + final ManageBucketController controller = Get.find(); + + final nameController = TextEditingController(text: bucket.name); + final descController = TextEditingController(text: bucket.description); + final searchController = TextEditingController(); + + final selectedIds = RxSet({...bucket.employeeIds}); + final searchText = ''.obs; + + InputDecoration _inputDecoration(String label) { + return InputDecoration( + labelText: label, + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + isDense: true, + ); + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + MySpacing.height(12), + Center( + child: MyText.titleMedium('Edit Bucket', fontWeight: 700), + ), + MySpacing.height(24), + + // Bucket Name + TextField( + controller: nameController, + decoration: _inputDecoration('Bucket Name'), + ), + MySpacing.height(16), + + // Description + TextField( + controller: descController, + maxLines: 2, + decoration: _inputDecoration('Description'), + ), + MySpacing.height(20), + + // Shared With + Align( + alignment: Alignment.centerLeft, + child: MyText.labelLarge('Shared With', fontWeight: 600), + ), + MySpacing.height(8), + + // Search + Obx(() => TextField( + controller: searchController, + onChanged: (value) => + searchText.value = value.toLowerCase(), + decoration: InputDecoration( + hintText: 'Search employee...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: searchText.value.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + searchController.clear(); + searchText.value = ''; + }, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + borderSide: + BorderSide(color: Colors.blueAccent, width: 1.5), + ), + ), + )), + MySpacing.height(8), + + // Employee list + Obx(() { + final filtered = allEmployees.where((emp) { + final fullName = + '${emp.firstName} ${emp.lastName}'.toLowerCase(); + return fullName.contains(searchText.value); + }).toList(); + + return SizedBox( + height: 180, + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final emp = filtered[index]; + final fullName = + '${emp.firstName} ${emp.lastName}'.trim(); + + return Obx(() => Theme( + data: Theme.of(context).copyWith( + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + side: const BorderSide( + color: Colors.black, width: 2), + fillColor: + MaterialStateProperty.resolveWith( + (states) { + if (states + .contains(MaterialState.selected)) { + return Colors.blueAccent; + } + return Colors.transparent; + }), + checkColor: + MaterialStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + visualDensity: + const VisualDensity(vertical: -4), + controlAffinity: + ListTileControlAffinity.leading, + value: selectedIds.contains(emp.id), + onChanged: (val) { + if (val == true) { + selectedIds.add(emp.id); + } else { + selectedIds.remove(emp.id); + } + }, + title: Text( + fullName.isNotEmpty ? fullName : 'Unnamed', + style: const TextStyle(fontSize: 13), + ), + ), + )); + }, + ), + ); + }), + + MySpacing.height(24), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", + color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 12), + ), + ), + ), + MySpacing.width(12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + final newName = nameController.text.trim(); + final newDesc = descController.text.trim(); + final newEmployeeIds = selectedIds.toList()..sort(); + final originalEmployeeIds = [...bucket.employeeIds] + ..sort(); + + final nameChanged = newName != bucket.name; + final descChanged = newDesc != bucket.description; + final employeeChanged = !(ListEquality() + .equals(newEmployeeIds, originalEmployeeIds)); + + if (!nameChanged && + !descChanged && + !employeeChanged) { + showAppSnackbar( + title: "No Changes", + message: + "No changes were made to update the bucket.", + type: SnackbarType.warning, + ); + return; + } + + final success = await controller.updateBucket( + id: bucket.id, + name: newName, + description: newDesc, + employeeIds: newEmployeeIds, + originalEmployeeIds: originalEmployeeIds, + ); + + if (success) { + final directoryController = + Get.find(); + await directoryController.fetchBuckets(); + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium("Save", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/directory/manage_bucket_screen.dart b/lib/view/directory/manage_bucket_screen.dart index a5a0ce7..3c63ec4 100644 --- a/lib/view/directory/manage_bucket_screen.dart +++ b/lib/view/directory/manage_bucket_screen.dart @@ -8,6 +8,7 @@ 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'; +import 'package:marco/model/directory/edit_bucket_bottom_sheet.dart'; class ManageBucketsScreen extends StatefulWidget { final PermissionController permissionController; @@ -93,199 +94,237 @@ class _ManageBucketsScreenState extends State { ), ), ), - 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), + body: SafeArea( + child: 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(); + 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 (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, + if (buckets.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, 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( + 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.back(); + Future.delayed(const Duration(milliseconds: 300), + () { + EditBucketBottomSheet.show(context, bucket, + manageBucketController.allEmployees); + }); + }, + ); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Expanded( + child: MyText.titleSmall( + bucket.name, + fontWeight: 700, + overflow: TextOverflow.ellipsis, + ), + ), + if (canEdit) + IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.red), + onPressed: () { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular( + 16), + ), + title: Row( + children: const [ + Icon( + Icons + .warning_amber_rounded, + color: Colors.red), + SizedBox(width: 8), + Text("Delete Bucket", + style: TextStyle( + fontWeight: + FontWeight + .bold)), + ], + ), + content: const Text( + "Are you sure you want to delete this bucket? This action cannot be undone.", + style: + TextStyle(fontSize: 15), + ), + actionsPadding: + const EdgeInsets + .symmetric( + horizontal: 12, + vertical: 8), + actions: [ + TextButton( + onPressed: () => + Navigator.of(ctx) + .pop(), + child: + const Text("Cancel"), + ), + ElevatedButton.icon( + style: ElevatedButton + .styleFrom( + foregroundColor: + Colors.white, + backgroundColor: + Colors.red, + shape: + RoundedRectangleBorder( + borderRadius: + BorderRadius + .circular(8), + ), + ), + icon: const Icon( + Icons.delete), + label: + const Text("Delete"), + onPressed: () { + Navigator.of(ctx).pop(); + manageBucketController + .deleteBucket( + bucket.id); + }, + ), + ], + ), + ); + }, + ) + ], + ), + if (!canEdit) MySpacing.height(12), + ], + ), + 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), + 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, + ), + ], + ), + MySpacing.height(4), + if (bucket.description.isNotEmpty) + LayoutBuilder( builder: (context, constraints) { final span = TextSpan( text: bucket.description, @@ -299,7 +338,6 @@ class _ManageBucketsScreenState extends State { maxLines: 2, textDirection: TextDirection.ltr, )..layout(maxWidth: constraints.maxWidth); - final hasOverflow = tp.didExceedMaxLines; return Column( @@ -338,18 +376,18 @@ class _ManageBucketsScreenState extends State { ); }, ), - ), - ], + ], + ), ), - ), - ], - ), - ); - }, - ); - }), - ), - ], + ], + ), + ); + }, + ); + }), + ), + ], + ), ), ); }