fixed the bugs

This commit is contained in:
Vaibhav Surve 2025-10-09 18:09:54 +05:30
parent e6238ca5b0
commit acb203848e
14 changed files with 1328 additions and 946 deletions

View File

@ -7,6 +7,7 @@ import 'package:marco/model/directory/directory_comment_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DirectoryController extends GetxController {
// Contacts
RxList<ContactModel> allContacts = <ContactModel>[].obs;
RxList<ContactModel> filteredContacts = <ContactModel>[].obs;
RxList<ContactCategory> contactCategories = <ContactCategory>[].obs;
@ -16,16 +17,10 @@ class DirectoryController extends GetxController {
RxBool isLoading = false.obs;
RxList<ContactBucket> contactBuckets = <ContactBucket>[].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;
}
// Notes / Comments
final Map<String, RxList<DirectoryComment>> activeCommentsMap = {};
final Map<String, RxList<DirectoryComment>> inactiveCommentsMap = {};
final editingCommentId = Rxn<String>();
@override
@ -34,26 +29,53 @@ class DirectoryController extends GetxController {
fetchContacts();
fetchBuckets();
}
// inside DirectoryController
// -------------------- COMMENTS --------------------
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);
final comments = data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
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;
}
}
}
RxList<DirectoryComment> combinedComments(String contactId) {
final active = getCommentsForContact(contactId, active: true).toList();
final inactive = getCommentsForContact(contactId, active: false).toList();
final combined = [...active, ...inactive]
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return combined.obs;
}
Future<void> updateComment(DirectoryComment comment) async {
try {
logSafe(
"Attempting to update comment. id: ${comment.id}, contactId: ${comment.contactId}");
final commentList = contactCommentsMap[comment.contactId];
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}");
}
getCommentsForContact(comment.contactId).firstWhereOrNull((c) => c.id == comment.id);
if (oldComment != null && oldComment.note.trim() == comment.note.trim()) {
logSafe("No changes detected in comment. id: ${comment.id}");
showAppSnackbar(
title: "No Changes",
message: "No changes were made to the comment.",
@ -62,33 +84,28 @@ class DirectoryController extends GetxController {
return;
}
final success = await ApiService.updateContactComment(
comment.id,
comment.note,
comment.contactId,
);
final success = await ApiService.updateContactComment(comment.id, comment.note, comment.contactId);
if (success) {
logSafe("Comment updated successfully. id: ${comment.id}");
await fetchCommentsForContact(comment.contactId);
await fetchCommentsForContact(comment.contactId, active: true);
await fetchCommentsForContact(comment.contactId, active: false);
// Show success message
showAppSnackbar(
title: "Success",
message: "Comment updated successfully.",
type: SnackbarType.success,
);
} else {
logSafe("Failed to update comment via API. id: ${comment.id}");
showAppSnackbar(
title: "Error",
message: "Failed to update comment.",
type: SnackbarType.error,
);
}
} catch (e, stackTrace) {
logSafe("Update comment failed: ${e.toString()}");
logSafe("StackTrace: ${stackTrace.toString()}");
} catch (e, stack) {
logSafe("Update comment failed: ${e.toString()}", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Failed to update comment.",
@ -97,45 +114,15 @@ 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 {
try {
logSafe("Deleting comment. id: $commentId");
final success = await ApiService.restoreContactComment(commentId, false);
if (success) {
logSafe("Comment deleted successfully. id: $commentId");
if (editingCommentId.value == commentId) editingCommentId.value = null;
// Refresh comments after deletion
await fetchCommentsForContact(contactId);
await fetchCommentsForContact(contactId, active: true);
await fetchCommentsForContact(contactId, active: false);
showAppSnackbar(
title: "Deleted",
@ -143,7 +130,6 @@ class DirectoryController extends GetxController {
type: SnackbarType.success,
);
} else {
logSafe("Failed to delete comment via API. id: $commentId");
showAppSnackbar(
title: "Error",
message: "Failed to delete comment.",
@ -151,8 +137,9 @@ class DirectoryController extends GetxController {
);
}
} catch (e, stack) {
logSafe("Delete comment failed: ${e.toString()}", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
logSafe("Delete comment failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while deleting comment.",
@ -161,18 +148,13 @@ class DirectoryController extends GetxController {
}
}
/// Restore a previously deleted comment
Future<void> restoreComment(String commentId, String contactId) async {
try {
logSafe("Restoring comment. id: $commentId");
final success = await ApiService.restoreContactComment(commentId, true);
if (success) {
logSafe("Comment restored successfully. id: $commentId");
// Refresh comments after restore
await fetchCommentsForContact(contactId);
await fetchCommentsForContact(contactId, active: true);
await fetchCommentsForContact(contactId, active: false);
showAppSnackbar(
title: "Restored",
@ -180,7 +162,6 @@ class DirectoryController extends GetxController {
type: SnackbarType.success,
);
} else {
logSafe("Failed to restore comment via API. id: $commentId");
showAppSnackbar(
title: "Error",
message: "Failed to restore comment.",
@ -188,8 +169,9 @@ class DirectoryController extends GetxController {
);
}
} catch (e, stack) {
logSafe("Restore comment failed: ${e.toString()}", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
logSafe("Restore comment failed: $e", level: LogLevel.error);
logSafe(stack.toString(), level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "Something went wrong while restoring comment.",
@ -198,6 +180,8 @@ class DirectoryController extends GetxController {
}
}
// -------------------- CONTACTS --------------------
Future<void> fetchBuckets() async {
try {
final response = await ApiService.getContactBucketList();
@ -219,7 +203,6 @@ class DirectoryController extends GetxController {
isLoading.value = true;
final response = await ApiService.getDirectoryData(isActive: active);
if (response != null) {
final contacts = response.map((e) => ContactModel.fromJson(e)).toList();
allContacts.assignAll(contacts);
@ -238,14 +221,12 @@ class DirectoryController extends GetxController {
void extractCategoriesFromContacts() {
final uniqueCategories = <String, ContactCategory>{};
for (final contact in allContacts) {
final category = contact.contactCategory;
if (category != null && !uniqueCategories.containsKey(category.id)) {
uniqueCategories[category.id] = category;
}
}
contactCategories.value = uniqueCategories.values.toList();
}
@ -271,11 +252,8 @@ class DirectoryController extends GetxController {
final categoryNameMatch =
contact.contactCategory?.name.toLowerCase().contains(query) ?? false;
final bucketNameMatch = contact.bucketIds.any((id) {
final bucketName = contactBuckets
.firstWhereOrNull((b) => b.id == id)
?.name
.toLowerCase() ??
'';
final bucketName =
contactBuckets.firstWhereOrNull((b) => b.id == id)?.name.toLowerCase() ?? '';
return bucketName.contains(query);
});
@ -291,7 +269,6 @@ class DirectoryController extends GetxController {
return categoryMatch && bucketMatch && searchMatch;
}).toList();
// 🔑 Ensure results are always alphabetically sorted
filteredContacts
.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/model/project_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 {
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 isLoadingMore = false.obs;
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(
String projectId, {
List<String>? serviceIds,
int pageNumber = 1,
int pageSize = 20,
bool isLoadMore = false,
@ -68,11 +84,19 @@ class DailyTaskController extends GetxController {
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(
projectId,
dateFrom: startDateTask,
dateTo: endDateTask,
serviceIds: serviceIds,
filter: filter,
pageNumber: pageNumber,
pageSize: pageSize,
);
@ -95,6 +119,35 @@ class DailyTaskController extends GetxController {
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(
BuildContext context,
DailyTaskController controller,

View File

@ -19,7 +19,9 @@ class DailyTaskPlanningController extends GetxController {
List<Map<String, dynamic>> roles = [];
RxBool isAssigningTask = false.obs;
RxnString selectedRoleId = RxnString();
RxBool isLoading = false.obs;
RxBool isFetchingTasks = true.obs;
RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs;
@override
void onInit() {
@ -109,7 +111,7 @@ class DailyTaskPlanningController extends GetxController {
}
Future<void> fetchProjects() async {
isLoading.value = true;
isFetchingProjects.value = true;
try {
final response = await ApiService.getProjects();
if (response?.isEmpty ?? true) {
@ -126,7 +128,7 @@ class DailyTaskPlanningController extends GetxController {
logSafe("Error fetching projects",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
isFetchingProjects.value = false;
}
}
@ -137,7 +139,7 @@ class DailyTaskPlanningController extends GetxController {
return;
}
isLoading.value = true;
isFetchingTasks.value = true;
try {
// Fetch infra details
final infraResponse = await ApiService.getInfraDetails(projectId);
@ -232,7 +234,7 @@ class DailyTaskPlanningController extends GetxController {
logSafe("Error fetching daily task data",
level: LogLevel.error, error: e, stackTrace: stack);
} finally {
isLoading.value = false;
isFetchingTasks.value = false;
update();
}
}
@ -244,7 +246,7 @@ class DailyTaskPlanningController extends GetxController {
return;
}
isLoading.value = true;
isFetchingEmployees.value = true;
try {
final response = await ApiService.getAllEmployeesByProject(projectId);
if (response != null && response.isNotEmpty) {
@ -272,7 +274,7 @@ class DailyTaskPlanningController extends GetxController {
stackTrace: stack,
);
} finally {
isLoading.value = false;
isFetchingEmployees.value = false;
update();
}
}

View File

@ -41,6 +41,7 @@ class ApiEndpoints {
static const String approveReportAction = "/task/approve";
static const String assignTask = "/project/task";
static const String getmasterWorkCategories = "/Master/work-categories";
static const String getDailyTaskProjectProgressFilter = "/task/filter";
////// Directory Module API Endpoints ///////
static const String getDirectoryContacts = "/directory";

View File

@ -21,6 +21,7 @@ import 'package:marco/model/document/document_version_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/dailyTaskPlanning/daily_task_model.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
class ApiService {
static const bool enableLogs = true;
@ -2125,43 +2126,68 @@ class ApiService {
}
// === 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(
String projectId, {
DateTime? dateFrom,
DateTime? dateTo,
List<String>? serviceIds,
Map<String, dynamic>? filter, // <-- New: combined filter
int pageNumber = 1,
int pageSize = 20,
}) async {
final filterBody = {
"serviceIds": serviceIds ?? [],
};
// Build query parameters
final query = {
"projectId": projectId,
"pageNumber": pageNumber.toString(),
"pageSize": pageSize.toString(),
if (dateFrom != null)
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
"filter": jsonEncode(filterBody),
if (filter != null) "filter": jsonEncode(filter),
};
final uri =
Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query);
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) {
return (parsed['data'] as List).map((e) => TaskModel.fromJson(e)).toList();
return (parsed['data'] as List)
.map((e) => TaskModel.fromJson(e))
.toList();
}
return null;
}
static Future<bool> reportTask({
required String id,
required int completedTask,

View File

@ -152,7 +152,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
Widget _buildEmployeeList() {
return Obx(() {
if (controller.isLoading.value) {
if (controller.isFetchingTasks.value) {
// Skeleton loader instead of CircularProgressIndicator
return ListView.separated(
shrinkWrap: true,

View File

@ -1,73 +1,200 @@
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/permission_controller.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';
class DailyProgressReportFilter extends StatelessWidget {
class DailyTaskFilterBottomSheet extends StatelessWidget {
final DailyTaskController controller;
final PermissionController permissionController;
const DailyProgressReportFilter({
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";
}
const DailyTaskFilterBottomSheet({super.key, required this.controller});
@override
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(
title: "Filter Tasks",
onCancel: () => Navigator.pop(context),
submitText: "Apply",
showButtons: hasFilters,
onCancel: () => Get.back(),
onSubmit: () {
Navigator.pop(context, {
'startDate': controller.startDateTask,
'endDate': controller.endDateTask,
});
if (controller.selectedProjectId != null) {
controller.fetchTaskData(
controller.selectedProjectId!,
);
}
Get.back();
},
child: Column(
child: SingleChildScrollView(
child: hasFilters
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Select Date Range", fontWeight: 600),
const SizedBox(height: 8),
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => controller.selectDateRangeForTaskData(
context,
controller,
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
controller.clearTaskFilters();
},
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(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.date_range, color: Colors.blue.shade600),
const SizedBox(width: 12),
Expanded(
child: Text(
getLabelText(),
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
child: MyText(
displayText,
style: const TextStyle(color: Colors.black87),
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

@ -479,7 +479,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
context: context,
initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
lastDate: DateTime.now(),
);
if (picked != null) {

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/Attendence/attendance_screen.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/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart';

View File

@ -17,6 +17,7 @@ import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/model/directory/directory_comment_model.dart';
// HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) {
@ -344,20 +345,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
Widget _buildCommentsTab() {
return Obx(() {
final contactId = contactRx.value.id;
// Get active and inactive comments
final activeComments = directoryController
.getCommentsForContact(contactId)
.where((c) => c.isActive)
.toList();
final inactiveComments = directoryController
.getCommentsForContact(contactId)
.where((c) => !c.isActive)
.toList();
// Combine both and keep the same sorting (recent first)
final comments =
[...activeComments, ...inactiveComments].reversed.toList();
final comments = directoryController.combinedComments(contactId);
final editingId = directoryController.editingCommentId.value;
return Stack(
@ -413,7 +401,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
});
}
Widget _buildCommentItem(comment, editingId, contactId) {
Widget _buildCommentItem(
DirectoryComment comment, String? editingId, String contactId) {
final isEditing = editingId == comment.id;
final initials = comment.createdBy.firstName.isNotEmpty
? comment.createdBy.firstName[0].toUpperCase()
@ -427,6 +416,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
)
: null;
final isInactive = !comment.isActive;
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
@ -459,29 +450,32 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full name on top
Text(
"${comment.createdBy.firstName} ${comment.createdBy.lastName}",
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: Colors.black87,
color: isInactive ? Colors.grey : Colors.black87,
fontStyle:
isInactive ? FontStyle.italic : FontStyle.normal,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Job Role
if (comment.createdBy.jobRoleName?.isNotEmpty ?? false)
if (comment.createdBy.jobRoleName.isNotEmpty)
Text(
comment.createdBy.jobRoleName,
style: TextStyle(
fontSize: 13,
color: Colors.indigo[600],
color:
isInactive ? Colors.grey : Colors.indigo[600],
fontWeight: FontWeight.w500,
fontStyle: isInactive
? FontStyle.italic
: FontStyle.normal,
),
),
const SizedBox(height: 2),
// Timestamp
Text(
DateTimeUtils.convertUtcToLocal(
comment.createdAt.toString(),
@ -490,6 +484,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle:
isInactive ? FontStyle.italic : FontStyle.normal,
),
),
],
@ -497,33 +493,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
// Action buttons
if (!isInactive)
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!comment.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 directoryController.restoreComment(
comment.id, contactId);
},
),
);
},
),
if (comment.isActive) ...[
IconButton(
icon: const Icon(Icons.edit_outlined,
size: 18, color: Colors.indigo),
@ -557,7 +530,29 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
},
),
],
],
)
else
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 directoryController.restoreComment(
comment.id, contactId);
},
),
);
},
),
],
),
@ -568,13 +563,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,
onCancel: () => directoryController.editingCommentId.value = null,
onCancel: () =>
directoryController.editingCommentId.value = null,
onSave: (ctrl) async {
final delta = ctrl.document.toDelta();
final htmlOutput = _convertDeltaToHtml(delta);
final updated = comment.copyWith(note: htmlOutput);
await directoryController.updateComment(updated);
await directoryController.fetchCommentsForContact(contactId);
await directoryController.fetchCommentsForContact(contactId,
active: true);
await directoryController.fetchCommentsForContact(contactId,
active: false);
directoryController.editingCommentId.value = null;
},
)
@ -586,7 +585,8 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
margin: html.Margins.zero,
padding: html.HtmlPaddings.zero,
fontSize: html.FontSize(14),
color: Colors.black87,
color: isInactive ? Colors.grey : Colors.black87,
fontStyle: isInactive ? FontStyle.italic : FontStyle.normal,
),
"p": html.Style(
margin: html.Margins.only(bottom: 6),
@ -594,13 +594,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
"strong": html.Style(
fontWeight: FontWeight.w700,
color: Colors.black87,
color: isInactive ? Colors.grey : Colors.black87,
),
},
),
],
),
);
));
}
Widget _iconInfoRow(

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

@ -48,8 +48,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
(newProjectId) {
if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController
.fetchServices(newProjectId);
serviceController.fetchServices(newProjectId);
}
},
);
@ -184,7 +183,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Widget dailyProgressReportTab() {
return Obx(() {
final isLoading = dailyTaskPlanningController.isLoading.value;
final isLoading = dailyTaskPlanningController.isFetchingTasks.value;
final dailyTasks = dailyTaskPlanningController.dailyTasks;
if (isLoading) {