Compare commits

..

14 Commits

29 changed files with 2910 additions and 1983 deletions

View File

@ -10,7 +10,7 @@ class AddContactController extends GetxController {
final RxList<String> tags = <String>[].obs; final RxList<String> tags = <String>[].obs;
final RxString selectedCategory = ''.obs; final RxString selectedCategory = ''.obs;
final RxString selectedBucket = ''.obs; final RxList<String> selectedBuckets = <String>[].obs;
final RxString selectedProject = ''.obs; final RxString selectedProject = ''.obs;
final RxList<String> enteredTags = <String>[].obs; final RxList<String> enteredTags = <String>[].obs;
@ -50,7 +50,7 @@ class AddContactController extends GetxController {
void resetForm() { void resetForm() {
selectedCategory.value = ''; selectedCategory.value = '';
selectedProject.value = ''; selectedProject.value = '';
selectedBucket.value = ''; selectedBuckets.clear();
enteredTags.clear(); enteredTags.clear();
filteredSuggestions.clear(); filteredSuggestions.clear();
filteredOrgSuggestions.clear(); filteredOrgSuggestions.clear();
@ -100,7 +100,21 @@ class AddContactController extends GetxController {
isSubmitting.value = true; isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value]; final categoryId = categoriesMap[selectedCategory.value];
final bucketId = bucketsMap[selectedBucket.value]; final bucketIds = selectedBuckets
.map((name) => bucketsMap[name])
.whereType<String>()
.toList();
if (bucketIds.isEmpty) {
showAppSnackbar(
title: "Missing Buckets",
message: "Please select at least one bucket.",
type: SnackbarType.warning,
);
isSubmitting.value = false;
return;
}
final projectIds = selectedProjects final projectIds = selectedProjects
.map((name) => projectsMap[name]) .map((name) => projectsMap[name])
.whereType<String>() .whereType<String>()
@ -126,10 +140,10 @@ class AddContactController extends GetxController {
return; return;
} }
if (selectedBucket.value.trim().isEmpty || bucketId == null) { if (selectedBuckets.isEmpty) {
showAppSnackbar( showAppSnackbar(
title: "Missing Bucket", title: "Missing Bucket",
message: "Please select a bucket.", message: "Please select at least one bucket.",
type: SnackbarType.warning, type: SnackbarType.warning,
); );
isSubmitting.value = false; isSubmitting.value = false;
@ -151,7 +165,7 @@ class AddContactController extends GetxController {
if (selectedCategory.value.isNotEmpty && categoryId != null) if (selectedCategory.value.isNotEmpty && categoryId != null)
"contactCategoryId": categoryId, "contactCategoryId": categoryId,
if (projectIds.isNotEmpty) "projectIds": projectIds, if (projectIds.isNotEmpty) "projectIds": projectIds,
"bucketIds": [bucketId], "bucketIds": bucketIds,
if (enteredTags.isNotEmpty) "tags": tagObjects, if (enteredTags.isNotEmpty) "tags": tagObjects,
if (emails.isNotEmpty) "contactEmails": emails, if (emails.isNotEmpty) "contactEmails": emails,
if (phones.isNotEmpty) "contactPhones": phones, if (phones.isNotEmpty) "contactPhones": phones,

View File

@ -1,12 +1,13 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/model/directory/contact_model.dart';
import 'package:marco/model/directory/contact_bucket_list_model.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart';
import 'package:marco/model/directory/directory_comment_model.dart'; import 'package:marco/model/directory/directory_comment_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DirectoryController extends GetxController { class DirectoryController extends GetxController {
// -------------------- CONTACTS --------------------
RxList<ContactModel> allContacts = <ContactModel>[].obs; RxList<ContactModel> allContacts = <ContactModel>[].obs;
RxList<ContactModel> filteredContacts = <ContactModel>[].obs; RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
RxList<ContactCategory> contactCategories = <ContactCategory>[].obs; RxList<ContactCategory> contactCategories = <ContactCategory>[].obs;
@ -16,16 +17,10 @@ class DirectoryController extends GetxController {
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs; RxList<ContactBucket> contactBuckets = <ContactBucket>[].obs;
RxString searchQuery = ''.obs; RxString searchQuery = ''.obs;
RxBool showFabMenu = false.obs;
final RxBool showFullEditorToolbar = false.obs;
final RxBool isEditorFocused = false.obs;
RxBool isNotesView = false.obs;
final Map<String, RxList<DirectoryComment>> contactCommentsMap = {};
RxList<DirectoryComment> getCommentsForContact(String contactId) {
return contactCommentsMap[contactId] ?? <DirectoryComment>[].obs;
}
// -------------------- COMMENTS --------------------
final Map<String, RxList<DirectoryComment>> activeCommentsMap = {};
final Map<String, RxList<DirectoryComment>> inactiveCommentsMap = {};
final editingCommentId = Rxn<String>(); final editingCommentId = Rxn<String>();
@override @override
@ -34,26 +29,75 @@ class DirectoryController extends GetxController {
fetchContacts(); fetchContacts();
fetchBuckets(); fetchBuckets();
} }
// inside DirectoryController
// -------------------- COMMENTS HANDLING --------------------
RxList<DirectoryComment> getCommentsForContact(String contactId,
{bool active = true}) {
return active
? activeCommentsMap[contactId] ?? <DirectoryComment>[].obs
: inactiveCommentsMap[contactId] ?? <DirectoryComment>[].obs;
}
Future<void> fetchCommentsForContact(String contactId,
{bool active = true}) async {
try {
final data =
await ApiService.getDirectoryComments(contactId, active: active);
var comments =
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
// Deduplicate by ID before storing
final Map<String, DirectoryComment> uniqueMap = {
for (var c in comments) c.id: c,
};
comments = uniqueMap.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
if (active) {
activeCommentsMap[contactId] = <DirectoryComment>[].obs
..assignAll(comments);
} else {
inactiveCommentsMap[contactId] = <DirectoryComment>[].obs
..assignAll(comments);
}
} catch (e, stack) {
logSafe("Error fetching ${active ? 'active' : 'inactive'} comments: $e",
level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
if (active) {
activeCommentsMap[contactId] = <DirectoryComment>[].obs;
} else {
inactiveCommentsMap[contactId] = <DirectoryComment>[].obs;
}
}
}
List<DirectoryComment> combinedComments(String contactId) {
final activeList = getCommentsForContact(contactId, active: true);
final inactiveList = getCommentsForContact(contactId, active: false);
// Deduplicate by ID (active wins)
final Map<String, DirectoryComment> byId = {};
for (final c in inactiveList) {
byId[c.id] = c;
}
for (final c in activeList) {
byId[c.id] = c;
}
final combined = byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return combined;
}
Future<void> updateComment(DirectoryComment comment) async { Future<void> updateComment(DirectoryComment comment) async {
try { try {
logSafe( final existing = getCommentsForContact(comment.contactId)
"Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}"); .firstWhereOrNull((c) => c.id == comment.id);
final commentList = contactCommentsMap[comment.contactId]; if (existing != null && existing.note.trim() == comment.note.trim()) {
final oldComment =
commentList?.firstWhereOrNull((c) => c.id == comment.id);
if (oldComment == null) {
logSafe("Old comment not found. id: ${comment.id}");
} else {
logSafe("Old comment note: ${oldComment.note}");
logSafe("New comment note: ${comment.note}");
}
if (oldComment != null && oldComment.note.trim() == comment.note.trim()) {
logSafe("No changes detected in comment. id: ${comment.id}");
showAppSnackbar( showAppSnackbar(
title: "No Changes", title: "No Changes",
message: "No changes were made to the comment.", message: "No changes were made to the comment.",
@ -63,32 +107,26 @@ class DirectoryController extends GetxController {
} }
final success = await ApiService.updateContactComment( final success = await ApiService.updateContactComment(
comment.id, comment.id, comment.note, comment.contactId);
comment.note,
comment.contactId,
);
if (success) { if (success) {
logSafe("Comment updated successfully. id: ${comment.id}"); await fetchCommentsForContact(comment.contactId, active: true);
await fetchCommentsForContact(comment.contactId); await fetchCommentsForContact(comment.contactId, active: false);
// Show success message
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "Comment updated successfully.", message: "Comment updated successfully.",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Failed to update comment via API. id: ${comment.id}");
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to update comment.", message: "Failed to update comment.",
type: SnackbarType.error, type: SnackbarType.error,
); );
} }
} catch (e, stackTrace) { } catch (e, stack) {
logSafe("Update comment failed: ${e.toString()}"); logSafe("Update comment failed: $e", level: LogLevel.error);
logSafe("StackTrace: ${stackTrace.toString()}"); logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to update comment.", message: "Failed to update comment.",
@ -97,53 +135,20 @@ class DirectoryController extends GetxController {
} }
} }
Future<void> fetchCommentsForContact(String contactId,
{bool active = true}) async {
try {
final data =
await ApiService.getDirectoryComments(contactId, active: active);
logSafe(
"Fetched ${active ? 'active' : 'inactive'} comments for contact $contactId: $data");
final comments =
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
if (!contactCommentsMap.containsKey(contactId)) {
contactCommentsMap[contactId] = <DirectoryComment>[].obs;
}
contactCommentsMap[contactId]!.assignAll(comments);
contactCommentsMap[contactId]?.refresh();
} catch (e) {
logSafe(
"Error fetching ${active ? 'active' : 'inactive'} comments for contact $contactId: $e",
level: LogLevel.error);
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
contactCommentsMap[contactId]!.clear();
}
}
/// 🗑 Delete a comment (soft delete)
Future<void> deleteComment(String commentId, String contactId) async { Future<void> deleteComment(String commentId, String contactId) async {
try { try {
logSafe("Deleting comment. id: $commentId");
final success = await ApiService.restoreContactComment(commentId, false); final success = await ApiService.restoreContactComment(commentId, false);
if (success) { if (success) {
logSafe("Comment deleted successfully. id: $commentId"); if (editingCommentId.value == commentId) editingCommentId.value = null;
await fetchCommentsForContact(contactId, active: true);
// Refresh comments after deletion await fetchCommentsForContact(contactId, active: false);
await fetchCommentsForContact(contactId);
showAppSnackbar( showAppSnackbar(
title: "Deleted", title: "Deleted",
message: "Comment deleted successfully.", message: "Comment deleted successfully.",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Failed to delete comment via API. id: $commentId");
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to delete comment.", message: "Failed to delete comment.",
@ -151,8 +156,8 @@ class DirectoryController extends GetxController {
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe("Delete comment failed: ${e.toString()}", level: LogLevel.error); logSafe("Delete comment failed: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Something went wrong while deleting comment.", message: "Something went wrong while deleting comment.",
@ -161,26 +166,19 @@ class DirectoryController extends GetxController {
} }
} }
/// Restore a previously deleted comment
Future<void> restoreComment(String commentId, String contactId) async { Future<void> restoreComment(String commentId, String contactId) async {
try { try {
logSafe("Restoring comment. id: $commentId");
final success = await ApiService.restoreContactComment(commentId, true); final success = await ApiService.restoreContactComment(commentId, true);
if (success) { if (success) {
logSafe("Comment restored successfully. id: $commentId"); await fetchCommentsForContact(contactId, active: true);
await fetchCommentsForContact(contactId, active: false);
// Refresh comments after restore
await fetchCommentsForContact(contactId);
showAppSnackbar( showAppSnackbar(
title: "Restored", title: "Restored",
message: "Comment restored successfully.", message: "Comment restored successfully.",
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
logSafe("Failed to restore comment via API. id: $commentId");
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Failed to restore comment.", message: "Failed to restore comment.",
@ -188,8 +186,8 @@ class DirectoryController extends GetxController {
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe("Restore comment failed: ${e.toString()}", level: LogLevel.error); logSafe("Restore comment failed: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
message: "Something went wrong while restoring comment.", message: "Something went wrong while restoring comment.",
@ -198,6 +196,8 @@ class DirectoryController extends GetxController {
} }
} }
// -------------------- CONTACTS HANDLING --------------------
Future<void> fetchBuckets() async { Future<void> fetchBuckets() async {
try { try {
final response = await ApiService.getContactBucketList(); final response = await ApiService.getContactBucketList();
@ -213,11 +213,71 @@ class DirectoryController extends GetxController {
logSafe("Bucket fetch error: $e", level: LogLevel.error); logSafe("Bucket fetch error: $e", level: LogLevel.error);
} }
} }
// -------------------- CONTACT DELETION / RESTORE --------------------
Future<void> deleteContact(String contactId) async {
try {
final success = await ApiService.deleteDirectoryContact(contactId);
if (success) {
// Refresh contacts after deletion
await fetchContacts(active: true);
await fetchContacts(active: false);
showAppSnackbar(
title: "Deleted",
message: "Contact deleted successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to delete contact.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Delete contact failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while deleting contact.",
type: SnackbarType.error,
);
}
}
Future<void> restoreContact(String contactId) async {
try {
final success = await ApiService.restoreDirectoryContact(contactId);
if (success) {
// Refresh contacts after restore
await fetchContacts(active: true);
await fetchContacts(active: false);
showAppSnackbar(
title: "Restored",
message: "Contact restored successfully.",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to restore contact.",
type: SnackbarType.error,
);
}
} catch (e, stack) {
logSafe("Restore contact failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while restoring contact.",
type: SnackbarType.error,
);
}
}
Future<void> fetchContacts({bool active = true}) async { Future<void> fetchContacts({bool active = true}) async {
try { try {
isLoading.value = true; isLoading.value = true;
final response = await ApiService.getDirectoryData(isActive: active); final response = await ApiService.getDirectoryData(isActive: active);
if (response != null) { if (response != null) {
@ -238,14 +298,12 @@ class DirectoryController extends GetxController {
void extractCategoriesFromContacts() { void extractCategoriesFromContacts() {
final uniqueCategories = <String, ContactCategory>{}; final uniqueCategories = <String, ContactCategory>{};
for (final contact in allContacts) { for (final contact in allContacts) {
final category = contact.contactCategory; final category = contact.contactCategory;
if (category != null && !uniqueCategories.containsKey(category.id)) { if (category != null) {
uniqueCategories[category.id] = category; uniqueCategories.putIfAbsent(category.id, () => category);
} }
} }
contactCategories.value = uniqueCategories.values.toList(); contactCategories.value = uniqueCategories.values.toList();
} }
@ -270,6 +328,7 @@ class DirectoryController extends GetxController {
contact.tags.any((tag) => tag.name.toLowerCase().contains(query)); contact.tags.any((tag) => tag.name.toLowerCase().contains(query));
final categoryNameMatch = final categoryNameMatch =
contact.contactCategory?.name.toLowerCase().contains(query) ?? false; contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
final bucketNameMatch = contact.bucketIds.any((id) { final bucketNameMatch = contact.bucketIds.any((id) {
final bucketName = contactBuckets final bucketName = contactBuckets
.firstWhereOrNull((b) => b.id == id) .firstWhereOrNull((b) => b.id == id)
@ -291,7 +350,6 @@ class DirectoryController extends GetxController {
return categoryMatch && bucketMatch && searchMatch; return categoryMatch && bucketMatch && searchMatch;
}).toList(); }).toList();
// 🔑 Ensure results are always alphabetically sorted
filteredContacts filteredContacts
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
} }

View File

@ -4,6 +4,7 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
class DailyTaskController extends GetxController { class DailyTaskController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
@ -23,6 +24,12 @@ class DailyTaskController extends GetxController {
} }
} }
RxSet<String> selectedBuildings = <String>{}.obs;
RxSet<String> selectedFloors = <String>{}.obs;
RxSet<String> selectedActivities = <String>{}.obs;
RxSet<String> selectedServices = <String>{}.obs;
RxBool isFilterLoading = false.obs;
RxBool isLoading = true.obs; RxBool isLoading = true.obs;
RxBool isLoadingMore = false.obs; RxBool isLoadingMore = false.obs;
Map<String, List<TaskModel>> groupedDailyTasks = {}; Map<String, List<TaskModel>> groupedDailyTasks = {};
@ -51,9 +58,18 @@ class DailyTaskController extends GetxController {
); );
} }
void clearTaskFilters() {
selectedBuildings.clear();
selectedFloors.clear();
selectedActivities.clear();
selectedServices.clear();
startDateTask = null;
endDateTask = null;
update();
}
Future<void> fetchTaskData( Future<void> fetchTaskData(
String projectId, { String projectId, {
List<String>? serviceIds,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 20, int pageSize = 20,
bool isLoadMore = false, bool isLoadMore = false,
@ -68,11 +84,19 @@ class DailyTaskController extends GetxController {
isLoadingMore.value = true; isLoadingMore.value = true;
} }
// Create the filter object
final filter = {
"buildingIds": selectedBuildings.toList(),
"floorIds": selectedFloors.toList(),
"activityIds": selectedActivities.toList(),
"serviceIds": selectedServices.toList(),
"dateFrom": startDateTask?.toIso8601String(),
"dateTo": endDateTask?.toIso8601String(),
};
final response = await ApiService.getDailyTasks( final response = await ApiService.getDailyTasks(
projectId, projectId,
dateFrom: startDateTask, filter: filter,
dateTo: endDateTask,
serviceIds: serviceIds,
pageNumber: pageNumber, pageNumber: pageNumber,
pageSize: pageSize, pageSize: pageSize,
); );
@ -95,6 +119,35 @@ class DailyTaskController extends GetxController {
update(); update();
} }
FilterData? taskFilterData;
Future<void> fetchTaskFilter(String projectId) async {
isFilterLoading.value = true;
try {
final filterResponse = await ApiService.getDailyTaskFilter(projectId);
if (filterResponse != null && filterResponse.success) {
taskFilterData =
filterResponse.data; // now taskFilterData is FilterData?
logSafe(
"Task filter fetched successfully. Buildings: ${taskFilterData?.buildings.length}, Floors: ${taskFilterData?.floors.length}",
level: LogLevel.info,
);
} else {
logSafe(
"Failed to fetch task filter for projectId: $projectId",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception in fetchTaskFilter: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isFilterLoading.value = false;
update();
}
}
Future<void> selectDateRangeForTaskData( Future<void> selectDateRangeForTaskData(
BuildContext context, BuildContext context,
DailyTaskController controller, DailyTaskController controller,

View File

@ -9,33 +9,28 @@ import 'package:marco/model/employees/employee_model.dart';
class DailyTaskPlanningController extends GetxController { class DailyTaskPlanningController extends GetxController {
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
List<EmployeeModel> employees = []; RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs; RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = [];
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
List<Map<String, dynamic>> roles = []; List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs; RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString(); RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs; RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchRoles(); fetchRoles();
_initializeDefaults();
}
void _initializeDefaults() {
fetchProjects();
} }
String? formFieldValidator(String? value, {required String fieldType}) { String? formFieldValidator(String? value, {required String fieldType}) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) return 'This field is required';
return 'This field is required';
}
if (fieldType == "target" && int.tryParse(value.trim()) == null) { if (fieldType == "target" && int.tryParse(value.trim()) == null) {
return 'Please enter a valid number'; return 'Please enter a valid number';
} }
@ -46,9 +41,8 @@ class DailyTaskPlanningController extends GetxController {
} }
void updateSelectedEmployees() { void updateSelectedEmployees() {
final selected = selectedEmployees.value =
employees.where((e) => uploadingStates[e.id]?.value == true).toList(); employees.where((e) => uploadingStates[e.id]?.value == true).toList();
selectedEmployees.value = selected;
logSafe("Updated selected employees", level: LogLevel.debug); logSafe("Updated selected employees", level: LogLevel.debug);
} }
@ -75,6 +69,8 @@ class DailyTaskPlanningController extends GetxController {
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async { }) async {
isAssigningTask.value = true; isAssigningTask.value = true;
logSafe("Starting assign task...", level: LogLevel.info); logSafe("Starting assign task...", level: LogLevel.info);
@ -85,6 +81,8 @@ class DailyTaskPlanningController extends GetxController {
description: description, description: description,
taskTeam: taskTeam, taskTeam: taskTeam,
assignmentDate: assignmentDate, assignmentDate: assignmentDate,
organizationId: organizationId,
serviceId: serviceId,
); );
isAssigningTask.value = false; isAssigningTask.value = false;
@ -108,70 +106,42 @@ class DailyTaskPlanningController extends GetxController {
} }
} }
Future<void> fetchProjects() async {
isLoading.value = true;
try {
final response = await ApiService.getProjects();
if (response?.isEmpty ?? true) {
logSafe("No project data found or API call failed",
level: LogLevel.warning);
return;
}
projects = response!.map((json) => ProjectModel.fromJson(json)).toList();
logSafe("Projects fetched: ${projects.length} projects loaded",
level: LogLevel.info);
update();
} catch (e, stack) {
logSafe("Error fetching projects",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
}
}
/// Fetch Infra details and then tasks per work area /// Fetch Infra details and then tasks per work area
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async { Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) { if (projectId == null) return;
logSafe("Project ID is null", level: LogLevel.warning);
return;
}
isLoading.value = true; isFetchingTasks.value = true;
try { try {
// Fetch infra details final infraResponse = await ApiService.getInfraDetails(
final infraResponse = await ApiService.getInfraDetails(projectId); projectId,
serviceId: serviceId,
);
final infraData = infraResponse?['data'] as List<dynamic>?; final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) { if (infraData == null || infraData.isEmpty) {
logSafe("No infra data found for project $projectId",
level: LogLevel.warning);
dailyTasks = []; dailyTasks = [];
return; return;
} }
// Map infra to dailyTasks structure
dailyTasks = infraData.map((buildingJson) { dailyTasks = infraData.map((buildingJson) {
final building = Building( final building = Building(
id: buildingJson['id'], id: buildingJson['id'],
name: buildingJson['buildingName'], name: buildingJson['buildingName'],
description: buildingJson['description'], description: buildingJson['description'],
floors: (buildingJson['floors'] as List<dynamic>).map((floorJson) { floors: (buildingJson['floors'] as List<dynamic>)
return Floor( .map((floorJson) => Floor(
id: floorJson['id'], id: floorJson['id'],
floorName: floorJson['floorName'], floorName: floorJson['floorName'],
workAreas: workAreas: (floorJson['workAreas'] as List<dynamic>)
(floorJson['workAreas'] as List<dynamic>).map((areaJson) { .map((areaJson) => WorkArea(
return WorkArea(
id: areaJson['id'], id: areaJson['id'],
areaName: areaJson['areaName'], areaName: areaJson['areaName'],
workItems: [], // Will fill after tasks API workItems: [],
))
.toList(),
))
.toList(),
); );
}).toList(),
);
}).toList(),
);
return TaskPlanningDetailsModel( return TaskPlanningDetailsModel(
id: building.id, id: building.id,
name: building.name, name: building.name,
@ -184,21 +154,16 @@ class DailyTaskPlanningController extends GetxController {
); );
}).toList(); }).toList();
// Fetch tasks for each work area, passing serviceId only if selected
await Future.wait(dailyTasks await Future.wait(dailyTasks
.expand((task) => task.buildings) .expand((task) => task.buildings)
.expand((b) => b.floors) .expand((b) => b.floors)
.expand((f) => f.workAreas) .expand((f) => f.workAreas)
.map((area) async { .map((area) async {
try { try {
final taskResponse = await ApiService.getWorkItemsByWorkArea( final taskResponse =
area.id, await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId);
// serviceId: serviceId, // <-- only pass if not null
);
final taskData = taskResponse?['data'] as List<dynamic>? ?? []; final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
area.workItems.addAll(taskData.map((taskJson) => WorkItemWrapper(
area.workItems.addAll(taskData.map((taskJson) {
return WorkItemWrapper(
workItemId: taskJson['id'], workItemId: taskJson['id'],
workItem: WorkItem( workItem: WorkItem(
id: taskJson['id'], id: taskJson['id'],
@ -206,73 +171,93 @@ class DailyTaskPlanningController extends GetxController {
? ActivityMaster.fromJson(taskJson['activityMaster']) ? ActivityMaster.fromJson(taskJson['activityMaster'])
: null, : null,
workCategoryMaster: taskJson['workCategoryMaster'] != null workCategoryMaster: taskJson['workCategoryMaster'] != null
? WorkCategoryMaster.fromJson( ? WorkCategoryMaster.fromJson(taskJson['workCategoryMaster'])
taskJson['workCategoryMaster'])
: null, : null,
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(), plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
completedWork: (taskJson['completedWork'] as num?)?.toDouble(), completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
todaysAssigned: todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(),
(taskJson['todaysAssigned'] as num?)?.toDouble(),
description: taskJson['description'] as String?, description: taskJson['description'] as String?,
taskDate: taskJson['taskDate'] != null taskDate: taskJson['taskDate'] != null
? DateTime.tryParse(taskJson['taskDate']) ? DateTime.tryParse(taskJson['taskDate'])
: null, : null,
), ),
); )));
}));
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching tasks for work area ${area.id}", logSafe("Error fetching tasks for work area ${area.id}",
level: LogLevel.error, error: e, stackTrace: stack); level: LogLevel.error, error: e, stackTrace: stack);
} }
})); }));
logSafe("Fetched infra and tasks for project $projectId",
level: LogLevel.info);
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching daily task data", logSafe("Error fetching daily task data",
level: LogLevel.error, error: e, stackTrace: stack); level: LogLevel.error, error: e, stackTrace: stack);
} finally { } finally {
isLoading.value = false; isFetchingTasks.value = false;
update(); update();
} }
} }
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProjectService({
if (projectId == null || projectId.isEmpty) { required String projectId,
logSafe("Project ID is required but was null or empty", String? serviceId,
level: LogLevel.error); String? organizationId,
return; }) async {
isFetchingEmployees.value = true;
try {
final response = await ApiService.getEmployeesByProjectService(
projectId,
serviceId: serviceId ?? '',
organizationId: organizationId ?? '',
);
if (response != null && response.isNotEmpty) {
employees.assignAll(response.map((json) => EmployeeModel.fromJson(json)));
if (serviceId == null && organizationId == null) {
allEmployeesCache = List.from(employees);
} }
isLoading.value = true; final currentEmployeeIds = employees.map((e) => e.id).toSet();
try {
final response = await ApiService.getAllEmployeesByProject(projectId); uploadingStates.removeWhere((key, _) => !currentEmployeeIds.contains(key));
if (response != null && response.isNotEmpty) { employees.forEach((emp) {
employees = uploadingStates.putIfAbsent(emp.id, () => false.obs);
response.map((json) => EmployeeModel.fromJson(json)).toList(); });
for (var emp in employees) {
uploadingStates[emp.id] = false.obs; selectedEmployees.removeWhere((e) => !currentEmployeeIds.contains(e.id));
}
logSafe( logSafe("Employees fetched: ${employees.length}", level: LogLevel.info);
"Employees fetched: ${employees.length} for project $projectId",
level: LogLevel.info,
);
} else { } else {
employees = []; employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
logSafe( logSafe(
"No employees found for project $projectId", serviceId != null || organizationId != null
? "Filtered employees empty"
: "No employees found",
level: LogLevel.warning, level: LogLevel.warning,
); );
} }
} catch (e, stack) { } catch (e, stack) {
logSafe( logSafe("Error fetching employees", level: LogLevel.error, error: e, stackTrace: stack);
"Error fetching employees for project $projectId",
level: LogLevel.error, if (serviceId == null && organizationId == null && allEmployeesCache.isNotEmpty) {
error: e, employees.assignAll(allEmployeesCache);
stackTrace: stack,
); final cachedEmployeeIds = employees.map((e) => e.id).toSet();
uploadingStates.removeWhere((key, _) => !cachedEmployeeIds.contains(key));
employees.forEach((emp) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
});
selectedEmployees.removeWhere((e) => !cachedEmployeeIds.contains(e.id));
} else {
employees.clear();
uploadingStates.clear();
selectedEmployees.clear();
}
} finally { } finally {
isLoading.value = false; isFetchingEmployees.value = false;
update(); update();
} }
} }

View File

@ -0,0 +1,66 @@
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/all_organization_model.dart';
class AllOrganizationController extends GetxController {
RxList<AllOrganization> organizations = <AllOrganization>[].obs;
Rxn<AllOrganization> selectedOrganization = Rxn<AllOrganization>();
final isLoadingOrganizations = false.obs;
String? passedOrgId;
AllOrganizationController({this.passedOrgId});
@override
void onInit() {
super.onInit();
fetchAllOrganizations();
}
Future<void> fetchAllOrganizations() async {
try {
isLoadingOrganizations.value = true;
final response = await ApiService.getAllOrganizations();
if (response != null && response.data.data.isNotEmpty) {
organizations.value = response.data.data;
// Select organization based on passed ID, or fallback to first
if (passedOrgId != null) {
selectedOrganization.value =
organizations.firstWhere(
(org) => org.id == passedOrgId,
orElse: () => organizations.first,
);
} else {
selectedOrganization.value ??= organizations.first;
}
} else {
organizations.clear();
selectedOrganization.value = null;
}
} catch (e, stackTrace) {
logSafe(
"Failed to fetch organizations: $e",
level: LogLevel.error,
error: e,
stackTrace: stackTrace,
);
organizations.clear();
selectedOrganization.value = null;
} finally {
isLoadingOrganizations.value = false;
}
}
void selectOrganization(AllOrganization? org) {
selectedOrganization.value = org;
}
void clearSelection() {
selectedOrganization.value = null;
}
String get currentSelection => selectedOrganization.value?.name ?? "All Organizations";
}

View File

@ -22,6 +22,7 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployeesByOrganization = "/project/get/task/team";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
@ -41,6 +42,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve"; static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task"; static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories"; static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints /////// ////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory"; static const String getDirectoryContacts = "/directory";
@ -52,6 +54,8 @@ class ApiEndpoints {
static const String getDirectoryOrganization = "/directory/organization"; static const String getDirectoryOrganization = "/directory/organization";
static const String createContact = "/directory"; static const String createContact = "/directory";
static const String updateContact = "/directory"; static const String updateContact = "/directory";
static const String deleteContact = "/directory";
static const String restoreContact = "/directory/note";
static const String getDirectoryNotes = "/directory/notes"; static const String getDirectoryNotes = "/directory/notes";
static const String updateDirectoryNotes = "/directory/note"; static const String updateDirectoryNotes = "/directory/note";
static const String createBucket = "/directory/bucket"; static const String createBucket = "/directory/bucket";
@ -93,5 +97,7 @@ class ApiEndpoints {
static const String getAssignedOrganizations = static const String getAssignedOrganizations =
"/project/get/assigned/organization"; "/project/get/assigned/organization";
static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAssignedServices = "/Project/get/assigned/services";
} }

View File

@ -21,6 +21,8 @@ import 'package:marco/model/document/document_version_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.dart'; import 'package:marco/model/tenant/tenant_services_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
import 'package:marco/model/all_organization_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -320,6 +322,32 @@ class ApiService {
return null; return null;
} }
static Future<AllOrganizationListResponse?> getAllOrganizations() async {
final endpoint = "${ApiEndpoints.getAllOrganizations}";
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("All Organizations request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "All Organizations");
if (jsonResponse != null) {
return AllOrganizationListResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getAllOrganizations: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
//// Get Services assigned to a Project //// Get Services assigned to a Project
static Future<ServiceListResponse?> getAssignedServices( static Future<ServiceListResponse?> getAssignedServices(
String projectId) async { String projectId) async {
@ -1785,6 +1813,52 @@ class ApiService {
return data is List ? data : null; return data is List ? data : null;
} }
/// Deletes a directory contact (sets active=false)
static Future<bool> deleteDirectoryContact(String contactId) async {
final endpoint = "${ApiEndpoints.updateContact}/$contactId/";
final queryParams = {'active': 'false'};
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("Deleting directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=false",
);
if (response != null && response.statusCode == 200) {
_log("Contact deleted successfully: ${response.body}");
return true;
}
_log("Failed to delete contact: ${response?.body}");
return false;
}
/// Restores a directory contact (sets active=true)
static Future<bool> restoreDirectoryContact(String contactId) async {
final endpoint = "${ApiEndpoints.updateContact}/$contactId/";
final queryParams = {'active': 'true'};
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
.replace(queryParameters: queryParams);
_log("Restoring directory contact at $uri");
final response = await _deleteRequest(
"$endpoint?active=true",
);
if (response != null && response.statusCode == 200) {
_log("Contact restored successfully: ${response.body}");
return true;
}
_log("Failed to restore contact: ${response?.body}");
return false;
}
static Future<bool> updateContact( static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async { String contactId, Map<String, dynamic> payload) async {
try { try {
@ -2055,6 +2129,36 @@ class ApiService {
); );
} }
/// Fetches employees by projectId, serviceId, and organizationId
static Future<List<dynamic>?> getEmployeesByProjectService(
String projectId, {
String? serviceId,
String? organizationId,
}) async {
if (projectId.isEmpty) {
throw ArgumentError('projectId must not be empty');
}
// Construct query parameters only if non-empty
final queryParams = <String, String>{};
if (serviceId != null && serviceId.isNotEmpty) {
queryParams['serviceId'] = serviceId;
}
if (organizationId != null && organizationId.isNotEmpty) {
queryParams['organizationId'] = organizationId;
}
final endpoint = "${ApiEndpoints.getAllEmployeesByOrganization}/$projectId";
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response != null) {
return _parseResponse(response, label: 'Employees by Project Service');
} else {
return null;
}
}
static Future<List<dynamic>?> getAllEmployees( static Future<List<dynamic>?> getAllEmployees(
{String? organizationId}) async { {String? organizationId}) async {
var endpoint = ApiEndpoints.getAllEmployees; var endpoint = ApiEndpoints.getAllEmployees;
@ -2125,42 +2229,67 @@ class ApiService {
} }
// === Daily Task APIs === // === Daily Task APIs ===
/// Get Daily Task Project Report Filter
static Future<DailyProgressReportFilterResponse?> getDailyTaskFilter(
String projectId) async {
final endpoint =
"${ApiEndpoints.getDailyTaskProjectProgressFilter}/$projectId";
logSafe("Fetching daily task Progress filter for projectId: $projectId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Daily task filter request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Daily Task Progress Filter");
if (jsonResponse != null) {
return DailyProgressReportFilterResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getDailyTask Progress Filter: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<List<TaskModel>?> getDailyTasks( static Future<List<TaskModel>?> getDailyTasks(
String projectId, { String projectId, {
DateTime? dateFrom, Map<String, dynamic>? filter,
DateTime? dateTo,
List<String>? serviceIds,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 20, int pageSize = 20,
}) async { }) async {
final filterBody = { // Build query parameters
"serviceIds": serviceIds ?? [],
};
final query = { final query = {
"projectId": projectId, "projectId": projectId,
"pageNumber": pageNumber.toString(), "pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(), "pageSize": pageSize.toString(),
if (dateFrom != null) if (filter != null) "filter": jsonEncode(filter),
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
"filter": jsonEncode(filterBody),
}; };
final uri = final uri =
Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query); Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query);
final response = await _getRequest(uri.toString()); final response = await _getRequest(uri.toString());
final parsed = response != null ? _parseResponse(response, label: 'Daily Tasks') : null; final parsed = response != null
? _parseResponse(response, label: 'Daily Tasks')
: null;
if (parsed != null && parsed['data'] != null) { if (parsed != null && parsed['data'] != null) {
return (parsed['data'] as List).map((e) => TaskModel.fromJson(e)).toList(); return (parsed['data'] as List)
.map((e) => TaskModel.fromJson(e))
.toList();
} }
return null; return null;
} }
static Future<bool> reportTask({ static Future<bool> reportTask({
required String id, required String id,
@ -2212,9 +2341,14 @@ class ApiService {
return response.statusCode == 200 && json['success'] == true; return response.statusCode == 200 && json['success'] == true;
} }
/// Fetch infra details for a given project /// Fetch infra details for a project, optionally filtered by service
static Future<Map<String, dynamic>?> getInfraDetails(String projectId) async { static Future<Map<String, dynamic>?> getInfraDetails(String projectId,
final endpoint = "/project/infra-details/$projectId"; {String? serviceId}) async {
String endpoint = "/project/infra-details/$projectId";
if (serviceId != null && serviceId.isNotEmpty) {
endpoint += "?serviceId=$serviceId";
}
final res = await _getRequest(endpoint); final res = await _getRequest(endpoint);
if (res == null) { if (res == null) {
@ -2227,10 +2361,14 @@ class ApiService {
as Map<String, dynamic>?; as Map<String, dynamic>?;
} }
/// Fetch work items for a given work area /// Fetch work items for a given work area, optionally filtered by service
static Future<Map<String, dynamic>?> getWorkItemsByWorkArea( static Future<Map<String, dynamic>?> getWorkItemsByWorkArea(String workAreaId,
String workAreaId) async { {String? serviceId}) async {
final endpoint = "/project/tasks/$workAreaId"; String endpoint = "/project/tasks/$workAreaId";
if (serviceId != null && serviceId.isNotEmpty) {
endpoint += "?serviceId=$serviceId";
}
final res = await _getRequest(endpoint); final res = await _getRequest(endpoint);
if (res == null) { if (res == null) {
@ -2249,12 +2387,16 @@ class ApiService {
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId,
String? serviceId,
}) async { }) async {
final body = { final body = {
"workItemId": workItemId, "workItemId": workItemId,
"plannedTask": plannedTask, "plannedTask": plannedTask,
"description": description, "description": description,
"taskTeam": taskTeam, "taskTeam": taskTeam,
"organizationId": organizationId,
"serviceId": serviceId,
"assignmentDate": "assignmentDate":
(assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(),
}; };

View File

@ -1,135 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:marco/helpers/widgets/my_text.dart';
class CommentEditorCard extends StatefulWidget {
final quill.QuillController controller;
final VoidCallback onCancel;
final Future<void> Function(quill.QuillController controller) onSave;
const CommentEditorCard({
super.key,
required this.controller,
required this.onCancel,
required this.onSave,
});
@override
State<CommentEditorCard> createState() => _CommentEditorCardState();
}
class _CommentEditorCardState extends State<CommentEditorCard> {
bool _isSubmitting = false;
Future<void> _handleSave() async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
try {
await widget.onSave(widget.controller);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
quill.QuillSimpleToolbar(
controller: widget.controller,
configurations: const quill.QuillSimpleToolbarConfigurations(
showBoldButton: true,
showItalicButton: true,
showUnderLineButton: true,
showListBullets: false,
showListNumbers: false,
showAlignmentButtons: true,
showLink: true,
showFontSize: false,
showFontFamily: false,
showColorButton: false,
showBackgroundColorButton: false,
showUndo: false,
showRedo: false,
showCodeBlock: false,
showQuote: false,
showSuperscript: false,
showSubscript: false,
showInlineCode: false,
showDirection: false,
showListCheck: false,
showStrikeThrough: false,
showClearFormat: false,
showDividers: false,
showHeaderStyle: false,
multiRowsDisplay: false,
),
),
const SizedBox(height: 24),
Container(
height: 140,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: const Color(0xFFFDFDFD),
),
child: quill.QuillEditor.basic(
controller: widget.controller,
configurations: const quill.QuillEditorConfigurations(
autoFocus: true,
expands: false,
scrollable: true,
),
),
),
const SizedBox(height: 16),
// 👇 Buttons same as BaseBottomSheet
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : widget.onCancel,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _handleSave,
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
_isSubmitting ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
],
);
}
}

View File

@ -38,7 +38,7 @@ void showAppSnackbar({
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
borderRadius: 8, borderRadius: 8,
duration: const Duration(seconds: 3), duration: const Duration(seconds: 5),
icon: Icon( icon: Icon(
iconData, iconData,
color: Colors.white, color: Colors.white,

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/tenant/all_organization_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/all_organization_model.dart';
class AllOrganizationListView extends StatelessWidget {
final AllOrganizationController controller;
/// Optional callback when an organization is tapped
final void Function(AllOrganization)? onTapOrganization;
const AllOrganizationListView({
super.key,
required this.controller,
this.onTapOrganization,
});
Widget _loadingPlaceholder() {
return ListView.separated(
itemCount: 5,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 150,
height: 14,
color: Colors.grey.shade400,
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.isLoadingOrganizations.value) {
return _loadingPlaceholder();
}
if (controller.organizations.isEmpty) {
return Center(
child: MyText.bodyMedium(
"No organizations found",
color: Colors.grey,
),
);
}
return ListView.separated(
itemCount: controller.organizations.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final org = controller.organizations[index];
return ListTile(
title: Text(org.name),
onTap: () {
if (onTapOrganization != null) {
onTapOrganization!(org);
}
},
);
},
);
});
}
}

View File

@ -0,0 +1,184 @@
class AllOrganizationListResponse {
final bool success;
final String message;
final OrganizationData data;
final dynamic errors;
final int statusCode;
final String timestamp;
AllOrganizationListResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory AllOrganizationListResponse.fromJson(Map<String, dynamic> json) {
return AllOrganizationListResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? OrganizationData.fromJson(json['data'])
: OrganizationData(currentPage: 0, totalPages: 0, totalEntities: 0, data: []),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
class OrganizationData {
final int currentPage;
final int totalPages;
final int totalEntities;
final List<AllOrganization> data;
OrganizationData({
required this.currentPage,
required this.totalPages,
required this.totalEntities,
required this.data,
});
factory OrganizationData.fromJson(Map<String, dynamic> json) {
return OrganizationData(
currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0,
totalEntities: json['totalEntities'] ?? 0,
data: (json['data'] as List<dynamic>?)
?.map((e) => AllOrganization.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntities': totalEntities,
'data': data.map((e) => e.toJson()).toList(),
};
}
}
class AllOrganization {
final String id;
final String name;
final String email;
final String contactPerson;
final String address;
final String contactNumber;
final int sprid;
final String? logoImage;
final String createdAt;
final User? createdBy;
final User? updatedBy;
final String? updatedAt;
final bool isActive;
AllOrganization({
required this.id,
required this.name,
required this.email,
required this.contactPerson,
required this.address,
required this.contactNumber,
required this.sprid,
this.logoImage,
required this.createdAt,
this.createdBy,
this.updatedBy,
this.updatedAt,
required this.isActive,
});
factory AllOrganization.fromJson(Map<String, dynamic> json) {
return AllOrganization(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'] ?? '',
contactPerson: json['contactPerson'] ?? '',
address: json['address'] ?? '',
contactNumber: json['contactNumber'] ?? '',
sprid: json['sprid'] ?? 0,
logoImage: json['logoImage'],
createdAt: json['createdAt'] ?? '',
createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
updatedBy: json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
updatedAt: json['updatedAt'],
isActive: json['isActive'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'contactNumber': contactNumber,
'sprid': sprid,
'logoImage': logoImage,
'createdAt': createdAt,
'createdBy': createdBy?.toJson(),
'updatedBy': updatedBy?.toJson(),
'updatedAt': updatedAt,
'isActive': isActive,
};
}
}
class User {
final String id;
final String firstName;
final String lastName;
final String photo;
final String jobRoleId;
final String jobRoleName;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
photo: json['photo'] ?? '',
jobRoleId: json['jobRoleId'] ?? '',
jobRoleName: json['jobRoleName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': firstName,
'lastName': lastName,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
}

View File

@ -2,10 +2,16 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/controller/tenant/service_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/tenant/organization_selector.dart';
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.dart';
class AssignTaskBottomSheet extends StatefulWidget { class AssignTaskBottomSheet extends StatefulWidget {
final String workLocation; final String workLocation;
@ -36,24 +42,46 @@ class AssignTaskBottomSheet extends StatefulWidget {
class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> { class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final DailyTaskPlanningController controller = Get.find(); final DailyTaskPlanningController controller = Get.find();
final ProjectController projectController = Get.find(); final ProjectController projectController = Get.find();
final OrganizationController orgController = Get.put(OrganizationController());
final ServiceController serviceController = Get.put(ServiceController());
final TextEditingController targetController = TextEditingController(); final TextEditingController targetController = TextEditingController();
final TextEditingController descriptionController = TextEditingController(); final TextEditingController descriptionController = TextEditingController();
final ScrollController _employeeListScrollController = ScrollController(); final ScrollController _employeeListScrollController = ScrollController();
String? selectedProjectId; String? selectedProjectId;
Organization? selectedOrganization;
Service? selectedService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
selectedProjectId = projectController.selectedProjectId.value; selectedProjectId = projectController.selectedProjectId.value;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) async {
if (selectedProjectId != null) { if (selectedProjectId != null) {
controller.fetchEmployeesByProject(selectedProjectId!); await orgController.fetchOrganizations(selectedProjectId!);
_resetSelections();
await _fetchEmployeesAndTasks();
} }
}); });
} }
void _resetSelections() {
controller.selectedEmployees.clear();
controller.uploadingStates.forEach((key, value) => value.value = false);
}
Future<void> _fetchEmployeesAndTasks() async {
await controller.fetchEmployeesByProjectService(
projectId: selectedProjectId!,
serviceId: selectedService?.id,
organizationId: selectedOrganization?.id,
);
await controller.fetchTaskData(selectedProjectId, serviceId: selectedService?.id);
}
@override @override
void dispose() { void dispose() {
_employeeListScrollController.dispose(); _employeeListScrollController.dispose();
@ -77,12 +105,47 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_infoRow(Icons.location_on, "Work Location", // Organization Selector
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), SizedBox(
Divider(), height: 50,
_infoRow(Icons.pending_actions, "Pending Task of Activity", child: OrganizationSelector(
"${widget.pendingTask}"), controller: orgController,
Divider(), onSelectionChanged: (org) async {
setState(() => selectedOrganization = org);
_resetSelections();
if (selectedProjectId != null) await _fetchEmployeesAndTasks();
},
),
),
MySpacing.height(12),
// Service Selector
SizedBox(
height: 50,
child: ServiceSelector(
controller: serviceController,
onSelectionChanged: (service) async {
setState(() => selectedService = service);
_resetSelections();
if (selectedProjectId != null) await _fetchEmployeesAndTasks();
},
),
),
MySpacing.height(16),
// Work Location Info
_infoRow(
Icons.location_on,
"Work Location",
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}",
),
const Divider(),
// Pending Task Info
_infoRow(Icons.pending_actions, "Pending Task", "${widget.pendingTask}"),
const Divider(),
// Role Selector
GestureDetector( GestureDetector(
onTap: _onRoleMenuPressed, onTap: _onRoleMenuPressed,
child: Row( child: Row(
@ -94,21 +157,34 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
), ),
), ),
MySpacing.height(8), MySpacing.height(8),
// Employee List
Container( Container(
constraints: const BoxConstraints(maxHeight: 150), constraints: const BoxConstraints(maxHeight: 180),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: _buildEmployeeList(), child: _buildEmployeeList(),
), ),
MySpacing.height(8), MySpacing.height(8),
// Selected Employees Chips
_buildSelectedEmployees(), _buildSelectedEmployees(),
MySpacing.height(8),
// Target Input
_buildTextField( _buildTextField(
icon: Icons.track_changes, icon: Icons.track_changes,
label: "Target for Today :", label: "Target for Today :",
controller: targetController, controller: targetController,
hintText: "Enter target", hintText: "Enter target",
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validatorType: "target", validatorType: "target",
), ),
MySpacing.height(24), MySpacing.height(16),
// Description Input
_buildTextField( _buildTextField(
icon: Icons.description, icon: Icons.description,
label: "Description :", label: "Description :",
@ -122,8 +198,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
} }
void _onRoleMenuPressed() { void _onRoleMenuPressed() {
final RenderBox overlay = final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
Overlay.of(context).context.findRenderObject() as RenderBox;
final Size screenSize = overlay.size; final Size screenSize = overlay.size;
showMenu( showMenu(
@ -144,56 +219,24 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
}), }),
], ],
).then((value) { ).then((value) {
if (value != null) { if (value != null) controller.onRoleSelected(value == 'all' ? null : value);
controller.onRoleSelected(value == 'all' ? null : value);
}
}); });
} }
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isFetchingEmployees.value) {
// Skeleton loader instead of CircularProgressIndicator return Center(child: CircularProgressIndicator());
return ListView.separated(
shrinkWrap: true,
itemCount: 5, // show 5 skeleton rows
separatorBuilder: (_, __) => const SizedBox(height: 4),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 14,
color: Colors.grey.shade300,
),
),
],
),
);
},
);
} }
final selectedRoleId = controller.selectedRoleId.value; final filteredEmployees = controller.selectedRoleId.value == null
final filteredEmployees = selectedRoleId == null
? controller.employees ? controller.employees
: controller.employees : controller.employees
.where((e) => e.jobRoleID.toString() == selectedRoleId) .where((e) => e.jobRoleID.toString() == controller.selectedRoleId.value)
.toList(); .toList();
if (filteredEmployees.isEmpty) { if (filteredEmployees.isEmpty) {
return const Text("No employees found for selected role."); return Center(child: Text("No employees available for selected role."));
} }
return Scrollbar( return Scrollbar(
@ -201,43 +244,32 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
thumbVisibility: true, thumbVisibility: true,
child: ListView.builder( child: ListView.builder(
controller: _employeeListScrollController, controller: _employeeListScrollController,
shrinkWrap: true,
itemCount: filteredEmployees.length, itemCount: filteredEmployees.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final employee = filteredEmployees[index]; final employee = filteredEmployees[index];
final rxBool = controller.uploadingStates[employee.id]; final rxBool = controller.uploadingStates[employee.id];
return Obx(() => Padding( return Obx(() => ListTile(
padding: const EdgeInsets.symmetric(vertical: 2), dense: true,
child: Row( contentPadding: const EdgeInsets.symmetric(horizontal: 8),
children: [ leading: Checkbox(
Checkbox( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
value: rxBool?.value ?? false, value: rxBool?.value ?? false,
onChanged: (bool? selected) { onChanged: (selected) {
if (rxBool != null) { if (rxBool != null) {
rxBool.value = selected ?? false; rxBool.value = selected ?? false;
controller.updateSelectedEmployees(); controller.updateSelectedEmployees();
} }
}, },
fillColor: fillColor: MaterialStateProperty.resolveWith((states) =>
WidgetStateProperty.resolveWith<Color>((states) { states.contains(MaterialState.selected)
if (states.contains(WidgetState.selected)) { ? const Color.fromARGB(255, 95, 132, 255)
return const Color.fromARGB(255, 95, 132, 255); : Colors.transparent),
}
return Colors.transparent;
}),
checkColor: Colors.white, checkColor: Colors.white,
side: const BorderSide(color: Colors.black), side: const BorderSide(color: Colors.black),
), ),
const SizedBox(width: 8), title: Text(employee.name, style: const TextStyle(fontSize: 14)),
Expanded( visualDensity: VisualDensity.compact,
child: Text(employee.name,
style: const TextStyle(fontSize: 14))),
],
),
)); ));
}, },
), ),
@ -249,20 +281,16 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Obx(() { return Obx(() {
if (controller.selectedEmployees.isEmpty) return Container(); if (controller.selectedEmployees.isEmpty) return Container();
return Padding( return Wrap(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
children: controller.selectedEmployees.map((e) { children: controller.selectedEmployees.map((e) {
return Obx(() { return Obx(() {
final isSelected = final isSelected = controller.uploadingStates[e.id]?.value ?? false;
controller.uploadingStates[e.id]?.value ?? false;
if (!isSelected) return Container(); if (!isSelected) return Container();
return Chip( return Chip(
label: label: Text(e.name, style: const TextStyle(color: Colors.white)),
Text(e.name, style: const TextStyle(color: Colors.white)),
backgroundColor: const Color.fromARGB(255, 95, 132, 255), backgroundColor: const Color.fromARGB(255, 95, 132, 255),
deleteIcon: const Icon(Icons.close, color: Colors.white), deleteIcon: const Icon(Icons.close, color: Colors.white),
onDeleted: () { onDeleted: () {
@ -272,7 +300,6 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
); );
}); });
}).toList(), }).toList(),
),
); );
}); });
} }
@ -289,25 +316,22 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(children: [
children: [
Icon(icon, size: 18, color: Colors.black54), Icon(icon, size: 18, color: Colors.black54),
const SizedBox(width: 6), const SizedBox(width: 6),
MyText.titleMedium(label, fontWeight: 600), MyText.titleMedium(label, fontWeight: 600),
], ]),
),
MySpacing.height(6), MySpacing.height(6),
TextFormField( TextFormField(
controller: controller, controller: controller,
keyboardType: keyboardType, keyboardType: keyboardType,
maxLines: maxLines, maxLines: maxLines,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: '', hintText: hintText,
border: OutlineInputBorder(), border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
validator: (value) => this validator: (value) => this.controller.formFieldValidator(value, fieldType: validatorType),
.controller
.formFieldValidator(value, fieldType: validatorType),
), ),
], ],
); );
@ -326,13 +350,9 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
text: TextSpan( text: TextSpan(
children: [ children: [
WidgetSpan( WidgetSpan(
child: MyText.titleMedium("$title: ", child: MyText.titleMedium("$title: ", fontWeight: 600, color: Colors.black),
fontWeight: 600, color: Colors.black),
),
TextSpan(
text: value,
style: const TextStyle(color: Colors.black),
), ),
TextSpan(text: value, style: const TextStyle(color: Colors.black)),
], ],
), ),
), ),
@ -349,29 +369,20 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
.toList(); .toList();
if (selectedTeam.isEmpty) { if (selectedTeam.isEmpty) {
showAppSnackbar( showAppSnackbar(title: "Team Required", message: "Please select at least one team member", type: SnackbarType.error);
title: "Team Required",
message: "Please select at least one team member",
type: SnackbarType.error,
);
return; return;
} }
final target = int.tryParse(targetController.text.trim()); final target = double.tryParse(targetController.text.trim());
if (target == null || target <= 0) { if (target == null || target <= 0) {
showAppSnackbar( showAppSnackbar(title: "Invalid Input", message: "Please enter a valid target number", type: SnackbarType.error);
title: "Invalid Input",
message: "Please enter a valid target number",
type: SnackbarType.error,
);
return; return;
} }
if (target > widget.pendingTask) { if (target > widget.pendingTask) {
showAppSnackbar( showAppSnackbar(
title: "Target Too High", title: "Target Too High",
message: message: "Target cannot exceed pending task (${widget.pendingTask})",
"Target cannot be greater than pending task (${widget.pendingTask})",
type: SnackbarType.error, type: SnackbarType.error,
); );
return; return;
@ -379,20 +390,18 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
final description = descriptionController.text.trim(); final description = descriptionController.text.trim();
if (description.isEmpty) { if (description.isEmpty) {
showAppSnackbar( showAppSnackbar(title: "Description Required", message: "Please enter a description", type: SnackbarType.error);
title: "Description Required",
message: "Please enter a description",
type: SnackbarType.error,
);
return; return;
} }
controller.assignDailyTask( controller.assignDailyTask(
workItemId: widget.workItemId, workItemId: widget.workItemId,
plannedTask: target, plannedTask: target.toInt(),
description: description, description: description,
taskTeam: selectedTeam, taskTeam: selectedTeam,
assignmentDate: widget.assignmentDate, assignmentDate: widget.assignmentDate,
organizationId: selectedOrganization?.id,
serviceId: selectedService?.id,
); );
} }
} }

View File

@ -1,73 +1,200 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:get/get.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart'; import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
class DailyProgressReportFilter extends StatelessWidget { class DailyTaskFilterBottomSheet extends StatelessWidget {
final DailyTaskController controller; final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({ const DailyTaskFilterBottomSheet({super.key, required this.controller});
super.key,
required this.controller,
required this.permissionController,
});
String getLabelText() {
final startDate = controller.startDateTask;
final endDate = controller.endDateTask;
if (startDate != null && endDate != null) {
final start = DateFormat('dd MM yyyy').format(startDate);
final end = DateFormat('dd MM yyyy').format(endDate);
return "$start - $end";
}
return "Select Date Range";
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filterData = controller.taskFilterData;
if (filterData == null) return const SizedBox.shrink();
final hasFilters = [
filterData.buildings,
filterData.floors,
filterData.activities,
filterData.services,
].any((list) => list.isNotEmpty);
return BaseBottomSheet( return BaseBottomSheet(
title: "Filter Tasks", title: "Filter Tasks",
onCancel: () => Navigator.pop(context), submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () { onSubmit: () {
Navigator.pop(context, { if (controller.selectedProjectId != null) {
'startDate': controller.startDateTask, controller.fetchTaskData(
'endDate': controller.endDateTask, controller.selectedProjectId!,
}); );
}
Get.back();
}, },
child: Column( child: SingleChildScrollView(
child: hasFilters
? Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.titleSmall("Select Date Range", fontWeight: 600), Align(
const SizedBox(height: 8), alignment: Alignment.centerRight,
InkWell( child: TextButton(
borderRadius: BorderRadius.circular(10), onPressed: () {
onTap: () => controller.selectDateRangeForTaskData( controller.clearTaskFilters();
context, },
controller, child: MyText(
"Reset Filter",
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
), ),
child: Ink( ),
),
),
MySpacing.height(8),
_multiSelectField(
label: "Buildings",
items: filterData.buildings,
fallback: "Select Buildings",
selectedValues: controller.selectedBuildings,
),
_multiSelectField(
label: "Floors",
items: filterData.floors,
fallback: "Select Floors",
selectedValues: controller.selectedFloors,
),
_multiSelectField(
label: "Activities",
items: filterData.activities,
fallback: "Select Activities",
selectedValues: controller.selectedActivities,
),
_multiSelectField(
label: "Services",
items: filterData.services,
fallback: "Select Services",
selectedValues: controller.selectedServices,
),
MySpacing.height(8),
_dateRangeSelector(context),
],
)
: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: MyText(
"No filters available",
style: const TextStyle(color: Colors.grey),
),
),
),
),
);
}
Widget _multiSelectField({
required String label,
required List<dynamic> items,
required String fallback,
required RxSet<String> selectedValues,
}) {
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
Obx(() {
final selectedNames = items
.where((item) => selectedValues.contains(item.id))
.map((item) => item.name)
.join(", ");
final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback;
return Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final RenderBox button =
context.findRenderObject() as RenderBox;
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero);
await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
items: items.map((item) {
return PopupMenuItem<String>(
enabled: false,
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(item.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: MyText(item.name),
// --- Styles to match Document Filter ---
checkColor: Colors.white,
side: const BorderSide(
color: Colors.black, width: 1.5),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.indigo;
}
return Colors.white;
},
),
onChanged: (val) {
if (val == true) {
selectedValues.add(item.id);
} else {
selectedValues.remove(item.id);
}
setState(() {});
},
);
},
),
);
}).toList(),
);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade100, color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Icon(Icons.date_range, color: Colors.blue.shade600),
const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: MyText(
getLabelText(), displayText,
style: const TextStyle( style: const TextStyle(color: Colors.black87),
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@ -75,9 +202,89 @@ class DailyProgressReportFilter extends StatelessWidget {
], ],
), ),
), ),
);
},
);
}),
MySpacing.height(16),
],
);
}
Widget _dateRangeSelector(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Select Date Range"),
MySpacing.height(8),
Row(
children: [
Expanded(
child: _dateButton(
label: controller.startDateTask != null
? "${controller.startDateTask!.day}/${controller.startDateTask!.month}/${controller.startDateTask!.year}"
: "From Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.startDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.startDateTask = picked;
controller.update(); // rebuild widget
}
},
),
),
MySpacing.width(12),
Expanded(
child: _dateButton(
label: controller.endDateTask != null
? "${controller.endDateTask!.day}/${controller.endDateTask!.month}/${controller.endDateTask!.year}"
: "To Date",
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: controller.endDateTask ?? DateTime.now(),
firstDate: DateTime(2022),
lastDate: DateTime.now(),
);
if (picked != null) {
controller.endDateTask = picked;
controller.update();
}
},
),
), ),
], ],
), ),
MySpacing.height(16),
],
);
}
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: MyText(label),
),
],
),
),
); );
} }
} }

View File

@ -0,0 +1,128 @@
class DailyProgressReportFilterResponse {
final bool success;
final String message;
final FilterData? data;
final dynamic errors;
final int statusCode;
final String timestamp;
DailyProgressReportFilterResponse({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory DailyProgressReportFilterResponse.fromJson(Map<String, dynamic> json) {
return DailyProgressReportFilterResponse(
success: json['success'],
message: json['message'],
data: json['data'] != null ? FilterData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: json['timestamp'],
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
class FilterData {
final List<Building> buildings;
final List<Floor> floors;
final List<Activity> activities;
final List<Service> services;
FilterData({
required this.buildings,
required this.floors,
required this.activities,
required this.services,
});
factory FilterData.fromJson(Map<String, dynamic> json) {
return FilterData(
buildings: (json['buildings'] as List)
.map((e) => Building.fromJson(e))
.toList(),
floors:
(json['floors'] as List).map((e) => Floor.fromJson(e)).toList(),
activities:
(json['activities'] as List).map((e) => Activity.fromJson(e)).toList(),
services:
(json['services'] as List).map((e) => Service.fromJson(e)).toList(),
);
}
Map<String, dynamic> toJson() => {
'buildings': buildings.map((e) => e.toJson()).toList(),
'floors': floors.map((e) => e.toJson()).toList(),
'activities': activities.map((e) => e.toJson()).toList(),
'services': services.map((e) => e.toJson()).toList(),
};
}
class Building {
final String id;
final String name;
Building({required this.id, required this.name});
factory Building.fromJson(Map<String, dynamic> json) =>
Building(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class Floor {
final String id;
final String name;
final String buildingId;
Floor({required this.id, required this.name, required this.buildingId});
factory Floor.fromJson(Map<String, dynamic> json) => Floor(
id: json['id'],
name: json['name'],
buildingId: json['buildingId'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'buildingId': buildingId,
};
}
class Activity {
final String id;
final String name;
Activity({required this.id, required this.name});
factory Activity.fromJson(Map<String, dynamic> json) =>
Activity(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class Service {
final String id;
final String name;
Service({required this.id, required this.name});
factory Service.fromJson(Map<String, dynamic> json) =>
Service(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}

View File

@ -1,10 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:marco/controller/directory/add_comment_controller.dart'; import 'package:marco/controller/directory/add_comment_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
class AddCommentBottomSheet extends StatefulWidget { class AddCommentBottomSheet extends StatefulWidget {
final String contactId; final String contactId;
@ -17,120 +14,59 @@ class AddCommentBottomSheet extends StatefulWidget {
class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> { class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
late final AddCommentController controller; late final AddCommentController controller;
late final quill.QuillController quillController; final TextEditingController textController = TextEditingController();
bool isSubmitting = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.put(AddCommentController(contactId: widget.contactId)); controller = Get.put(AddCommentController(contactId: widget.contactId));
quillController = quill.QuillController.basic();
} }
@override @override
void dispose() { void dispose() {
quillController.dispose(); textController.dispose();
Get.delete<AddCommentController>();
super.dispose(); super.dispose();
} }
Future<void> handleSubmit() async {
final noteText = textController.text.trim();
if (noteText.isEmpty) return;
setState(() {
isSubmitting = true;
});
controller.updateNote(noteText);
await controller.submitComment();
if (mounted) {
setState(() {
isSubmitting = false;
});
Get.back(result: true);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return BaseBottomSheet(
padding: MediaQuery.of(context).viewInsets, title: "Add Note",
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),
),
],
),
child: Padding(
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("Add Note", fontWeight: 700)),
MySpacing.height(24),
CommentEditorCard(
controller: quillController,
onCancel: () => Get.back(), onCancel: () => Get.back(),
onSave: (editorController) async { onSubmit: handleSubmit,
final delta = editorController.document.toDelta(); isSubmitting: isSubmitting,
final htmlOutput = _convertDeltaToHtml(delta); child: TextField(
controller.updateNote(htmlOutput); controller: textController,
await controller.submitComment(); maxLines: null,
}, minLines: 5,
), decoration: InputDecoration(
], hintText: "Enter your note here...",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
), ),
contentPadding: const EdgeInsets.all(12),
), ),
), ),
); );
} }
String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer();
bool inList = false;
for (var op in delta.toList()) {
final data = op.data?.toString() ?? '';
final attr = op.attributes ?? {};
final isListItem = attr.containsKey('list');
final trimmedData = data.trim();
if (isListItem && !inList) {
buffer.write('<ul>');
inList = true;
}
if (!isListItem && inList) {
buffer.write('</ul>');
inList = false;
}
if (isListItem && trimmedData.isEmpty) continue;
if (isListItem) buffer.write('<li>');
if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(trimmedData.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>');
if (attr.containsKey('italic')) buffer.write('</em>');
if (attr.containsKey('bold')) buffer.write('</strong>');
if (isListItem) {
buffer.write('</li>');
} else if (data.contains('\n')) {
buffer.write('<br>');
}
}
if (inList) buffer.write('</ul>');
return buffer.toString();
}
} }

View File

@ -74,12 +74,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
// Buckets - map all
if (c.bucketIds.isNotEmpty) {
final names = c.bucketIds
.map((id) {
return controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.key;
})
.whereType<String>()
.toList();
controller.selectedBuckets.assignAll(names);
}
// Projects and Category mapping - as before
final projectIds = c.projectIds; final projectIds = c.projectIds;
final bucketId = c.bucketIds.firstOrNull;
final category = c.contactCategory?.name;
if (category != null) controller.selectedCategory.value = category;
if (projectIds != null) { if (projectIds != null) {
controller.selectedProjects.assignAll( controller.selectedProjects.assignAll(
projectIds projectIds
@ -90,16 +98,12 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
.toList(), .toList(),
); );
} }
final category = c.contactCategory?.name;
if (bucketId != null) { if (category != null) controller.selectedCategory.value = category;
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key;
if (name != null) controller.selectedBucket.value = name;
}
} }
}); });
} else { } else {
showAdvanced.value = false; // Optional
emailCtrls.add(TextEditingController()); emailCtrls.add(TextEditingController());
emailLabels.add('Office'.obs); emailLabels.add('Office'.obs);
phoneCtrls.add(TextEditingController()); phoneCtrls.add(TextEditingController());
@ -363,10 +367,129 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
); );
} }
Widget _bucketMultiSelectField() {
return _multiSelectField(
items: controller.buckets
.map((name) => FilterItem(id: name, name: name))
.toList(),
fallback: "Choose Buckets",
selectedValues: controller.selectedBuckets,
);
}
Widget _multiSelectField({
required List<FilterItem> items,
required String fallback,
required RxList<String> selectedValues,
}) {
if (items.isEmpty) return const SizedBox.shrink();
return Obx(() {
final selectedNames = items
.where((f) => selectedValues.contains(f.id))
.map((f) => f.name)
.join(", ");
final displayText = selectedNames.isNotEmpty ? selectedNames : fallback;
return Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final RenderBox button = context.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero);
await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
items: [
PopupMenuItem(
enabled: false,
child: StatefulBuilder(
builder: (context, setState) {
return SizedBox(
width: 250,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: items.map((f) {
final isChecked = selectedValues.contains(f.id);
return CheckboxListTile(
dense: true,
title: Text(f.name),
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity:
ListTileControlAffinity.leading,
side: const BorderSide(
color: Colors.black, width: 1.5),
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states
.contains(MaterialState.selected)) {
return Colors.indigo; // selected color
}
return Colors
.white; // unselected background
}),
checkColor: Colors.white, // tick color
onChanged: (val) {
if (val == true) {
selectedValues.add(f.id);
} else {
selectedValues.remove(f.id);
}
setState(() {});
},
);
}).toList(),
),
),
);
},
),
),
],
);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
displayText,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
},
);
});
}
void _handleSubmit() { void _handleSubmit() {
bool valid = formKey.currentState?.validate() ?? false; bool valid = formKey.currentState?.validate() ?? false;
if (controller.selectedBucket.value.isEmpty) { if (controller.selectedBuckets.isEmpty) {
bucketError.value = "Bucket is required"; bucketError.value = "Bucket is required";
valid = false; valid = false;
} else { } else {
@ -430,29 +553,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(16), MySpacing.height(16),
_textField("Organization", orgCtrl, required: true), _textField("Organization", orgCtrl, required: true),
MySpacing.height(16), MySpacing.height(16),
_labelWithStar("Bucket", required: true), _labelWithStar("Buckets", required: true),
MySpacing.height(8), MySpacing.height(8),
Stack( Stack(
children: [ children: [
_popupSelector(controller.selectedBucket, controller.buckets, _bucketMultiSelectField(),
"Choose Bucket"),
Positioned(
left: 0,
right: 0,
top: 56,
child: Obx(() => bucketError.value.isEmpty
? const SizedBox.shrink()
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(bucketError.value,
style: const TextStyle(
color: Colors.red, fontSize: 12)),
)),
),
], ],
), ),
MySpacing.height(24), MySpacing.height(12),
Obx(() => GestureDetector( Obx(() => GestureDetector(
onTap: () => showAdvanced.toggle(), onTap: () => showAdvanced.toggle(),
child: Row( child: Row(
@ -562,3 +670,9 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
}); });
} }
} }
class FilterItem {
final String id;
final String name;
FilterItem({required this.id, required this.name});
}

View File

@ -69,6 +69,15 @@ class DirectoryComment {
isActive: json['isActive'] ?? true, isActive: json['isActive'] ?? true,
); );
} }
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DirectoryComment &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
DirectoryComment copyWith({ DirectoryComment copyWith({
String? id, String? id,

View File

@ -194,8 +194,11 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sheetTitle = widget.isEmployee
? "Upload Employee Document"
: "Upload Project Document";
return BaseBottomSheet( return BaseBottomSheet(
title: "Upload Document", title: sheetTitle,
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: Form( child: Form(

View File

@ -5,7 +5,7 @@ import 'package:intl/intl.dart';
import 'package:marco/controller/employee/add_employee_controller.dart'; import 'package:marco/controller/employee/add_employee_controller.dart';
import 'package:marco/controller/employee/employees_screen_controller.dart'; import 'package:marco/controller/employee/employees_screen_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart'; import 'package:marco/controller/tenant/all_organization_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
@ -24,8 +24,7 @@ class AddEmployeeBottomSheet extends StatefulWidget {
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin { with UIMixin {
late final AddEmployeeController _controller; late final AddEmployeeController _controller;
final OrganizationController _organizationController = late final AllOrganizationController _organizationController;
Get.put(OrganizationController());
// Local UI state // Local UI state
bool _hasApplicationAccess = false; bool _hasApplicationAccess = false;
@ -39,52 +38,62 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize text controllers
_orgFieldController = TextEditingController(); _orgFieldController = TextEditingController();
_joiningDateController = TextEditingController(); _joiningDateController = TextEditingController();
_genderController = TextEditingController(); _genderController = TextEditingController();
_roleController = TextEditingController(); _roleController = TextEditingController();
// Initialize AddEmployeeController
_controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString()); _controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString());
// Pass organization ID from employeeData if available
final orgIdFromEmployee =
widget.employeeData?['organization_id'] as String?;
_organizationController = Get.put(
AllOrganizationController(passedOrgId: orgIdFromEmployee),
tag: UniqueKey().toString(),
);
// Keep _orgFieldController in sync with selected organization safely
ever(_organizationController.selectedOrganization, (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_orgFieldController.text =
_organizationController.selectedOrganization.value?.name ??
'All Organizations';
});
});
// Prefill other fields if editing
if (widget.employeeData != null) { if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData; _controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields(); _controller.prefillFields();
// Application access
_hasApplicationAccess = _hasApplicationAccess =
widget.employeeData?['hasApplicationAccess'] ?? false; widget.employeeData?['hasApplicationAccess'] ?? false;
// Email
final email = widget.employeeData?['email']; final email = widget.employeeData?['email'];
if (email != null && email.toString().isNotEmpty) { if (email != null && email.toString().isNotEmpty) {
_controller.basicValidator.getController('email')?.text = _controller.basicValidator.getController('email')?.text =
email.toString(); email.toString();
} }
final orgId = widget.employeeData?['organization_id']; // Joining date
if (orgId != null) {
final org = _organizationController.organizations
.firstWhereOrNull((o) => o.id == orgId);
if (org != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_organizationController.selectOrganization(org);
_controller.selectedOrganizationId = org.id;
_orgFieldController.text = org.name;
});
}
}
// Prefill Joining date
if (_controller.joiningDate != null) { if (_controller.joiningDate != null) {
_joiningDateController.text = _joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!); DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
} }
// Prefill Gender // Gender
if (_controller.selectedGender != null) { if (_controller.selectedGender != null) {
_genderController.text = _genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? ''; _controller.selectedGender!.name.capitalizeFirst ?? '';
} }
// Prefill Role // Prefill Role
_controller.fetchRoles().then((_) { _controller.fetchRoles().then((_) {
if (_controller.selectedRoleId != null) { if (_controller.selectedRoleId != null) {
final roleName = _controller.roles.firstWhereOrNull( final roleName = _controller.roles.firstWhereOrNull(
@ -97,6 +106,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
} }
}); });
} else { } else {
// Not editing: fetch roles
_controller.fetchRoles(); _controller.fetchRoles();
} }
} }
@ -151,7 +161,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
MySpacing.height(16), MySpacing.height(16),
_sectionLabel('Organization'), _sectionLabel('Organization'),
MySpacing.height(8), MySpacing.height(8),
GestureDetector( Obx(() {
return GestureDetector(
onTap: () => _showOrganizationPopup(context), onTap: () => _showOrganizationPopup(context),
child: AbsorbPointer( child: AbsorbPointer(
child: TextFormField( child: TextFormField(
@ -167,11 +178,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
}, },
decoration: decoration:
_inputDecoration('Select Organization').copyWith( _inputDecoration('Select Organization').copyWith(
suffixIcon: const Icon(Icons.expand_more), suffixIcon: _organizationController
), .isLoadingOrganizations.value
? const SizedBox(
width: 24,
height: 24,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
), ),
), ),
), ),
);
}),
MySpacing.height(24), MySpacing.height(24),
_sectionLabel('Application Access'), _sectionLabel('Application Access'),
Row( Row(
@ -479,7 +499,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
context: context, context: context,
initialDate: _controller.joiningDate ?? DateTime.now(), initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000), firstDate: DateTime(2000),
lastDate: DateTime(2100), lastDate: DateTime.now(),
); );
if (picked != null) { if (picked != null) {
@ -493,12 +513,13 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
final isValid = final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false; _controller.basicValidator.formKey.currentState?.validate() ?? false;
final selectedOrg = _organizationController.selectedOrganization.value;
if (!isValid || if (!isValid ||
_controller.joiningDate == null || _controller.joiningDate == null ||
_controller.selectedGender == null || _controller.selectedGender == null ||
_controller.selectedRoleId == null || _controller.selectedRoleId == null ||
_organizationController.currentSelection.isEmpty || selectedOrg == null) {
_organizationController.currentSelection == 'All Organizations') {
showAppSnackbar( showAppSnackbar(
title: 'Missing Fields', title: 'Missing Fields',
message: 'Please complete all required fields.', message: 'Please complete all required fields.',
@ -507,6 +528,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return; return;
} }
_controller.selectedOrganizationId = selectedOrg.id;
final result = await _controller.createOrUpdateEmployee( final result = await _controller.createOrUpdateEmployee(
email: _controller.basicValidator.getController('email')?.text.trim(), email: _controller.basicValidator.getController('email')?.text.trim(),
hasApplicationAccess: _hasApplicationAccess, hasApplicationAccess: _hasApplicationAccess,
@ -539,7 +562,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return; return;
} }
final selected = await showMenu<String>( final selectedOrgId = await showMenu<String>(
context: context, context: context,
position: _popupMenuPosition(context), position: _popupMenuPosition(context),
items: orgs items: orgs
@ -552,12 +575,10 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
.toList(), .toList(),
); );
if (selected != null && selected.trim().isNotEmpty) { if (selectedOrgId != null) {
final chosen = orgs.firstWhere((e) => e.id == selected); final chosenOrg = orgs.firstWhere((org) => org.id == selectedOrgId,
_organizationController.selectOrganization(chosen); orElse: () => orgs.first);
_controller.selectedOrganizationId = chosen.id; _organizationController.selectOrganization(chosenOrg);
_orgFieldController.text = chosen.name;
_controller.update();
} }
} }

View File

@ -12,7 +12,7 @@ import 'package:marco/view/error_pages/error_500_screen.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/view/Attendence/attendance_screen.dart'; import 'package:marco/view/Attendence/attendance_screen.dart';
import 'package:marco/view/taskPlanning/daily_task_planning.dart'; import 'package:marco/view/taskPlanning/daily_task_planning.dart';
import 'package:marco/view/taskPlanning/daily_progress.dart'; import 'package:marco/view/taskPlanning/daily_progress_report.dart';
import 'package:marco/view/employees/employees_screen.dart'; import 'package:marco/view/employees/employees_screen.dart';
import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart';

View File

@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_html/flutter_html.dart' as html;
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/directory/directory_controller.dart'; import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
@ -9,57 +7,12 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/model/directory/contact_model.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:tab_indicator_styler/tab_indicator_styler.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
// HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer();
bool inList = false;
for (var op in delta.toList()) {
final String data = op.data?.toString() ?? '';
final attr = op.attributes ?? {};
final bool isListItem = attr.containsKey('list');
if (isListItem && !inList) {
buffer.write('<ul>');
inList = true;
}
if (!isListItem && inList) {
buffer.write('</ul>');
inList = false;
}
if (isListItem) buffer.write('<li>');
if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(data.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>');
if (attr.containsKey('italic')) buffer.write('</em>');
if (attr.containsKey('bold')) buffer.write('</strong>');
if (isListItem)
buffer.write('</li>');
else if (data.contains('\n')) {
buffer.write('<br>');
}
}
if (inList) buffer.write('</ul>');
return buffer.toString();
}
class ContactDetailScreen extends StatefulWidget { class ContactDetailScreen extends StatefulWidget {
final ContactModel contact; final ContactModel contact;
@ -69,10 +22,10 @@ class ContactDetailScreen extends StatefulWidget {
State<ContactDetailScreen> createState() => _ContactDetailScreenState(); State<ContactDetailScreen> createState() => _ContactDetailScreenState();
} }
class _ContactDetailScreenState extends State<ContactDetailScreen> { class _ContactDetailScreenState extends State<ContactDetailScreen>
with UIMixin {
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
late Rx<ContactModel> contactRx; late Rx<ContactModel> contactRx;
@override @override
@ -89,7 +42,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
active: false); active: false);
}); });
// Listen to controller's allContacts and update contact if changed
ever(directoryController.allContacts, (_) { ever(directoryController.allContacts, (_) {
final updated = directoryController.allContacts final updated = directoryController.allContacts
.firstWhereOrNull((c) => c.id == contactRx.value.id); .firstWhereOrNull((c) => c.id == contactRx.value.id);
@ -172,11 +124,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(children: [ Row(children: [
Avatar( Avatar(firstName: firstName, lastName: lastName, size: 35),
firstName: firstName,
lastName: lastName,
size: 35,
),
MySpacing.width(12), MySpacing.width(12),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -190,16 +138,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
), ),
]), ]),
TabBar( TabBar(
labelColor: Colors.red, labelColor: Colors.black,
unselectedLabelColor: Colors.black, unselectedLabelColor: Colors.grey,
indicator: MaterialIndicator( indicatorColor: contentTheme.primary,
color: Colors.red,
height: 4,
topLeftRadius: 8,
topRightRadius: 8,
bottomLeftRadius: 8,
bottomRightRadius: 8,
),
tabs: const [ tabs: const [
Tab(text: "Details"), Tab(text: "Details"),
Tab(text: "Notes"), Tab(text: "Notes"),
@ -287,8 +228,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
_iconInfoRow(Icons.location_on, "Address", contact.address), _iconInfoRow(Icons.location_on, "Address", contact.address),
]), ]),
_infoCard("Organization", [ _infoCard("Organization", [
_iconInfoRow( _iconInfoRow(Icons.business, "Organization", contact.organization),
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category), _iconInfoRow(Icons.category, "Category", category),
]), ]),
_infoCard("Meta Info", [ _infoCard("Meta Info", [
@ -316,7 +256,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20, bottom: 20,
right: 20, right: 20,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
backgroundColor: Colors.red, backgroundColor: contentTheme.primary,
onPressed: () async { onPressed: () async {
final result = await Get.bottomSheet( final result = await Get.bottomSheet(
AddContactBottomSheet(existingContact: contact), AddContactBottomSheet(existingContact: contact),
@ -345,7 +285,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
return Obx(() { return Obx(() {
final contactId = contactRx.value.id; final contactId = contactRx.value.id;
// Get active and inactive comments
final activeComments = directoryController final activeComments = directoryController
.getCommentsForContact(contactId) .getCommentsForContact(contactId)
.where((c) => c.isActive) .where((c) => c.isActive)
@ -355,18 +294,13 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
.where((c) => !c.isActive) .where((c) => !c.isActive)
.toList(); .toList();
// Combine both and keep the same sorting (recent first)
final comments = final comments =
[...activeComments, ...inactiveComments].reversed.toList(); [...activeComments, ...inactiveComments].reversed.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
return Stack( return Stack(
children: [ children: [
comments.isEmpty MyRefreshIndicator(
? Center(
child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
)
: MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
await directoryController.fetchCommentsForContact(contactId, await directoryController.fetchCommentsForContact(contactId,
active: true); active: true);
@ -375,7 +309,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}, },
child: Padding( child: Padding(
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
child: ListView.separated( child: comments.isEmpty
? Center(
child:
MyText.bodyLarge("No notes yet.", color: Colors.grey),
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100), padding: const EdgeInsets.only(bottom: 100),
itemCount: comments.length, itemCount: comments.length,
@ -390,12 +329,14 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20, bottom: 20,
right: 20, right: 20,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
backgroundColor: Colors.red, backgroundColor: contentTheme.primary,
onPressed: () async { onPressed: () async {
final result = await Get.bottomSheet( final result = await Get.bottomSheet(
AddCommentBottomSheet(contactId: contactId), AddCommentBottomSheet(contactId: contactId),
isScrollControlled: true, isScrollControlled: true,
enableDrag: true,
); );
if (result == true) { if (result == true) {
await directoryController.fetchCommentsForContact(contactId, await directoryController.fetchCommentsForContact(contactId,
active: true); active: true);
@ -419,13 +360,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
? comment.createdBy.firstName[0].toUpperCase() ? comment.createdBy.firstName[0].toUpperCase()
: "?"; : "?";
final decodedDelta = HtmlToDelta().convert(comment.note); final textController = TextEditingController(text: comment.note);
final quillController = isEditing
? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed(offset: decodedDelta.length),
)
: null;
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
@ -445,21 +380,16 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 🧑 Header // Header: Avatar + Name + Role + Timestamp + Actions
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Avatar( Avatar(firstName: initials, lastName: '', size: 40),
firstName: initials,
lastName: '',
size: 40,
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Full name on top
Text( Text(
"${comment.createdBy.firstName} ${comment.createdBy.lastName}", "${comment.createdBy.firstName} ${comment.createdBy.lastName}",
style: const TextStyle( style: const TextStyle(
@ -470,7 +400,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
// Job Role
if (comment.createdBy.jobRoleName?.isNotEmpty ?? false) if (comment.createdBy.jobRoleName?.isNotEmpty ?? false)
Text( Text(
comment.createdBy.jobRoleName, comment.createdBy.jobRoleName,
@ -481,7 +410,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
// Timestamp
Text( Text(
DateTimeUtils.convertUtcToLocal( DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(), comment.createdAt.toString(),
@ -495,8 +423,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
], ],
), ),
), ),
// Action buttons
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -561,42 +487,77 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (isEditing)
// 📝 Comment Content Column(
if (isEditing && quillController != null) crossAxisAlignment: CrossAxisAlignment.stretch,
CommentEditorCard( children: [
controller: quillController, TextField(
onCancel: () => directoryController.editingCommentId.value = null, controller: textController,
onSave: (ctrl) async { maxLines: null,
final delta = ctrl.document.toDelta(); minLines: 5,
final htmlOutput = _convertDeltaToHtml(delta); decoration: InputDecoration(
final updated = comment.copyWith(note: htmlOutput); hintText: "Edit note...",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.all(12),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
directoryController.editingCommentId.value = null,
icon: const Icon(Icons.close, color: Colors.white),
label: const Text(
"Cancel",
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final updated =
comment.copyWith(note: textController.text);
await directoryController.updateComment(updated); await directoryController.updateComment(updated);
await directoryController.fetchCommentsForContact(contactId); await directoryController
.fetchCommentsForContact(contactId);
directoryController.editingCommentId.value = null; directoryController.editingCommentId.value = null;
}, },
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: const Text(
"Save",
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
) )
else else
html.Html( Text(
data: comment.note, comment.note,
style: { style: TextStyle(color: Colors.grey[800], fontSize: 14),
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize(14),
color: Colors.black87,
),
"p": html.Style(
margin: html.Margins.only(bottom: 6),
lineHeight: const html.LineHeight(1.4),
),
"strong": html.Style(
fontWeight: FontWeight.w700,
color: Colors.black87,
),
},
), ),
], ],
), ),
@ -662,7 +623,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
} }
} }
// Helper widget for Project label in AppBar
class ProjectLabel extends StatelessWidget { class ProjectLabel extends StatelessWidget {
final String? projectName; final String? projectName;
const ProjectLabel(this.projectName, {super.key}); const ProjectLabel(this.projectName, {super.key});

View File

@ -17,6 +17,7 @@ import 'package:marco/view/directory/contact_detail_screen.dart';
import 'package:marco/view/directory/manage_bucket_screen.dart'; import 'package:marco/view/directory/manage_bucket_screen.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
class DirectoryView extends StatefulWidget { class DirectoryView extends StatefulWidget {
@override @override
@ -89,7 +90,7 @@ class _DirectoryViewState extends State<DirectoryView> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(5),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -114,7 +115,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.grey[300], backgroundColor: Colors.grey[300],
foregroundColor: Colors.black, foregroundColor: Colors.black,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
@ -129,7 +130,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
@ -179,6 +180,7 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
body: Column( body: Column(
children: [ children: [
// Search + Filter + More menu
Padding( Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
@ -200,9 +202,8 @@ class _DirectoryViewState extends State<DirectoryView> {
suffixIcon: ValueListenableBuilder<TextEditingValue>( suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController, valueListenable: searchController,
builder: (context, value, _) { builder: (context, value, _) {
if (value.text.isEmpty) { if (value.text.isEmpty)
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return IconButton( return IconButton(
icon: const Icon(Icons.clear, icon: const Icon(Icons.clear,
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
@ -254,7 +255,7 @@ class _DirectoryViewState extends State<DirectoryView> {
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(
top: Radius.circular(20)), top: Radius.circular(5)),
), ),
builder: (_) => builder: (_) =>
const DirectoryFilterBottomSheet(), const DirectoryFilterBottomSheet(),
@ -292,8 +293,7 @@ class _DirectoryViewState extends State<DirectoryView> {
icon: const Icon(Icons.more_vert, icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5)),
),
itemBuilder: (context) { itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = []; List<PopupMenuEntry<int>> menuItems = [];
@ -302,17 +302,13 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
child: Text( child: Text("Actions",
"Actions",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.grey, color: Colors.grey)),
),
),
), ),
); );
// Conditionally show Create Bucket option
if (permissionController if (permissionController
.hasPermission(Permissions.directoryAdmin) || .hasPermission(Permissions.directoryAdmin) ||
permissionController permissionController
@ -378,13 +374,10 @@ class _DirectoryViewState extends State<DirectoryView> {
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
child: Text( child: Text("Preferences",
"Preferences",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.grey, color: Colors.grey)),
),
),
), ),
); );
@ -398,7 +391,8 @@ class _DirectoryViewState extends State<DirectoryView> {
const Icon(Icons.visibility_off_outlined, const Icon(Icons.visibility_off_outlined,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
const SizedBox(width: 10), const SizedBox(width: 10),
const Expanded(child: Text('Show Deleted Contacts')), const Expanded(
child: Text('Show Deleted Contacts')),
Switch.adaptive( Switch.adaptive(
value: !controller.isActive.value, value: !controller.isActive.value,
activeColor: Colors.indigo, activeColor: Colors.indigo,
@ -420,6 +414,7 @@ class _DirectoryViewState extends State<DirectoryView> {
], ],
), ),
), ),
// Contact List
Expanded( Expanded(
child: Obx(() { child: Obx(() {
return MyRefreshIndicator( return MyRefreshIndicator(
@ -445,81 +440,121 @@ class _DirectoryViewState extends State<DirectoryView> {
itemBuilder: (_, index) { itemBuilder: (_, index) {
final contact = final contact =
controller.filteredContacts[index]; controller.filteredContacts[index];
final nameParts = contact.name.trim().split(" "); final isDeleted = !controller
.isActive.value; // mark deleted contacts
final nameParts =
contact.name.trim().split(" ");
final firstName = nameParts.first; final firstName = nameParts.first;
final lastName = final lastName =
nameParts.length > 1 ? nameParts.last : ""; nameParts.length > 1 ? nameParts.last : "";
final tags = final tags = contact.tags
contact.tags.map((tag) => tag.name).toList(); .map((tag) => tag.name)
.toList();
return InkWell( return Card(
onTap: () { shape: RoundedRectangleBorder(
Get.to(() => borderRadius: BorderRadius.circular(5),
ContactDetailScreen(contact: contact)); ),
}, elevation: 3,
shadowColor: Colors.grey.withOpacity(0.3),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(5),
onTap: isDeleted
? null
: () => Get.to(() =>
ContactDetailScreen(
contact: contact)),
child: Padding( child: Padding(
padding: padding: const EdgeInsets.all(12),
const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row( child: Row(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
// Avatar
Avatar( Avatar(
firstName: firstName, firstName: firstName,
lastName: lastName, lastName: lastName,
size: 35), size: 40,
backgroundColor: isDeleted
? Colors.grey.shade400
: null,
),
MySpacing.width(12), MySpacing.width(12),
// Contact Info
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
MyText.titleSmall(contact.name, MyText.titleSmall(
contact.name,
fontWeight: 600, fontWeight: 600,
overflow: overflow:
TextOverflow.ellipsis), TextOverflow.ellipsis,
color: isDeleted
? Colors.grey
: Colors.black87,
),
MyText.bodySmall( MyText.bodySmall(
contact.organization, contact.organization,
color: Colors.grey[700], color: isDeleted
? Colors.grey
: Colors.grey[700],
overflow: overflow:
TextOverflow.ellipsis), TextOverflow.ellipsis,
MySpacing.height(8), ),
MySpacing.height(6),
if (contact if (contact
.contactEmails.isNotEmpty) .contactEmails.isNotEmpty)
GestureDetector( Padding(
onTap: () =>
LauncherUtils.launchEmail(
contact
.contactEmails
.first
.emailAddress),
onLongPress: () => LauncherUtils
.copyToClipboard(
contact.contactEmails.first
.emailAddress,
typeLabel: 'Email',
),
child: Padding(
padding: padding:
const EdgeInsets.only( const EdgeInsets.only(
bottom: 4), bottom: 4),
child: Row( child: GestureDetector(
children: [ onTap: isDeleted
const Icon( ? null
Icons.email_outlined, : () => LauncherUtils
size: 16, .launchEmail(contact
color: Colors.indigo), .contactEmails
MySpacing.width(4), .first
Expanded( .emailAddress),
child: onLongPress: isDeleted
MyText.labelSmall( ? null
: () => LauncherUtils
.copyToClipboard(
contact contact
.contactEmails .contactEmails
.first .first
.emailAddress, .emailAddress,
overflow: TextOverflow typeLabel:
'Email',
),
child: Row(
children: [
Icon(
Icons
.email_outlined,
size: 16,
color: isDeleted
? Colors.grey
: Colors
.indigo),
MySpacing.width(4),
Expanded(
child: MyText
.labelSmall(
contact
.contactEmails
.first
.emailAddress,
overflow:
TextOverflow
.ellipsis, .ellipsis,
color: Colors.indigo, color: isDeleted
? Colors.grey
: Colors
.indigo,
decoration: decoration:
TextDecoration TextDecoration
.underline, .underline,
@ -532,35 +567,47 @@ class _DirectoryViewState extends State<DirectoryView> {
if (contact if (contact
.contactPhones.isNotEmpty) .contactPhones.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only( padding:
const EdgeInsets.only(
bottom: 8, top: 4), bottom: 8, top: 4),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: GestureDetector( child:
onTap: () => LauncherUtils GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchPhone(contact .launchPhone(contact
.contactPhones .contactPhones
.first .first
.phoneNumber), .phoneNumber),
onLongPress: () => onLongPress:
isDeleted
? null
: () =>
LauncherUtils LauncherUtils
.copyToClipboard( .copyToClipboard(
contact contact
.contactPhones .contactPhones
.first .first
.phoneNumber, .phoneNumber,
typeLabel: 'Phone', typeLabel:
'Phone',
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons Icons
.phone_outlined, .phone_outlined,
size: 16, size: 16,
color: Colors color: isDeleted
? Colors
.grey
: Colors
.indigo), .indigo),
MySpacing.width(4), MySpacing.width(
4),
Expanded( Expanded(
child: MyText child: MyText
.labelSmall( .labelSmall(
@ -571,7 +618,10 @@ class _DirectoryViewState extends State<DirectoryView> {
overflow: overflow:
TextOverflow TextOverflow
.ellipsis, .ellipsis,
color: Colors color: isDeleted
? Colors
.grey
: Colors
.indigo, .indigo,
decoration: decoration:
TextDecoration TextDecoration
@ -584,47 +634,127 @@ class _DirectoryViewState extends State<DirectoryView> {
), ),
MySpacing.width(8), MySpacing.width(8),
GestureDetector( GestureDetector(
onTap: () => LauncherUtils onTap: isDeleted
.launchWhatsApp( ? null
contact : () => LauncherUtils
.launchWhatsApp(contact
.contactPhones .contactPhones
.first .first
.phoneNumber), .phoneNumber),
child: const FaIcon( child: FaIcon(
FontAwesomeIcons FontAwesomeIcons
.whatsapp, .whatsapp,
color: Colors.green, color: isDeleted
size: 25, ? Colors.grey
: Colors
.green,
size: 25),
),
],
),
),
if (tags.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
top: 0),
child: Wrap(
spacing: 6,
runSpacing: 2,
children: tags
.map(
(tag) => Chip(
label: Text(tag),
backgroundColor:
Colors.indigo
.shade50,
labelStyle: TextStyle(
color: isDeleted
? Colors
.grey
: Colors
.indigo,
fontSize: 12),
visualDensity:
VisualDensity
.compact,
shape:
RoundedRectangleBorder(
borderRadius:
BorderRadius
.circular(
5),
),
),
)
.toList(),
), ),
), ),
], ],
), ),
), ),
if (tags.isNotEmpty) ...[ // Actions Column (Arrow + Icons)
MySpacing.height(2),
MyText.labelSmall(tags.join(', '),
color: Colors.grey[500],
maxLines: 1,
overflow:
TextOverflow.ellipsis),
],
],
),
),
Column( Column(
children: [ children: [
const Icon(Icons.arrow_forward_ios, IconButton(
color: Colors.grey, size: 16), icon: Icon(
MySpacing.height(8), isDeleted
], ? Icons.restore
), : Icons.delete,
], color: isDeleted
? Colors.green
: Colors.redAccent,
size: 20,
), ),
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: isDeleted
? "Restore Contact"
: "Delete Contact",
message: isDeleted
? "Are you sure you want to restore this contact?"
: "Are you sure you want to delete this contact?",
confirmText: isDeleted
? "Restore"
: "Delete",
confirmColor: isDeleted
? Colors.green
: Colors.redAccent,
icon: isDeleted
? Icons.restore
: Icons
.delete_forever,
onConfirm: () async {
if (isDeleted) {
await controller
.restoreContact(
contact.id);
} else {
await controller
.deleteContact(
contact.id);
}
},
), ),
barrierDismissible: false,
); );
}, },
), ),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey,
size: 20,
)
],
),
],
),
),
),
); );
}));
}), }),
) )
], ],

View File

@ -1,24 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:flutter_html/flutter_html.dart' as html;
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/directory/notes_controller.dart'; import 'package:marco/controller/directory/notes_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class NotesView extends StatelessWidget { class NotesView extends StatefulWidget {
const NotesView({super.key});
@override
State<NotesView> createState() => _NotesViewState();
}
class _NotesViewState extends State<NotesView> with UIMixin {
final NotesController controller = Get.find(); final NotesController controller = Get.find();
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
NotesView({super.key});
Future<void> _refreshNotes() async { Future<void> _refreshNotes() async {
try { try {
await controller.fetchNotes(); await controller.fetchNotes();
@ -28,50 +29,6 @@ class NotesView extends StatelessWidget {
} }
} }
String _convertDeltaToHtml(dynamic delta) {
final buffer = StringBuffer();
bool inList = false;
for (var op in delta.toList()) {
final data = op.data?.toString() ?? '';
final attr = op.attributes ?? {};
final isListItem = attr.containsKey('list');
if (isListItem && !inList) {
buffer.write('<ul>');
inList = true;
}
if (!isListItem && inList) {
buffer.write('</ul>');
inList = false;
}
if (isListItem) buffer.write('<li>');
if (attr.containsKey('bold')) buffer.write('<strong>');
if (attr.containsKey('italic')) buffer.write('<em>');
if (attr.containsKey('underline')) buffer.write('<u>');
if (attr.containsKey('strike')) buffer.write('<s>');
if (attr.containsKey('link')) buffer.write('<a href="${attr['link']}">');
buffer.write(data.replaceAll('\n', ''));
if (attr.containsKey('link')) buffer.write('</a>');
if (attr.containsKey('strike')) buffer.write('</s>');
if (attr.containsKey('underline')) buffer.write('</u>');
if (attr.containsKey('italic')) buffer.write('</em>');
if (attr.containsKey('bold')) buffer.write('</strong>');
if (isListItem)
buffer.write('</li>');
else if (data.contains('\n')) buffer.write('<br>');
}
if (inList) buffer.write('</ul>');
return buffer.toString();
}
Widget _buildEmptyState() { Widget _buildEmptyState() {
return Center( return Center(
child: Column( child: Column(
@ -94,11 +51,206 @@ class NotesView extends StatelessWidget {
); );
} }
Widget _buildNoteItem(note) {
final isEditing = controller.editingNoteId.value == note.id;
final textController = TextEditingController(text: note.note);
final initials = note.contactName.trim().isNotEmpty
? note.contactName
.trim()
.split(' ')
.map((e) => e[0])
.take(2)
.join()
.toUpperCase()
: "NA";
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Avatar + Name + Timestamp + Actions
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: initials, lastName: '', size: 40),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(
"${note.contactName} (${note.organizationName})",
fontWeight: 600,
color: Colors.black87,
),
const SizedBox(height: 2),
MyText.bodySmall(
"by ${note.createdBy.firstName} ${note.createdBy.lastName}"
"${DateTimeUtils.convertUtcToLocal(note.createdAt.toString(), format: 'dd MMM yyyy, hh:mm a')}",
color: Colors.grey[600],
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!note.isActive)
IconButton(
icon: const Icon(Icons.restore,
size: 18, color: Colors.green),
tooltip: "Restore",
splashRadius: 18,
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Restore Note",
message:
"Are you sure you want to restore this note?",
confirmText: "Restore",
confirmColor: Colors.green,
icon: Icons.restore,
onConfirm: () async {
await controller.restoreOrDeleteNote(note,
restore: true);
},
),
);
},
),
if (note.isActive) ...[
IconButton(
icon: Icon(isEditing ? Icons.close : Icons.edit_outlined,
color: Colors.indigo, size: 18),
splashRadius: 18,
onPressed: () {
controller.editingNoteId.value =
isEditing ? null : note.id;
},
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 18, color: Colors.red),
splashRadius: 18,
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Delete Note",
message:
"Are you sure you want to delete this note?",
confirmText: "Delete",
confirmColor: Colors.red,
icon: Icons.delete_forever,
onConfirm: () async {
await controller.restoreOrDeleteNote(note,
restore: false);
},
),
);
},
),
],
],
),
],
),
const SizedBox(height: 8),
// Content: TextField when editing or plain text
if (isEditing)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: textController,
maxLines: null,
minLines: 5,
decoration: InputDecoration(
hintText: "Edit note...",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5)),
contentPadding: const EdgeInsets.all(12),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => controller.editingNoteId.value = null,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final updated =
note.copyWith(note: textController.text);
await controller.updateNote(updated);
controller.editingNoteId.value = null;
},
icon: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: MyText.bodyMedium(
"Save",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
],
)
else
Text(
note.note,
style: TextStyle(color: Colors.grey[800], fontSize: 14),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
/// 🔍 Search + Refresh (Top Row) // Search
Padding( Padding(
padding: MySpacing.xy(8, 8), padding: MySpacing.xy(8, 8),
child: Row( child: Row(
@ -132,7 +284,7 @@ class NotesView extends StatelessWidget {
), ),
), ),
/// 📄 Notes List View // Notes List
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
@ -168,196 +320,8 @@ class NotesView extends StatelessWidget {
padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80), padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 80),
itemCount: notes.length, itemCount: notes.length,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) { itemBuilder: (_, index) =>
final note = notes[index]; Obx(() => _buildNoteItem(notes[index])),
return Obx(() {
final isEditing = controller.editingNoteId.value == note.id;
final initials = note.contactName.trim().isNotEmpty
? note.contactName
.trim()
.split(' ')
.map((e) => e[0])
.take(2)
.join()
.toUpperCase()
: "NA";
final createdDate = DateTimeUtils.convertUtcToLocal(
note.createdAt.toString(),
format: 'dd MMM yyyy');
final createdTime = DateTimeUtils.convertUtcToLocal(
note.createdAt.toString(),
format: 'hh:mm a');
final decodedDelta = HtmlToDelta().convert(note.note);
final quillController = isEditing
? quill.QuillController(
document: quill.Document.fromDelta(decodedDelta),
selection: TextSelection.collapsed(
offset: decodedDelta.length),
)
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: MySpacing.xy(12, 12),
decoration: BoxDecoration(
color: isEditing ? Colors.indigo[50] : Colors.white,
border: Border.all(
color:
isEditing ? Colors.indigo : Colors.grey.shade300,
width: 1.1,
),
borderRadius: BorderRadius.circular(5),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: initials, lastName: '', size: 40),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(
"${note.contactName} (${note.organizationName})",
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.indigo[800],
),
MyText.bodySmall(
"by ${note.createdBy.firstName}$createdDate, $createdTime",
color: Colors.grey[600],
),
],
),
),
/// Edit / Delete / Restore Icons
if (!note.isActive)
IconButton(
icon: const Icon(Icons.restore,
color: Colors.green, size: 20),
tooltip: "Restore",
padding: EdgeInsets
.zero,
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Restore Note",
message:
"Are you sure you want to restore this note?",
confirmText: "Restore",
confirmColor: Colors.green,
icon: Icons.restore,
onConfirm: () async {
await controller.restoreOrDeleteNote(
note,
restore: true);
},
),
barrierDismissible: false,
);
},
)
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
/// Edit Icon
IconButton(
icon: Icon(
isEditing ? Icons.close : Icons.edit,
color: Colors.indigo,
size: 20,
),
padding: EdgeInsets
.zero,
constraints:
const BoxConstraints(),
onPressed: () {
controller.editingNoteId.value =
isEditing ? null : note.id;
},
),
const SizedBox(
width: 6),
/// Delete Icon
IconButton(
icon: const Icon(Icons.delete_outline,
color: Colors.redAccent, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: "Delete Note",
message:
"Are you sure you want to delete this note?",
confirmText: "Delete",
confirmColor: Colors.redAccent,
icon: Icons.delete_forever,
onConfirm: () async {
await controller
.restoreOrDeleteNote(note,
restore: false);
},
),
barrierDismissible: false,
);
},
),
],
),
],
),
MySpacing.height(12),
/// Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () =>
controller.editingNoteId.value = null,
onSave: (quillCtrl) async {
final delta = quillCtrl.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated = note.copyWith(note: htmlOutput);
await controller.updateNote(updated);
controller.editingNoteId.value = null;
},
)
else
html.Html(
data: note.note,
style: {
"body": html.Style(
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize.medium,
color: Colors.black87,
),
},
),
],
),
);
});
},
), ),
); );
}), }),

View File

@ -313,7 +313,7 @@ Widget _switchTenantRow() {
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),
_menuItemRow( _menuItemRow(
icon: LucideIcons.badge_help, icon: LucideIcons.badge_alert,
label: 'Support', label: 'Support',
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),

View File

@ -1,572 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlanning/task_action_buttons.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/tenant/service_controller.dart';
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key});
@override
State<DailyProgressReportScreen> createState() =>
_DailyProgressReportScreenState();
}
class TaskChartData {
final String label;
final num value;
final Color color;
TaskChartData(this.label, this.value, this.color);
}
class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
with UIMixin {
final DailyTaskController dailyTaskController =
Get.put(DailyTaskController());
final PermissionController permissionController =
Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController());
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 100 &&
dailyTaskController.hasMore &&
!dailyTaskController.isLoadingMore.value) {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null && projectId.isNotEmpty) {
dailyTaskController.fetchTaskData(
projectId,
pageNumber: dailyTaskController.currentPage + 1,
pageSize: dailyTaskController.pageSize,
isLoadMore: true,
);
}
}
});
final initialProjectId = projectController.selectedProjectId.value;
if (initialProjectId.isNotEmpty) {
dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId);
serviceController.fetchServices(initialProjectId);
}
// Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
await serviceController.fetchServices(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
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(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
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: SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
// --- ADD SERVICE SELECTOR HERE ---
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
dailyTaskController.selectedProjectId;
if (projectId?.isNotEmpty ?? false) {
await dailyTaskController.fetchTaskData(
projectId!,
serviceIds:
service != null ? [service.id] : null,
pageNumber: 1,
pageSize: 20,
);
}
},
),
),
_buildActionBar(),
Padding(
padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
),
],
),
),
),
);
}
Widget _buildActionBar() {
return Padding(
padding: MySpacing.x(flexSpacing),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildActionItem(
label: "Filter",
icon: Icons.tune,
tooltip: 'Filter Project',
onTap: _openFilterSheet,
),
],
),
);
}
Widget _buildActionItem({
required String label,
required IconData icon,
required String tooltip,
required VoidCallback onTap,
Color? color,
}) {
return Row(
children: [
MyText.bodyMedium(label, fontWeight: 600),
Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(icon, color: color, size: 22),
),
),
),
),
],
);
}
Future<void> _openFilterSheet() async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DailyProgressReportFilter(
controller: dailyTaskController,
permissionController: permissionController,
),
);
if (result != null) {
final selectedProjectId = result['projectId'] as String?;
if (selectedProjectId != null &&
selectedProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = selectedProjectId;
await dailyTaskController.fetchTaskData(selectedProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
}
}
Future<void> _refreshData() async {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null) {
try {
await dailyTaskController.fetchTaskData(projectId);
} catch (e) {
debugPrint('Error refreshing task data: $e');
}
}
}
void _showTeamMembersBottomSheet(List<dynamic> members) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) {
return GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Team Members',
fontWeight: 600,
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 8),
...members.map((member) {
final firstName = member.firstName ?? 'Unnamed';
final lastName = member.lastName ?? 'User';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 31,
),
title: MyText.bodyMedium(
'$firstName $lastName',
fontWeight: 600,
),
);
}),
const SizedBox(height: 8),
],
),
),
);
},
);
}
Widget _buildDailyProgressReportTab() {
return Obx(() {
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
// Initial loading skeleton
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
}
// No tasks
if (groupedTasks.isEmpty) {
return Center(
child: MyText.bodySmall(
"No Progress Report Found",
fontWeight: 600,
),
);
}
final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a));
// If only one date, make it expanded by default
if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]);
}
return MyCard.bordered(
borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 8,
child: ListView.builder(
controller: _scrollController,
shrinkWrap: true,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: sortedDates.length + 1, // +1 for loading indicator
itemBuilder: (context, dateIndex) {
// Bottom loading indicator
if (dateIndex == sortedDates.length) {
return Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink());
}
final dateKey = sortedDates[dateIndex];
final tasksForDate = groupedTasks[dateKey]!;
final date = DateTime.tryParse(dateKey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
date != null
? DateFormat('dd MMM yyyy').format(date)
: dateKey,
fontWeight: 700,
),
Obx(() => Icon(
dailyTaskController.expandedDates.contains(dateKey)
? Icons.remove_circle
: Icons.add_circle,
color: Colors.blueAccent,
)),
],
),
),
Obx(() {
if (!dailyTaskController.expandedDates.contains(dateKey)) {
return const SizedBox.shrink();
}
return Column(
children: tasksForDate.asMap().entries.map((entry) {
final task = entry.value;
final activityName =
task.workItem?.activityMaster?.activityName ?? 'N/A';
final activityId = task.workItem?.activityMaster?.id;
final workAreaId = task.workItem?.workArea?.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName
].where((e) => e?.isNotEmpty ?? false).join(' > ');
final planned = task.plannedTask;
final completed = task.completedTask;
final progress = (planned != 0)
? (completed / planned).clamp(0.0, 1.0)
: 0.0;
final parentTaskID = task.id;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: MyContainer(
paddingAll: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(activityName, fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(location, color: Colors.grey),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _showTeamMembersBottomSheet(
task.teamMembers),
child: Row(
children: [
const Icon(Icons.group,
size: 18, color: Colors.blueAccent),
const SizedBox(width: 6),
MyText.bodyMedium('Team',
color: Colors.blueAccent,
fontWeight: 600),
],
),
),
const SizedBox(height: 8),
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
color: Colors.black87,
),
const SizedBox(height: 6),
Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
),
FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 5,
decoration: BoxDecoration(
color: progress >= 1.0
? Colors.green
: progress >= 0.5
? Colors.amber
: Colors.red,
borderRadius: BorderRadius.circular(6),
),
),
),
],
),
const SizedBox(height: 4),
MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500,
color: progress >= 1.0
? Colors.green[700]
: progress >= 0.5
? Colors.amber[800]
: Colors.red[700],
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if ((task.reportedDate == null ||
task.reportedDate
.toString()
.isEmpty) &&
permissionController.hasPermission(
Permissions.assignReportTask)) ...[
TaskActionButtons.reportButton(
context: context,
task: task,
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 4),
] else if (task.approvedBy == null &&
permissionController.hasPermission(
Permissions.approveTask)) ...[
TaskActionButtons.reportActionButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 5),
],
TaskActionButtons.commentButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
refreshCallback: _refreshData,
),
],
),
),
],
),
),
);
}).toList(),
);
})
],
);
},
),
);
});
}
}

View File

@ -0,0 +1,562 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlanning/task_action_buttons.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key});
@override
State<DailyProgressReportScreen> createState() =>
_DailyProgressReportScreenState();
}
class TaskChartData {
final String label;
final num value;
final Color color;
TaskChartData(this.label, this.value, this.color);
}
class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
with UIMixin {
final DailyTaskController dailyTaskController =
Get.put(DailyTaskController());
final PermissionController permissionController =
Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 100 &&
dailyTaskController.hasMore &&
!dailyTaskController.isLoadingMore.value) {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null && projectId.isNotEmpty) {
dailyTaskController.fetchTaskData(
projectId,
pageNumber: dailyTaskController.currentPage + 1,
pageSize: dailyTaskController.pageSize,
isLoadMore: true,
);
}
}
});
final initialProjectId = projectController.selectedProjectId.value;
if (initialProjectId.isNotEmpty) {
dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId);
}
// Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
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(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
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: SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: _openFilterSheet,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
children: [
MyText.bodySmall(
"Filter",
fontWeight: 600,
color: Colors.black,
),
const SizedBox(width: 4),
Icon(Icons.tune,
size: 20, color: Colors.black),
],
),
),
),
],
),
),
MySpacing.height(8),
Padding(
padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
),
],
),
),
),
);
}
Future<void> _openFilterSheet() async {
// Fetch filter data first
if (dailyTaskController.taskFilterData == null) {
await dailyTaskController
.fetchTaskFilter(dailyTaskController.selectedProjectId ?? '');
}
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DailyTaskFilterBottomSheet(
controller: dailyTaskController,
),
);
if (result != null) {
final selectedProjectId = result['projectId'] as String?;
if (selectedProjectId != null &&
selectedProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = selectedProjectId;
await dailyTaskController.fetchTaskData(selectedProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
}
}
Future<void> _refreshData() async {
final projectId = dailyTaskController.selectedProjectId;
if (projectId != null) {
try {
await dailyTaskController.fetchTaskData(projectId);
} catch (e) {
debugPrint('Error refreshing task data: $e');
}
}
}
void _showTeamMembersBottomSheet(List<dynamic> members) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) {
return GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Team Members',
fontWeight: 600,
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 8),
...members.map((member) {
final firstName = member.firstName ?? 'Unnamed';
final lastName = member.lastName ?? 'User';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Avatar(
firstName: firstName,
lastName: lastName,
size: 31,
),
title: MyText.bodyMedium(
'$firstName $lastName',
fontWeight: 600,
),
);
}),
const SizedBox(height: 8),
],
),
),
);
},
);
}
Widget _buildDailyProgressReportTab() {
return Obx(() {
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
// 🟡 Show loading skeleton on first load
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
}
// No data available
if (groupedTasks.isEmpty) {
return Center(
child: MyText.bodySmall(
"No Progress Report Found",
fontWeight: 600,
),
);
}
// 🔽 Sort all date keys by descending (latest first)
final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a));
// 🔹 Auto expand if only one date present
if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]);
}
// 🧱 Return a scrollable column of cards
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...sortedDates.map((dateKey) {
final tasksForDate = groupedTasks[dateKey]!;
final date = DateTime.tryParse(dateKey);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyCard.bordered(
borderRadiusAll: 10,
border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow:
MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🗓 Date Header
GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
date != null
? DateFormat('dd MMM yyyy').format(date)
: dateKey,
fontWeight: 700,
),
Obx(() => Icon(
dailyTaskController.expandedDates
.contains(dateKey)
? Icons.remove_circle
: Icons.add_circle,
color: Colors.blueAccent,
)),
],
),
),
),
// 🔽 Task List (expandable)
Obx(() {
if (!dailyTaskController.expandedDates
.contains(dateKey)) {
return const SizedBox.shrink();
}
return Column(
children: tasksForDate.map((task) {
final activityName =
task.workItem?.activityMaster?.activityName ??
'N/A';
final activityId = task.workItem?.activityMaster?.id;
final workAreaId = task.workItem?.workArea?.id;
final location = [
task.workItem?.workArea?.floor?.building?.name,
task.workItem?.workArea?.floor?.floorName,
task.workItem?.workArea?.areaName
].where((e) => e?.isNotEmpty ?? false).join(' > ');
final planned = task.plannedTask;
final completed = task.completedTask;
final progress = (planned != 0)
? (completed / planned).clamp(0.0, 1.0)
: 0.0;
final parentTaskID = task.id;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MyContainer(
paddingAll: 12,
borderRadiusAll: 8,
border: Border.all(
color: Colors.grey.withOpacity(0.2)),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏗 Activity name & location
MyText.bodyMedium(activityName,
fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(location,
color: Colors.grey),
const SizedBox(height: 8),
// 👥 Team Members
GestureDetector(
onTap: () => _showTeamMembersBottomSheet(
task.teamMembers),
child: Row(
children: [
const Icon(Icons.group,
size: 18, color: Colors.blueAccent),
const SizedBox(width: 6),
MyText.bodyMedium(
'Team',
color: Colors.blueAccent,
fontWeight: 600,
),
],
),
),
const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
color: Colors.black87,
),
const SizedBox(height: 6),
Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius:
BorderRadius.circular(6),
),
),
FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 5,
decoration: BoxDecoration(
color: progress >= 1.0
? Colors.green
: progress >= 0.5
? Colors.amber
: Colors.red,
borderRadius:
BorderRadius.circular(6),
),
),
),
],
),
const SizedBox(height: 4),
MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500,
color: progress >= 1.0
? Colors.green[700]
: progress >= 0.5
? Colors.amber[800]
: Colors.red[700],
),
const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
primary: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if ((task.reportedDate == null ||
task.reportedDate
.toString()
.isEmpty) &&
permissionController.hasPermission(
Permissions
.assignReportTask)) ...[
TaskActionButtons.reportButton(
context: context,
task: task,
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 4),
] else if (task.approvedBy == null &&
permissionController.hasPermission(
Permissions.approveTask)) ...[
TaskActionButtons.reportActionButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
completed: completed.toInt(),
refreshCallback: _refreshData,
),
const SizedBox(width: 5),
],
TaskActionButtons.commentButton(
context: context,
task: task,
parentTaskID: parentTaskID,
workAreaId: workAreaId.toString(),
activityId: activityId.toString(),
refreshCallback: _refreshData,
),
],
),
),
],
),
),
);
}).toList(),
);
}),
],
),
),
);
}),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink()),
],
);
});
}
}

View File

@ -40,16 +40,16 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(projectId); dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId); // <-- Fetch services here serviceController.fetchServices(projectId);
} }
// Whenever project changes, fetch tasks & services
ever<String>( ever<String>(
projectController.selectedProjectId, projectController.selectedProjectId,
(newProjectId) { (newProjectId) {
if (newProjectId.isNotEmpty) { if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId); dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController serviceController.fetchServices(newProjectId);
.fetchServices(newProjectId);
} }
}, },
); );
@ -123,18 +123,19 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
try { try {
await dailyTaskPlanningController.fetchTaskData(projectId); await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) { } catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}'); debugPrint('Error refreshing task data: ${e.toString()}');
} }
} }
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
physics: physics: const AlwaysScrollableScrollPhysics(),
const AlwaysScrollableScrollPhysics(), // <-- always allow drag
padding: MySpacing.x(0), padding: MySpacing.x(0),
child: ConstrainedBox( child: ConstrainedBox(
// <-- ensures full screen height
constraints: BoxConstraints( constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height - minHeight: MediaQuery.of(context).size.height -
kToolbarHeight - kToolbarHeight -
@ -159,8 +160,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData( await dailyTaskPlanningController.fetchTaskData(
projectId, projectId,
// serviceId: service serviceId:
// ?.id, service?.id, // <-- pass selected service
); );
} }
}, },
@ -184,7 +185,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Widget dailyProgressReportTab() { Widget dailyProgressReportTab() {
return Obx(() { return Obx(() {
final isLoading = dailyTaskPlanningController.isLoading.value; final isLoading = dailyTaskPlanningController.isFetchingTasks.value;
final dailyTasks = dailyTaskPlanningController.dailyTasks; final dailyTasks = dailyTaskPlanningController.dailyTasks;
if (isLoading) { if (isLoading) {
@ -288,7 +289,6 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final validWorkAreas = floor.workAreas final validWorkAreas = floor.workAreas
.where((area) => area.workItems.isNotEmpty); .where((area) => area.workItems.isNotEmpty);
// For each valid work area, return a Floor+WorkArea ExpansionTile
return validWorkAreas.map((area) { return validWorkAreas.map((area) {
final floorWorkAreaKey = final floorWorkAreaKey =
"${buildingKey}_${floor.floorName}_${area.areaName}"; "${buildingKey}_${floor.floorName}_${area.areaName}";
@ -302,6 +302,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final totalProgress = totalPlanned == 0 final totalProgress = totalPlanned == 0
? 0.0 ? 0.0
: (totalCompleted / totalPlanned).clamp(0.0, 1.0); : (totalCompleted / totalPlanned).clamp(0.0, 1.0);
return ExpansionTile( return ExpansionTile(
onExpansionChanged: (expanded) { onExpansionChanged: (expanded) {
setMainState(() { setMainState(() {
@ -353,7 +354,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
percent: totalProgress, percent: totalProgress,
center: Text( center: Text(
"${(totalProgress * 100).toStringAsFixed(0)}%", "${(totalProgress * 100).toStringAsFixed(0)}%",
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 10.0, fontSize: 10.0,
), ),
@ -439,7 +440,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
permissionController.hasPermission( permissionController.hasPermission(
Permissions.assignReportTask)) Permissions.assignReportTask))
IconButton( IconButton(
icon: Icon( icon: const Icon(
Icons.person_add_alt_1_rounded, Icons.person_add_alt_1_rounded,
color: color:
Color.fromARGB(255, 46, 161, 233), Color.fromARGB(255, 46, 161, 233),
@ -503,7 +504,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
), ),
], ],
), ),
SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodySmall( MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%", "${(progress * 100).toStringAsFixed(1)}%",
fontWeight: 500, fontWeight: 500,

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart'; import 'package:marco/images.dart';
@ -205,31 +206,35 @@ class TenantCardList extends StatelessWidget {
return const Center(child: CircularProgressIndicator(strokeWidth: 2)); return const Center(child: CircularProgressIndicator(strokeWidth: 2));
} }
if (controller.tenants.isEmpty) { final hasTenants = controller.tenants.isNotEmpty;
return Center(
child: MyText( return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!hasTenants) ...[
MyText(
"No dashboards available for your account.", "No dashboards available for your account.",
fontSize: 14, fontSize: 14,
color: Colors.black54, color: Colors.black54,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
); const SizedBox(height: 16),
} ],
return Column( if (hasTenants) ...controller.tenants.map(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...controller.tenants.map(
(tenant) => _TenantCard( (tenant) => _TenantCard(
tenant: tenant, tenant: tenant,
onTap: () => onTenantSelected(tenant.id), onTap: () => onTenantSelected(tenant.id),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton.icon( TextButton.icon(
onPressed: () => Get.back(), onPressed: () async {
icon: await LocalStorage.logout();
const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent), },
icon: const Icon(Icons.arrow_back, size: 20, color: Colors.redAccent),
label: MyText( label: MyText(
'Back to Login', 'Back to Login',
color: Colors.red, color: Colors.red,
@ -360,8 +365,8 @@ class _WavePainter extends CustomPainter {
final path1 = Path() final path1 = Path()
..moveTo(0, size.height * 0.2) ..moveTo(0, size.height * 0.2)
..quadraticBezierTo(size.width * 0.25, size.height * 0.05, ..quadraticBezierTo(
size.width * 0.5, size.height * 0.15) size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15)
..quadraticBezierTo( ..quadraticBezierTo(
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
..lineTo(size.width, 0) ..lineTo(size.width, 0)
@ -372,8 +377,7 @@ class _WavePainter extends CustomPainter {
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
final path2 = Path() final path2 = Path()
..moveTo(0, size.height * 0.25) ..moveTo(0, size.height * 0.25)
..quadraticBezierTo( ..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
..lineTo(size.width, 0) ..lineTo(size.width, 0)
..lineTo(0, 0) ..lineTo(0, 0)
..close(); ..close();

View File

@ -48,7 +48,6 @@ dependencies:
carousel_slider: ^5.0.0 carousel_slider: ^5.0.0
reorderable_grid: ^1.0.10 reorderable_grid: ^1.0.10
loading_animation_widget: ^1.3.0 loading_animation_widget: ^1.3.0
flutter_quill: ^10.8.5
intl: ^0.19.0 intl: ^0.19.0
syncfusion_flutter_core: ^29.1.40 syncfusion_flutter_core: ^29.1.40
syncfusion_flutter_sliders: ^29.1.40 syncfusion_flutter_sliders: ^29.1.40
@ -74,9 +73,6 @@ dependencies:
font_awesome_flutter: ^10.8.0 font_awesome_flutter: ^10.8.0
flutter_html: ^3.0.0 flutter_html: ^3.0.0
tab_indicator_styler: ^2.0.0 tab_indicator_styler: ^2.0.0
html_editor_enhanced: ^2.7.0
flutter_quill_delta_from_html: ^1.5.2
quill_delta: ^3.0.0-nullsafety.2
connectivity_plus: ^6.1.4 connectivity_plus: ^6.1.4
geocoding: ^4.0.0 geocoding: ^4.0.0
firebase_core: ^4.0.0 firebase_core: ^4.0.0