removed unwanted files
This commit is contained in:
parent
3515cab0d5
commit
4feb2875f0
@ -1,152 +0,0 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/master_work_category_model.dart';
|
||||
|
||||
class AddTaskController extends GetxController {
|
||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||
MyFormValidator basicValidator = MyFormValidator();
|
||||
RxnString selectedCategoryId = RxnString();
|
||||
RxnString selectedCategoryName = RxnString();
|
||||
var categoryIdNameMap = <String, String>{}.obs;
|
||||
|
||||
List<Map<String, dynamic>> roles = [];
|
||||
RxnString selectedRoleId = RxnString();
|
||||
RxBool isLoadingWorkMasterCategories = false.obs;
|
||||
RxList<WorkCategoryModel> workMasterCategories = <WorkCategoryModel>[].obs;
|
||||
|
||||
RxBool isLoading = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchWorkMasterCategories();
|
||||
}
|
||||
|
||||
String? formFieldValidator(String? value, {required String fieldType}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'This field is required';
|
||||
}
|
||||
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
if (fieldType == "description" && value.trim().length < 5) {
|
||||
return 'Description must be at least 5 characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> assignDailyTask({
|
||||
required String workItemId,
|
||||
required int plannedTask,
|
||||
required String description,
|
||||
required List<String> taskTeam,
|
||||
DateTime? assignmentDate,
|
||||
}) async {
|
||||
logSafe("Starting task assignment...", level: LogLevel.info);
|
||||
|
||||
final response = await ApiService.assignDailyTask(
|
||||
workItemId: workItemId,
|
||||
plannedTask: plannedTask,
|
||||
description: description,
|
||||
taskTeam: taskTeam,
|
||||
assignmentDate: assignmentDate,
|
||||
);
|
||||
|
||||
if (response == true) {
|
||||
logSafe("Task assigned successfully.", level: LogLevel.info);
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Task assigned successfully!",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Failed to assign task.", level: LogLevel.error);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to assign task.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> createTask({
|
||||
required String parentTaskId,
|
||||
required String workAreaId,
|
||||
required String activityId,
|
||||
required int plannedTask,
|
||||
required String comment,
|
||||
required String categoryId,
|
||||
DateTime? assignmentDate,
|
||||
}) async {
|
||||
logSafe("Creating new task...", level: LogLevel.info);
|
||||
|
||||
final response = await ApiService.createTask(
|
||||
parentTaskId: parentTaskId,
|
||||
plannedTask: plannedTask,
|
||||
comment: comment,
|
||||
workAreaId: workAreaId,
|
||||
activityId: activityId,
|
||||
assignmentDate: assignmentDate,
|
||||
categoryId: categoryId,
|
||||
);
|
||||
|
||||
if (response == true) {
|
||||
logSafe("Task created successfully.", level: LogLevel.info);
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Task created successfully!",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Failed to create task.", level: LogLevel.error);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to create task.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchWorkMasterCategories() async {
|
||||
isLoadingWorkMasterCategories.value = true;
|
||||
|
||||
try {
|
||||
final response = await ApiService.getMasterWorkCategories();
|
||||
if (response != null) {
|
||||
final dataList = response['data'] ?? [];
|
||||
|
||||
final parsedList = List<WorkCategoryModel>.from(
|
||||
dataList.map((e) => WorkCategoryModel.fromJson(e)),
|
||||
);
|
||||
|
||||
workMasterCategories.assignAll(parsedList);
|
||||
final mapped = {for (var item in parsedList) item.id: item.name};
|
||||
categoryIdNameMap.assignAll(mapped);
|
||||
|
||||
logSafe("Work categories fetched: ${dataList.length}", level: LogLevel.info);
|
||||
} else {
|
||||
logSafe("No work categories found or API call failed.", level: LogLevel.warning);
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe("Error parsing work categories", level: LogLevel.error, error: e, stackTrace: st);
|
||||
workMasterCategories.clear();
|
||||
categoryIdNameMap.clear();
|
||||
}
|
||||
|
||||
isLoadingWorkMasterCategories.value = false;
|
||||
update();
|
||||
}
|
||||
|
||||
void selectCategory(String id) {
|
||||
selectedCategoryId.value = id;
|
||||
selectedCategoryName.value = categoryIdNameMap[id];
|
||||
logSafe("Category selected", level: LogLevel.debug, );
|
||||
}
|
||||
}
|
@ -1,146 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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/project_model.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
|
||||
|
||||
class DailyTaskController extends GetxController {
|
||||
List<ProjectModel> projects = [];
|
||||
String? selectedProjectId;
|
||||
|
||||
DateTime? startDateTask;
|
||||
DateTime? endDateTask;
|
||||
|
||||
List<TaskModel> dailyTasks = [];
|
||||
final RxSet<String> expandedDates = <String>{}.obs;
|
||||
|
||||
void toggleDate(String dateKey) {
|
||||
if (expandedDates.contains(dateKey)) {
|
||||
expandedDates.remove(dateKey);
|
||||
} else {
|
||||
expandedDates.add(dateKey);
|
||||
}
|
||||
}
|
||||
|
||||
RxBool isLoading = true.obs;
|
||||
RxBool isLoadingMore = false.obs;
|
||||
Map<String, List<TaskModel>> groupedDailyTasks = {};
|
||||
// Pagination
|
||||
int currentPage = 1;
|
||||
int pageSize = 20;
|
||||
bool hasMore = true;
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initializeDefaults();
|
||||
}
|
||||
|
||||
void _initializeDefaults() {
|
||||
_setDefaultDateRange();
|
||||
}
|
||||
|
||||
void _setDefaultDateRange() {
|
||||
final today = DateTime.now();
|
||||
startDateTask = today.subtract(const Duration(days: 7));
|
||||
endDateTask = today;
|
||||
|
||||
logSafe(
|
||||
"Default date range set: $startDateTask to $endDateTask",
|
||||
level: LogLevel.info,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchTaskData(
|
||||
String projectId, {
|
||||
List<String>? serviceIds,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 20,
|
||||
bool isLoadMore = false,
|
||||
}) async {
|
||||
if (!isLoadMore) {
|
||||
isLoading.value = true;
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
groupedDailyTasks.clear();
|
||||
dailyTasks.clear();
|
||||
} else {
|
||||
isLoadingMore.value = true;
|
||||
}
|
||||
|
||||
final response = await ApiService.getDailyTasks(
|
||||
projectId,
|
||||
dateFrom: startDateTask,
|
||||
dateTo: endDateTask,
|
||||
serviceIds: serviceIds,
|
||||
pageNumber: pageNumber,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
|
||||
if (response != null && response.isNotEmpty) {
|
||||
for (var taskJson in response) {
|
||||
final task = TaskModel.fromJson(taskJson);
|
||||
final assignmentDateKey =
|
||||
task.assignmentDate.toIso8601String().split('T')[0];
|
||||
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
|
||||
}
|
||||
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
|
||||
currentPage = pageNumber;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> selectDateRangeForTaskData(
|
||||
BuildContext context,
|
||||
DailyTaskController controller,
|
||||
) async {
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2022),
|
||||
lastDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(
|
||||
start:
|
||||
startDateTask ?? DateTime.now().subtract(const Duration(days: 7)),
|
||||
end: endDateTask ?? DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (picked == null) {
|
||||
logSafe("Date range picker cancelled by user.", level: LogLevel.debug);
|
||||
return;
|
||||
}
|
||||
|
||||
startDateTask = picked.start;
|
||||
endDateTask = picked.end;
|
||||
|
||||
logSafe(
|
||||
"Date range selected: $startDateTask to $endDateTask",
|
||||
level: LogLevel.info,
|
||||
);
|
||||
|
||||
// ✅ Add null check before calling fetchTaskData
|
||||
final projectId = controller.selectedProjectId;
|
||||
if (projectId != null && projectId.isNotEmpty) {
|
||||
await controller.fetchTaskData(projectId);
|
||||
} else {
|
||||
logSafe("Project ID is null or empty, skipping fetchTaskData",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
}
|
||||
|
||||
void refreshTasksFromNotification({
|
||||
required String projectId,
|
||||
required String taskAllocationId,
|
||||
}) async {
|
||||
// re-fetch tasks
|
||||
await fetchTaskData(projectId);
|
||||
|
||||
update(); // rebuilds UI
|
||||
}
|
||||
}
|
@ -1,279 +0,0 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/project_model.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/daily_task_planning_model.dart';
|
||||
import 'package:marco/model/employees/employee_model.dart';
|
||||
|
||||
class DailyTaskPlanningController extends GetxController {
|
||||
List<ProjectModel> projects = [];
|
||||
List<EmployeeModel> employees = [];
|
||||
List<TaskPlanningDetailsModel> dailyTasks = [];
|
||||
|
||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
|
||||
|
||||
MyFormValidator basicValidator = MyFormValidator();
|
||||
List<Map<String, dynamic>> roles = [];
|
||||
RxBool isAssigningTask = false.obs;
|
||||
RxnString selectedRoleId = RxnString();
|
||||
RxBool isLoading = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchRoles();
|
||||
_initializeDefaults();
|
||||
}
|
||||
|
||||
void _initializeDefaults() {
|
||||
fetchProjects();
|
||||
}
|
||||
|
||||
String? formFieldValidator(String? value, {required String fieldType}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'This field is required';
|
||||
}
|
||||
if (fieldType == "target" && int.tryParse(value.trim()) == null) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
if (fieldType == "description" && value.trim().length < 5) {
|
||||
return 'Description must be at least 5 characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void updateSelectedEmployees() {
|
||||
final selected =
|
||||
employees.where((e) => uploadingStates[e.id]?.value == true).toList();
|
||||
selectedEmployees.value = selected;
|
||||
logSafe("Updated selected employees", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
void onRoleSelected(String? roleId) {
|
||||
selectedRoleId.value = roleId;
|
||||
logSafe("Role selected", level: LogLevel.info);
|
||||
}
|
||||
|
||||
Future<void> fetchRoles() async {
|
||||
logSafe("Fetching roles...", level: LogLevel.info);
|
||||
final result = await ApiService.getRoles();
|
||||
if (result != null) {
|
||||
roles = List<Map<String, dynamic>>.from(result);
|
||||
logSafe("Roles fetched successfully", level: LogLevel.info);
|
||||
update();
|
||||
} else {
|
||||
logSafe("Failed to fetch roles", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> assignDailyTask({
|
||||
required String workItemId,
|
||||
required int plannedTask,
|
||||
required String description,
|
||||
required List<String> taskTeam,
|
||||
DateTime? assignmentDate,
|
||||
}) async {
|
||||
isAssigningTask.value = true;
|
||||
logSafe("Starting assign task...", level: LogLevel.info);
|
||||
|
||||
final response = await ApiService.assignDailyTask(
|
||||
workItemId: workItemId,
|
||||
plannedTask: plannedTask,
|
||||
description: description,
|
||||
taskTeam: taskTeam,
|
||||
assignmentDate: assignmentDate,
|
||||
);
|
||||
|
||||
isAssigningTask.value = false;
|
||||
|
||||
if (response == true) {
|
||||
logSafe("Task assigned successfully", level: LogLevel.info);
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Task assigned successfully!",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
logSafe("Failed to assign task", level: LogLevel.error);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to assign task.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
|
||||
if (projectId == null) {
|
||||
logSafe("Project ID is null", level: LogLevel.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
// Fetch infra details
|
||||
final infraResponse = await ApiService.getInfraDetails(projectId);
|
||||
final infraData = infraResponse?['data'] as List<dynamic>?;
|
||||
|
||||
if (infraData == null || infraData.isEmpty) {
|
||||
logSafe("No infra data found for project $projectId",
|
||||
level: LogLevel.warning);
|
||||
dailyTasks = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Map infra to dailyTasks structure
|
||||
dailyTasks = infraData.map((buildingJson) {
|
||||
final building = Building(
|
||||
id: buildingJson['id'],
|
||||
name: buildingJson['buildingName'],
|
||||
description: buildingJson['description'],
|
||||
floors: (buildingJson['floors'] as List<dynamic>).map((floorJson) {
|
||||
return Floor(
|
||||
id: floorJson['id'],
|
||||
floorName: floorJson['floorName'],
|
||||
workAreas:
|
||||
(floorJson['workAreas'] as List<dynamic>).map((areaJson) {
|
||||
return WorkArea(
|
||||
id: areaJson['id'],
|
||||
areaName: areaJson['areaName'],
|
||||
workItems: [], // Will fill after tasks API
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
return TaskPlanningDetailsModel(
|
||||
id: building.id,
|
||||
name: building.name,
|
||||
projectAddress: "",
|
||||
contactPerson: "",
|
||||
startDate: DateTime.now(),
|
||||
endDate: DateTime.now(),
|
||||
projectStatusId: "",
|
||||
buildings: [building],
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Fetch tasks for each work area, passing serviceId only if selected
|
||||
await Future.wait(dailyTasks
|
||||
.expand((task) => task.buildings)
|
||||
.expand((b) => b.floors)
|
||||
.expand((f) => f.workAreas)
|
||||
.map((area) async {
|
||||
try {
|
||||
final taskResponse = await ApiService.getWorkItemsByWorkArea(
|
||||
area.id,
|
||||
// serviceId: serviceId, // <-- only pass if not null
|
||||
);
|
||||
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
|
||||
|
||||
area.workItems.addAll(taskData.map((taskJson) {
|
||||
return WorkItemWrapper(
|
||||
workItemId: taskJson['id'],
|
||||
workItem: WorkItem(
|
||||
id: taskJson['id'],
|
||||
activityMaster: taskJson['activityMaster'] != null
|
||||
? ActivityMaster.fromJson(taskJson['activityMaster'])
|
||||
: null,
|
||||
workCategoryMaster: taskJson['workCategoryMaster'] != null
|
||||
? WorkCategoryMaster.fromJson(
|
||||
taskJson['workCategoryMaster'])
|
||||
: null,
|
||||
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
|
||||
completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
|
||||
todaysAssigned:
|
||||
(taskJson['todaysAssigned'] as num?)?.toDouble(),
|
||||
description: taskJson['description'] as String?,
|
||||
taskDate: taskJson['taskDate'] != null
|
||||
? DateTime.tryParse(taskJson['taskDate'])
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}));
|
||||
} catch (e, stack) {
|
||||
logSafe("Error fetching tasks for work area ${area.id}",
|
||||
level: LogLevel.error, error: e, stackTrace: stack);
|
||||
}
|
||||
}));
|
||||
|
||||
logSafe("Fetched infra and tasks for project $projectId",
|
||||
level: LogLevel.info);
|
||||
} catch (e, stack) {
|
||||
logSafe("Error fetching daily task data",
|
||||
level: LogLevel.error, error: e, stackTrace: stack);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
||||
if (projectId == null || projectId.isEmpty) {
|
||||
logSafe("Project ID is required but was null or empty",
|
||||
level: LogLevel.error);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final response = await ApiService.getAllEmployeesByProject(projectId);
|
||||
if (response != null && response.isNotEmpty) {
|
||||
employees =
|
||||
response.map((json) => EmployeeModel.fromJson(json)).toList();
|
||||
for (var emp in employees) {
|
||||
uploadingStates[emp.id] = false.obs;
|
||||
}
|
||||
logSafe(
|
||||
"Employees fetched: ${employees.length} for project $projectId",
|
||||
level: LogLevel.info,
|
||||
);
|
||||
} else {
|
||||
employees = [];
|
||||
logSafe(
|
||||
"No employees found for project $projectId",
|
||||
level: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe(
|
||||
"Error fetching employees for project $projectId",
|
||||
level: LogLevel.error,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,296 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
import 'package:marco/controller/my_controller.dart';
|
||||
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||
import 'package:marco/helpers/widgets/my_image_compressor.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/work_status_model.dart';
|
||||
|
||||
enum ApiStatus { idle, loading, success, failure }
|
||||
|
||||
class ReportTaskActionController extends MyController {
|
||||
final RxBool isLoading = false.obs;
|
||||
final Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
|
||||
final Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
|
||||
|
||||
final RxList<File> selectedImages = <File>[].obs;
|
||||
final RxList<WorkStatus> workStatus = <WorkStatus>[].obs;
|
||||
final RxList<WorkStatus> workStatuses = <WorkStatus>[].obs;
|
||||
|
||||
final RxBool showAddTaskCheckbox = false.obs;
|
||||
final RxBool isAddTaskChecked = false.obs;
|
||||
|
||||
final RxBool isLoadingWorkStatus = false.obs;
|
||||
final Rxn<Map<String, dynamic>> selectedTask = Rxn<Map<String, dynamic>>();
|
||||
|
||||
final RxString selectedWorkStatusName = ''.obs;
|
||||
|
||||
final MyFormValidator basicValidator = MyFormValidator();
|
||||
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
final assignedDateController = TextEditingController();
|
||||
final workAreaController = TextEditingController();
|
||||
final activityController = TextEditingController();
|
||||
final teamSizeController = TextEditingController();
|
||||
final taskIdController = TextEditingController();
|
||||
final assignedController = TextEditingController();
|
||||
final completedWorkController = TextEditingController();
|
||||
final commentController = TextEditingController();
|
||||
final assignedByController = TextEditingController();
|
||||
final teamMembersController = TextEditingController();
|
||||
final plannedWorkController = TextEditingController();
|
||||
final approvedTaskController = TextEditingController();
|
||||
|
||||
List<TextEditingController> get _allControllers => [
|
||||
assignedDateController,
|
||||
workAreaController,
|
||||
activityController,
|
||||
teamSizeController,
|
||||
taskIdController,
|
||||
assignedController,
|
||||
completedWorkController,
|
||||
commentController,
|
||||
assignedByController,
|
||||
teamMembersController,
|
||||
plannedWorkController,
|
||||
approvedTaskController,
|
||||
];
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
logSafe("Initializing ReportTaskController...");
|
||||
_initializeFormFields();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
for (final controller in _allControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
logSafe("Disposed all text controllers in ReportTaskActionController.");
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
void _initializeFormFields() {
|
||||
basicValidator
|
||||
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
|
||||
..addField('work_area', label: "Work Area", controller: workAreaController)
|
||||
..addField('activity', label: "Activity", controller: activityController)
|
||||
..addField('team_size', label: "Team Size", controller: teamSizeController)
|
||||
..addField('task_id', label: "Task Id", controller: taskIdController)
|
||||
..addField('assigned', label: "Assigned", controller: assignedController)
|
||||
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
|
||||
..addField('comment', label: "Comment", required: true, controller: commentController)
|
||||
..addField('assigned_by', label: "Assigned By", controller: assignedByController)
|
||||
..addField('team_members', label: "Team Members", controller: teamMembersController)
|
||||
..addField('planned_work', label: "Planned Work", controller: plannedWorkController)
|
||||
..addField('approved_task', label: "Approved Task", required: true, controller: approvedTaskController);
|
||||
}
|
||||
|
||||
Future<bool> approveTask({
|
||||
required String projectId,
|
||||
required String comment,
|
||||
required String reportActionId,
|
||||
required String approvedTaskCount,
|
||||
List<File>? images,
|
||||
}) async {
|
||||
logSafe("approveTask() started", sensitive: false);
|
||||
|
||||
if (projectId.isEmpty || reportActionId.isEmpty) {
|
||||
_showError("Project ID and Report Action ID are required.");
|
||||
logSafe("Missing required projectId or reportActionId", level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
final approvedTaskInt = int.tryParse(approvedTaskCount);
|
||||
final completedWorkInt = int.tryParse(completedWorkController.text.trim());
|
||||
|
||||
if (approvedTaskInt == null) {
|
||||
_showError("Invalid approved task count.");
|
||||
logSafe("Invalid approvedTaskCount: $approvedTaskCount", level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (completedWorkInt != null && approvedTaskInt > completedWorkInt) {
|
||||
_showError("Approved task count cannot exceed completed work.");
|
||||
logSafe("Validation failed: approved > completed", level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (comment.trim().isEmpty) {
|
||||
_showError("Comment is required.");
|
||||
logSafe("Comment field is empty", level: LogLevel.warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
reportStatus.value = ApiStatus.loading;
|
||||
isLoading.value = true;
|
||||
logSafe("Calling _prepareImages() for approval...");
|
||||
final imageData = await _prepareImages(images);
|
||||
|
||||
logSafe("Calling ApiService.approveTask()");
|
||||
final success = await ApiService.approveTask(
|
||||
id: projectId,
|
||||
workStatus: reportActionId,
|
||||
approvedTask: approvedTaskInt,
|
||||
comment: comment,
|
||||
images: imageData,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logSafe("Task approved successfully");
|
||||
_showSuccess("Task approved successfully!");
|
||||
await taskController.fetchTaskData(projectId);
|
||||
return true;
|
||||
} else {
|
||||
logSafe("API returned failure on approveTask", level: LogLevel.error);
|
||||
_showError("Failed to approve task.");
|
||||
return false;
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe("Error in approveTask: $e", level: LogLevel.error, error: e, stackTrace: st);
|
||||
_showError("An error occurred.");
|
||||
return false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
reportStatus.value = ApiStatus.idle;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> commentTask({
|
||||
required String projectId,
|
||||
required String comment,
|
||||
List<File>? images,
|
||||
}) async {
|
||||
logSafe("commentTask() started", sensitive: false);
|
||||
|
||||
if (commentController.text.trim().isEmpty) {
|
||||
_showError("Comment is required.");
|
||||
logSafe("Comment field is empty", level: LogLevel.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
logSafe("Calling _prepareImages() for comment...");
|
||||
final imageData = await _prepareImages(images);
|
||||
|
||||
logSafe("Calling ApiService.commentTask()");
|
||||
final success = await ApiService.commentTask(
|
||||
id: projectId,
|
||||
comment: commentController.text.trim(),
|
||||
images: imageData,
|
||||
).timeout(const Duration(seconds: 30), onTimeout: () {
|
||||
throw Exception("Request timed out.");
|
||||
});
|
||||
|
||||
if (success) {
|
||||
logSafe("Comment added successfully");
|
||||
_showSuccess("Task commented successfully!");
|
||||
await taskController.fetchTaskData(projectId);
|
||||
} else {
|
||||
logSafe("API returned failure on commentTask", level: LogLevel.error);
|
||||
_showError("Failed to comment task.");
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe("Error in commentTask: $e", level: LogLevel.error, error: e, stackTrace: st);
|
||||
_showError("An error occurred while commenting the task.");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchWorkStatuses() async {
|
||||
logSafe("Fetching work statuses...");
|
||||
isLoadingWorkStatus.value = true;
|
||||
|
||||
final response = await ApiService.getWorkStatus();
|
||||
if (response != null) {
|
||||
final model = WorkStatusResponseModel.fromJson(response);
|
||||
workStatus.assignAll(model.data);
|
||||
logSafe("Fetched ${model.data.length} work statuses");
|
||||
} else {
|
||||
logSafe("No work statuses found or API call failed", level: LogLevel.warning);
|
||||
}
|
||||
|
||||
isLoadingWorkStatus.value = false;
|
||||
update(['dashboard_controller']);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images) async {
|
||||
if (images == null || images.isEmpty) {
|
||||
logSafe("_prepareImages: No images selected.");
|
||||
return null;
|
||||
}
|
||||
|
||||
logSafe("_prepareImages: Compressing and encoding images...");
|
||||
final results = await Future.wait(images.map((file) async {
|
||||
final compressedBytes = await compressImageToUnder100KB(file);
|
||||
if (compressedBytes == null) return null;
|
||||
|
||||
return {
|
||||
"fileName": file.path.split('/').last,
|
||||
"base64Data": base64Encode(compressedBytes),
|
||||
"contentType": _getContentTypeFromFileName(file.path),
|
||||
"fileSize": compressedBytes.lengthInBytes,
|
||||
"description": "Image uploaded for task",
|
||||
};
|
||||
}));
|
||||
|
||||
logSafe("_prepareImages: Prepared ${results.whereType<Map<String, dynamic>>().length} images.");
|
||||
return results.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
String _getContentTypeFromFileName(String fileName) {
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
return switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
'gif' => 'image/gif',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> pickImages({required bool fromCamera}) async {
|
||||
logSafe("Opening image picker...");
|
||||
if (fromCamera) {
|
||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
|
||||
if (pickedFile != null) {
|
||||
selectedImages.add(File(pickedFile.path));
|
||||
logSafe("Image added from camera: ${pickedFile.path}", );
|
||||
}
|
||||
} else {
|
||||
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
|
||||
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
|
||||
logSafe("${pickedFiles.length} images added from gallery.", );
|
||||
}
|
||||
}
|
||||
|
||||
void removeImageAt(int index) {
|
||||
if (index >= 0 && index < selectedImages.length) {
|
||||
logSafe("Removing image at index $index", );
|
||||
selectedImages.removeAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) => showAppSnackbar(
|
||||
title: "Error", message: message, type: SnackbarType.error);
|
||||
|
||||
void _showSuccess(String message) => showAppSnackbar(
|
||||
title: "Success", message: message, type: SnackbarType.success);
|
||||
}
|
@ -1,248 +0,0 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:marco/controller/my_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:marco/helpers/widgets/my_image_compressor.dart';
|
||||
|
||||
enum ApiStatus { idle, loading, success, failure }
|
||||
|
||||
final DailyTaskPlanningController taskController = Get.put(DailyTaskPlanningController());
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
class ReportTaskController extends MyController {
|
||||
List<PlatformFile> files = [];
|
||||
MyFormValidator basicValidator = MyFormValidator();
|
||||
RxBool isLoading = false.obs;
|
||||
Rx<ApiStatus> reportStatus = ApiStatus.idle.obs;
|
||||
Rx<ApiStatus> commentStatus = ApiStatus.idle.obs;
|
||||
|
||||
RxList<File> selectedImages = <File>[].obs;
|
||||
|
||||
final assignedDateController = TextEditingController();
|
||||
final workAreaController = TextEditingController();
|
||||
final activityController = TextEditingController();
|
||||
final teamSizeController = TextEditingController();
|
||||
final taskIdController = TextEditingController();
|
||||
final assignedController = TextEditingController();
|
||||
final completedWorkController = TextEditingController();
|
||||
final commentController = TextEditingController();
|
||||
final assignedByController = TextEditingController();
|
||||
final teamMembersController = TextEditingController();
|
||||
final plannedWorkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
logSafe("Initializing ReportTaskController...");
|
||||
basicValidator
|
||||
..addField('assigned_date', label: "Assigned Date", controller: assignedDateController)
|
||||
..addField('work_area', label: "Work Area", controller: workAreaController)
|
||||
..addField('activity', label: "Activity", controller: activityController)
|
||||
..addField('team_size', label: "Team Size", controller: teamSizeController)
|
||||
..addField('task_id', label: "Task Id", controller: taskIdController)
|
||||
..addField('assigned', label: "Assigned", controller: assignedController)
|
||||
..addField('completed_work', label: "Completed Work", required: true, controller: completedWorkController)
|
||||
..addField('comment', label: "Comment", required: true, controller: commentController)
|
||||
..addField('assigned_by', label: "Assigned By", controller: assignedByController)
|
||||
..addField('team_members', label: "Team Members", controller: teamMembersController)
|
||||
..addField('planned_work', label: "Planned Work", controller: plannedWorkController);
|
||||
logSafe("Form fields initialized.");
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
[
|
||||
assignedDateController,
|
||||
workAreaController,
|
||||
activityController,
|
||||
teamSizeController,
|
||||
taskIdController,
|
||||
assignedController,
|
||||
completedWorkController,
|
||||
commentController,
|
||||
assignedByController,
|
||||
teamMembersController,
|
||||
plannedWorkController,
|
||||
].forEach((controller) => controller.dispose());
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<bool> reportTask({
|
||||
required String projectId,
|
||||
required String comment,
|
||||
required int completedTask,
|
||||
required List<Map<String, dynamic>> checklist,
|
||||
required DateTime reportedDate,
|
||||
List<File>? images,
|
||||
}) async {
|
||||
logSafe("Reporting task for projectId", );
|
||||
final completedWork = completedWorkController.text.trim();
|
||||
if (completedWork.isEmpty || int.tryParse(completedWork) == null || int.parse(completedWork) < 0) {
|
||||
_showError("Completed work must be a positive number.");
|
||||
return false;
|
||||
}
|
||||
|
||||
final commentField = commentController.text.trim();
|
||||
if (commentField.isEmpty) {
|
||||
_showError("Comment is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
reportStatus.value = ApiStatus.loading;
|
||||
isLoading.value = true;
|
||||
|
||||
final imageData = await _prepareImages(images, "task report");
|
||||
|
||||
final success = await ApiService.reportTask(
|
||||
id: projectId,
|
||||
comment: commentField,
|
||||
completedTask: int.parse(completedWork),
|
||||
checkList: checklist,
|
||||
images: imageData,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
reportStatus.value = ApiStatus.success;
|
||||
_showSuccess("Task reported successfully!");
|
||||
await taskController.fetchTaskData(projectId);
|
||||
return true;
|
||||
} else {
|
||||
reportStatus.value = ApiStatus.failure;
|
||||
_showError("Failed to report task.");
|
||||
return false;
|
||||
}
|
||||
} catch (e, s) {
|
||||
logSafe("Exception while reporting task", level: LogLevel.error, error: e, stackTrace: s);
|
||||
reportStatus.value = ApiStatus.failure;
|
||||
_showError("An error occurred while reporting the task.");
|
||||
return false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
reportStatus.value = ApiStatus.idle;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> commentTask({
|
||||
required String projectId,
|
||||
required String comment,
|
||||
List<File>? images,
|
||||
}) async {
|
||||
logSafe("Submitting comment for project", );
|
||||
|
||||
final commentField = commentController.text.trim();
|
||||
if (commentField.isEmpty) {
|
||||
_showError("Comment is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final imageData = await _prepareImages(images, "task comment");
|
||||
|
||||
final success = await ApiService.commentTask(
|
||||
id: projectId,
|
||||
comment: commentField,
|
||||
images: imageData,
|
||||
).timeout(const Duration(seconds: 30), onTimeout: () {
|
||||
logSafe("Task comment request timed out.", level: LogLevel.error);
|
||||
throw Exception("Request timed out.");
|
||||
});
|
||||
|
||||
if (success) {
|
||||
_showSuccess("Task commented successfully!");
|
||||
await taskController.fetchTaskData(projectId);
|
||||
} else {
|
||||
_showError("Failed to comment task.");
|
||||
}
|
||||
} catch (e, s) {
|
||||
logSafe("Exception while commenting task", level: LogLevel.error, error: e, stackTrace: s);
|
||||
_showError("An error occurred while commenting the task.");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>?> _prepareImages(List<File>? images, String context) async {
|
||||
if (images == null || images.isEmpty) return null;
|
||||
|
||||
logSafe("Preparing images for $context upload...");
|
||||
|
||||
final results = await Future.wait(images.map((file) async {
|
||||
try {
|
||||
final compressed = await compressImageToUnder100KB(file);
|
||||
if (compressed == null) return null;
|
||||
|
||||
return {
|
||||
"fileName": file.path.split('/').last,
|
||||
"base64Data": base64Encode(compressed),
|
||||
"contentType": _getContentTypeFromFileName(file.path),
|
||||
"fileSize": compressed.lengthInBytes,
|
||||
"description": "Image uploaded for $context",
|
||||
};
|
||||
} catch (e) {
|
||||
logSafe("Image processing failed: ${file.path}", level: LogLevel.warning, error: e);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
return results.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
String _getContentTypeFromFileName(String fileName) {
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
return switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
'gif' => 'image/gif',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> pickImages({required bool fromCamera}) async {
|
||||
try {
|
||||
if (fromCamera) {
|
||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera, imageQuality: 75);
|
||||
if (pickedFile != null) {
|
||||
selectedImages.add(File(pickedFile.path));
|
||||
}
|
||||
} else {
|
||||
final pickedFiles = await _picker.pickMultiImage(imageQuality: 75);
|
||||
selectedImages.addAll(pickedFiles.map((xfile) => File(xfile.path)));
|
||||
}
|
||||
logSafe("Images picked: ${selectedImages.length}", );
|
||||
} catch (e) {
|
||||
logSafe("Error picking images", level: LogLevel.warning, error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void removeImageAt(int index) {
|
||||
if (index >= 0 && index < selectedImages.length) {
|
||||
selectedImages.removeAt(index);
|
||||
logSafe("Removed image at index $index");
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) => showAppSnackbar(
|
||||
title: "Error",
|
||||
message: message,
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
|
||||
void _showSuccess(String message) => showAppSnackbar(
|
||||
title: "Success",
|
||||
message: message,
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
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/attendance/organization_per_project_list_model.dart';
|
||||
|
||||
class OrganizationController extends GetxController {
|
||||
/// List of organizations assigned to the selected project
|
||||
List<Organization> organizations = [];
|
||||
|
||||
/// Currently selected organization (reactive)
|
||||
Rxn<Organization> selectedOrganization = Rxn<Organization>();
|
||||
|
||||
/// Loading state for fetching organizations
|
||||
final isLoadingOrganizations = false.obs;
|
||||
|
||||
/// Fetch organizations assigned to a given project
|
||||
Future<void> fetchOrganizations(String projectId) async {
|
||||
try {
|
||||
isLoadingOrganizations.value = true;
|
||||
|
||||
final response = await ApiService.getAssignedOrganizations(projectId);
|
||||
if (response != null && response.data.isNotEmpty) {
|
||||
organizations = response.data;
|
||||
logSafe("Organizations fetched: ${organizations.length}");
|
||||
} else {
|
||||
organizations = [];
|
||||
logSafe("No organizations found for project $projectId",
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logSafe("Failed to fetch organizations: $e",
|
||||
level: LogLevel.error, error: e, stackTrace: stackTrace);
|
||||
organizations = [];
|
||||
} finally {
|
||||
isLoadingOrganizations.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select an organization
|
||||
void selectOrganization(Organization? org) {
|
||||
selectedOrganization.value = org;
|
||||
}
|
||||
|
||||
/// Clear the selection (set to "All Organizations")
|
||||
void clearSelection() {
|
||||
selectedOrganization.value = null;
|
||||
}
|
||||
|
||||
/// Current selection name for UI
|
||||
String get currentSelection =>
|
||||
selectedOrganization.value?.name ?? "All Organizations";
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/model/tenant/tenant_services_model.dart';
|
||||
|
||||
class ServiceController extends GetxController {
|
||||
List<Service> services = [];
|
||||
Service? selectedService;
|
||||
final isLoadingServices = false.obs;
|
||||
|
||||
/// Fetch services assigned to a project
|
||||
Future<void> fetchServices(String projectId) async {
|
||||
try {
|
||||
isLoadingServices.value = true;
|
||||
final response = await ApiService.getAssignedServices(projectId);
|
||||
if (response != null) {
|
||||
services = response.data;
|
||||
logSafe("Services fetched: ${services.length}");
|
||||
} else {
|
||||
logSafe("Failed to fetch services for project $projectId",
|
||||
level: LogLevel.error);
|
||||
}
|
||||
} finally {
|
||||
isLoadingServices.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a service
|
||||
void selectService(Service? service) {
|
||||
selectedService = service;
|
||||
update();
|
||||
}
|
||||
|
||||
/// Clear selection
|
||||
void clearSelection() {
|
||||
selectedService = null;
|
||||
update();
|
||||
}
|
||||
|
||||
/// Current selected name
|
||||
String get currentSelection => selectedService?.name ?? "All Services";
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/tenant_service.dart';
|
||||
import 'package:marco/model/tenant/tenant_list_model.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
|
||||
class TenantSelectionController extends GetxController {
|
||||
final TenantService _tenantService = TenantService();
|
||||
|
||||
var tenants = <Tenant>[].obs;
|
||||
var isLoading = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
loadTenants();
|
||||
}
|
||||
|
||||
/// Load tenants from API
|
||||
Future<void> loadTenants({bool fromTenantSelectionScreen = false}) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final data = await _tenantService.getTenants();
|
||||
if (data != null) {
|
||||
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
|
||||
|
||||
final recentTenantId = LocalStorage.getRecentTenantId();
|
||||
|
||||
// ✅ If user came from TenantSelectionScreen & recent tenant exists, auto-select
|
||||
if (fromTenantSelectionScreen && recentTenantId != null) {
|
||||
final tenantExists = tenants.any((t) => t.id == recentTenantId);
|
||||
if (tenantExists) {
|
||||
await onTenantSelected(recentTenantId);
|
||||
return;
|
||||
} else {
|
||||
// if tenant is no longer valid, clear recentTenant
|
||||
await LocalStorage.removeRecentTenantId();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Auto-select if only one tenant
|
||||
if (tenants.length == 1) {
|
||||
await onTenantSelected(tenants.first.id);
|
||||
}
|
||||
} else {
|
||||
tenants.clear();
|
||||
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe("❌ Exception in loadTenants",
|
||||
level: LogLevel.error, error: e, stackTrace: st);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select tenant
|
||||
Future<void> onTenantSelected(String tenantId) async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final success = await _tenantService.selectTenant(tenantId);
|
||||
if (success) {
|
||||
logSafe("✅ Tenant selection successful: $tenantId");
|
||||
|
||||
// Store selected tenant in memory
|
||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||
TenantService.setSelectedTenant(selectedTenant);
|
||||
|
||||
// 🔥 Save in LocalStorage
|
||||
await LocalStorage.setRecentTenantId(tenantId);
|
||||
|
||||
// Navigate to dashboard
|
||||
Get.offAllNamed('/dashboard');
|
||||
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Organization selected successfully.",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
logSafe("❌ Tenant selection failed for: $tenantId",
|
||||
level: LogLevel.warning);
|
||||
|
||||
// Show error snackbar
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Unable to select organization. Please try again.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
logSafe("❌ Exception in onTenantSelected",
|
||||
level: LogLevel.error, error: e, stackTrace: st);
|
||||
|
||||
// Show error snackbar for exception
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred while selecting organization.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
@ -19,7 +19,6 @@ import 'package:marco/model/document/master_document_type_model.dart';
|
||||
import 'package:marco/model/document/document_details_model.dart';
|
||||
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';
|
||||
|
||||
class ApiService {
|
||||
static const bool enableLogs = true;
|
||||
@ -319,36 +318,6 @@ class ApiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
//// Get Services assigned to a Project
|
||||
static Future<ServiceListResponse?> getAssignedServices(
|
||||
String projectId) async {
|
||||
final endpoint = "${ApiEndpoints.getAssignedServices}/$projectId";
|
||||
logSafe("Fetching services assigned to projectId: $projectId");
|
||||
|
||||
try {
|
||||
final response = await _getRequest(endpoint);
|
||||
|
||||
if (response == null) {
|
||||
logSafe("Assigned Services request failed: null response",
|
||||
level: LogLevel.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonResponse =
|
||||
_parseResponseForAllData(response, label: "Assigned Services");
|
||||
|
||||
if (jsonResponse != null) {
|
||||
return ServiceListResponse.fromJson(jsonResponse);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Exception during getAssignedServices: $e",
|
||||
level: LogLevel.error);
|
||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> postLogsApi(List<Map<String, dynamic>> logs) async {
|
||||
const endpoint = "${ApiEndpoints.uploadLogs}";
|
||||
logSafe("Posting logs... count=${logs.length}");
|
||||
|
@ -1,9 +1,6 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
|
||||
import 'package:marco/controller/task_planning/daily_task_controller.dart';
|
||||
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
||||
import 'package:marco/controller/directory/directory_controller.dart';
|
||||
@ -72,26 +69,6 @@ class NotificationActionHandler {
|
||||
// Call method to handle team modifications and dashboard update
|
||||
_handleDashboardUpdate(data);
|
||||
break;
|
||||
|
||||
/// 🔹 Tasks
|
||||
case 'Report_Task':
|
||||
_handleTaskUpdated(data, isComment: false);
|
||||
_handleDashboardUpdate(data);
|
||||
break;
|
||||
|
||||
case 'Task_Comment':
|
||||
_handleTaskUpdated(data, isComment: true);
|
||||
_handleDashboardUpdate(data);
|
||||
break;
|
||||
|
||||
case 'Task_Modified':
|
||||
case 'WorkArea_Modified':
|
||||
case 'Floor_Modified':
|
||||
case 'Building_Modified':
|
||||
_handleTaskPlanningUpdated(data);
|
||||
_handleDashboardUpdate(data);
|
||||
break;
|
||||
|
||||
/// 🔹 Expenses
|
||||
case 'Expenses_Modified':
|
||||
_handleExpenseUpdated(data);
|
||||
@ -128,23 +105,7 @@ class NotificationActionHandler {
|
||||
|
||||
/// ---------------------- HANDLERS ----------------------
|
||||
|
||||
static void _handleTaskPlanningUpdated(Map<String, dynamic> data) {
|
||||
final projectId = data['ProjectId'];
|
||||
if (projectId == null) {
|
||||
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
|
||||
return;
|
||||
}
|
||||
|
||||
_safeControllerUpdate<DailyTaskPlanningController>(
|
||||
onFound: (controller) {
|
||||
controller.fetchTaskData(projectId);
|
||||
},
|
||||
notFoundMessage:
|
||||
'⚠️ DailyTaskPlanningController not found, cannot refresh.',
|
||||
successMessage:
|
||||
'✅ DailyTaskPlanningController refreshed from notification.',
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isAttendanceAction(String? action) {
|
||||
const validActions = {
|
||||
@ -199,18 +160,6 @@ class NotificationActionHandler {
|
||||
);
|
||||
}
|
||||
|
||||
static void _handleTaskUpdated(Map<String, dynamic> data,
|
||||
{required bool isComment}) {
|
||||
_safeControllerUpdate<DailyTaskController>(
|
||||
onFound: (controller) => controller.refreshTasksFromNotification(
|
||||
projectId: data['ProjectId'],
|
||||
taskAllocationId: data['TaskAllocationId'],
|
||||
),
|
||||
notFoundMessage: '⚠️ DailyTaskController not found, cannot update.',
|
||||
successMessage: '✅ DailyTaskController refreshed from notification.',
|
||||
);
|
||||
}
|
||||
|
||||
/// ---------------------- DOCUMENT HANDLER ----------------------
|
||||
static void _handleDocumentModified(Map<String, dynamic> data) {
|
||||
String entityTypeId;
|
||||
|
@ -1,152 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
|
||||
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/services/auth_service.dart';
|
||||
import 'package:marco/model/tenant/tenant_list_model.dart';
|
||||
|
||||
/// Abstract interface for tenant service functionality
|
||||
abstract class ITenantService {
|
||||
Future<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false});
|
||||
Future<bool> selectTenant(String tenantId, {bool hasRetried = false});
|
||||
}
|
||||
|
||||
/// Tenant API service
|
||||
class TenantService implements ITenantService {
|
||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||
static const Map<String, String> _headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
/// Currently selected tenant
|
||||
static Tenant? currentTenant;
|
||||
|
||||
/// Set the selected tenant
|
||||
static void setSelectedTenant(Tenant tenant) {
|
||||
currentTenant = tenant;
|
||||
}
|
||||
|
||||
/// Check if tenant is selected
|
||||
static bool get isTenantSelected => currentTenant != null;
|
||||
|
||||
/// Build authorized headers
|
||||
static Future<Map<String, String>> _authorizedHeaders() async {
|
||||
final token = await LocalStorage.getJwtToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('Missing JWT token');
|
||||
}
|
||||
return {..._headers, 'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
/// Handle API errors
|
||||
static void _handleApiError(
|
||||
http.Response response, dynamic data, String context) {
|
||||
final message = data['message'] ?? 'Unknown error';
|
||||
final level =
|
||||
response.statusCode >= 500 ? LogLevel.error : LogLevel.warning;
|
||||
logSafe("❌ $context failed: $message [Status: ${response.statusCode}]",
|
||||
level: level);
|
||||
}
|
||||
|
||||
/// Log exceptions
|
||||
static void _logException(dynamic e, dynamic st, String context) {
|
||||
logSafe("❌ $context exception",
|
||||
level: LogLevel.error, error: e, stackTrace: st);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>?> getTenants(
|
||||
{bool hasRetried = false}) async {
|
||||
try {
|
||||
final headers = await _authorizedHeaders();
|
||||
logSafe("➡️ GET $_baseUrl/auth/get/user/tenants\nHeaders: $headers",
|
||||
level: LogLevel.info);
|
||||
|
||||
final response = await http
|
||||
.get(Uri.parse("$_baseUrl/auth/get/user/tenants"), headers: headers);
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
logSafe(
|
||||
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]",
|
||||
level: LogLevel.info);
|
||||
|
||||
if (response.statusCode == 200 && data['success'] == true) {
|
||||
logSafe("✅ Tenants fetched successfully.");
|
||||
return List<Map<String, dynamic>>.from(data['data']);
|
||||
}
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
logSafe("⚠️ Unauthorized while fetching tenants. Refreshing token...",
|
||||
level: LogLevel.warning);
|
||||
final refreshed = await AuthService.refreshToken();
|
||||
if (refreshed) return getTenants(hasRetried: true);
|
||||
logSafe("❌ Token refresh failed while fetching tenants.",
|
||||
level: LogLevel.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
_handleApiError(response, data, "Fetching tenants");
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
_logException(e, st, "Get Tenants API");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> selectTenant(String tenantId, {bool hasRetried = false}) async {
|
||||
try {
|
||||
final headers = await _authorizedHeaders();
|
||||
logSafe(
|
||||
"➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers",
|
||||
level: LogLevel.info);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"),
|
||||
headers: headers,
|
||||
);
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
logSafe(
|
||||
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]",
|
||||
level: LogLevel.info);
|
||||
|
||||
if (response.statusCode == 200 && data['success'] == true) {
|
||||
await LocalStorage.setJwtToken(data['data']['token']);
|
||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||
logSafe("✅ Tenant selected successfully. Tokens updated.");
|
||||
|
||||
// 🔥 Refresh projects when tenant changes
|
||||
try {
|
||||
final projectController = Get.find<ProjectController>();
|
||||
projectController.clearProjects();
|
||||
projectController.fetchProjects();
|
||||
} catch (_) {
|
||||
logSafe("⚠️ ProjectController not found while refreshing projects");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 401 && !hasRetried) {
|
||||
logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...",
|
||||
level: LogLevel.warning);
|
||||
final refreshed = await AuthService.refreshToken();
|
||||
if (refreshed) return selectTenant(tenantId, hasRetried: true);
|
||||
logSafe("❌ Token refresh failed while selecting tenant.",
|
||||
level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
_handleApiError(response, data, "Selecting tenant");
|
||||
return false;
|
||||
} catch (e, st) {
|
||||
_logException(e, st, "Select Tenant API");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/tenant/organization_selection_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
|
||||
|
||||
class OrganizationSelector extends StatelessWidget {
|
||||
final OrganizationController controller;
|
||||
|
||||
/// Called whenever a new organization is selected (including "All Organizations").
|
||||
final Future<void> Function(Organization?)? onSelectionChanged;
|
||||
|
||||
/// Optional height for the selector. If null, uses default padding-based height.
|
||||
final double? height;
|
||||
|
||||
const OrganizationSelector({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.onSelectionChanged,
|
||||
this.height,
|
||||
});
|
||||
|
||||
Widget _popupSelector({
|
||||
required String currentValue,
|
||||
required List<String> items,
|
||||
}) {
|
||||
return PopupMenuButton<String>(
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (name) async {
|
||||
Organization? org = name == "All Organizations"
|
||||
? null
|
||||
: controller.organizations.firstWhere((e) => e.name == name);
|
||||
|
||||
controller.selectOrganization(org);
|
||||
|
||||
if (onSelectionChanged != null) {
|
||||
await onSelectionChanged!(org);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => items
|
||||
.map((e) => PopupMenuItem<String>(value: e, child: MyText(e)))
|
||||
.toList(),
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentValue,
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (controller.isLoadingOrganizations.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (controller.organizations.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: MyText.bodyMedium(
|
||||
"No organizations found",
|
||||
fontWeight: 500,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final orgNames = [
|
||||
"All Organizations",
|
||||
...controller.organizations.map((e) => e.name)
|
||||
];
|
||||
|
||||
// Listen to selectedOrganization.value
|
||||
return _popupSelector(
|
||||
currentValue: controller.currentSelection,
|
||||
items: orgNames,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/model/tenant/tenant_services_model.dart';
|
||||
import 'package:marco/controller/tenant/service_controller.dart';
|
||||
|
||||
class ServiceSelector extends StatelessWidget {
|
||||
final ServiceController controller;
|
||||
|
||||
/// Called whenever a new service is selected (including "All Services")
|
||||
final Future<void> Function(Service?)? onSelectionChanged;
|
||||
|
||||
/// Optional height for the selector
|
||||
final double? height;
|
||||
|
||||
const ServiceSelector({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.onSelectionChanged,
|
||||
this.height,
|
||||
});
|
||||
|
||||
Widget _popupSelector({
|
||||
required String currentValue,
|
||||
required List<String> items,
|
||||
}) {
|
||||
return PopupMenuButton<String>(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
onSelected: items.isEmpty
|
||||
? null
|
||||
: (name) async {
|
||||
Service? service = name == "All Services"
|
||||
? null
|
||||
: controller.services.firstWhere((e) => e.name == name);
|
||||
|
||||
controller.selectService(service);
|
||||
|
||||
if (onSelectionChanged != null) {
|
||||
await onSelectionChanged!(service);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
if (items.isEmpty || items.length == 1 && items[0] == "All Services") {
|
||||
return [
|
||||
const PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
"No services found",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return items
|
||||
.map((e) => PopupMenuItem<String>(value: e, child: MyText(e)))
|
||||
.toList();
|
||||
},
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentValue.isEmpty ? "No services found" : currentValue,
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (controller.isLoadingServices.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final serviceNames = controller.services.isEmpty
|
||||
? <String>[]
|
||||
: <String>[
|
||||
"All Services",
|
||||
...controller.services.map((e) => e.name).toList(),
|
||||
];
|
||||
|
||||
final currentValue =
|
||||
controller.services.isEmpty ? "" : controller.currentSelection;
|
||||
|
||||
return _popupSelector(
|
||||
currentValue: currentValue,
|
||||
items: serviceNames,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,368 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
class AssignTaskBottomSheet extends StatefulWidget {
|
||||
final String workLocation;
|
||||
final String activityName;
|
||||
final int pendingTask;
|
||||
final String workItemId;
|
||||
final DateTime assignmentDate;
|
||||
final String buildingName;
|
||||
final String floorName;
|
||||
final String workAreaName;
|
||||
|
||||
const AssignTaskBottomSheet({
|
||||
super.key,
|
||||
required this.buildingName,
|
||||
required this.workLocation,
|
||||
required this.floorName,
|
||||
required this.workAreaName,
|
||||
required this.activityName,
|
||||
required this.pendingTask,
|
||||
required this.workItemId,
|
||||
required this.assignmentDate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AssignTaskBottomSheet> createState() => _AssignTaskBottomSheetState();
|
||||
}
|
||||
|
||||
class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
||||
final DailyTaskPlanningController controller = Get.find();
|
||||
final ProjectController projectController = Get.find();
|
||||
final TextEditingController targetController = TextEditingController();
|
||||
final TextEditingController descriptionController = TextEditingController();
|
||||
final ScrollController _employeeListScrollController = ScrollController();
|
||||
|
||||
String? selectedProjectId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectedProjectId = projectController.selectedProjectId.value;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (selectedProjectId != null) {
|
||||
controller.fetchEmployeesByProject(selectedProjectId!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_employeeListScrollController.dispose();
|
||||
targetController.dispose();
|
||||
descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() => BaseBottomSheet(
|
||||
title: "Assign Task",
|
||||
child: _buildAssignTaskForm(),
|
||||
onCancel: () => Get.back(),
|
||||
onSubmit: _onAssignTaskPressed,
|
||||
isSubmitting: controller.isAssigningTask.value,
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAssignTaskForm() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_infoRow(Icons.location_on, "Work Location",
|
||||
"${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"),
|
||||
Divider(),
|
||||
_infoRow(Icons.pending_actions, "Pending Task of Activity",
|
||||
"${widget.pendingTask}"),
|
||||
Divider(),
|
||||
GestureDetector(
|
||||
onTap: _onRoleMenuPressed,
|
||||
child: Row(
|
||||
children: [
|
||||
MyText.titleMedium("Select Team :", fontWeight: 600),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)),
|
||||
],
|
||||
),
|
||||
),
|
||||
MySpacing.height(8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: _buildEmployeeList(),
|
||||
),
|
||||
MySpacing.height(8),
|
||||
_buildSelectedEmployees(),
|
||||
_buildTextField(
|
||||
icon: Icons.track_changes,
|
||||
label: "Target for Today :",
|
||||
controller: targetController,
|
||||
hintText: "Enter target",
|
||||
keyboardType: TextInputType.number,
|
||||
validatorType: "target",
|
||||
),
|
||||
MySpacing.height(24),
|
||||
_buildTextField(
|
||||
icon: Icons.description,
|
||||
label: "Description :",
|
||||
controller: descriptionController,
|
||||
hintText: "Enter task description",
|
||||
maxLines: 3,
|
||||
validatorType: "description",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onRoleMenuPressed() {
|
||||
final RenderBox overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final Size screenSize = overlay.size;
|
||||
|
||||
showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
screenSize.width / 2 - 100,
|
||||
screenSize.height / 2 - 20,
|
||||
screenSize.width / 2 - 100,
|
||||
screenSize.height / 2 - 20,
|
||||
),
|
||||
items: [
|
||||
const PopupMenuItem(value: 'all', child: Text("All Roles")),
|
||||
...controller.roles.map((role) {
|
||||
return PopupMenuItem(
|
||||
value: role['id'].toString(),
|
||||
child: Text(role['name'] ?? 'Unknown Role'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
controller.onRoleSelected(value == 'all' ? null : value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildEmployeeList() {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final selectedRoleId = controller.selectedRoleId.value;
|
||||
final filteredEmployees = selectedRoleId == null
|
||||
? controller.employees
|
||||
: controller.employees
|
||||
.where((e) => e.jobRoleID.toString() == selectedRoleId)
|
||||
.toList();
|
||||
|
||||
if (filteredEmployees.isEmpty) {
|
||||
return const Text("No employees found for selected role.");
|
||||
}
|
||||
|
||||
return Scrollbar(
|
||||
controller: _employeeListScrollController,
|
||||
thumbVisibility: true,
|
||||
child: ListView.builder(
|
||||
controller: _employeeListScrollController,
|
||||
shrinkWrap: true,
|
||||
itemCount: filteredEmployees.length,
|
||||
itemBuilder: (context, index) {
|
||||
final employee = filteredEmployees[index];
|
||||
final rxBool = controller.uploadingStates[employee.id];
|
||||
|
||||
return Obx(() => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
value: rxBool?.value ?? false,
|
||||
onChanged: (bool? selected) {
|
||||
if (rxBool != null) {
|
||||
rxBool.value = selected ?? false;
|
||||
controller.updateSelectedEmployees();
|
||||
}
|
||||
},
|
||||
fillColor:
|
||||
WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const Color.fromARGB(255, 95, 132, 255);
|
||||
}
|
||||
return Colors.transparent;
|
||||
}),
|
||||
checkColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.black),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(employee.name,
|
||||
style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildSelectedEmployees() {
|
||||
return Obx(() {
|
||||
if (controller.selectedEmployees.isEmpty) return Container();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: controller.selectedEmployees.map((e) {
|
||||
return Obx(() {
|
||||
final isSelected =
|
||||
controller.uploadingStates[e.id]?.value ?? false;
|
||||
if (!isSelected) return Container();
|
||||
|
||||
return Chip(
|
||||
label:
|
||||
Text(e.name, style: const TextStyle(color: Colors.white)),
|
||||
backgroundColor: const Color.fromARGB(255, 95, 132, 255),
|
||||
deleteIcon: const Icon(Icons.close, color: Colors.white),
|
||||
onDeleted: () {
|
||||
controller.uploadingStates[e.id]?.value = false;
|
||||
controller.updateSelectedEmployees();
|
||||
},
|
||||
);
|
||||
});
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
int maxLines = 1,
|
||||
required String validatorType,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.black54),
|
||||
const SizedBox(width: 6),
|
||||
MyText.titleMedium(label, fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(6),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
maxLines: maxLines,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) =>
|
||||
this.controller.formFieldValidator(value, fieldType: validatorType),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(IconData icon, String title, String value) {
|
||||
return Padding(
|
||||
padding: MySpacing.y(6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey[700]),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: MyText.titleMedium("$title: ",
|
||||
fontWeight: 600, color: Colors.black),
|
||||
),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: const TextStyle(color: Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAssignTaskPressed() {
|
||||
final selectedTeam = controller.uploadingStates.entries
|
||||
.where((e) => e.value.value)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
if (selectedTeam.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Team Required",
|
||||
message: "Please select at least one team member",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final target = int.tryParse(targetController.text.trim());
|
||||
if (target == null || target <= 0) {
|
||||
showAppSnackbar(
|
||||
title: "Invalid Input",
|
||||
message: "Please enter a valid target number",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target > widget.pendingTask) {
|
||||
showAppSnackbar(
|
||||
title: "Target Too High",
|
||||
message:
|
||||
"Target cannot be greater than pending task (${widget.pendingTask})",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final description = descriptionController.text.trim();
|
||||
if (description.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Description Required",
|
||||
message: "Please enter a description",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.assignDailyTask(
|
||||
workItemId: widget.workItemId,
|
||||
plannedTask: target,
|
||||
description: description,
|
||||
taskTeam: selectedTeam,
|
||||
assignmentDate: widget.assignmentDate,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,678 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
// --- Assumed Imports (ensure these paths are correct in your project) ---
|
||||
import 'package:marco/controller/task_planning/report_task_controller.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
// --- Form Field Keys (Unchanged) ---
|
||||
class _FormFieldKeys {
|
||||
static const String assignedDate = 'assigned_date';
|
||||
static const String assignedBy = 'assigned_by';
|
||||
static const String workArea = 'work_area';
|
||||
static const String activity = 'activity';
|
||||
static const String plannedWork = 'planned_work';
|
||||
static const String completedWork = 'completed_work';
|
||||
static const String teamMembers = 'team_members';
|
||||
static const String assigned = 'assigned';
|
||||
static const String taskId = 'task_id';
|
||||
static const String comment = 'comment';
|
||||
}
|
||||
|
||||
// --- Main Widget: CommentTaskBottomSheet ---
|
||||
class CommentTaskBottomSheet extends StatefulWidget {
|
||||
final Map<String, dynamic> taskData;
|
||||
final VoidCallback? onCommentSuccess;
|
||||
final String taskDataId;
|
||||
final String workAreaId;
|
||||
final String activityId;
|
||||
|
||||
const CommentTaskBottomSheet({
|
||||
super.key,
|
||||
required this.taskData,
|
||||
this.onCommentSuccess,
|
||||
required this.taskDataId,
|
||||
required this.workAreaId,
|
||||
required this.activityId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CommentTaskBottomSheet> createState() => _CommentTaskBottomSheetState();
|
||||
}
|
||||
|
||||
class _Member {
|
||||
final String firstName;
|
||||
_Member(this.firstName);
|
||||
}
|
||||
|
||||
class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
|
||||
with UIMixin {
|
||||
late final ReportTaskController controller;
|
||||
List<Map<String, dynamic>> _sortedComments = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = Get.put(ReportTaskController(),
|
||||
tag: widget.taskData['taskId'] ?? UniqueKey().toString());
|
||||
_initializeControllerData();
|
||||
|
||||
final comments = List<Map<String, dynamic>>.from(
|
||||
widget.taskData['taskComments'] as List? ?? []);
|
||||
comments.sort((a, b) {
|
||||
final aDate = DateTime.tryParse(a['date'] ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = DateTime.tryParse(b['date'] ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return bDate.compareTo(aDate); // Newest first
|
||||
});
|
||||
_sortedComments = comments;
|
||||
}
|
||||
|
||||
void _initializeControllerData() {
|
||||
final data = widget.taskData;
|
||||
|
||||
final fieldMappings = {
|
||||
_FormFieldKeys.assignedDate: data['assignedOn'],
|
||||
_FormFieldKeys.assignedBy: data['assignedBy'],
|
||||
_FormFieldKeys.workArea: data['location'],
|
||||
_FormFieldKeys.activity: data['activity'],
|
||||
_FormFieldKeys.plannedWork: data['plannedWork'],
|
||||
_FormFieldKeys.completedWork: data['completedWork'],
|
||||
_FormFieldKeys.teamMembers: (data['teamMembers'] as List?)?.join(', '),
|
||||
_FormFieldKeys.assigned: data['assigned'],
|
||||
_FormFieldKeys.taskId: data['taskId'],
|
||||
};
|
||||
|
||||
for (final entry in fieldMappings.entries) {
|
||||
controller.basicValidator.getController(entry.key)?.text =
|
||||
entry.value ?? '';
|
||||
}
|
||||
|
||||
controller.basicValidator.getController(_FormFieldKeys.comment)?.clear();
|
||||
controller.selectedImages.clear();
|
||||
}
|
||||
|
||||
String _timeAgo(String dateString) {
|
||||
// This logic remains unchanged
|
||||
try {
|
||||
final date = DateTime.parse(dateString + "Z").toLocal();
|
||||
final difference = DateTime.now().difference(date);
|
||||
|
||||
if (difference.inDays > 8) return DateFormat('dd-MM-yyyy').format(date);
|
||||
if (difference.inDays >= 1)
|
||||
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
|
||||
if (difference.inHours >= 1)
|
||||
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
|
||||
if (difference.inMinutes >= 1)
|
||||
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
|
||||
return 'just now';
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing date for timeAgo: $e');
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// --- REFACTORING POINT ---
|
||||
// The entire widget now returns a BaseBottomSheet, passing the content as its child.
|
||||
// The GetBuilder provides reactive state (like isLoading) to the BaseBottomSheet.
|
||||
return GetBuilder<ReportTaskController>(
|
||||
tag: widget.taskData['taskId'] ?? '',
|
||||
builder: (controller) {
|
||||
return BaseBottomSheet(
|
||||
title: "Task Details & Comments",
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: _submitComment,
|
||||
isSubmitting: controller.isLoading.value,
|
||||
bottomContent: _buildCommentsSection(),
|
||||
child: Form(
|
||||
// moved to last
|
||||
key: controller.basicValidator.formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeaderActions(),
|
||||
MySpacing.height(12),
|
||||
_buildTaskDetails(),
|
||||
_buildReportedImages(),
|
||||
_buildCommentInput(),
|
||||
_buildImagePicker(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- REFACTORING POINT ---
|
||||
// The original _buildHeader is now split. The title is handled by BaseBottomSheet.
|
||||
// This new widget contains the remaining actions from the header.
|
||||
Widget _buildHeaderActions() {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkWell(
|
||||
onTap: () => _showCreateTaskBottomSheet(),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueAccent.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: MyText.bodySmall(
|
||||
"+ Create Task",
|
||||
fontWeight: 600,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskDetails() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
"Assigned By",
|
||||
controller.basicValidator
|
||||
.getController(_FormFieldKeys.assignedBy)
|
||||
?.text,
|
||||
icon: Icons.person_outline),
|
||||
_buildDetailRow(
|
||||
"Work Area",
|
||||
controller.basicValidator
|
||||
.getController(_FormFieldKeys.workArea)
|
||||
?.text,
|
||||
icon: Icons.place_outlined),
|
||||
_buildDetailRow(
|
||||
"Activity",
|
||||
controller.basicValidator
|
||||
.getController(_FormFieldKeys.activity)
|
||||
?.text,
|
||||
icon: Icons.assignment_outlined),
|
||||
_buildDetailRow(
|
||||
"Planned Work",
|
||||
controller.basicValidator
|
||||
.getController(_FormFieldKeys.plannedWork)
|
||||
?.text,
|
||||
icon: Icons.schedule_outlined),
|
||||
_buildDetailRow(
|
||||
"Completed Work",
|
||||
controller.basicValidator
|
||||
.getController(_FormFieldKeys.completedWork)
|
||||
?.text,
|
||||
icon: Icons.done_all_outlined),
|
||||
_buildTeamMembers(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportedImages() {
|
||||
final imageUrls =
|
||||
List<String>.from(widget.taskData['reportedPreSignedUrls'] ?? []);
|
||||
if (imageUrls.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: _buildSectionHeader("Reported Images", Icons.image_outlined),
|
||||
),
|
||||
// --- Refactoring Note ---
|
||||
// Using the reusable _ImageHorizontalListView widget.
|
||||
_ImageHorizontalListView(
|
||||
imageSources: imageUrls,
|
||||
onPreview: (index) => _showImageViewer(imageUrls, index),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader("Add Note", Icons.comment_outlined),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
validator:
|
||||
controller.basicValidator.getValidation(_FormFieldKeys.comment),
|
||||
controller:
|
||||
controller.basicValidator.getController(_FormFieldKeys.comment),
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null, // Allows for multiline input
|
||||
decoration: InputDecoration(
|
||||
hintText: "eg: Work done successfully",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
border: outlineInputBorder,
|
||||
enabledBorder: outlineInputBorder,
|
||||
focusedBorder: focusedInputBorder,
|
||||
contentPadding: MySpacing.all(16),
|
||||
isCollapsed: true,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePicker() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader("Attach Photos", Icons.camera_alt_outlined),
|
||||
MySpacing.height(12),
|
||||
Obx(() {
|
||||
final images = controller.selectedImages;
|
||||
return Column(
|
||||
children: [
|
||||
// --- Refactoring Note ---
|
||||
// Using the reusable _ImageHorizontalListView for picked images.
|
||||
_ImageHorizontalListView(
|
||||
imageSources: images.toList(),
|
||||
onPreview: (index) => _showImageViewer(images.toList(), index),
|
||||
onRemove: (index) => controller.removeImageAt(index),
|
||||
emptyStatePlaceholder: Container(
|
||||
height: 70,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300, width: 1.5),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(Icons.photo_library_outlined,
|
||||
size: 36, color: Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
Row(
|
||||
children: [
|
||||
_buildPickerButton(
|
||||
onTap: () => controller.pickImages(fromCamera: true),
|
||||
icon: Icons.camera_alt,
|
||||
label: 'Capture',
|
||||
),
|
||||
MySpacing.width(12),
|
||||
_buildPickerButton(
|
||||
onTap: () => controller.pickImages(fromCamera: false),
|
||||
icon: Icons.upload_file,
|
||||
label: 'Upload',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentsSection() {
|
||||
if (_sortedComments.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(24),
|
||||
_buildSectionHeader("Comments", Icons.chat_bubble_outline),
|
||||
MySpacing.height(12),
|
||||
// --- Refactoring Note ---
|
||||
// Using a ListView instead of a fixed-height SizedBox for better responsiveness.
|
||||
// It's constrained by the parent SingleChildScrollView.
|
||||
ListView.builder(
|
||||
shrinkWrap:
|
||||
true, // Important for ListView inside SingleChildScrollView
|
||||
physics:
|
||||
const NeverScrollableScrollPhysics(), // Parent handles scrolling
|
||||
itemCount: _sortedComments.length,
|
||||
itemBuilder: (context, index) {
|
||||
final comment = _sortedComments[index];
|
||||
// --- Refactoring Note ---
|
||||
// Extracted the comment item into its own widget for clarity.
|
||||
return _CommentCard(
|
||||
comment: comment,
|
||||
timeAgo: _timeAgo(comment['date'] ?? ''),
|
||||
onPreviewImage: (imageUrls, idx) =>
|
||||
_showImageViewer(imageUrls, idx),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helper and Builder methods ---
|
||||
|
||||
Widget _buildDetailRow(String label, String? value,
|
||||
{required IconData icon}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0, top: 2),
|
||||
child: Icon(icon, size: 18, color: Colors.grey[700]),
|
||||
),
|
||||
MyText.titleSmall("$label:", fontWeight: 600),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: MyText.bodyMedium(
|
||||
value != null && value.isNotEmpty ? value : "-",
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall(title, fontWeight: 600),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeamMembers() {
|
||||
final teamMembersText = controller.basicValidator
|
||||
.getController(_FormFieldKeys.teamMembers)
|
||||
?.text ??
|
||||
'';
|
||||
final members = teamMembersText
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
if (members.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
const double avatarSize = 32.0;
|
||||
const double avatarOverlap = 22.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.group_outlined, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Team:", fontWeight: 600),
|
||||
MySpacing.width(12),
|
||||
GestureDetector(
|
||||
onTap: () => TeamBottomSheet.show(
|
||||
context: context,
|
||||
teamMembers: members.map((name) => _Member(name)).toList()),
|
||||
child: SizedBox(
|
||||
height: avatarSize,
|
||||
// Calculate width based on number of avatars shown
|
||||
width: (math.min(members.length, 3) * avatarOverlap) +
|
||||
(avatarSize - avatarOverlap),
|
||||
child: Stack(
|
||||
children: [
|
||||
...List.generate(math.min(members.length, 3), (i) {
|
||||
return Positioned(
|
||||
left: i * avatarOverlap,
|
||||
child: Tooltip(
|
||||
message: members[i],
|
||||
child: Avatar(
|
||||
firstName: members[i],
|
||||
lastName: '',
|
||||
size: avatarSize),
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (members.length > 3)
|
||||
Positioned(
|
||||
left: 3 * avatarOverlap,
|
||||
child: CircleAvatar(
|
||||
radius: avatarSize / 2,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
child: MyText.bodySmall('+${members.length - 3}',
|
||||
fontWeight: 600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerButton(
|
||||
{required VoidCallback onTap,
|
||||
required IconData icon,
|
||||
required String label}) {
|
||||
return Expanded(
|
||||
child: MyButton.outlined(
|
||||
onPressed: onTap,
|
||||
padding: MySpacing.xy(12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.blueAccent),
|
||||
MySpacing.width(8),
|
||||
MyText.bodySmall(label, color: Colors.blueAccent, fontWeight: 600),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Action Handlers ---
|
||||
|
||||
void _showCreateTaskBottomSheet() {
|
||||
showCreateTaskBottomSheet(
|
||||
workArea: widget.taskData['location'] ?? '',
|
||||
activity: widget.taskData['activity'] ?? '',
|
||||
completedWork: widget.taskData['completedWork'] ?? '',
|
||||
unit: widget.taskData['unit'] ?? '',
|
||||
onCategoryChanged: (category) =>
|
||||
debugPrint("Category changed to: $category"),
|
||||
parentTaskId: widget.taskDataId,
|
||||
plannedTask: int.tryParse(widget.taskData['plannedWork'] ?? '0') ?? 0,
|
||||
activityId: widget.activityId,
|
||||
workAreaId: widget.workAreaId,
|
||||
onSubmit: () => Navigator.of(context).pop(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showImageViewer(List<dynamic> sources, int initialIndex) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: sources,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitComment() async {
|
||||
if (controller.basicValidator.validateForm()) {
|
||||
await controller.commentTask(
|
||||
projectId: controller.basicValidator
|
||||
.getController(_FormFieldKeys.taskId)
|
||||
?.text ??
|
||||
'',
|
||||
comment: controller.basicValidator
|
||||
.getController(_FormFieldKeys.comment)
|
||||
?.text ??
|
||||
'',
|
||||
images: controller.selectedImages,
|
||||
);
|
||||
// Callback to the parent widget to refresh data if needed
|
||||
widget.onCommentSuccess?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Refactoring Note ---
|
||||
// A reusable widget for displaying a horizontal list of images.
|
||||
// It can handle both network URLs (String) and local files (File).
|
||||
class _ImageHorizontalListView extends StatelessWidget {
|
||||
final List<dynamic> imageSources; // Can be List<String> or List<File>
|
||||
final Function(int) onPreview;
|
||||
final Function(int)? onRemove;
|
||||
final Widget? emptyStatePlaceholder;
|
||||
|
||||
const _ImageHorizontalListView({
|
||||
required this.imageSources,
|
||||
required this.onPreview,
|
||||
this.onRemove,
|
||||
this.emptyStatePlaceholder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (imageSources.isEmpty) {
|
||||
return emptyStatePlaceholder ?? const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 70,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: imageSources.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final source = imageSources[index];
|
||||
return GestureDetector(
|
||||
onTap: () => onPreview(index),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: source is File
|
||||
? Image.file(source,
|
||||
width: 70, height: 70, fit: BoxFit.cover)
|
||||
: Image.network(
|
||||
source as String,
|
||||
width: 70,
|
||||
height: 70,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: Colors.grey.shade200,
|
||||
child: Icon(Icons.broken_image,
|
||||
color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRemove != null)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: GestureDetector(
|
||||
onTap: () => onRemove!(index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Refactoring Note ---
|
||||
// A dedicated widget for a single comment card. This cleans up the main
|
||||
// widget's build method and makes the comment layout easier to manage.
|
||||
class _CommentCard extends StatelessWidget {
|
||||
final Map<String, dynamic> comment;
|
||||
final String timeAgo;
|
||||
final Function(List<String> imageUrls, int index) onPreviewImage;
|
||||
|
||||
const _CommentCard({
|
||||
required this.comment,
|
||||
required this.timeAgo,
|
||||
required this.onPreviewImage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commentedBy = comment['commentedBy'] ?? 'Unknown';
|
||||
final commentText = comment['text'] ?? '-';
|
||||
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: commentedBy.split(' ').first,
|
||||
lastName: commentedBy.split(' ').length > 1
|
||||
? commentedBy.split(' ').last
|
||||
: '',
|
||||
size: 32,
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodyMedium(commentedBy,
|
||||
fontWeight: 700, color: Colors.black87),
|
||||
MyText.bodySmall(timeAgo,
|
||||
color: Colors.black54, fontSize: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
MyText.bodyMedium(commentText, color: Colors.black87),
|
||||
if (imageUrls.isNotEmpty) ...[
|
||||
MySpacing.height(12),
|
||||
_ImageHorizontalListView(
|
||||
imageSources: imageUrls,
|
||||
onPreview: (index) => onPreviewImage(imageUrls, index),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,213 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_planning/add_task_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
void showCreateTaskBottomSheet({
|
||||
required String workArea,
|
||||
required String activity,
|
||||
required String completedWork,
|
||||
required String unit,
|
||||
required Function(String) onCategoryChanged,
|
||||
required String parentTaskId,
|
||||
required int plannedTask,
|
||||
required String activityId,
|
||||
required String workAreaId,
|
||||
required VoidCallback onSubmit,
|
||||
}) {
|
||||
final controller = Get.put(AddTaskController());
|
||||
final TextEditingController plannedTaskController =
|
||||
TextEditingController(text: plannedTask.toString());
|
||||
final TextEditingController descriptionController = TextEditingController();
|
||||
|
||||
Get.bottomSheet(
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return BaseBottomSheet(
|
||||
title: "Create Task",
|
||||
onCancel: () => Get.back(),
|
||||
onSubmit: () async {
|
||||
final plannedValue =
|
||||
int.tryParse(plannedTaskController.text.trim()) ?? 0;
|
||||
final comment = descriptionController.text.trim();
|
||||
final selectedCategoryId = controller.selectedCategoryId.value;
|
||||
|
||||
if (selectedCategoryId == null) {
|
||||
showAppSnackbar(
|
||||
title: "error",
|
||||
message: "Please select a work category!",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await controller.createTask(
|
||||
parentTaskId: parentTaskId,
|
||||
plannedTask: plannedValue,
|
||||
comment: comment,
|
||||
workAreaId: workAreaId,
|
||||
activityId: activityId,
|
||||
categoryId: selectedCategoryId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Get.back();
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
onSubmit();
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Task created successfully!",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
submitText: "Submit",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_infoCardSection([
|
||||
_infoRowWithIcon(
|
||||
Icons.workspaces, "Selected Work Area", workArea),
|
||||
_infoRowWithIcon(Icons.list_alt, "Selected Activity", activity),
|
||||
_infoRowWithIcon(Icons.check_circle_outline, "Completed Work",
|
||||
completedWork),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_sectionTitle(Icons.edit_calendar, "Planned Work"),
|
||||
const SizedBox(height: 6),
|
||||
_customTextField(
|
||||
controller: plannedTaskController,
|
||||
hint: "Enter planned work",
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_sectionTitle(Icons.description_outlined, "Comment"),
|
||||
const SizedBox(height: 6),
|
||||
_customTextField(
|
||||
controller: descriptionController,
|
||||
hint: "Enter task description",
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_sectionTitle(Icons.category_outlined, "Selected Work Category"),
|
||||
const SizedBox(height: 6),
|
||||
Obx(() {
|
||||
final categoryMap = controller.categoryIdNameMap;
|
||||
final String selectedName =
|
||||
controller.selectedCategoryId.value != null
|
||||
? (categoryMap[controller.selectedCategoryId.value!] ??
|
||||
'Select Category')
|
||||
: 'Select Category';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: PopupMenuButton<String>(
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (val) {
|
||||
controller.selectCategory(val);
|
||||
onCategoryChanged(val);
|
||||
},
|
||||
itemBuilder: (context) => categoryMap.entries
|
||||
.map((entry) => PopupMenuItem<String>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
))
|
||||
.toList(),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
selectedName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.black87),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(IconData icon, String title) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.grey[700], size: 18),
|
||||
const SizedBox(width: 8),
|
||||
MyText.bodyMedium(title, fontWeight: 600),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _customTextField({
|
||||
required TextEditingController controller,
|
||||
required String hint,
|
||||
int maxLines = 1,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCardSection(List<Widget> children) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRowWithIcon(IconData icon, String title, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: Colors.grey[700], size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodyMedium(title, fontWeight: 600),
|
||||
const SizedBox(height: 2),
|
||||
MyText.bodySmall(value, color: Colors.grey[800]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.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_text.dart';
|
||||
|
||||
class DailyProgressReportFilter 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";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: "Filter Tasks",
|
||||
onCancel: () => Navigator.pop(context),
|
||||
|
||||
onSubmit: () {
|
||||
Navigator.pop(context, {
|
||||
'startDate': controller.startDateTask,
|
||||
'endDate': controller.endDateTask,
|
||||
});
|
||||
},
|
||||
child: 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,
|
||||
),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
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,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,222 +0,0 @@
|
||||
class TaskModel {
|
||||
final DateTime assignmentDate;
|
||||
final DateTime? reportedDate;
|
||||
final String id;
|
||||
final WorkItem? workItem;
|
||||
final String workItemId;
|
||||
final double plannedTask;
|
||||
final double completedTask;
|
||||
final AssignedBy assignedBy;
|
||||
final AssignedBy? approvedBy;
|
||||
final List<TeamMember> teamMembers;
|
||||
final List<Comment> comments;
|
||||
final List<String> reportedPreSignedUrls;
|
||||
|
||||
TaskModel({
|
||||
required this.assignmentDate,
|
||||
this.reportedDate,
|
||||
required this.id,
|
||||
required this.workItem,
|
||||
required this.workItemId,
|
||||
required this.plannedTask,
|
||||
required this.completedTask,
|
||||
required this.assignedBy,
|
||||
this.approvedBy,
|
||||
required this.teamMembers,
|
||||
required this.comments,
|
||||
required this.reportedPreSignedUrls,
|
||||
});
|
||||
|
||||
factory TaskModel.fromJson(Map<String, dynamic> json) {
|
||||
return TaskModel(
|
||||
assignmentDate: DateTime.parse(json['assignmentDate']),
|
||||
reportedDate: json['reportedDate'] != null
|
||||
? DateTime.tryParse(json['reportedDate'])
|
||||
: null,
|
||||
id: json['id'],
|
||||
workItem:
|
||||
json['workItem'] != null ? WorkItem.fromJson(json['workItem']) : null,
|
||||
workItemId: json['workItemId'],
|
||||
plannedTask: (json['plannedTask'] as num).toDouble(),
|
||||
completedTask: (json['completedTask'] as num).toDouble(),
|
||||
assignedBy: AssignedBy.fromJson(json['assignedBy']),
|
||||
approvedBy: json['approvedBy'] != null
|
||||
? AssignedBy.fromJson(json['approvedBy'])
|
||||
: null,
|
||||
teamMembers: (json['teamMembers'] as List)
|
||||
.map((e) => TeamMember.fromJson(e))
|
||||
.toList(),
|
||||
comments:
|
||||
(json['comments'] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
reportedPreSignedUrls: (json['reportedPreSignedUrls'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkItem {
|
||||
final String? id;
|
||||
final ActivityMaster? activityMaster;
|
||||
final WorkArea? workArea;
|
||||
final double? plannedWork;
|
||||
final double? completedWork;
|
||||
final List<String> preSignedUrls;
|
||||
|
||||
WorkItem({
|
||||
this.id,
|
||||
this.activityMaster,
|
||||
this.workArea,
|
||||
this.plannedWork,
|
||||
this.completedWork,
|
||||
this.preSignedUrls = const [],
|
||||
});
|
||||
|
||||
factory WorkItem.fromJson(Map<String, dynamic> json) {
|
||||
return WorkItem(
|
||||
id: json['id']?.toString(),
|
||||
activityMaster: json['activityMaster'] != null
|
||||
? ActivityMaster.fromJson(json['activityMaster'])
|
||||
: null,
|
||||
workArea:
|
||||
json['workArea'] != null ? WorkArea.fromJson(json['workArea']) : null,
|
||||
plannedWork: (json['plannedWork'] as num?)?.toDouble(),
|
||||
completedWork: (json['completedWork'] as num?)?.toDouble(),
|
||||
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityMaster {
|
||||
final String? id; // ✅ Added
|
||||
final String activityName;
|
||||
|
||||
ActivityMaster({
|
||||
this.id,
|
||||
required this.activityName,
|
||||
});
|
||||
|
||||
factory ActivityMaster.fromJson(Map<String, dynamic> json) {
|
||||
return ActivityMaster(
|
||||
id: json['id']?.toString(),
|
||||
activityName: json['activityName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkArea {
|
||||
final String? id; // ✅ Added
|
||||
final String areaName;
|
||||
final Floor? floor;
|
||||
|
||||
WorkArea({
|
||||
this.id,
|
||||
required this.areaName,
|
||||
this.floor,
|
||||
});
|
||||
|
||||
factory WorkArea.fromJson(Map<String, dynamic> json) {
|
||||
return WorkArea(
|
||||
id: json['id']?.toString(),
|
||||
areaName: json['areaName'] ?? '',
|
||||
floor: json['floor'] != null ? Floor.fromJson(json['floor']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Floor {
|
||||
final String floorName;
|
||||
final Building? building;
|
||||
|
||||
Floor({required this.floorName, this.building});
|
||||
|
||||
factory Floor.fromJson(Map<String, dynamic> json) {
|
||||
return Floor(
|
||||
floorName: json['floorName'] ?? '',
|
||||
building:
|
||||
json['building'] != null ? Building.fromJson(json['building']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Building {
|
||||
final String name;
|
||||
|
||||
Building({required this.name});
|
||||
|
||||
factory Building.fromJson(Map<String, dynamic> json) {
|
||||
return Building(name: json['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
class AssignedBy {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String? lastName;
|
||||
|
||||
AssignedBy({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
this.lastName,
|
||||
});
|
||||
|
||||
factory AssignedBy.fromJson(Map<String, dynamic> json) {
|
||||
return AssignedBy(
|
||||
id: json['id']?.toString() ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TeamMember {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String? lastName;
|
||||
|
||||
TeamMember({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
this.lastName,
|
||||
});
|
||||
|
||||
factory TeamMember.fromJson(Map<String, dynamic> json) {
|
||||
return TeamMember(
|
||||
id: json['id']?.toString() ?? '',
|
||||
firstName: json['firstName']?.toString() ?? '',
|
||||
lastName: json['lastName']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Comment {
|
||||
final String comment;
|
||||
final TeamMember commentedBy;
|
||||
final DateTime timestamp;
|
||||
final List<String> preSignedUrls;
|
||||
|
||||
Comment({
|
||||
required this.comment,
|
||||
required this.commentedBy,
|
||||
required this.timestamp,
|
||||
required this.preSignedUrls,
|
||||
});
|
||||
|
||||
factory Comment.fromJson(Map<String, dynamic> json) {
|
||||
return Comment(
|
||||
comment: json['comment']?.toString() ?? '',
|
||||
commentedBy: json['employee'] != null
|
||||
? TeamMember.fromJson(json['employee'])
|
||||
: TeamMember(id: '', firstName: '', lastName: null),
|
||||
timestamp: DateTime.parse(json['commentDate'] ?? ''),
|
||||
preSignedUrls: (json['preSignedUrls'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
class DailyTaskPlanningFilter extends StatelessWidget {
|
||||
final DailyTaskPlanningController controller;
|
||||
final PermissionController permissionController;
|
||||
|
||||
const DailyTaskPlanningFilter({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.permissionController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? tempSelectedProjectId = '654563563645';
|
||||
bool showProjectList = false;
|
||||
|
||||
final accessibleProjects = controller.projects
|
||||
.where((project) =>
|
||||
permissionController.isUserAssignedToProject(project.id.toString()))
|
||||
.toList();
|
||||
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
List<Widget> filterWidgets;
|
||||
|
||||
if (showProjectList) {
|
||||
filterWidgets = accessibleProjects.isEmpty
|
||||
? [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Center(
|
||||
child: MyText.titleSmall(
|
||||
'No Projects Assigned',
|
||||
fontWeight: 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: accessibleProjects.map((project) {
|
||||
final isSelected =
|
||||
tempSelectedProjectId == project.id.toString();
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: MyText.titleSmall(project.name),
|
||||
trailing: isSelected ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
tempSelectedProjectId = project.id.toString();
|
||||
showProjectList = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
} else {
|
||||
final selectedProject = accessibleProjects.isNotEmpty
|
||||
? accessibleProjects.firstWhere(
|
||||
(p) => p.id.toString() == tempSelectedProjectId,
|
||||
orElse: () => accessibleProjects[0],
|
||||
)
|
||||
: null;
|
||||
|
||||
final selectedProjectName = selectedProject?.name ?? "Select Project";
|
||||
|
||||
filterWidgets = [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: MyText.titleSmall(
|
||||
'Select Project',
|
||||
fontWeight: 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: MyText.titleSmall(selectedProjectName),
|
||||
trailing: const Icon(Icons.arrow_drop_down),
|
||||
onTap: () => setState(() => showProjectList = true),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...filterWidgets,
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Color.fromARGB(255, 95, 132, 255),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: MyText.titleSmall(
|
||||
'Apply Filter',
|
||||
fontWeight: 600,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, {
|
||||
'projectId': tempSelectedProjectId,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,246 +0,0 @@
|
||||
class TaskPlanningDetailsModel {
|
||||
final List<Building> buildings;
|
||||
final String id;
|
||||
final String name;
|
||||
final String projectAddress;
|
||||
final String contactPerson;
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
final String projectStatusId;
|
||||
|
||||
TaskPlanningDetailsModel({
|
||||
required this.buildings,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.projectAddress,
|
||||
required this.contactPerson,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.projectStatusId,
|
||||
});
|
||||
|
||||
factory TaskPlanningDetailsModel.fromJson(Map<String, dynamic> json) {
|
||||
return TaskPlanningDetailsModel(
|
||||
buildings: (json['buildings'] as List<dynamic>?)
|
||||
?.map((b) => Building.fromJson(b))
|
||||
.toList() ??
|
||||
[],
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
projectAddress: json['projectAddress'],
|
||||
contactPerson: json['contactPerson'],
|
||||
startDate: DateTime.parse(json['startDate']),
|
||||
endDate: DateTime.parse(json['endDate']),
|
||||
projectStatusId: json['projectStatusId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Building {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final List<Floor> floors;
|
||||
|
||||
Building({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.floors,
|
||||
});
|
||||
|
||||
factory Building.fromJson(Map<String, dynamic> json) {
|
||||
return Building(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
floors: (json['floors'] as List).map((f) => Floor.fromJson(f)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Floor {
|
||||
final String id;
|
||||
final String floorName;
|
||||
final List<WorkArea> workAreas;
|
||||
|
||||
Floor({
|
||||
required this.id,
|
||||
required this.floorName,
|
||||
required this.workAreas,
|
||||
});
|
||||
|
||||
factory Floor.fromJson(Map<String, dynamic> json) {
|
||||
return Floor(
|
||||
id: json['id'],
|
||||
floorName: json['floorName'],
|
||||
workAreas:
|
||||
(json['workAreas'] as List).map((w) => WorkArea.fromJson(w)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkArea {
|
||||
final String id;
|
||||
final String areaName;
|
||||
final List<WorkItemWrapper> workItems;
|
||||
|
||||
WorkArea({
|
||||
required this.id,
|
||||
required this.areaName,
|
||||
required this.workItems,
|
||||
});
|
||||
|
||||
factory WorkArea.fromJson(Map<String, dynamic> json) {
|
||||
return WorkArea(
|
||||
id: json['id'],
|
||||
areaName: json['areaName'],
|
||||
workItems: (json['workItems'] as List)
|
||||
.map((w) => WorkItemWrapper.fromJson(w))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkItemWrapper {
|
||||
final String workItemId;
|
||||
final WorkItem workItem;
|
||||
|
||||
WorkItemWrapper({
|
||||
required this.workItemId,
|
||||
required this.workItem,
|
||||
});
|
||||
|
||||
factory WorkItemWrapper.fromJson(Map<String, dynamic> json) {
|
||||
return WorkItemWrapper(
|
||||
workItemId: json['workItemId'],
|
||||
workItem: WorkItem.fromJson(json['workItem']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkItem {
|
||||
final String? id;
|
||||
final String? activityId;
|
||||
final String? workCategoryId;
|
||||
final String? workAreaId;
|
||||
final WorkAreaBasic? workArea;
|
||||
final ActivityMaster? activityMaster;
|
||||
final WorkCategoryMaster? workCategoryMaster;
|
||||
final double? plannedWork;
|
||||
final double? completedWork;
|
||||
final String? description;
|
||||
final double? todaysAssigned;
|
||||
final DateTime? taskDate;
|
||||
final String? tenantId;
|
||||
final Tenant? tenant;
|
||||
|
||||
WorkItem({
|
||||
this.id,
|
||||
this.activityId,
|
||||
this.workCategoryId,
|
||||
this.workAreaId,
|
||||
this.workArea,
|
||||
this.activityMaster,
|
||||
this.workCategoryMaster,
|
||||
this.description,
|
||||
this.plannedWork,
|
||||
this.completedWork,
|
||||
this.todaysAssigned,
|
||||
this.taskDate,
|
||||
this.tenantId,
|
||||
this.tenant,
|
||||
});
|
||||
|
||||
factory WorkItem.fromJson(Map<String, dynamic> json) {
|
||||
return WorkItem(
|
||||
id: json['id'] as String?,
|
||||
activityId: json['activityId'] as String?,
|
||||
workCategoryId: json['workCategoryId'] as String?,
|
||||
workAreaId: json['workAreaId'] as String?,
|
||||
workArea: json['workArea'] != null
|
||||
? WorkAreaBasic.fromJson(json['workArea'] as Map<String, dynamic>)
|
||||
: null,
|
||||
activityMaster: json['activityMaster'] != null
|
||||
? ActivityMaster.fromJson(
|
||||
json['activityMaster'] as Map<String, dynamic>)
|
||||
: null,
|
||||
workCategoryMaster: json['workCategoryMaster'] != null
|
||||
? WorkCategoryMaster.fromJson(
|
||||
json['workCategoryMaster'] as Map<String, dynamic>)
|
||||
: null,
|
||||
plannedWork: json['plannedWork'] != null
|
||||
? (json['plannedWork'] as num).toDouble()
|
||||
: null,
|
||||
completedWork: json['completedWork'] != null
|
||||
? (json['completedWork'] as num).toDouble()
|
||||
: null,
|
||||
todaysAssigned: json['todaysAssigned'] != null
|
||||
? (json['todaysAssigned'] as num).toDouble()
|
||||
: null,
|
||||
description: json['description'] as String?,
|
||||
taskDate:
|
||||
json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null,
|
||||
tenantId: json['tenantId'] as String?,
|
||||
tenant: json['tenant'] != null
|
||||
? Tenant.fromJson(json['tenant'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkAreaBasic {
|
||||
final String? id;
|
||||
final String? name;
|
||||
|
||||
WorkAreaBasic({this.id, this.name});
|
||||
|
||||
factory WorkAreaBasic.fromJson(Map<String, dynamic> json) {
|
||||
return WorkAreaBasic(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityMaster {
|
||||
final String? id;
|
||||
final String? name;
|
||||
|
||||
ActivityMaster({this.id, this.name});
|
||||
|
||||
factory ActivityMaster.fromJson(Map<String, dynamic> json) {
|
||||
return ActivityMaster(
|
||||
id: json['id'] as String?,
|
||||
name: json['activityName'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkCategoryMaster {
|
||||
final String? id;
|
||||
final String? name;
|
||||
|
||||
WorkCategoryMaster({this.id, this.name});
|
||||
|
||||
factory WorkCategoryMaster.fromJson(Map<String, dynamic> json) {
|
||||
return WorkCategoryMaster(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Tenant {
|
||||
final String? id;
|
||||
final String? name;
|
||||
|
||||
Tenant({this.id, this.name});
|
||||
|
||||
factory Tenant.fromJson(Map<String, dynamic> json) {
|
||||
return Tenant(
|
||||
id: json['id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
class WorkCategoryModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final bool isSystem;
|
||||
|
||||
WorkCategoryModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.isSystem,
|
||||
});
|
||||
|
||||
factory WorkCategoryModel.fromJson(Map<String, dynamic> json) {
|
||||
return WorkCategoryModel(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
isSystem: json['isSystem'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'isSystem': isSystem,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,500 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_planning/report_task_action_controller.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/report_action_widgets.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
class ReportActionBottomSheet extends StatefulWidget {
|
||||
final Map<String, dynamic> taskData;
|
||||
final VoidCallback? onCommentSuccess;
|
||||
final String taskDataId;
|
||||
final String workAreaId;
|
||||
final String activityId;
|
||||
final VoidCallback onReportSuccess;
|
||||
|
||||
const ReportActionBottomSheet({
|
||||
super.key,
|
||||
required this.taskData,
|
||||
this.onCommentSuccess,
|
||||
required this.taskDataId,
|
||||
required this.workAreaId,
|
||||
required this.activityId,
|
||||
required this.onReportSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReportActionBottomSheet> createState() =>
|
||||
_ReportActionBottomSheetState();
|
||||
}
|
||||
|
||||
class _Member {
|
||||
final String firstName;
|
||||
_Member(this.firstName);
|
||||
}
|
||||
|
||||
class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
|
||||
with UIMixin {
|
||||
late ReportTaskActionController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = Get.put(
|
||||
ReportTaskActionController(),
|
||||
tag: widget.taskData['taskId'] ?? '',
|
||||
);
|
||||
controller.fetchWorkStatuses();
|
||||
final data = widget.taskData;
|
||||
controller.basicValidator.getController('approved_task')?.text =
|
||||
data['approvedTask']?.toString() ?? '';
|
||||
controller.basicValidator.getController('assigned_date')?.text =
|
||||
data['assignedOn'] ?? '';
|
||||
controller.basicValidator.getController('assigned_by')?.text =
|
||||
data['assignedBy'] ?? '';
|
||||
controller.basicValidator.getController('work_area')?.text =
|
||||
data['location'] ?? '';
|
||||
controller.basicValidator.getController('activity')?.text =
|
||||
data['activity'] ?? '';
|
||||
controller.basicValidator.getController('planned_work')?.text =
|
||||
data['plannedWork'] ?? '';
|
||||
controller.basicValidator.getController('completed_work')?.text =
|
||||
data['completedWork'] ?? '';
|
||||
controller.basicValidator.getController('team_members')?.text =
|
||||
(data['teamMembers'] as List<dynamic>).join(', ');
|
||||
controller.basicValidator.getController('assigned')?.text =
|
||||
data['assigned'] ?? '';
|
||||
controller.basicValidator.getController('task_id')?.text =
|
||||
widget.taskDataId;
|
||||
controller.basicValidator.getController('comment')?.clear();
|
||||
controller.selectedImages.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<ReportTaskActionController>(
|
||||
tag: widget.taskData['taskId'] ?? '',
|
||||
builder: (controller) {
|
||||
return BaseBottomSheet(
|
||||
title: "Take Report Action",
|
||||
isSubmitting: controller.isLoading.value,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: () async {}, // not used since buttons moved
|
||||
showButtons: false, // disable internal buttons
|
||||
child: _buildForm(context, controller),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(
|
||||
BuildContext context, ReportTaskActionController controller) {
|
||||
return Form(
|
||||
key: controller.basicValidator.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 📋 Task Details
|
||||
buildRow("Assigned By",
|
||||
controller.basicValidator.getController('assigned_by')?.text,
|
||||
icon: Icons.person_outline),
|
||||
buildRow("Work Area",
|
||||
controller.basicValidator.getController('work_area')?.text,
|
||||
icon: Icons.place_outlined),
|
||||
buildRow("Activity",
|
||||
controller.basicValidator.getController('activity')?.text,
|
||||
icon: Icons.assignment_outlined),
|
||||
buildRow("Planned Work",
|
||||
controller.basicValidator.getController('planned_work')?.text,
|
||||
icon: Icons.schedule_outlined),
|
||||
buildRow("Completed Work",
|
||||
controller.basicValidator.getController('completed_work')?.text,
|
||||
icon: Icons.done_all_outlined),
|
||||
buildTeamMembers(),
|
||||
MySpacing.height(8),
|
||||
|
||||
// ✅ Approved Task Field
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Approved Task:", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(10),
|
||||
TextFormField(
|
||||
controller:
|
||||
controller.basicValidator.getController('approved_task'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Required';
|
||||
if (int.tryParse(value) == null) return 'Must be a number';
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: "eg: 5",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
border: outlineInputBorder,
|
||||
contentPadding: MySpacing.all(16),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(10),
|
||||
if ((widget.taskData['reportedPreSignedUrls'] as List<dynamic>?)
|
||||
?.isNotEmpty ==
|
||||
true)
|
||||
buildReportedImagesSection(
|
||||
imageUrls: List<String>.from(
|
||||
widget.taskData['reportedPreSignedUrls'] ?? []),
|
||||
context: context,
|
||||
),
|
||||
|
||||
MySpacing.height(10),
|
||||
MyText.titleSmall("Report Actions", fontWeight: 600),
|
||||
MySpacing.height(10),
|
||||
|
||||
Obx(() {
|
||||
if (controller.isLoadingWorkStatus.value)
|
||||
return const CircularProgressIndicator();
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (String value) {
|
||||
controller.selectedWorkStatusName.value = value;
|
||||
controller.showAddTaskCheckbox.value = true;
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
itemBuilder: (BuildContext context) {
|
||||
return controller.workStatus.map((status) {
|
||||
return PopupMenuItem<String>(
|
||||
value: status.name,
|
||||
child: Row(
|
||||
children: [
|
||||
Radio<String>(
|
||||
value: status.name,
|
||||
groupValue: controller.selectedWorkStatusName.value,
|
||||
onChanged: (_) => Navigator.pop(context, status.name),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
MyText.bodySmall(status.name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
child: Container(
|
||||
padding: MySpacing.xy(16, 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
controller.selectedWorkStatusName.value.isEmpty
|
||||
? "Select Work Status"
|
||||
: controller.selectedWorkStatusName.value,
|
||||
color: Colors.black87,
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
MySpacing.height(10),
|
||||
|
||||
Obx(() {
|
||||
if (!controller.showAddTaskCheckbox.value)
|
||||
return const SizedBox.shrink();
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: Colors.black, width: 2),
|
||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return Colors.blueAccent;
|
||||
}
|
||||
return Colors.white;
|
||||
}),
|
||||
checkColor:
|
||||
MaterialStateProperty.all(Colors.white),
|
||||
),
|
||||
),
|
||||
child: CheckboxListTile(
|
||||
title: MyText.titleSmall("Add new task", fontWeight: 600),
|
||||
value: controller.isAddTaskChecked.value,
|
||||
onChanged: (val) =>
|
||||
controller.isAddTaskChecked.value = val ?? false,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
MySpacing.height(24),
|
||||
|
||||
// ✏️ Comment Field
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Comment:", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
validator: controller.basicValidator.getValidation('comment'),
|
||||
controller: controller.basicValidator.getController('comment'),
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: "eg: Work done successfully",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
border: outlineInputBorder,
|
||||
contentPadding: MySpacing.all(16),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 📸 Image Attachments
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.camera_alt_outlined,
|
||||
size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall("Attach Photos:", fontWeight: 600),
|
||||
MySpacing.height(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Obx(() {
|
||||
final images = controller.selectedImages;
|
||||
return buildImagePickerSection(
|
||||
images: images,
|
||||
onCameraTap: () => controller.pickImages(fromCamera: true),
|
||||
onUploadTap: () => controller.pickImages(fromCamera: false),
|
||||
onRemoveImage: (index) => controller.removeImageAt(index),
|
||||
onPreviewImage: (index) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: images,
|
||||
initialIndex: index,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
MySpacing.height(12),
|
||||
|
||||
// ✅ Submit/Cancel Buttons moved here
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
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: controller.isLoading.value
|
||||
? null
|
||||
: () async {
|
||||
if (controller.basicValidator.validateForm()) {
|
||||
final selectedStatusName =
|
||||
controller.selectedWorkStatusName.value;
|
||||
final selectedStatus = controller.workStatus
|
||||
.firstWhereOrNull(
|
||||
(s) => s.name == selectedStatusName);
|
||||
final reportActionId =
|
||||
selectedStatus?.id.toString() ?? '';
|
||||
final approvedTaskCount = controller.basicValidator
|
||||
.getController('approved_task')
|
||||
?.text
|
||||
.trim() ??
|
||||
'';
|
||||
|
||||
final shouldShowAddTaskSheet =
|
||||
controller.isAddTaskChecked.value;
|
||||
|
||||
final success = await controller.approveTask(
|
||||
projectId: controller.basicValidator
|
||||
.getController('task_id')
|
||||
?.text ??
|
||||
'',
|
||||
comment: controller.basicValidator
|
||||
.getController('comment')
|
||||
?.text ??
|
||||
'',
|
||||
images: controller.selectedImages,
|
||||
reportActionId: reportActionId,
|
||||
approvedTaskCount: approvedTaskCount,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Navigator.of(context).pop();
|
||||
if (shouldShowAddTaskSheet) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 100));
|
||||
showCreateTaskBottomSheet(
|
||||
workArea: widget.taskData['location'] ?? '',
|
||||
activity: widget.taskData['activity'] ?? '',
|
||||
completedWork:
|
||||
widget.taskData['completedWork'] ?? '',
|
||||
unit: widget.taskData['unit'] ?? '',
|
||||
parentTaskId: widget.taskDataId,
|
||||
plannedTask: int.tryParse(
|
||||
widget.taskData['plannedWork'] ??
|
||||
'0') ??
|
||||
0,
|
||||
activityId: widget.activityId,
|
||||
workAreaId: widget.workAreaId,
|
||||
onSubmit: () => Navigator.of(context).pop(),
|
||||
onCategoryChanged: (category) {},
|
||||
);
|
||||
}
|
||||
widget.onReportSuccess.call();
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
label: MyText.bodyMedium(
|
||||
controller.isLoading.value ? "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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
MySpacing.height(12),
|
||||
|
||||
// 💬 Previous Comments List (only below submit)
|
||||
if ((widget.taskData['taskComments'] as List<dynamic>?)?.isNotEmpty ==
|
||||
true) ...[
|
||||
Row(
|
||||
children: [
|
||||
MySpacing.width(10),
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Comments", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
buildCommentList(
|
||||
List<Map<String, dynamic>>.from(
|
||||
widget.taskData['taskComments'] as List),
|
||||
context,
|
||||
timeAgo,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTeamMembers() {
|
||||
final teamMembersText =
|
||||
controller.basicValidator.getController('team_members')?.text ?? '';
|
||||
final members = teamMembersText
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
MyText.titleSmall("Team Members:", fontWeight: 600),
|
||||
MySpacing.width(12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
TeamBottomSheet.show(
|
||||
context: context,
|
||||
teamMembers: members.map((name) => _Member(name)).toList(),
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
width: 100,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (int i = 0; i < members.length.clamp(0, 3); i++)
|
||||
Positioned(
|
||||
left: i * 24.0,
|
||||
child: Tooltip(
|
||||
message: members[i],
|
||||
child: Avatar(
|
||||
firstName: members[i],
|
||||
lastName: '',
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (members.length > 3)
|
||||
Positioned(
|
||||
left: 2 * 24.0,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
child: MyText.bodyMedium(
|
||||
'+${members.length - 3}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,392 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/helpers/widgets/avatar.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// Show labeled row with optional icon
|
||||
Widget buildRow(String label, String? value, {IconData? icon}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0, top: 2),
|
||||
child: Icon(icon, size: 18, color: Colors.grey[700]),
|
||||
),
|
||||
MyText.titleSmall("$label:", fontWeight: 600),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show uploaded network images
|
||||
Widget buildReportedImagesSection({
|
||||
required List<String> imageUrls,
|
||||
required BuildContext context,
|
||||
String title = "Reported Images",
|
||||
}) {
|
||||
if (imageUrls.isEmpty) return const SizedBox();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall(title, fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
SizedBox(
|
||||
height: 70,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: imageUrls.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final url = imageUrls[index];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: imageUrls,
|
||||
initialIndex: index,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 70,
|
||||
height: 70,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
color: Colors.grey.shade200,
|
||||
child: Icon(Icons.broken_image, color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Local image picker preview (with file images)
|
||||
Widget buildImagePickerSection({
|
||||
required List<File> images,
|
||||
required VoidCallback onCameraTap,
|
||||
required VoidCallback onUploadTap,
|
||||
required void Function(int index) onRemoveImage,
|
||||
required void Function(int initialIndex) onPreviewImage,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (images.isEmpty)
|
||||
Container(
|
||||
height: 70,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300, width: 2),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(Icons.photo_camera_outlined,
|
||||
size: 48, color: Colors.grey.shade400),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 70,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: images.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final file = images[index];
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => onPreviewImage(index),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
file,
|
||||
height: 70,
|
||||
width: 70,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: GestureDetector(
|
||||
onTap: () => onRemoveImage(index),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close,
|
||||
size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyButton.outlined(
|
||||
onPressed: onCameraTap,
|
||||
padding: MySpacing.xy(12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.camera_alt,
|
||||
size: 16, color: Colors.blueAccent),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Capture', color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: MyButton.outlined(
|
||||
onPressed: onUploadTap,
|
||||
padding: MySpacing.xy(12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.upload_file,
|
||||
size: 16, color: Colors.blueAccent),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Upload', color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Comment list widget
|
||||
Widget buildCommentList(
|
||||
List<Map<String, dynamic>> comments, BuildContext context, String Function(String) timeAgo) {
|
||||
comments.sort((a, b) {
|
||||
final aDate = DateTime.tryParse(a['date'] ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = DateTime.tryParse(b['date'] ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return bDate.compareTo(aDate); // newest first
|
||||
});
|
||||
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, index) {
|
||||
final comment = comments[index];
|
||||
final commentText = comment['text'] ?? '-';
|
||||
final commentedBy = comment['commentedBy'] ?? 'Unknown';
|
||||
final relativeTime = timeAgo(comment['date'] ?? '');
|
||||
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Avatar(
|
||||
firstName: commentedBy.split(' ').first,
|
||||
lastName: commentedBy.split(' ').length > 1
|
||||
? commentedBy.split(' ').last
|
||||
: '',
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
MyText.bodyMedium(commentedBy,
|
||||
fontWeight: 700, color: Colors.black87),
|
||||
MyText.bodySmall(
|
||||
relativeTime,
|
||||
fontSize: 12,
|
||||
color: Colors.black54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MyText.bodyMedium(commentText,
|
||||
fontWeight: 500, color: Colors.black87),
|
||||
const SizedBox(height: 12),
|
||||
if (imageUrls.isNotEmpty) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.attach_file_outlined,
|
||||
size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.bodyMedium('Attachments',
|
||||
fontWeight: 600, color: Colors.black87),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: imageUrls.length,
|
||||
itemBuilder: (context, imageIndex) {
|
||||
final imageUrl = imageUrls[imageIndex];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: imageUrls,
|
||||
initialIndex: imageIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel + Submit buttons
|
||||
Widget buildCommentActionButtons({
|
||||
required VoidCallback onCancel,
|
||||
required Future<void> Function() onSubmit,
|
||||
required RxBool isLoading,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onCancel,
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
label:
|
||||
MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isLoading.value ? null : () => onSubmit(),
|
||||
icon: isLoading.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send, color: Colors.white, size: 18),
|
||||
label: isLoading.value
|
||||
? const SizedBox()
|
||||
: MyText.bodyMedium("Submit",
|
||||
color: Colors.white, fontWeight: 600),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a UTC timestamp to a relative time string
|
||||
String timeAgo(String dateString) {
|
||||
try {
|
||||
DateTime date = DateTime.parse(dateString + "Z").toLocal();
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
if (difference.inDays > 8) {
|
||||
return "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}";
|
||||
} else if (difference.inDays >= 1) {
|
||||
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
|
||||
} else if (difference.inHours >= 1) {
|
||||
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
|
||||
} else if (difference.inMinutes >= 1) {
|
||||
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
|
||||
} else {
|
||||
return 'just now';
|
||||
}
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
@ -1,310 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/task_planning/report_task_controller.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_button.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
class ReportTaskBottomSheet extends StatefulWidget {
|
||||
final Map<String, dynamic> taskData;
|
||||
final VoidCallback? onReportSuccess;
|
||||
|
||||
const ReportTaskBottomSheet({
|
||||
super.key,
|
||||
required this.taskData,
|
||||
this.onReportSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReportTaskBottomSheet> createState() => _ReportTaskBottomSheetState();
|
||||
}
|
||||
|
||||
class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
|
||||
with UIMixin {
|
||||
late final ReportTaskController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = Get.put(
|
||||
ReportTaskController(),
|
||||
tag: widget.taskData['taskId'] ?? UniqueKey().toString(),
|
||||
);
|
||||
_preFillFormFields();
|
||||
}
|
||||
|
||||
void _preFillFormFields() {
|
||||
final data = widget.taskData;
|
||||
final v = controller.basicValidator;
|
||||
|
||||
v.getController('assigned_date')?.text = data['assignedOn'] ?? '';
|
||||
v.getController('assigned_by')?.text = data['assignedBy'] ?? '';
|
||||
v.getController('work_area')?.text = data['location'] ?? '';
|
||||
v.getController('activity')?.text = data['activity'] ?? '';
|
||||
v.getController('team_size')?.text = data['teamSize']?.toString() ?? '';
|
||||
v.getController('assigned')?.text = data['assigned'] ?? '';
|
||||
v.getController('task_id')?.text = data['taskId'] ?? '';
|
||||
v.getController('completed_work')?.clear();
|
||||
v.getController('comment')?.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
return BaseBottomSheet(
|
||||
title: "Report Task",
|
||||
isSubmitting: controller.reportStatus.value == ApiStatus.loading,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: _handleSubmit,
|
||||
child: Form(
|
||||
key: controller.basicValidator.formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text),
|
||||
_buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text),
|
||||
_buildRow("Work Area", controller.basicValidator.getController('work_area')?.text),
|
||||
_buildRow("Activity", controller.basicValidator.getController('activity')?.text),
|
||||
_buildRow("Team Size", controller.basicValidator.getController('team_size')?.text),
|
||||
_buildRow(
|
||||
"Assigned",
|
||||
"${controller.basicValidator.getController('assigned')?.text ?? '-'} "
|
||||
"of ${widget.taskData['pendingWork'] ?? '-'} Pending",
|
||||
),
|
||||
_buildCompletedWorkField(),
|
||||
_buildCommentField(),
|
||||
Obx(() => _buildImageSection()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
final v = controller.basicValidator;
|
||||
|
||||
if (v.validateForm()) {
|
||||
final success = await controller.reportTask(
|
||||
projectId: v.getController('task_id')?.text ?? '',
|
||||
comment: v.getController('comment')?.text ?? '',
|
||||
completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0,
|
||||
checklist: [],
|
||||
reportedDate: DateTime.now(),
|
||||
images: controller.selectedImages,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
widget.onReportSuccess?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRow(String label, String? value) {
|
||||
final icons = {
|
||||
"Assigned Date": Icons.calendar_today_outlined,
|
||||
"Assigned By": Icons.person_outline,
|
||||
"Work Area": Icons.place_outlined,
|
||||
"Activity": Icons.run_circle_outlined,
|
||||
"Team Size": Icons.group_outlined,
|
||||
"Assigned": Icons.assignment_turned_in_outlined,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("$label:", fontWeight: 600),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedWorkField() {
|
||||
final pending = widget.taskData['pendingWork'] ?? 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.work_outline, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Completed Work:", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller.basicValidator.getController('completed_work'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) return 'Please enter completed work';
|
||||
final completed = int.tryParse(value.trim());
|
||||
if (completed == null) return 'Enter a valid number';
|
||||
if (completed > pending) return 'Completed work cannot exceed pending work $pending';
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: "eg: 10",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
border: outlineInputBorder,
|
||||
enabledBorder: outlineInputBorder,
|
||||
focusedBorder: focusedInputBorder,
|
||||
contentPadding: MySpacing.all(16),
|
||||
isCollapsed: true,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
),
|
||||
),
|
||||
MySpacing.height(24),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Comment:", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller.basicValidator.getController('comment'),
|
||||
validator: controller.basicValidator.getValidation('comment'),
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: "eg: Work done successfully",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
border: outlineInputBorder,
|
||||
enabledBorder: outlineInputBorder,
|
||||
focusedBorder: focusedInputBorder,
|
||||
contentPadding: MySpacing.all(16),
|
||||
isCollapsed: true,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
),
|
||||
),
|
||||
MySpacing.height(24),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageSection() {
|
||||
final images = controller.selectedImages;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]),
|
||||
MySpacing.width(8),
|
||||
MyText.titleSmall("Attach Photos:", fontWeight: 600),
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
if (images.isEmpty)
|
||||
Container(
|
||||
height: 70,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300, width: 2),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 70,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: images.length,
|
||||
separatorBuilder: (_, __) => MySpacing.width(12),
|
||||
itemBuilder: (context, index) {
|
||||
final file = images[index];
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
child: InteractiveViewer(child: Image.file(file)),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.removeImageAt(index),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.close, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyButton.outlined(
|
||||
onPressed: () => controller.pickImages(fromCamera: true),
|
||||
padding: MySpacing.xy(12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.camera_alt, size: 16, color: Colors.blueAccent),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Capture', color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: MyButton.outlined(
|
||||
onPressed: () => controller.pickImages(fromCamera: false),
|
||||
padding: MySpacing.xy(12, 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Upload', color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,210 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/comment_task_bottom_sheet.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/report_task_bottom_sheet.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/report_action_bottom_sheet.dart';
|
||||
|
||||
class TaskActionButtons {
|
||||
static Widget reportButton({
|
||||
required BuildContext context,
|
||||
required dynamic task,
|
||||
required int completed,
|
||||
required VoidCallback refreshCallback,
|
||||
}) {
|
||||
return OutlinedButton.icon(
|
||||
icon: const Icon(Icons.report, size: 18, color: Colors.blueAccent),
|
||||
label: const Text('Report', style: TextStyle(color: Colors.blueAccent)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.blueAccent),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
textStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
onPressed: () {
|
||||
final activityName =
|
||||
task.workItem?.activityMaster?.activityName ?? 'N/A';
|
||||
final assigned = '${(task.plannedTask - completed)}';
|
||||
final assignedBy =
|
||||
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
|
||||
final assignedOn = DateFormat('dd-MM-yyyy').format(task.assignmentDate);
|
||||
final taskId = task.id;
|
||||
final location = [
|
||||
task.workItem?.workArea?.floor?.building?.name,
|
||||
task.workItem?.workArea?.floor?.floorName,
|
||||
task.workItem?.workArea?.areaName,
|
||||
].where((e) => e != null && e.isNotEmpty).join(' > ');
|
||||
|
||||
final teamMembers = task.teamMembers.map((e) => e.firstName).toList();
|
||||
final pendingWork = (task.workItem?.plannedWork ?? 0) -
|
||||
(task.workItem?.completedWork ?? 0);
|
||||
|
||||
final taskData = {
|
||||
'activity': activityName,
|
||||
'assigned': assigned,
|
||||
'taskId': taskId,
|
||||
'assignedBy': assignedBy,
|
||||
'completed': completed,
|
||||
'assignedOn': assignedOn,
|
||||
'location': location,
|
||||
'teamSize': task.teamMembers.length,
|
||||
'teamMembers': teamMembers,
|
||||
'pendingWork': pendingWork,
|
||||
};
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: MediaQuery.of(ctx).viewInsets,
|
||||
child: ReportTaskBottomSheet(
|
||||
taskData: taskData,
|
||||
onReportSuccess: refreshCallback,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget commentButton({
|
||||
required BuildContext context,
|
||||
required dynamic task,
|
||||
required VoidCallback refreshCallback,
|
||||
required String parentTaskID,
|
||||
required String activityId,
|
||||
required String workAreaId,
|
||||
}) {
|
||||
return OutlinedButton.icon(
|
||||
icon: const Icon(Icons.comment, size: 18, color: Colors.blueAccent),
|
||||
label: const Text('Comment', style: TextStyle(color: Colors.blueAccent)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.blueAccent),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
textStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
onPressed: () {
|
||||
final taskData =
|
||||
_prepareTaskData(task: task, completed: task.completedTask.toInt());
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => CommentTaskBottomSheet(
|
||||
taskData: taskData,
|
||||
taskDataId: parentTaskID,
|
||||
workAreaId: workAreaId,
|
||||
activityId: activityId,
|
||||
onCommentSuccess: () {
|
||||
refreshCallback();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget reportActionButton({
|
||||
required BuildContext context,
|
||||
required dynamic task,
|
||||
required int completed,
|
||||
required VoidCallback refreshCallback,
|
||||
required String parentTaskID,
|
||||
required String activityId,
|
||||
required String workAreaId,
|
||||
}) {
|
||||
return OutlinedButton.icon(
|
||||
icon: const Icon(Icons.report, size: 18, color: Colors.amber),
|
||||
label: const Text('Take Report Action',
|
||||
style: TextStyle(color: Colors.amber)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.amber),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
textStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
onPressed: () {
|
||||
final taskData = _prepareTaskData(task: task, completed: completed);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: MediaQuery.of(ctx).viewInsets,
|
||||
child: ReportActionBottomSheet(
|
||||
taskData: taskData,
|
||||
taskDataId: parentTaskID,
|
||||
workAreaId: workAreaId,
|
||||
activityId: activityId,
|
||||
onReportSuccess: refreshCallback,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _prepareTaskData({
|
||||
required dynamic task,
|
||||
required int completed,
|
||||
}) {
|
||||
final activityName = task.workItem?.activityMaster?.activityName ?? 'N/A';
|
||||
final assigned = '${(task.plannedTask - completed)}';
|
||||
final assignedBy =
|
||||
"${task.assignedBy.firstName} ${task.assignedBy.lastName ?? ''}";
|
||||
final assignedOn = DateFormat('yyyy-MM-dd').format(task.assignmentDate);
|
||||
final taskId = task.id;
|
||||
|
||||
final location = [
|
||||
task.workItem?.workArea?.floor?.building?.name,
|
||||
task.workItem?.workArea?.floor?.floorName,
|
||||
task.workItem?.workArea?.areaName,
|
||||
].where((e) => e != null && e.isNotEmpty).join(' > ');
|
||||
|
||||
final teamMembers = task.teamMembers
|
||||
.map((e) => '${e.firstName} ${e.lastName ?? ''}')
|
||||
.toList();
|
||||
|
||||
final pendingWork =
|
||||
(task.workItem?.plannedWork ?? 0) - (task.workItem?.completedWork ?? 0);
|
||||
|
||||
final taskComments = task.comments.map((comment) {
|
||||
final isoDate = comment.timestamp.toIso8601String();
|
||||
final commenterName = comment.commentedBy.firstName.isNotEmpty
|
||||
? "${comment.commentedBy.firstName} ${comment.commentedBy.lastName ?? ''}"
|
||||
.trim()
|
||||
: "Unknown";
|
||||
|
||||
return {
|
||||
'text': comment.comment,
|
||||
'date': isoDate,
|
||||
'commentedBy': commenterName,
|
||||
'preSignedUrls': comment.preSignedUrls,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final taskLevelPreSignedUrls = task.reportedPreSignedUrls;
|
||||
|
||||
return {
|
||||
'activity': activityName,
|
||||
'assigned': assigned,
|
||||
'taskId': taskId,
|
||||
'assignedBy': assignedBy,
|
||||
'completed': completed,
|
||||
'plannedWork': task.plannedTask.toString(),
|
||||
'completedWork': completed.toString(),
|
||||
'assignedOn': assignedOn,
|
||||
'location': location,
|
||||
'teamSize': task.teamMembers.length,
|
||||
'teamMembers': teamMembers,
|
||||
'pendingWork': pendingWork,
|
||||
'taskComments': taskComments,
|
||||
'reportedPreSignedUrls': taskLevelPreSignedUrls,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:marco/helpers/services/json_decoder.dart';
|
||||
import 'package:marco/model/identifier_model.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class TaskListModel extends IdentifierModel {
|
||||
final String title, description, priority, status;
|
||||
final DateTime dueDate;
|
||||
late bool isSelectTask;
|
||||
|
||||
TaskListModel(super.id, this.title, this.description, this.priority, this.status, this.dueDate, this.isSelectTask);
|
||||
|
||||
static TaskListModel fromJSON(Map<String, dynamic> json) {
|
||||
JSONDecoder decoder = JSONDecoder(json);
|
||||
|
||||
String title = decoder.getString('title');
|
||||
String description = decoder.getString('description');
|
||||
String priority = decoder.getString('priority');
|
||||
String status = decoder.getString('status');
|
||||
DateTime dueDate = decoder.getDateTime('due_date');
|
||||
bool isSelectTask = decoder.getBool('isSelectTask');
|
||||
|
||||
return TaskListModel(decoder.getId, title, description, priority, status, dueDate, isSelectTask);
|
||||
}
|
||||
|
||||
static List<TaskListModel> listFromJSON(List<dynamic> list) {
|
||||
return list.map((e) => TaskListModel.fromJSON(e)).toList();
|
||||
}
|
||||
|
||||
static List<TaskListModel>? _dummyList;
|
||||
|
||||
static Future<List<TaskListModel>> get dummyList async {
|
||||
if (_dummyList == null) {
|
||||
dynamic data = json.decode(await getData());
|
||||
_dummyList = listFromJSON(data);
|
||||
}
|
||||
|
||||
return _dummyList!;
|
||||
}
|
||||
|
||||
static Future<String> getData() async {
|
||||
return await rootBundle.loadString('assets/data/task_list.json');
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
class WorkStatusResponseModel {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<WorkStatus> data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final DateTime timestamp;
|
||||
|
||||
WorkStatusResponseModel({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory WorkStatusResponseModel.fromJson(Map<String, dynamic> json) {
|
||||
return WorkStatusResponseModel(
|
||||
success: json['success'],
|
||||
message: json['message'],
|
||||
data: List<WorkStatus>.from(
|
||||
json['data'].map((item) => WorkStatus.fromJson(item)),
|
||||
),
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'],
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkStatus {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final bool isSystem;
|
||||
|
||||
WorkStatus({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.isSystem,
|
||||
});
|
||||
|
||||
factory WorkStatus.fromJson(Map<String, dynamic> json) {
|
||||
return WorkStatus(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
isSystem: json['isSystem'],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
class Tenant {
|
||||
final String id;
|
||||
final String name;
|
||||
final String email;
|
||||
final String? domainName;
|
||||
final String contactName;
|
||||
final String contactNumber;
|
||||
final String? logoImage;
|
||||
final String? organizationSize;
|
||||
final Industry? industry;
|
||||
final TenantStatus? tenantStatus;
|
||||
|
||||
Tenant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.email,
|
||||
this.domainName,
|
||||
required this.contactName,
|
||||
required this.contactNumber,
|
||||
this.logoImage,
|
||||
this.organizationSize,
|
||||
this.industry,
|
||||
this.tenantStatus,
|
||||
});
|
||||
|
||||
factory Tenant.fromJson(Map<String, dynamic> json) {
|
||||
return Tenant(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
domainName: json['domainName'] as String?,
|
||||
contactName: json['contactName'] ?? '',
|
||||
contactNumber: json['contactNumber'] ?? '',
|
||||
logoImage: json['logoImage'] is String ? json['logoImage'] : null,
|
||||
organizationSize: json['organizationSize'] is String
|
||||
? json['organizationSize']
|
||||
: null,
|
||||
industry: json['industry'] != null
|
||||
? Industry.fromJson(json['industry'])
|
||||
: null,
|
||||
tenantStatus: json['tenantStatus'] != null
|
||||
? TenantStatus.fromJson(json['tenantStatus'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'domainName': domainName,
|
||||
'contactName': contactName,
|
||||
'contactNumber': contactNumber,
|
||||
'logoImage': logoImage,
|
||||
'organizationSize': organizationSize,
|
||||
'industry': industry?.toJson(),
|
||||
'tenantStatus': tenantStatus?.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Industry {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
Industry({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory Industry.fromJson(Map<String, dynamic> json) {
|
||||
return Industry(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TenantStatus {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
TenantStatus({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory TenantStatus.fromJson(Map<String, dynamic> json) {
|
||||
return TenantStatus(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
class ServiceListResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<Service> data;
|
||||
final dynamic errors;
|
||||
final int statusCode;
|
||||
final String timestamp;
|
||||
|
||||
ServiceListResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory ServiceListResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ServiceListResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: (json['data'] as List<dynamic>?)
|
||||
?.map((e) => Service.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: json['timestamp'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data.map((e) => e.toJson()).toList(),
|
||||
'errors': errors,
|
||||
'statusCode': statusCode,
|
||||
'timestamp': timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Service {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final bool isSystem;
|
||||
final bool isActive;
|
||||
|
||||
Service({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.isSystem,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory Service.fromJson(Map<String, dynamic> json) {
|
||||
return Service(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
isSystem: json['isSystem'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'isSystem': isSystem,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
}
|
@ -11,8 +11,6 @@ import 'package:marco/view/error_pages/error_404_screen.dart';
|
||||
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/employees/employees_screen.dart';
|
||||
import 'package:marco/view/auth/login_option_screen.dart';
|
||||
import 'package:marco/view/auth/mpin_screen.dart';
|
||||
@ -55,15 +53,7 @@ getPageRoute() {
|
||||
name: '/dashboard/employees',
|
||||
page: () => EmployeesScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Daily Task Planning
|
||||
GetPage(
|
||||
name: '/dashboard/daily-task-Planning',
|
||||
page: () => DailyTaskPlanningScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
GetPage(
|
||||
name: '/dashboard/daily-task-progress',
|
||||
page: () => DailyProgressReportScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
|
||||
GetPage(
|
||||
name: '/dashboard/directory-main-page',
|
||||
page: () => DirectoryMainScreen(),
|
||||
|
@ -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.find<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(),
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,531 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/theme/app_theme.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_card.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_Planning_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:percent_indicator/percent_indicator.dart';
|
||||
import 'package:marco/model/dailyTaskPlanning/assign_task_bottom_sheet .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 DailyTaskPlanningScreen extends StatefulWidget {
|
||||
DailyTaskPlanningScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DailyTaskPlanningScreen> createState() =>
|
||||
_DailyTaskPlanningScreenState();
|
||||
}
|
||||
|
||||
class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
with UIMixin {
|
||||
final DailyTaskPlanningController dailyTaskPlanningController =
|
||||
Get.put(DailyTaskPlanningController());
|
||||
final PermissionController permissionController =
|
||||
Get.put(PermissionController());
|
||||
final ProjectController projectController = Get.find<ProjectController>();
|
||||
final ServiceController serviceController = Get.put(ServiceController());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final projectId = projectController.selectedProjectId.value;
|
||||
if (projectId.isNotEmpty) {
|
||||
dailyTaskPlanningController.fetchTaskData(projectId);
|
||||
serviceController.fetchServices(projectId); // <-- Fetch services here
|
||||
}
|
||||
|
||||
ever<String>(
|
||||
projectController.selectedProjectId,
|
||||
(newProjectId) {
|
||||
if (newProjectId.isNotEmpty) {
|
||||
dailyTaskPlanningController.fetchTaskData(newProjectId);
|
||||
serviceController
|
||||
.fetchServices(newProjectId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@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 Task Planning',
|
||||
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: () async {
|
||||
final projectId = projectController.selectedProjectId.value;
|
||||
if (projectId.isNotEmpty) {
|
||||
try {
|
||||
await dailyTaskPlanningController.fetchTaskData(projectId);
|
||||
} catch (e) {
|
||||
debugPrint('Error refreshing task data: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(), // <-- always allow drag
|
||||
padding: MySpacing.x(0),
|
||||
child: ConstrainedBox(
|
||||
// <-- ensures full screen height
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.of(context).size.height -
|
||||
kToolbarHeight -
|
||||
MediaQuery.of(context).padding.top,
|
||||
),
|
||||
child: GetBuilder<DailyTaskPlanningController>(
|
||||
init: dailyTaskPlanningController,
|
||||
tag: 'daily_task_Planning_controller',
|
||||
builder: (controller) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(flexSpacing),
|
||||
Padding(
|
||||
padding: MySpacing.x(10),
|
||||
child: ServiceSelector(
|
||||
controller: serviceController,
|
||||
height: 40,
|
||||
onSelectionChanged: (service) async {
|
||||
final projectId =
|
||||
projectController.selectedProjectId.value;
|
||||
if (projectId.isNotEmpty) {
|
||||
await dailyTaskPlanningController.fetchTaskData(
|
||||
projectId,
|
||||
// serviceId: service
|
||||
// ?.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
MySpacing.height(flexSpacing),
|
||||
Padding(
|
||||
padding: MySpacing.x(8),
|
||||
child: dailyProgressReportTab(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget dailyProgressReportTab() {
|
||||
return Obx(() {
|
||||
final isLoading = dailyTaskPlanningController.isLoading.value;
|
||||
final dailyTasks = dailyTaskPlanningController.dailyTasks;
|
||||
|
||||
if (isLoading) {
|
||||
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
|
||||
}
|
||||
|
||||
if (dailyTasks.isEmpty) {
|
||||
return Center(
|
||||
child: MyText.bodySmall(
|
||||
"No Progress Report Found",
|
||||
fontWeight: 600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final buildingExpansionState = <String, bool>{};
|
||||
final floorExpansionState = <String, bool>{};
|
||||
|
||||
Widget buildExpandIcon(bool isExpanded) {
|
||||
return Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey.shade200,
|
||||
),
|
||||
child: Icon(
|
||||
isExpanded ? Icons.remove : Icons.add,
|
||||
size: 20,
|
||||
color: Colors.black87,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return StatefulBuilder(builder: (context, setMainState) {
|
||||
final filteredBuildings = dailyTasks.expand((task) {
|
||||
return task.buildings.where((building) {
|
||||
return building.floors.any((floor) =>
|
||||
floor.workAreas.any((area) => area.workItems.isNotEmpty));
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (filteredBuildings.isEmpty) {
|
||||
return Center(
|
||||
child: MyText.bodySmall(
|
||||
"No Progress Report Found",
|
||||
fontWeight: 600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: filteredBuildings.map((building) {
|
||||
final buildingKey = building.id.toString();
|
||||
|
||||
return MyCard.bordered(
|
||||
borderRadiusAll: 10,
|
||||
paddingAll: 0,
|
||||
margin: MySpacing.bottom(10),
|
||||
child: Theme(
|
||||
data: Theme.of(context)
|
||||
.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
onExpansionChanged: (expanded) {
|
||||
setMainState(() {
|
||||
buildingExpansionState[buildingKey] = expanded;
|
||||
});
|
||||
},
|
||||
trailing: buildExpandIcon(
|
||||
buildingExpansionState[buildingKey] ?? false),
|
||||
tilePadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
||||
collapsedShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueAccent.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.location_city_rounded,
|
||||
color: Colors.blueAccent,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
title: MyText.titleMedium(
|
||||
building.name,
|
||||
fontWeight: 700,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
childrenPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
||||
children: building.floors.expand((floor) {
|
||||
final validWorkAreas = floor.workAreas
|
||||
.where((area) => area.workItems.isNotEmpty);
|
||||
|
||||
// For each valid work area, return a Floor+WorkArea ExpansionTile
|
||||
return validWorkAreas.map((area) {
|
||||
final floorWorkAreaKey =
|
||||
"${buildingKey}_${floor.floorName}_${area.areaName}";
|
||||
final isExpanded =
|
||||
floorExpansionState[floorWorkAreaKey] ?? false;
|
||||
final workItems = area.workItems;
|
||||
final totalPlanned = workItems.fold<double>(
|
||||
0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0));
|
||||
final totalCompleted = workItems.fold<double>(0,
|
||||
(sum, wi) => sum + (wi.workItem.completedWork ?? 0));
|
||||
final totalProgress = totalPlanned == 0
|
||||
? 0.0
|
||||
: (totalCompleted / totalPlanned).clamp(0.0, 1.0);
|
||||
return ExpansionTile(
|
||||
onExpansionChanged: (expanded) {
|
||||
setMainState(() {
|
||||
floorExpansionState[floorWorkAreaKey] = expanded;
|
||||
});
|
||||
},
|
||||
trailing: Icon(
|
||||
isExpanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 28,
|
||||
color: Colors.black54,
|
||||
),
|
||||
tilePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 0),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleSmall(
|
||||
"Floor: ${floor.floorName}",
|
||||
fontWeight: 600,
|
||||
color: Colors.teal,
|
||||
maxLines: null,
|
||||
overflow: TextOverflow.visible,
|
||||
softWrap: true,
|
||||
),
|
||||
MySpacing.height(4),
|
||||
MyText.titleSmall(
|
||||
"Work Area: ${area.areaName}",
|
||||
fontWeight: 600,
|
||||
color: Colors.blueGrey,
|
||||
maxLines: null,
|
||||
overflow: TextOverflow.visible,
|
||||
softWrap: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
CircularPercentIndicator(
|
||||
radius: 20.0,
|
||||
lineWidth: 4.0,
|
||||
animation: true,
|
||||
percent: totalProgress,
|
||||
center: Text(
|
||||
"${(totalProgress * 100).toStringAsFixed(0)}%",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10.0,
|
||||
),
|
||||
),
|
||||
circularStrokeCap: CircularStrokeCap.round,
|
||||
progressColor: totalProgress >= 1.0
|
||||
? Colors.green
|
||||
: (totalProgress >= 0.5
|
||||
? Colors.amber
|
||||
: Colors.red),
|
||||
backgroundColor: Colors.grey[300]!,
|
||||
),
|
||||
],
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(
|
||||
left: 16, right: 0, bottom: 8),
|
||||
children: area.workItems.map((wItem) {
|
||||
final item = wItem.workItem;
|
||||
final completed = item.completedWork ?? 0;
|
||||
final planned = item.plannedWork ?? 0;
|
||||
final progress = (planned == 0)
|
||||
? 0.0
|
||||
: (completed / planned).clamp(0.0, 1.0);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyText.bodyMedium(
|
||||
item.activityMaster?.name ??
|
||||
"No Activity",
|
||||
fontWeight: 600,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.visible,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
if (item.workCategoryMaster?.name != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade100,
|
||||
borderRadius:
|
||||
BorderRadius.circular(20),
|
||||
),
|
||||
child: MyText.bodySmall(
|
||||
item.workCategoryMaster!.name!,
|
||||
fontWeight: 500,
|
||||
color: Colors.blue.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(8),
|
||||
MyText.bodySmall(
|
||||
"Completed: $completed / $planned",
|
||||
fontWeight: 600,
|
||||
color: const Color.fromARGB(
|
||||
221, 0, 0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MySpacing.width(16),
|
||||
if (progress < 1.0 &&
|
||||
permissionController.hasPermission(
|
||||
Permissions.assignReportTask))
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.person_add_alt_1_rounded,
|
||||
color:
|
||||
Color.fromARGB(255, 46, 161, 233),
|
||||
),
|
||||
onPressed: () {
|
||||
final pendingTask =
|
||||
(planned - completed)
|
||||
.clamp(0, planned)
|
||||
.toInt();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.vertical(
|
||||
top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) =>
|
||||
AssignTaskBottomSheet(
|
||||
buildingName: building.name,
|
||||
floorName: floor.floorName,
|
||||
workAreaName: area.areaName,
|
||||
workLocation: area.areaName,
|
||||
activityName:
|
||||
item.activityMaster?.name ??
|
||||
"Unknown Activity",
|
||||
pendingTask: pendingTask,
|
||||
workItemId: item.id.toString(),
|
||||
assignmentDate: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}).toList();
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -1,391 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/images.dart';
|
||||
import 'package:marco/controller/tenant/tenant_selection_controller.dart';
|
||||
|
||||
class TenantSelectionScreen extends StatefulWidget {
|
||||
const TenantSelectionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TenantSelectionScreen> createState() => _TenantSelectionScreenState();
|
||||
}
|
||||
|
||||
class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
||||
with UIMixin, SingleTickerProviderStateMixin {
|
||||
late final TenantSelectionController _controller;
|
||||
late final AnimationController _logoAnimController;
|
||||
late final Animation<double> _logoAnimation;
|
||||
final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = Get.put(TenantSelectionController());
|
||||
_logoAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_logoAnimation = CurvedAnimation(
|
||||
parent: _logoAnimController,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
_logoAnimController.forward();
|
||||
|
||||
// 🔥 Tell controller this is tenant selection screen
|
||||
_controller.loadTenants(fromTenantSelectionScreen: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logoAnimController.dispose();
|
||||
Get.delete<TenantSelectionController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onTenantSelected(String tenantId) async {
|
||||
setState(() => _isLoading = true);
|
||||
await _controller.onTenantSelected(tenantId);
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
_RedWaveBackground(brandRed: contentTheme.brandRed),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
_AnimatedLogo(animation: _logoAnimation),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
const _WelcomeTexts(),
|
||||
if (_isBetaEnvironment) ...[
|
||||
const SizedBox(height: 12),
|
||||
const _BetaBadge(),
|
||||
],
|
||||
const SizedBox(height: 36),
|
||||
// Tenant list directly reacts to controller
|
||||
TenantCardList(
|
||||
controller: _controller,
|
||||
isLoading: _isLoading,
|
||||
onTenantSelected: _onTenantSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLogo extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
const _AnimatedLogo({required this.animation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(Images.logoDark),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeTexts extends StatelessWidget {
|
||||
const _WelcomeTexts();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
MyText(
|
||||
"Welcome",
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
MyText(
|
||||
"Please select which dashboard you want to explore!.",
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BetaBadge extends StatelessWidget {
|
||||
const _BetaBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orangeAccent,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: MyText(
|
||||
'BETA',
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TenantCardList extends StatelessWidget {
|
||||
final TenantSelectionController controller;
|
||||
final bool isLoading;
|
||||
final Function(String tenantId) onTenantSelected;
|
||||
|
||||
const TenantCardList({
|
||||
required this.controller,
|
||||
required this.isLoading,
|
||||
required this.onTenantSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value || isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
if (controller.tenants.isEmpty) {
|
||||
return Center(
|
||||
child: MyText(
|
||||
"No dashboards available for your account.",
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (controller.tenants.length == 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
...controller.tenants.map(
|
||||
(tenant) => _TenantCard(
|
||||
tenant: tenant,
|
||||
onTap: () => onTenantSelected(tenant.id),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () => Get.back(),
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
size: 20, color: Colors.redAccent),
|
||||
label: MyText(
|
||||
'Back to Login',
|
||||
color: Colors.red,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _TenantCard extends StatelessWidget {
|
||||
final dynamic tenant;
|
||||
final VoidCallback onTap;
|
||||
const _TenantCard({required this.tenant, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
color: Colors.grey.shade200,
|
||||
child: TenantLogo(logoImage: tenant.logoImage),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText(
|
||||
tenant.name,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
MyText(
|
||||
"Industry: ${tenant.industry?.name ?? "-"}",
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 24,
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TenantLogo extends StatelessWidget {
|
||||
final String? logoImage;
|
||||
const TenantLogo({required this.logoImage});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (logoImage == null || logoImage!.isEmpty) {
|
||||
return Center(
|
||||
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||
);
|
||||
}
|
||||
if (logoImage!.startsWith("data:image")) {
|
||||
try {
|
||||
final base64Str = logoImage!.split(',').last;
|
||||
final bytes = base64Decode(base64Str);
|
||||
return Image.memory(bytes, fit: BoxFit.cover);
|
||||
} catch (_) {
|
||||
return Center(
|
||||
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Image.network(
|
||||
logoImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Center(
|
||||
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RedWaveBackground extends StatelessWidget {
|
||||
final Color brandRed;
|
||||
const _RedWaveBackground({required this.brandRed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: _WavePainter(brandRed),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WavePainter extends CustomPainter {
|
||||
final Color brandRed;
|
||||
|
||||
_WavePainter(this.brandRed);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint1 = Paint()
|
||||
..shader = LinearGradient(
|
||||
colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||
|
||||
final path1 = Path()
|
||||
..moveTo(0, size.height * 0.2)
|
||||
..quadraticBezierTo(size.width * 0.25, size.height * 0.05,
|
||||
size.width * 0.5, size.height * 0.15)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
canvas.drawPath(path1, paint1);
|
||||
|
||||
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||
final path2 = Path()
|
||||
..moveTo(0, size.height * 0.25)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(0, 0)
|
||||
..close();
|
||||
canvas.drawPath(path2, paint2);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user