Compare commits

...

17 Commits

75 changed files with 2289 additions and 8526 deletions

View File

@ -37,7 +37,7 @@ android {
// Default configuration for your application
defaultConfig {
// Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.marco.aiot"
applicationId = "com.marcoonfieldwork.aiot"
// Set minimum and target SDK versions based on Flutter's configuration
minSdk = 23
targetSdk = flutter.targetSdkVersion

View File

@ -9,7 +9,7 @@
"client_info": {
"mobilesdk_app_id": "1:626581282477:android:8d3cf5009ff92ef67ff024",
"android_client_info": {
"package_name": "com.marco.aiot"
"package_name": "com.marcoonfieldwork.aiot"
}
},
"oauth_client": [],

View File

@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="Marco"
android:label="On Field Work"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

View File

@ -1,7 +1,7 @@
#!/bin/bash
# ===============================
# Flutter APK Build Script (AAB Disabled)
# Flutter APK & AAB Build Script
# ===============================
# Exit immediately if a command exits with a non-zero status
@ -30,19 +30,19 @@ flutter pub get
# ==============================
# Step 3: Build AAB (Commented)
# ==============================
# echo -e "${CYAN}🏗 Building AAB file...${NC}"
# flutter build appbundle --release
echo -e "${CYAN}🏗 Building AAB file...${NC}"
flutter build appbundle --release
# Step 4: Build APK
echo -e "${CYAN}🏗 Building APK file...${NC}"
flutter build apk --release
# Step 5: Show output paths
# AAB_PATH="$BUILD_DIR/bundle/release/app-release.aab"
AAB_PATH="$BUILD_DIR/bundle/release/app-release.aab"
APK_PATH="$BUILD_DIR/apk/release/app-release.apk"
echo -e "${GREEN}✅ Build completed successfully!${NC}"
# echo -e "${YELLOW}📍 AAB file: ${CYAN}$AAB_PATH${NC}"
echo -e "${YELLOW}📍 AAB file: ${CYAN}$AAB_PATH${NC}"
echo -e "${YELLOW}📍 APK file: ${CYAN}$APK_PATH${NC}"
# Optional: open the folder (Mac/Linux)

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart';
@ -79,6 +80,7 @@ class LoginController extends MyController {
enableRemoteLogging();
logSafe("✅ Remote logging enabled after login.");
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
@ -89,9 +91,9 @@ class LoginController extends MyController {
level: LogLevel.warning);
}
logSafe("Login successful for user: ${loginData['username']}");
Get.toNamed('/select_tenant');
logSafe("Login successful for user: ${loginData['username']}");
Get.toNamed('/home');
}
} catch (e, stacktrace) {
logSafe("Exception during login",

View File

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

View File

@ -73,7 +73,8 @@ class AddEmployeeController extends MyController {
controller: TextEditingController(),
);
logSafe('Fields initialized for first_name, phone_number, last_name, email.');
logSafe(
'Fields initialized for first_name, phone_number, last_name, email.');
}
// Prefill fields in edit mode
@ -87,7 +88,8 @@ class AddEmployeeController extends MyController {
editingEmployeeData?['phone_number'] ?? '';
selectedGender = editingEmployeeData?['gender'] != null
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
? Gender.values
.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
: null;
basicValidator.getController('email')?.text =
@ -121,12 +123,24 @@ class AddEmployeeController extends MyController {
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe('Roles fetched successfully.');
// If editing, and role already selected, update the role controller text here
if (editingEmployeeData != null && selectedRoleId != null) {
final selectedRole = roles.firstWhereOrNull(
(r) => r['id'] == selectedRoleId,
);
if (selectedRole != null) {
update();
}
}
update();
} else {
logSafe('Failed to fetch roles: null result', level: LogLevel.error);
}
} catch (e, st) {
logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st);
logSafe('Error fetching roles',
level: LogLevel.error, error: e, stackTrace: st);
}
}
@ -156,7 +170,8 @@ class AddEmployeeController extends MyController {
final firstName = basicValidator.getController('first_name')?.text.trim();
final lastName = basicValidator.getController('last_name')?.text.trim();
final phoneNumber = basicValidator.getController('phone_number')?.text.trim();
final phoneNumber =
basicValidator.getController('phone_number')?.text.trim();
try {
// sanitize orgId before sending
@ -216,7 +231,8 @@ class AddEmployeeController extends MyController {
showAppSnackbar(
title: 'Permission Required',
message: 'Please allow Contacts permission from settings to pick a contact.',
message:
'Please allow Contacts permission from settings to pick a contact.',
type: SnackbarType.warning,
);
return false;

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/app_logger.dart';
class ProjectStatus {
final String id;
final String name;
ProjectStatus({required this.id, required this.name});
}
class CreateProjectController extends GetxController {
// Observables
var isSubmitting = false.obs;
var statusList = <ProjectStatus>[].obs;
ProjectStatus? selectedStatus;
/// Text controllers for form fields
final nameCtrl = TextEditingController();
final shortNameCtrl = TextEditingController();
final addressCtrl = TextEditingController();
final contactCtrl = TextEditingController();
final startDateCtrl = TextEditingController();
final endDateCtrl = TextEditingController();
@override
void onInit() {
super.onInit();
loadHardcodedStatuses();
}
/// Hardcoded project statuses
void loadHardcodedStatuses() {
final List<ProjectStatus> statuses = [
ProjectStatus(
id: "b74da4c2-d07e-46f2-9919-e75e49b12731", name: "Active"),
ProjectStatus(
id: "cdad86aa-8a56-4ff4-b633-9c629057dfef", name: "In Progress"),
ProjectStatus(
id: "603e994b-a27f-4e5d-a251-f3d69b0498ba", name: "On Hold"),
ProjectStatus(
id: "ef1c356e-0fe0-42df-a5d3-8daee355492d", name: "In Active"),
ProjectStatus(
id: "33deaef9-9af1-4f2a-b443-681ea0d04f81", name: "Completed"),
];
statusList.assignAll(statuses);
}
/// Create project API call using ApiService
Future<bool> createProject({
required String name,
required String projectAddress,
required String shortName,
required String contactPerson,
required DateTime startDate,
required DateTime endDate,
required String projectStatusId,
}) async {
try {
isSubmitting.value = true;
final success = await ApiService.createProjectApi(
name: name,
projectAddress: projectAddress,
shortName: shortName,
contactPerson: contactPerson,
startDate: startDate,
endDate: endDate,
projectStatusId: projectStatusId,
);
if (success) {
showAppSnackbar(
title: "Success",
message: "Project created successfully",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Failed to create project",
type: SnackbarType.error,
);
}
return success;
} catch (e, stack) {
logSafe("Create project error: $e", level: LogLevel.error);
logSafe("Stacktrace: $stack", level: LogLevel.debug);
showAppSnackbar(
title: "Error",
message: "An unexpected error occurred",
type: SnackbarType.error,
);
return false;
} finally {
isSubmitting.value = false;
}
}
@override
void onClose() {
nameCtrl.dispose();
shortNameCtrl.dispose();
addressCtrl.dispose();
contactCtrl.dispose();
startDateCtrl.dispose();
endDateCtrl.dispose();
super.onClose();
}
}

View File

@ -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, );
}
}

View File

@ -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
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}

View File

@ -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,
);
}

View File

@ -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";
}

View File

@ -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";
}

View File

@ -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;
}
}
}

View File

@ -1,5 +1,5 @@
class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
@ -11,6 +11,11 @@ class ApiEndpoints {
static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects";
///// Projects Module API Endpoints
static const String createProject = "/project";
// Attendance Module API Endpoints
static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic";

View File

@ -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;
@ -289,6 +288,60 @@ class ApiService {
}
}
/// Create Project API
static Future<bool> createProjectApi({
required String name,
required String projectAddress,
required String shortName,
required String contactPerson,
required DateTime startDate,
required DateTime endDate,
required String projectStatusId,
}) async {
const endpoint = ApiEndpoints.createProject;
logSafe("Creating project: $name");
final Map<String, dynamic> payload = {
"name": name,
"projectAddress": projectAddress,
"shortName": shortName,
"contactPerson": contactPerson,
"startDate": startDate.toIso8601String(),
"endDate": endDate.toIso8601String(),
"projectStatusId": projectStatusId,
};
try {
final response =
await _postRequest(endpoint, payload, customTimeout: extendedTimeout);
if (response == null) {
logSafe("Create project failed: null response", level: LogLevel.error);
return false;
}
logSafe("Create project response status: ${response.statusCode}");
logSafe("Create project response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Project created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create project: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception during createProjectApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
/// Get Organizations assigned to a Project
static Future<OrganizationListResponse?> getAssignedOrganizations(
String projectId) async {
@ -319,36 +372,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}");
@ -1761,10 +1784,10 @@ class ApiService {
return false;
}
static Future<List<dynamic>?> getDirectoryComments(
static Future<List<dynamic>?> getDirectoryComments(
String contactId, {
bool active = true,
}) async {
}) async {
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active";
final response = await _getRequest(url);
final data = response != null
@ -1772,8 +1795,7 @@ static Future<List<dynamic>?> getDirectoryComments(
: null;
return data is List ? data : null;
}
}
static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async {

View File

@ -83,7 +83,7 @@ class AuthService {
logSafe("Login payload (raw): $data");
logSafe("Login payload (JSON): ${jsonEncode(data)}");
final responseData = await _post("/auth/app/login", data);
final responseData = await _post("/auth/login-mobile", data);
if (responseData == null)
return {"error": "Network error. Please check your connection."};
@ -179,7 +179,7 @@ class AuthService {
if (employeeInfo == null) return null;
final token = await LocalStorage.getJwtToken();
return _post(
"/auth/login-mpin",
"/auth/login-mpin/v1",
{
"employeeId": employeeInfo.id,
"mpin": mpin,
@ -202,7 +202,7 @@ class AuthService {
required String email,
required String otp,
}) async {
final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
final data = await _post("/auth/login-otp/v1", {"email": email, "otp": otp});
if (data != null && data['data'] != null) {
await _handleLoginSuccess(data['data']);
return null;

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -19,14 +19,19 @@ enum ContentThemeColor {
pink,
green,
red,
brandRed;
brandRed,
brandGreen;
Color get color {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black;
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]
?['color']) ??
Colors.black;
}
Color get onColor {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['onColor']) ?? Colors.white;
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]
?['onColor']) ??
Colors.white;
}
}
@ -77,7 +82,9 @@ class TopBarTheme {
static final TopBarTheme lightTopBarTheme = TopBarTheme();
static final TopBarTheme darkTopBarTheme = TopBarTheme(background: const Color(0xff2c3036), onBackground: const Color(0xffdcdcdc));
static final TopBarTheme darkTopBarTheme = TopBarTheme(
background: const Color(0xff2c3036),
onBackground: const Color(0xffdcdcdc));
}
class RightBarTheme {
@ -121,6 +128,7 @@ class ContentTheme {
final Color pink, onPink;
final Color red, onRed;
final Color brandRed, onBrandRed;
final Color brandGreen, onBrandGreen;
final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted;
final Color title;
@ -130,7 +138,10 @@ class ContentTheme {
var c = AdminTheme.theme.contentTheme;
return {
ContentThemeColor.primary: {'color': c.primary, 'onColor': c.onPrimary},
ContentThemeColor.secondary: {'color': c.secondary, 'onColor': c.onSecondary},
ContentThemeColor.secondary: {
'color': c.secondary,
'onColor': c.onSecondary
},
ContentThemeColor.success: {'color': c.success, 'onColor': c.onSuccess},
ContentThemeColor.info: {'color': c.info, 'onColor': c.onInfo},
ContentThemeColor.warning: {'color': c.warning, 'onColor': c.onWarning},
@ -139,14 +150,21 @@ class ContentTheme {
ContentThemeColor.dark: {'color': c.dark, 'onColor': c.onDark},
ContentThemeColor.pink: {'color': c.pink, 'onColor': c.onPink},
ContentThemeColor.red: {'color': c.red, 'onColor': c.onRed},
ContentThemeColor.brandRed: {'color': c.brandRed, 'onColor': c.onBrandRed},
ContentThemeColor.brandRed: {
'color': c.brandRed,
'onColor': c.onBrandRed
},
ContentThemeColor.green: {
'color': c.brandGreen,
'onColor': c.onBrandGreen
},
};
}
ContentTheme({
this.background = const Color(0xfffafbfe),
this.onBackground = const Color(0xffF1F1F2),
this.primary = const Color(0xff663399),
this.primary = const Color(0xFF49BF3C),
this.onPrimary = const Color(0xffffffff),
this.secondary = const Color(0xff6c757d),
this.onSecondary = const Color(0xffffffff),
@ -178,12 +196,12 @@ class ContentTheme {
this.title = const Color(0xff6c757d),
this.disabled = const Color(0xffffffff),
this.onDisabled = const Color(0xffffffff),
this.brandGreen = const Color(0xFF49BF3C),
this.onBrandGreen = const Color(0xFFFFFFFF),
});
//-------------------------------------- Left Bar Theme ----------------------------------------//
static final ContentTheme lightContentTheme = ContentTheme(
primary: Color(0xff663399),
primary: Color(0xFF49BF3C),
background: const Color(0xfffafbfe),
onBackground: const Color(0xff313a46),
cardBorder: const Color(0xffe8ecf1),
@ -197,7 +215,7 @@ class ContentTheme {
);
static final ContentTheme darkContentTheme = ContentTheme(
primary: Color(0xff32BFAE),
primary: Color(0xFF49BF3C),
background: const Color(0xff343a40),
onBackground: const Color(0xffF1F1F2),
disabled: const Color(0xff444d57),
@ -236,9 +254,17 @@ class AdminTheme {
static void setTheme() {
theme = AdminTheme(
leftBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? LeftBarTheme.darkLeftBarTheme : LeftBarTheme.lightLeftBarTheme,
topBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? TopBarTheme.darkTopBarTheme : TopBarTheme.lightTopBarTheme,
rightBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? RightBarTheme.darkRightBarTheme : RightBarTheme.lightRightBarTheme,
contentTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? ContentTheme.darkContentTheme : ContentTheme.lightContentTheme);
leftBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark
? LeftBarTheme.darkLeftBarTheme
: LeftBarTheme.lightLeftBarTheme,
topBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark
? TopBarTheme.darkTopBarTheme
: TopBarTheme.lightTopBarTheme,
rightBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark
? RightBarTheme.darkRightBarTheme
: RightBarTheme.lightRightBarTheme,
contentTheme: ThemeCustomizer.instance.theme == ThemeMode.dark
? ContentTheme.darkContentTheme
: ContentTheme.lightContentTheme);
}
}

View File

@ -1,15 +1,17 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class BaseBottomSheet extends StatelessWidget {
class BaseBottomSheet extends StatefulWidget {
final String title;
final String? subtitle;
final Widget child;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final bool isSubmitting;
final String submitText;
final Color submitColor;
final Color? submitColor;
final IconData submitIcon;
final bool showButtons;
final Widget? bottomContent;
@ -20,18 +22,26 @@ class BaseBottomSheet extends StatelessWidget {
required this.child,
required this.onCancel,
required this.onSubmit,
this.subtitle,
this.isSubmitting = false,
this.submitText = 'Submit',
this.submitColor = Colors.indigo,
this.submitColor,
this.submitIcon = Icons.check_circle_outline,
this.showButtons = true,
this.bottomContent,
});
@override
State<BaseBottomSheet> createState() => _BaseBottomSheetState();
}
class _BaseBottomSheetState extends State<BaseBottomSheet> with UIMixin {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final effectiveSubmitColor =
widget.submitColor ?? contentTheme.primary;
return SingleChildScrollView(
padding: mediaQuery.viewInsets,
@ -50,15 +60,16 @@ class BaseBottomSheet extends StatelessWidget {
],
),
child: SafeArea(
// 👈 prevents overlap with nav bar
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(5),
Container(
Center(
child: Container(
width: 40,
height: 5,
decoration: BoxDecoration(
@ -66,17 +77,33 @@ class BaseBottomSheet extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
),
),
MySpacing.height(12),
MyText.titleLarge(title, fontWeight: 700),
Center(
child: MyText.titleLarge(
widget.title,
fontWeight: 700,
textAlign: TextAlign.center,
),
),
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
MySpacing.height(4),
MyText.bodySmall(
widget.subtitle!,
fontWeight: 600,
color: Colors.grey[700],
),
],
MySpacing.height(12),
child,
widget.child,
MySpacing.height(12),
if (showButtons) ...[
if (widget.showButtons) ...[
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onCancel,
onPressed: widget.onCancel,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
@ -88,34 +115,40 @@ class BaseBottomSheet extends StatelessWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
padding:
const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit,
icon: Icon(submitIcon, color: Colors.white),
onPressed:
widget.isSubmitting ? null : widget.onSubmit,
icon:
Icon(widget.submitIcon, color: Colors.white),
label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText,
widget.isSubmitting
? "Submitting..."
: widget.submitText,
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: submitColor,
backgroundColor: effectiveSubmitColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
padding:
const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
if (bottomContent != null) ...[
if (widget.bottomContent != null) ...[
MySpacing.height(12),
bottomContent!,
widget.bottomContent!,
],
],
],

View File

@ -9,6 +9,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
@ -72,7 +73,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
}
}
class SearchAndFilter extends StatelessWidget {
class SearchAndFilter extends StatefulWidget {
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onFilterTap;
@ -86,6 +87,11 @@ class SearchAndFilter extends StatelessWidget {
super.key,
});
@override
State<SearchAndFilter> createState() => _SearchAndFilterState();
}
class _SearchAndFilterState extends State<SearchAndFilter> with UIMixin {
@override
Widget build(BuildContext context) {
return Padding(
@ -96,8 +102,8 @@ class SearchAndFilter extends StatelessWidget {
child: SizedBox(
height: 35,
child: TextField(
controller: controller,
onChanged: onChanged,
controller: widget.controller,
onChanged: widget.onChanged,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon:
@ -109,6 +115,11 @@ class SearchAndFilter extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
@ -124,7 +135,7 @@ class SearchAndFilter extends StatelessWidget {
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black),
if (expenseController.isFilterApplied)
if (widget.expenseController.isFilterApplied)
Positioned(
top: -1,
right: -1,
@ -140,7 +151,7 @@ class SearchAndFilter extends StatelessWidget {
),
],
),
onPressed: onFilterTap,
onPressed: widget.onFilterTap,
);
}),
],

View File

@ -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,
);
});
}
}

View File

@ -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,
);
});
}
}

View File

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
class WaveBackground extends StatelessWidget {
final Color color;
final double heightFactor;
const WaveBackground({
super.key,
required this.color,
this.heightFactor = 0.2,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _WavePainter(color, heightFactor),
size: Size.infinite,
);
}
}
class _WavePainter extends CustomPainter {
final Color color;
final double heightFactor;
_WavePainter(this.color, this.heightFactor);
@override
void paint(Canvas canvas, Size size) {
final paint1 = Paint()
..shader = LinearGradient(
colors: [
const Color(0xFF49BF3C),
const Color(0xFF81C784),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
final path1 = Path()
..moveTo(0, size.height * heightFactor)
..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);
// Secondary wave (overlay) with same green but lighter opacity
final paint2 = Paint()..color = const Color(0xFF49BF3C).withOpacity(0.15);
final path2 = Path()
..moveTo(0, size.height * (heightFactor + 0.05))
..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;
}

View File

@ -7,6 +7,7 @@ import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/utils/attendance_actions.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class AttendanceActionButton extends StatefulWidget {
final dynamic employee;
@ -84,6 +85,8 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
final controller = widget.attendanceController;
final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id;
final projectName =
projectController.selectedProject?.name ?? 'Unknown Project';
if (selectedProjectId == null) {
showAppSnackbar(
@ -108,8 +111,10 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
break;
case 1:
final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
final isOldCheckIn =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2;
@ -158,7 +163,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
actionText,
selectedTime: selectedTime,
checkInDate: widget.employee.checkIn,
projectName: projectName,
);
if (comment == null || comment.isEmpty) {
controller.uploadingStates[uniqueLogKey]?.value = false;
return;
@ -167,7 +174,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
String? markTime;
if (actionText == ButtonActions.requestRegularize) {
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!);
markTime = selectedTime != null ? DateFormat("hh:mm a").format(selectedTime) : null;
markTime = selectedTime != null
? DateFormat("hh:mm a").format(selectedTime)
: null;
} else if (selectedTime != null) {
markTime = DateFormat("hh:mm a").format(selectedTime);
}
@ -205,13 +214,17 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
Widget build(BuildContext context) {
return Obx(() {
final controller = widget.attendanceController;
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false;
final isUploading =
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isYesterday =
AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved);
AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading,
@ -288,15 +301,17 @@ class AttendanceActionButtonUI extends StatelessWidget {
if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested')
const Icon(Icons.hourglass_top, size: 16, color: Colors.orange),
const Icon(Icons.hourglass_top,
size: 16, color: Colors.orange),
if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase()))
const SizedBox(width: 4),
Flexible(
child: Text(
child: MyText.bodySmall(
buttonText,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
fontWeight: 500,
color: Colors.white,
),
),
],
@ -311,15 +326,18 @@ Future<String?> _showCommentBottomSheet(
String actionText, {
DateTime? selectedTime,
DateTime? checkInDate,
String? projectName,
}) async {
final commentController = TextEditingController();
String? errorText;
// Prepare title
String sheetTitle = "Add Comment for ${capitalizeFirstLetter(actionText)}";
String sheetTitle =
"Adding Comment for ${capitalizeFirstLetter(actionText)}";
if (selectedTime != null && checkInDate != null) {
sheetTitle =
"${capitalizeFirstLetter(actionText)} for ${DateFormat('dd MMM yyyy').format(checkInDate)} at ${DateFormat('hh:mm a').format(selectedTime)}";
"${capitalizeFirstLetter(actionText)}${DateFormat('dd MMM yyyy').format(checkInDate)} "
"at ${DateFormat('hh:mm a').format(selectedTime)}\n"
"${projectName ?? 'Project'}";
}
return showModalBottomSheet<String>(
@ -342,14 +360,24 @@ Future<String?> _showCommentBottomSheet(
}
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet(
title: sheetTitle, // 👈 now showing full sentence as title
title: sheetTitle,
subtitle: projectName,
onCancel: () => Navigator.of(context).pop(),
onSubmit: submit,
isSubmitting: false,
submitText: 'Submit',
child: TextField(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
'Add a comment to proceed',
fontWeight: 500,
),
const SizedBox(height: 8),
TextField(
controller: commentController,
maxLines: 4,
decoration: InputDecoration(
@ -367,6 +395,8 @@ Future<String?> _showCommentBottomSheet(
}
},
),
],
),
),
);
},
@ -375,6 +405,5 @@ Future<String?> _showCommentBottomSheet(
);
}
String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -4,7 +4,6 @@ import 'package:marco/controller/attendance/attendance_screen_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
class AttendanceFilterBottomSheet extends StatefulWidget {
@ -39,78 +38,13 @@ class _AttendanceFilterBottomSheetState
final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) {
final start =
DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end";
}
return "Date Range";
}
Widget _popupSelector({
required String currentValue,
required List<String> items,
required ValueChanged<String> onSelected,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: MyText(e),
))
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
Widget _buildOrganizationSelector(BuildContext context) {
final orgNames = [
"All Organizations",
...widget.controller.organizations.map((e) => e.name)
];
return _popupSelector(
currentValue:
widget.controller.selectedOrganization?.name ?? "All Organizations",
items: orgNames,
onSelected: (name) {
if (name == "All Organizations") {
setState(() {
widget.controller.selectedOrganization = null;
});
} else {
final selectedOrg = widget.controller.organizations
.firstWhere((org) => org.name == name);
setState(() {
widget.controller.selectedOrganization = selectedOrg;
});
}
},
);
}
List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
@ -149,35 +83,6 @@ class _AttendanceFilterBottomSheetState
}),
];
// 🔹 Organization filter
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("Choose Organization", fontWeight: 600),
),
),
Obx(() {
if (widget.controller.isLoadingOrganizations.value) {
return const Center(child: CircularProgressIndicator());
} else if (widget.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,
),
),
);
}
return _buildOrganizationSelector(context);
}),
]);
// 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([
@ -237,7 +142,6 @@ class _AttendanceFilterBottomSheetState
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -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,
);
}
}

View File

@ -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),
),
],
],
),
);
}
}

View File

@ -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]),
],
),
),
],
),
);
}

View File

@ -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),
],
),
),
),
],
),
);
}
}

View File

@ -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() ??
[],
);
}
}

View File

@ -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,
});
},
),
),
),
],
),
),
);
});
}
}

View File

@ -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?,
);
}
}

View File

@ -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,
};
}
}

View File

@ -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),
),
),
),
],
),
),
),
],
),
);
}
}

View File

@ -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 '';
}
}

View File

@ -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),
],
),
),
),
],
),
],
);
}
}

View File

@ -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,
};
}
}

View File

@ -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');
}
}

View File

@ -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'],
);
}
}

View File

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

View File

@ -5,7 +5,6 @@ import 'package:intl/intl.dart';
import 'package:marco/controller/employee/add_employee_controller.dart';
import 'package:marco/controller/employee/employees_screen_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
@ -24,8 +23,6 @@ class AddEmployeeBottomSheet extends StatefulWidget {
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin {
late final AddEmployeeController _controller;
final OrganizationController _organizationController =
Get.put(OrganizationController());
// Local UI state
bool _hasApplicationAccess = false;
@ -39,51 +36,56 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
@override
void initState() {
super.initState();
_orgFieldController = TextEditingController();
_joiningDateController = TextEditingController();
_genderController = TextEditingController();
_roleController = TextEditingController();
_controller = Get.put(
AddEmployeeController(),
// Unique tag to avoid clashes, but stable for this widget instance
tag: UniqueKey().toString(),
);
_controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString());
_orgFieldController = TextEditingController(text: '');
_joiningDateController = TextEditingController(text: '');
_genderController = TextEditingController(text: '');
_roleController = TextEditingController(text: '');
// Prefill when editing
if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields();
final orgId = widget.employeeData!['organizationId'];
if (orgId != null) {
_controller.selectedOrganizationId = orgId;
// Prepopulate hasApplicationAccess and email
_hasApplicationAccess =
widget.employeeData?['hasApplicationAccess'] ?? false;
final selectedOrg = _organizationController.organizations
.firstWhereOrNull((o) => o.id == orgId);
if (selectedOrg != null) {
_organizationController.selectOrganization(selectedOrg);
_orgFieldController.text = selectedOrg.name;
}
final email = widget.employeeData?['email'];
if (email != null && email.toString().isNotEmpty) {
_controller.basicValidator.getController('email')?.text =
email.toString();
}
// Trigger UI rebuild to reflect email & checkbox
setState(() {});
// Joining date
if (_controller.joiningDate != null) {
_joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
}
// Gender
if (_controller.selectedGender != null) {
_genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? '';
}
// Role
_controller.fetchRoles().then((_) {
if (_controller.selectedRoleId != null) {
final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId)?['name'] ??
'';
(r) => r['id'] == _controller.selectedRoleId,
)?['name'];
if (roleName != null) {
_roleController.text = roleName;
}
_controller.update();
}
});
} else {
_orgFieldController.text = _organizationController.currentSelection;
_controller.fetchRoles();
}
}
@ -102,7 +104,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
init: _controller,
builder: (_) {
// Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet(
title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
@ -135,30 +136,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.basicValidator.getValidation('last_name'),
),
MySpacing.height(16),
_sectionLabel('Organization'),
MySpacing.height(8),
GestureDetector(
onTap: () => _showOrganizationPopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: _orgFieldController,
validator: (val) {
if (val == null ||
val.trim().isEmpty ||
val == 'All Organizations') {
return 'Organization is required';
}
return null;
},
decoration:
_inputDecoration('Select Organization').copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(24),
_sectionLabel('Application Access'),
Row(
children: [
@ -333,8 +310,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return null;
},
keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(
),
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(),
),
],
);
@ -466,7 +442,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
context: context,
initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
lastDate: DateTime.now(),
);
if (picked != null) {
@ -479,13 +455,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
Future<void> _handleSubmit() async {
final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false;
if (_controller.joiningDate != null &&
_controller.joiningDate!.isAfter(DateTime.now())) {
showAppSnackbar(
title: 'Invalid Date',
message: 'Joining Date cannot be in the future.',
type: SnackbarType.warning,
);
return;
}
if (!isValid ||
_controller.joiningDate == null ||
_controller.selectedGender == null ||
_controller.selectedRoleId == null ||
_organizationController.currentSelection.isEmpty ||
_organizationController.currentSelection == 'All Organizations') {
_controller.selectedRoleId == null) {
showAppSnackbar(
title: 'Missing Fields',
message: 'Please complete all required fields.',
@ -514,40 +497,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
}
}
void _showOrganizationPopup(BuildContext context) async {
final orgs = _organizationController.organizations;
if (orgs.isEmpty) {
showAppSnackbar(
title: 'No Organizations',
message: 'No organizations available to select.',
type: SnackbarType.warning,
);
return;
}
final selected = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: orgs
.map(
(org) => PopupMenuItem<String>(
value: org.id,
child: Text(org.name),
),
)
.toList(),
);
if (selected != null && selected.trim().isNotEmpty) {
final chosen = orgs.firstWhere((e) => e.id == selected);
_organizationController.selectOrganization(chosen);
_controller.selectedOrganizationId = chosen.id;
_orgFieldController.text = chosen.name;
_controller.update();
}
}
void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>(
context: context,

View File

@ -12,15 +12,17 @@ class EmployeeDetailsModel {
final String phoneNumber;
final String? emergencyPhoneNumber;
final String? emergencyContactPerson;
final String? aadharNumber;
final bool isActive;
final String? panNumber;
final String? photo;
final String? applicationUserId;
final String jobRoleId;
final bool isRootUser;
final bool isSystem;
final String jobRole;
final String jobRoleId;
final String? photo;
final String? applicationUserId;
final bool hasApplicationAccess;
final String? organizationId;
final String? aadharNumber;
final String? panNumber;
EmployeeDetailsModel({
required this.id,
required this.firstName,
@ -35,14 +37,17 @@ class EmployeeDetailsModel {
required this.phoneNumber,
this.emergencyPhoneNumber,
this.emergencyContactPerson,
this.aadharNumber,
required this.isActive,
this.panNumber,
this.photo,
this.applicationUserId,
required this.jobRoleId,
required this.isRootUser,
required this.isSystem,
required this.jobRole,
required this.jobRoleId,
this.photo,
this.applicationUserId,
required this.hasApplicationAccess,
this.organizationId,
this.aadharNumber,
this.panNumber,
});
factory EmployeeDetailsModel.fromJson(Map<String, dynamic> json) {
@ -60,24 +65,20 @@ class EmployeeDetailsModel {
phoneNumber: json['phoneNumber'],
emergencyPhoneNumber: json['emergencyPhoneNumber'],
emergencyContactPerson: json['emergencyContactPerson'],
aadharNumber: json['aadharNumber'],
isActive: json['isActive'],
panNumber: json['panNumber'],
photo: json['photo'],
applicationUserId: json['applicationUserId'],
jobRoleId: json['jobRoleId'],
isRootUser: json['isRootUser'],
isSystem: json['isSystem'],
jobRole: json['jobRole'],
jobRoleId: json['jobRoleId'],
photo: json['photo'],
applicationUserId: json['applicationUserId'],
hasApplicationAccess: json['hasApplicationAccess'],
organizationId: json['organizationId'],
aadharNumber: json['aadharNumber'],
panNumber: json['panNumber'],
);
}
static DateTime? _parseDate(String? dateStr) {
if (dateStr == null || dateStr == "0001-01-01T00:00:00") {
return null;
}
return DateTime.tryParse(dateStr);
}
Map<String, dynamic> toJson() {
return {
'id': id,
@ -93,14 +94,24 @@ class EmployeeDetailsModel {
'phoneNumber': phoneNumber,
'emergencyPhoneNumber': emergencyPhoneNumber,
'emergencyContactPerson': emergencyContactPerson,
'aadharNumber': aadharNumber,
'isActive': isActive,
'panNumber': panNumber,
'photo': photo,
'applicationUserId': applicationUserId,
'jobRoleId': jobRoleId,
'isRootUser': isRootUser,
'isSystem': isSystem,
'jobRole': jobRole,
'jobRoleId': jobRoleId,
'photo': photo,
'applicationUserId': applicationUserId,
'hasApplicationAccess': hasApplicationAccess,
'organizationId': organizationId,
'aadharNumber': aadharNumber,
'panNumber': panNumber,
};
}
static DateTime? _parseDate(String? dateStr) {
if (dateStr == null || dateStr == "0001-01-01T00:00:00") {
return null;
}
return DateTime.tryParse(dateStr);
}
}

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/view/project/create_project_bottom_sheet.dart';
/// Show bottom sheet wrapper
Future<T?> showAddExpenseBottomSheet<T>({
@ -18,10 +19,7 @@ Future<T?> showAddExpenseBottomSheet<T>({
Map<String, dynamic>? existingExpense,
}) {
return Get.bottomSheet<T>(
_AddExpenseBottomSheet(
isEdit: isEdit,
existingExpense: existingExpense,
),
_AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense),
isScrollControlled: true,
);
}
@ -48,95 +46,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey();
/// Show employee list
Future<void> _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
),
);
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
/// Generic option list
Future<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey,
) async {
final RenderBox button =
triggerKey.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map((opt) => PopupMenuItem<T>(
value: opt,
child: Text(getLabel(opt)),
))
.toList(),
);
if (selected != null) onSelected(selected);
}
/// Validate required selections
bool _validateSelections() {
if (controller.selectedProject.value.isEmpty) {
_showError("Please select a project");
return false;
}
if (controller.selectedExpenseType.value == null) {
_showError("Please select an expense type");
return false;
}
if (controller.selectedPaymentMode.value == null) {
_showError("Please select a payment mode");
return false;
}
if (controller.selectedPaidBy.value == null) {
_showError("Please select a person who paid");
return false;
}
if (controller.attachments.isEmpty &&
controller.existingAttachments.isEmpty) {
_showError("Please attach at least one document");
return false;
}
return true;
}
void _showError(String msg) {
showAppSnackbar(
title: "Error",
message: msg,
type: SnackbarType.error,
);
}
@override
Widget build(BuildContext context) {
return Obx(
@ -146,244 +55,183 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: widget.isEdit ? "Edit Expense" : "Add Expense",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
onSubmit: () {
if (_formKey.currentState!.validate() && _validateSelections()) {
controller.submitOrUpdateExpense();
} else {
_showError("Please fill all required fields correctly");
}
},
onSubmit: _handleSubmit,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdownField<String>(
_buildCreateProjectButton(),
_buildProjectDropdown(),
_gap(),
_buildExpenseTypeDropdown(),
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
_gap(),
_buildNumberField(
icon: Icons.people_outline,
title: "No. of Persons",
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
validator: Validators.requiredField,
),
],
_gap(),
_buildPaymentModeDropdown(),
_gap(),
_buildPaidBySection(),
_gap(),
_buildAmountField(),
_gap(),
_buildSupplierField(),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildTransactionIdField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildDescriptionField(),
],
),
),
),
),
);
}
/// 🟦 UI SECTION BUILDERS
Widget _buildCreateProjectButton() {
return Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () async {
await Get.bottomSheet(const CreateProjectBottomSheet(),
isScrollControlled: true);
await controller.fetchGlobalProjects();
},
icon: const Icon(Icons.add, color: Colors.blue),
label: const Text(
"Create Project",
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.w600),
),
),
);
}
Widget _buildProjectDropdown() {
return _buildDropdownField<String>(
icon: Icons.work_outline,
title: "Project",
requiredField: true,
value: controller.selectedProject.value.isEmpty
? "Select Project"
: controller.selectedProject.value,
onTap: () => _showOptionList<String>(
controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val,
_projectDropdownKey,
),
onTap: _showProjectSelector,
dropdownKey: _projectDropdownKey,
),
_gap(),
);
}
_buildDropdownField<ExpenseTypeModel>(
Widget _buildExpenseTypeDropdown() {
return _buildDropdownField<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
value:
controller.selectedExpenseType.value?.name ?? "Select Expense Type",
onTap: () => _showOptionList(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey,
),
dropdownKey: _expenseTypeDropdownKey,
),
);
}
// Persons if required
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[
_gap(),
_buildTextFieldSection(
icon: Icons.people_outline,
title: "No. of Persons",
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
validator: Validators.requiredField,
),
],
_gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
controller: controller.gstController,
hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>(
Widget _buildPaymentModeDropdown() {
return _buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
value:
controller.selectedPaymentMode.value?.name ?? "Select Payment Mode",
onTap: () => _showOptionList(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
),
_gap(),
);
}
_buildPaidBySection(),
_gap(),
Widget _buildPaidBySection() {
return _buildTileSelector(
icon: Icons.person_outline,
title: "Paid By",
required: true,
displayText: controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
onTap: _showEmployeeList,
);
}
_buildTextFieldSection(
Widget _buildAmountField() => _buildNumberField(
icon: Icons.currency_rupee,
title: "Amount",
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
validator: (v) => Validators.isNumeric(v ?? "")
? null
: "Enter valid amount",
),
_gap(),
validator: (v) =>
Validators.isNumeric(v ?? "") ? null : "Enter valid amount",
);
_buildTextFieldSection(
Widget _buildSupplierField() => _buildTextField(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name/Transporter Name/Other",
controller: controller.supplierController,
hint: "Enter Supplier Name/Transporter Name or Other",
validator: Validators.nameValidator,
),
_gap(),
);
_buildTextFieldSection(
Widget _buildTransactionIdField() {
final paymentMode =
controller.selectedPaymentMode.value?.name.toLowerCase() ?? '';
final isRequired = paymentMode.isNotEmpty &&
paymentMode != 'cash' &&
paymentMode != 'cheque';
return _buildTextField(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
controller: controller.transactionIdController,
hint: "Enter Transaction ID",
validator: (v) => (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: null,
),
_gap(),
_buildTransactionDateField(),
_gap(),
_buildLocationField(),
_gap(),
_buildAttachmentsSection(),
_gap(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
),
],
),
),
),
),
);
validator: (v) {
if (isRequired) {
if (v == null || v.isEmpty)
return "Transaction ID is required for this payment mode";
return Validators.transactionIdValidator(v);
}
Widget _gap([double h = 16]) => MySpacing.height(h);
Widget _buildDropdownField<T>({
required IconData icon,
required String title,
required bool requiredField,
required String value,
required VoidCallback onTap,
required GlobalKey dropdownKey,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
DropdownTile(key: dropdownKey, title: value, onTap: onTap),
],
);
}
Widget _buildTextFieldSection({
required IconData icon,
required String title,
required TextEditingController controller,
String? hint,
TextInputType? keyboardType,
FormFieldValidator<String>? validator,
int maxLines = 1,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: icon, title: title, requiredField: validator != null),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
keyboardType:
keyboardType ?? TextInputType.text,
validator: validator,
maxLines: maxLines,
),
],
);
}
Widget _buildPaidBySection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.person_outline, title: "Paid By", requiredField: true),
MySpacing.height(6),
GestureDetector(
onTap: _showEmployeeList,
child: TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
],
return null;
},
requiredField: isRequired,
);
}
Widget _buildTransactionDateField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
return Obx(() => _buildTileSelector(
icon: Icons.calendar_today,
title: "Transaction Date",
requiredField: true),
MySpacing.height(6),
GestureDetector(
required: true,
displayText: controller.selectedTransactionDate.value == null
? "Select Transaction Date"
: DateFormat('dd MMM yyyy')
.format(controller.selectedTransactionDate.value!),
onTap: () => controller.pickTransactionDate(context),
child: AbsorbPointer(
child: CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
validator: Validators.requiredField,
),
),
),
],
);
));
}
Widget _buildLocationField() {
@ -426,13 +274,196 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.attach_file, title: "Attachments", requiredField: true),
icon: Icons.attach_file,
title: "Attachments",
requiredField: true,
),
MySpacing.height(6),
AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
onRemoveExisting: (item) async {
onRemoveExisting: _confirmRemoveAttachment,
onAdd: controller.pickAttachments,
),
],
);
}
Widget _buildDescriptionField() => _buildTextField(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
);
/// 🟩 COMMON HELPERS
Widget _gap([double h = 16]) => MySpacing.height(h);
Widget _buildDropdownField<T>({
required IconData icon,
required String title,
required bool requiredField,
required String value,
required VoidCallback onTap,
required GlobalKey dropdownKey,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
DropdownTile(key: dropdownKey, title: value, onTap: onTap),
],
);
}
Widget _buildTextField({
required IconData icon,
required String title,
required TextEditingController controller,
String? hint,
FormFieldValidator<String>? validator,
bool requiredField = true,
int maxLines = 1,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: requiredField),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
validator: validator,
maxLines: maxLines,
),
],
);
}
Widget _buildNumberField({
required IconData icon,
required String title,
required TextEditingController controller,
String? hint,
FormFieldValidator<String>? validator,
}) {
return _buildTextField(
icon: icon,
title: title,
controller: controller,
hint: hint,
validator: validator,
);
}
Widget _buildTileSelector({
required IconData icon,
required String title,
required String displayText,
required VoidCallback onTap,
bool required = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: required),
MySpacing.height(6),
GestureDetector(
onTap: onTap,
child: TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(displayText, style: const TextStyle(fontSize: 14)),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
],
);
}
/// 🧰 LOGIC HELPERS
Future<void> _showProjectSelector() async {
final sortedProjects = controller.globalProjects.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
const specialOption = 'Create New Project';
final displayList = [...sortedProjects, specialOption];
final selected = await showMenu<String>(
context: context,
position: _getPopupMenuPosition(_projectDropdownKey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: displayList.map((opt) {
final isSpecial = opt == specialOption;
return PopupMenuItem<String>(
value: opt,
child: isSpecial
? Row(
children: const [
Icon(Icons.add, color: Colors.blue),
SizedBox(width: 8),
Text(
specialOption,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.blue,
),
),
],
)
: Text(
opt,
style: const TextStyle(
fontWeight: FontWeight.normal,
color: Colors.black,
),
),
);
}).toList(),
);
if (selected == null) return;
if (selected == specialOption) {
controller.selectedProject.value = specialOption;
await Get.bottomSheet(const CreateProjectBottomSheet(),
isScrollControlled: true);
await controller.fetchGlobalProjects();
controller.selectedProject.value = "";
} else {
controller.selectedProject.value = selected;
}
}
Future<void> _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
),
);
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
Future<void> _confirmRemoveAttachment(item) async {
await showDialog(
context: context,
barrierDismissible: false,
@ -456,10 +487,72 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
},
),
);
},
onAdd: controller.pickAttachments,
),
],
}
Future<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> onSelected,
GlobalKey triggerKey,
) async {
final selected = await showMenu<T>(
context: context,
position: _getPopupMenuPosition(triggerKey),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map((opt) => PopupMenuItem<T>(
value: opt,
child: Text(getLabel(opt)),
))
.toList(),
);
if (selected != null) onSelected(selected);
}
RelativeRect _getPopupMenuPosition(GlobalKey key) {
final RenderBox button =
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
return RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
);
}
bool _validateSelections() {
if (controller.selectedProject.value.isEmpty) {
return _error("Please select a project");
}
if (controller.selectedExpenseType.value == null) {
return _error("Please select an expense type");
}
if (controller.selectedPaymentMode.value == null) {
return _error("Please select a payment mode");
}
if (controller.selectedPaidBy.value == null) {
return _error("Please select a person who paid");
}
if (controller.attachments.isEmpty &&
controller.existingAttachments.isEmpty) {
return _error("Please attach at least one document");
}
return true;
}
bool _error(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false;
}
void _handleSubmit() {
if (_formKey.currentState!.validate() && _validateSelections()) {
controller.submitOrUpdateExpense();
} else {
_error("Please fill all required fields correctly");
}
}
}

View File

@ -157,6 +157,7 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
decoration: _inputDecoration("Enter transaction ID"),
),
MySpacing.height(16),
MySpacing.height(16),
MyText.labelMedium("Reimbursement Date"),
MySpacing.height(8),
GestureDetector(
@ -165,12 +166,13 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
context: context,
initialDate: dateStr.value.isEmpty
? DateTime.now()
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
: DateFormat('dd MMM yyyy').parse(dateStr.value),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
dateStr.value =
DateFormat('dd MMM yyyy').format(picked);
}
},
child: AbsorbPointer(

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/auth/forgot_password_screen.dart';
import 'package:marco/view/auth/login_screen.dart';
import 'package:marco/view/auth/register_account_screen.dart';
@ -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';
@ -20,21 +18,13 @@ import 'package:marco/view/auth/mpin_auth_screen.dart';
import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
class AuthMiddleware extends GetMiddleware {
@override
RouteSettings? redirect(String? route) {
if (!AuthService.isLoggedIn) {
if (route != '/auth/login-option') {
return const RouteSettings(name: '/auth/login-option');
}
} else if (!TenantService.isTenantSelected) {
if (route != '/select-tenant') {
return const RouteSettings(name: '/select-tenant');
}
}
return null;
return AuthService.isLoggedIn
? null
: RouteSettings(name: '/auth/login-option');
}
}
@ -49,10 +39,6 @@ getPageRoute() {
page: () => DashboardScreen(), // or your actual home screen
middlewares: [AuthMiddleware()],
),
GetPage(
name: '/select-tenant',
page: () => const TenantSelectionScreen(),
middlewares: [AuthMiddleware()]),
// Dashboard
GetPage(
@ -67,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(),

View File

@ -94,7 +94,7 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
MaterialStateProperty.resolveWith<Color>(
(states) =>
states.contains(WidgetState.selected)
? contentTheme.brandRed
? contentTheme.primary
: Colors.white,
),
checkColor: contentTheme.onPrimary,
@ -128,7 +128,7 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
elevation: 2,
padding: MySpacing.xy(80, 16),
borderRadiusAll: 10,
backgroundColor: contentTheme.brandRed,
backgroundColor: contentTheme.primary,
child: MyText.labelLarge(
'Login',
fontWeight: 700,

View File

@ -8,6 +8,7 @@ import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@ -59,7 +60,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
return Scaffold(
body: Stack(
children: [
_RedWaveBackground(brandRed: contentTheme.brandRed),
WaveBackground(color: contentTheme.brandRed),
SafeArea(
child: Center(
child: Column(
@ -230,8 +231,8 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
borderRadiusAll: 10,
backgroundColor: _isLoading
? contentTheme.brandRed.withOpacity(0.6)
: contentTheme.brandRed,
? contentTheme.primary.withOpacity(0.6)
: contentTheme.primary,
child: _isLoading
? const SizedBox(
height: 20,
@ -253,68 +254,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
Widget _buildBackButton() {
return TextButton.icon(
onPressed: () async => await LocalStorage.logout(),
icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent),
icon: Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
label: MyText.bodyMedium(
'Back to Login',
color: contentTheme.brandRed,
color: contentTheme.primary,
fontWeight: 600,
fontSize: 14,
),
);
}
}
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;
}

View File

@ -7,6 +7,7 @@ import 'package:marco/view/auth/otp_login_form.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
enum LoginOption { email, otp }
@ -55,7 +56,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
context: context,
barrierDismissible: false,
builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
insetPadding: const EdgeInsets.all(24),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
@ -101,13 +102,14 @@ class _WelcomeScreenState extends State<WelcomeScreen>
return Scaffold(
body: Stack(
children: [
_RedWaveBackground(brandRed: contentTheme.brandRed),
WaveBackground(color: contentTheme.brandRed),
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: isNarrow ? double.infinity : 420),
constraints: BoxConstraints(
maxWidth: isNarrow ? double.infinity : 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -166,7 +168,10 @@ class _WelcomeScreenState extends State<WelcomeScreen>
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))],
boxShadow: [
BoxShadow(
color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))
],
),
child: Image.asset(Images.logoDark),
),
@ -199,7 +204,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
),
child: MyText(
'BETA',
@ -230,9 +235,9 @@ class _WelcomeScreenState extends State<WelcomeScreen>
),
),
style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.brandRed,
backgroundColor: contentTheme.brandGreen,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
elevation: 4,
shadowColor: Colors.black26,
),
@ -247,55 +252,3 @@ class _WelcomeScreenState extends State<WelcomeScreen>
);
}
}
// Red wave background painter
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;
}

View File

@ -49,7 +49,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
padding: MySpacing.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.02),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: contentTheme.primary.withOpacity(0.5),
),
@ -77,7 +77,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(5),
),
child: Text(
'BETA',
@ -148,7 +148,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
value: controller.isChecked.value,
onChanged: controller.onChangeCheckBox,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(5),
),
fillColor: MaterialStateProperty
.resolveWith<Color>(
@ -192,8 +192,8 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
onPressed: controller.onLogin,
elevation: 2,
padding: MySpacing.xy(24, 16),
borderRadiusAll: 16,
backgroundColor: Colors.blueAccent,
borderRadiusAll: 5,
backgroundColor:contentTheme.brandGreen,
child: MyText.labelMedium(
'Login',
fontWeight: 600,
@ -242,7 +242,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
return Material(
elevation: 2,
shadowColor: contentTheme.secondary.withAlpha(30),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
child: TextFormField(
controller: controller,
validator: validator,
@ -255,7 +255,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
filled: true,
fillColor: theme.cardColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(2),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide.none,
),
prefixIcon: Icon(icon, size: 18),

View File

@ -8,6 +8,7 @@ import 'package:marco/images.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
class MPINAuthScreen extends StatefulWidget {
const MPINAuthScreen({super.key});
@ -51,7 +52,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
return Scaffold(
body: Stack(
children: [
_RedWaveBackground(brandRed: contentTheme.brandRed),
WaveBackground(color: contentTheme.brandRed),
SafeArea(
child: Center(
child: Column(
@ -110,7 +111,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(5),
),
child: MyText(
'BETA',
@ -145,7 +146,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(5),
boxShadow: const [
BoxShadow(
color: Colors.black12,
@ -264,7 +265,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide.none,
),
),
@ -279,10 +280,10 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
onPressed: controller.isLoading.value ? null : controller.onSubmitMPIN,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
borderRadiusAll: 10,
borderRadiusAll: 5,
backgroundColor: controller.isLoading.value
? contentTheme.brandRed.withOpacity(0.6)
: contentTheme.brandRed,
? contentTheme.primary.withOpacity(0.6)
: contentTheme.primary,
child: controller.isLoading.value
? const SizedBox(
height: 20,
@ -341,57 +342,3 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
});
}
}
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;
const _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;
}

View File

@ -81,7 +81,7 @@ class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
elevation: 2,
padding: MySpacing.xy(24, 16),
borderRadiusAll: 10,
backgroundColor: isDisabled ? Colors.grey : contentTheme.brandRed,
backgroundColor: isDisabled ? Colors.grey : contentTheme.primary,
child: controller.isSending.value
? SizedBox(
width: 20,

View File

@ -166,10 +166,9 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
onChanged: (val) => setState(() => _agreed = val ?? false),
fillColor: MaterialStateProperty.resolveWith((states) =>
states.contains(MaterialState.selected)
? contentTheme.brandRed
? contentTheme.primary
: Colors.white),
checkColor: Colors.white,
side: const BorderSide(color: Colors.red, width: 2),
),
Row(
children: [
@ -179,7 +178,7 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
),
MyText(
'privacy policy & terms',
color: contentTheme.brandRed,
color: contentTheme.primary,
fontWeight: 600,
),
],
@ -188,44 +187,44 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton.icon(
Expanded(
child: ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back, color: Colors.red),
label: MyText.bodyMedium("Back", color: Colors.red),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
),
),
ElevatedButton.icon(
onPressed: _loading ? null : _submitForm,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
strokeWidth: 2,
fontWeight: 600,
),
)
: const Icon(Icons.check_circle_outline,
color: Colors.white),
label: _loading
? const SizedBox.shrink()
: MyText.bodyMedium("Submit", color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 14),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _loading ? null : _submitForm,
icon:
Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
_loading ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: contentTheme
.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
@ -235,11 +234,11 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
child: TextButton.icon(
onPressed: () => Navigator.pop(context),
icon:
const Icon(Icons.arrow_back, size: 18, color: Colors.red),
Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
label: MyText.bodySmall(
'Back to log in',
fontWeight: 600,
color: contentTheme.brandRed,
color: contentTheme.primary,
),
),
),
@ -291,19 +290,19 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[400]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: contentTheme.brandRed, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(color: Colors.red),
),
),
@ -367,15 +366,15 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[400]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.brandRed, width: 1.5),
),

View File

@ -10,25 +10,15 @@ 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/helpers/widgets/dashbaord/attendance_overview_chart.dart';
import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart';
import 'package:marco/view/layouts/layout.dart';
import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
static const String dashboardRoute = "/dashboard";
static const String employeesRoute = "/dashboard/employees";
static const String projectsRoute = "/dashboard";
static const String attendanceRoute = "/dashboard/attendance";
static const String tasksRoute = "/dashboard/daily-task";
static const String dailyTasksRoute = "/dashboard/daily-task-Planning";
static const String dailyTasksProgressRoute =
"/dashboard/daily-task-progress";
static const String directoryMainPageRoute = "/dashboard/directory-main-page";
static const String expenseMainPageRoute = "/dashboard/expense-main-page";
static const String documentMainPageRoute = "/dashboard/document-main-page";
@override
State<DashboardScreen> createState() => _DashboardScreenState();
@ -37,7 +27,6 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final DashboardController dashboardController =
Get.put(DashboardController(), permanent: true);
final DynamicMenuController menuController = Get.put(DynamicMenuController());
bool hasMpin = true;
@ -62,62 +51,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
children: [
_buildDashboardStats(context),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.teamsOverview(),
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.tasksOverview(),
),
MySpacing.height(24),
_buildAttendanceChartSection(),
MySpacing.height(24),
_buildProjectProgressChartSection(),
],
),
),
);
}
/// Project Progress Chart Section
Widget _buildProjectProgressChartSection() {
return Obx(() {
if (dashboardController.projectChartData.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text("No project progress data available."),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: ProjectProgressChart(
data: dashboardController.projectChartData,
),
),
);
});
}
/// Attendance Chart Section
Widget _buildAttendanceChartSection() {
return Obx(() {
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance");
if (!isAttendanceAllowed) {
// 🚫 Don't render anything if attendance menu is not allowed
return const SizedBox.shrink();
}
return GetBuilder<ProjectController>(
id: 'dashboard_controller',
builder: (projectController) {
@ -137,7 +79,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
);
},
);
});
}
/// No Project Assigned Message
@ -165,80 +106,18 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
);
}
/// Loading Skeletons
Widget _buildLoadingSkeleton(BuildContext context) {
return Wrap(
spacing: 10,
runSpacing: 10,
children: List.generate(
4,
(index) =>
_buildStatCardSkeleton(MediaQuery.of(context).size.width / 3),
),
);
}
/// Skeleton Card
Widget _buildStatCardSkeleton(double width) {
return MyCard.bordered(
width: width,
height: 100,
paddingAll: 5,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyContainer.rounded(
paddingAll: 12,
color: Colors.grey.shade300,
child: const SizedBox(width: 18, height: 18),
),
MySpacing.height(8),
Container(
height: 12,
width: 60,
color: Colors.grey.shade300,
),
],
),
);
}
/// Dashboard Statistics Section
Widget _buildDashboardStats(BuildContext context) {
return Obx(() {
if (menuController.isLoading.value) {
return _buildLoadingSkeleton(context);
}
if (menuController.hasError.value && menuController.menuItems.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: MyText.bodySmall(
"Failed to load menus. Please try again later.",
color: Colors.red,
),
),
);
}
final stats = [
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
DashboardScreen.attendanceRoute),
_StatItem(LucideIcons.users, "Employees", contentTheme.warning,
DashboardScreen.employeesRoute),
_StatItem(LucideIcons.logs, "Daily Task Planning", contentTheme.info,
DashboardScreen.dailyTasksRoute),
_StatItem(LucideIcons.list_todo, "Daily Progress Report",
contentTheme.info, DashboardScreen.dailyTasksProgressRoute),
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
DashboardScreen.directoryMainPageRoute),
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
DashboardScreen.expenseMainPageRoute),
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
DashboardScreen.documentMainPageRoute),
];
final projectController = Get.find<ProjectController>();
@ -250,7 +129,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
if (!isProjectSelected) _buildNoProjectMessage(),
LayoutBuilder(
builder: (context, constraints) {
// smaller width cards fit more in a row
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8);
double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) /
@ -261,10 +139,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
runSpacing: 6,
alignment: WrapAlignment.start,
children: stats
.where((stat) {
if (stat.title == "Documents") return true;
return menuController.isMenuAllowed(stat.title);
})
.map((stat) =>
_buildStatCard(stat, isProjectSelected, cardWidth))
.toList()
@ -274,7 +148,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
),
],
);
});
}
/// Stat Card (Compact + Small)

View File

@ -9,7 +9,6 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/directory/contact_model.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:tab_indicator_styler/tab_indicator_styler.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
@ -17,6 +16,7 @@ import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
// HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) {
@ -69,7 +69,7 @@ class ContactDetailScreen extends StatefulWidget {
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
class _ContactDetailScreenState extends State<ContactDetailScreen> {
class _ContactDetailScreenState extends State<ContactDetailScreen> with UIMixin{
late final DirectoryController directoryController;
late final ProjectController projectController;
@ -190,16 +190,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
]),
TabBar(
labelColor: Colors.red,
unselectedLabelColor: Colors.black,
indicator: MaterialIndicator(
color: Colors.red,
height: 4,
topLeftRadius: 8,
topRightRadius: 8,
bottomLeftRadius: 8,
bottomRightRadius: 8,
),
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: contentTheme.primary,
tabs: const [
Tab(text: "Details"),
Tab(text: "Notes"),
@ -316,7 +309,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20,
right: 20,
child: FloatingActionButton.extended(
backgroundColor: Colors.red,
backgroundColor: contentTheme.primary,
onPressed: () async {
final result = await Get.bottomSheet(
AddContactBottomSheet(existingContact: contact),
@ -360,12 +353,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
[...activeComments, ...inactiveComments].reversed.toList();
final editingId = directoryController.editingCommentId.value;
if (comments.isEmpty) {
return Center(
child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
);
}
return Stack(
children: [
MyRefreshIndicator(
@ -377,13 +364,18 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
},
child: Padding(
padding: MySpacing.xy(12, 12),
child: ListView.separated(
child: comments.isEmpty
? Center(
child:
MyText.bodyLarge("No notes yet.", color: Colors.grey),
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) =>
_buildCommentItem(comments[index], editingId, contactId),
itemBuilder: (_, index) => _buildCommentItem(
comments[index], editingId, contactId),
),
),
),
@ -392,7 +384,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20,
right: 20,
child: FloatingActionButton.extended(
backgroundColor: Colors.red,
backgroundColor: contentTheme.primary,
onPressed: () async {
final result = await Get.bottomSheet(
AddCommentBottomSheet(contactId: contactId),

View File

@ -17,13 +17,15 @@ import 'package:marco/view/directory/contact_detail_screen.dart';
import 'package:marco/view/directory/manage_bucket_screen.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class DirectoryView extends StatefulWidget {
@override
State<DirectoryView> createState() => _DirectoryViewState();
}
class _DirectoryViewState extends State<DirectoryView> {
class _DirectoryViewState extends State<DirectoryView> with UIMixin {
final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController();
final PermissionController permissionController =
@ -126,7 +128,7 @@ class _DirectoryViewState extends State<DirectoryView> {
child: ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
backgroundColor: contentTheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@ -172,7 +174,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.grey[100],
floatingActionButton: FloatingActionButton.extended(
heroTag: 'createContact',
backgroundColor: Colors.red,
backgroundColor: contentTheme.primary,
onPressed: _handleCreateContact,
icon: const Icon(Icons.person_add_alt_1, color: Colors.white),
label: const Text("Add Contact", style: TextStyle(color: Colors.white)),
@ -246,7 +248,7 @@ class _DirectoryViewState extends State<DirectoryView> {
icon: Icon(Icons.tune,
size: 20,
color: isFilterActive
? Colors.indigo
? contentTheme.primary
: Colors.black87),
onPressed: () {
showModalBottomSheet(
@ -323,13 +325,13 @@ class _DirectoryViewState extends State<DirectoryView> {
PopupMenuItem<int>(
value: 2,
child: Row(
children: const [
children: [
Icon(Icons.add_box_outlined,
size: 20, color: Colors.black87),
SizedBox(width: 10),
Expanded(child: Text("Create Bucket")),
Icon(Icons.chevron_right,
size: 20, color: Colors.red),
size: 20, color: contentTheme.primary),
],
),
onTap: () {
@ -356,13 +358,13 @@ class _DirectoryViewState extends State<DirectoryView> {
PopupMenuItem<int>(
value: 1,
child: Row(
children: const [
children: [
Icon(Icons.label_outline,
size: 20, color: Colors.black87),
SizedBox(width: 10),
Expanded(child: Text("Manage Buckets")),
Icon(Icons.chevron_right,
size: 20, color: Colors.red),
size: 20, color: contentTheme.primary),
],
),
onTap: () {
@ -401,7 +403,7 @@ class _DirectoryViewState extends State<DirectoryView> {
const Expanded(child: Text('Show Deleted Contacts')),
Switch.adaptive(
value: !controller.isActive.value,
activeColor: Colors.indigo,
activeColor: contentTheme.primary ,
onChanged: (val) {
controller.isActive.value = !val;
controller.fetchContacts(active: !val);
@ -424,7 +426,7 @@ class _DirectoryViewState extends State<DirectoryView> {
child: Obx(() {
return MyRefreshIndicator(
onRefresh: _refreshDirectory,
backgroundColor: Colors.indigo,
backgroundColor: contentTheme.primary,
color: Colors.white,
child: controller.isLoading.value
? ListView.separated(

View File

@ -12,6 +12,8 @@ import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class NotesView extends StatelessWidget {
final NotesController controller = Get.find();

View File

@ -13,6 +13,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/document/document_edit_bottom_sheet.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class DocumentDetailsPage extends StatefulWidget {
final String documentId;
@ -23,7 +24,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin {
final DocumentDetailsController controller =
Get.find<DocumentDetailsController>();
@ -155,7 +156,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (permissionController
.hasPermission(Permissions.modifyDocument))
IconButton(
icon: const Icon(Icons.edit, color: Colors.red),
icon: Icon(Icons.edit, color: contentTheme.primary),
onPressed: () async {
// existing bottom sheet flow
await controller

View File

@ -19,6 +19,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'dart:convert';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class UserDocumentsPage extends StatefulWidget {
final String? entityId;
@ -34,7 +35,7 @@ class UserDocumentsPage extends StatefulWidget {
State<UserDocumentsPage> createState() => _UserDocumentsPageState();
}
class _UserDocumentsPageState extends State<UserDocumentsPage> {
class _UserDocumentsPageState extends State<UserDocumentsPage> with UIMixin {
final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController =
Get.find<PermissionController>();
@ -304,6 +305,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
);
},
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
hintText: 'Search documents...',
filled: true,
fillColor: Colors.white,
@ -415,7 +421,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
const Expanded(child: Text('Show Deleted Documents')),
Switch.adaptive(
value: docController.showInactive.value,
activeColor: Colors.indigo,
activeColor: contentTheme.primary,
onChanged: (val) {
docController.showInactive.value = val;
docController.fetchDocuments(
@ -640,7 +646,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
color: Colors.white,
fontWeight: 600,
),
backgroundColor: Colors.red,
backgroundColor: contentTheme.primary,
)
: null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,

View File

@ -6,12 +6,11 @@ import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class EmployeeDetailPage extends StatefulWidget {
final String employeeId;
@ -27,11 +26,10 @@ class EmployeeDetailPage extends StatefulWidget {
State<EmployeeDetailPage> createState() => _EmployeeDetailPageState();
}
class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
final EmployeesScreenController controller =
Get.put(EmployeesScreenController());
final PermissionController _permissionController =
Get.find<PermissionController>();
@override
void initState() {
@ -91,7 +89,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
value,
style: TextStyle(
fontWeight: FontWeight.normal,
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
color: (isEmail || isPhone) ? contentTheme.primary : Colors.black54,
fontSize: 14,
decoration: (isEmail || isPhone)
? TextDecoration.underline
@ -251,8 +249,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
),
),
IconButton(
icon: const Icon(Icons.edit,
size: 24, color: Colors.red),
icon:
Icon(Icons.edit, size: 24, color: contentTheme.primary),
onPressed: () async {
final result =
await showModalBottomSheet<Map<String, dynamic>>(
@ -265,6 +263,9 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
'first_name': employee.firstName,
'last_name': employee.lastName,
'phone_number': employee.phoneNumber,
'email': employee.email,
'hasApplicationAccess':
employee.hasApplicationAccess,
'gender': employee.gender.toLowerCase(),
'job_role_id': employee.jobRoleId,
'joining_date':
@ -288,35 +289,6 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
),
);
}),
floatingActionButton: Obx(() {
if (!_permissionController.hasPermission(Permissions.assignToProject)) {
return const SizedBox.shrink();
}
if (controller.isLoadingEmployeeDetails.value ||
controller.selectedEmployeeDetails.value == null) {
return const SizedBox.shrink();
}
final employee = controller.selectedEmployeeDetails.value!;
return FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AssignProjectBottomSheet(
employeeId: widget.employeeId,
jobRoleId: employee.jobRoleId,
),
);
},
backgroundColor: Colors.red,
icon: const Icon(Icons.assignment),
label: const Text(
'Assign to Project',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
);
}),
);
}
}

View File

@ -11,13 +11,11 @@ import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/widgets/tenant/organization_selector.dart';
class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key});
@ -33,8 +31,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Get.find<PermissionController>();
final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
final OrganizationController _organizationController =
Get.put(OrganizationController());
@override
void initState() {
@ -47,44 +43,15 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
}
Future<void> _initEmployees() async {
final projectId = Get.find<ProjectController>().selectedProject?.id;
final orgId = _organizationController.selectedOrganization.value?.id;
if (projectId != null) {
await _organizationController.fetchOrganizations(projectId);
}
if (_employeeController.isAllEmployeeSelected.value) {
_employeeController.selectedProjectId = null;
await _employeeController.fetchAllEmployees(organizationId: orgId);
} else if (projectId != null) {
_employeeController.selectedProjectId = projectId;
await _employeeController.fetchEmployeesByProject(projectId,
organizationId: orgId);
} else {
_employeeController.clearEmployees();
}
await _employeeController.fetchAllEmployees();
_filterEmployees(_searchController.text);
}
Future<void> _refreshEmployees() async {
try {
final projectId = Get.find<ProjectController>().selectedProject?.id;
final orgId = _organizationController.selectedOrganization.value?.id;
final allSelected = _employeeController.isAllEmployeeSelected.value;
_employeeController.selectedProjectId = allSelected ? null : projectId;
if (allSelected) {
await _employeeController.fetchAllEmployees(organizationId: orgId);
} else if (projectId != null) {
await _employeeController.fetchEmployeesByProject(projectId,
organizationId: orgId);
} else {
_employeeController.clearEmployees();
}
_employeeController.selectedProjectId = null;
await _employeeController.fetchAllEmployees();
_filterEmployees(_searchController.text);
_employeeController.update(['employee_screen_controller']);
} catch (e, stackTrace) {
@ -132,22 +99,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
if (result == null || result['success'] != true) return;
final employeeData = result['data'];
final employeeId = employeeData['id'] as String;
final jobRoleId = employeeData['jobRoleId'] as String?;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
backgroundColor: Colors.transparent,
builder: (context) => AssignProjectBottomSheet(
employeeId: employeeId,
jobRoleId: jobRoleId ?? '',
),
);
await _refreshEmployees();
}
@ -258,14 +210,14 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red,
color: contentTheme.primary,
borderRadius: BorderRadius.circular(28),
boxShadow: const [
BoxShadow(
color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))
],
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white),
@ -287,43 +239,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Row(
children: [
Expanded(child: _buildSearchField()),
const SizedBox(width: 8),
_buildPopupMenu(),
],
),
// Organization Selector Row
Row(
children: [
Expanded(
child: OrganizationSelector(
controller: _organizationController,
height: 36,
onSelectionChanged: (org) async {
// Make sure the selectedOrganization is updated immediately
_organizationController.selectOrganization(org);
final projectId =
Get.find<ProjectController>().selectedProject?.id;
if (_employeeController.isAllEmployeeSelected.value) {
await _employeeController.fetchAllEmployees(
organizationId: _organizationController
.selectedOrganization.value?.id);
} else if (projectId != null) {
await _employeeController.fetchEmployeesByProject(
projectId,
organizationId: _organizationController
.selectedOrganization.value?.id);
}
_employeeController.update(['employee_screen_controller']);
},
),
),
],
),
MySpacing.height(8),
],
),
);
@ -346,6 +263,11 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
hintStyle: const TextStyle(fontSize: 13, color: Colors.grey),
filled: true,
fillColor: Colors.white,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300, width: 1),
@ -370,63 +292,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
);
}
Widget _buildPopupMenu() {
if (!_permissionController.hasPermission(Permissions.viewAllEmployees)) {
return const SizedBox.shrink();
}
return PopupMenuButton<String>(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black),
Obx(() => _employeeController.isAllEmployeeSelected.value
? Positioned(
right: -1,
top: -1,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle),
),
)
: const SizedBox.shrink()),
],
),
onSelected: (value) async {
if (value == 'all_employees') {
_employeeController.isAllEmployeeSelected.toggle();
await _initEmployees();
_employeeController.update(['employee_screen_controller']);
}
},
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'all_employees',
child: Obx(
() => Row(
children: [
Checkbox(
value: _employeeController.isAllEmployeeSelected.value,
onChanged: (_) => Navigator.pop(context, 'all_employees'),
checkColor: Colors.white,
activeColor: Colors.blueAccent,
side: const BorderSide(color: Colors.black, width: 1.5),
fillColor: MaterialStateProperty.resolveWith<Color>(
(states) => states.contains(MaterialState.selected)
? Colors.blueAccent
: Colors.white),
),
const Text('All Employees'),
],
),
),
),
],
);
}
Widget _buildEmployeeList() {
return Obx(() {
if (_employeeController.isLoading.value) {

View File

@ -38,7 +38,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
Get.back();
},
submitText: 'Submit',
submitColor: Colors.indigo,
submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView(
controller: scrollController,

View File

@ -11,7 +11,7 @@ import 'package:marco/view/expense/expense_filter_bottom_sheet.dart';
import 'package:marco/helpers/widgets/expense/expense_main_components.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key});
@ -21,7 +21,7 @@ class ExpenseMainScreen extends StatefulWidget {
}
class _ExpenseMainScreenState extends State<ExpenseMainScreen>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin,UIMixin {
late TabController _tabController;
final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController());
@ -82,7 +82,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController),
@ -111,7 +111,8 @@ Widget build(BuildContext context) {
children: [
// ---------------- Search ----------------
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
@ -136,18 +137,20 @@ Widget build(BuildContext context) {
),
],
),
floatingActionButton:
permissionController.hasPermission(Permissions.expenseUpload)
? FloatingActionButton(
backgroundColor: Colors.red,
? FloatingActionButton.extended(
backgroundColor: contentTheme.primary,
onPressed: showAddExpenseBottomSheet,
child: const Icon(Icons.add, color: Colors.white),
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
"Create New Expense",
style: TextStyle(color: Colors.white),
),
)
: null,
);
}
}
Widget _buildExpenseList({required bool isHistory}) {
return Obx(() {
@ -197,4 +200,3 @@ Widget build(BuildContext context) {
});
}
}

View File

@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class FAQScreen extends StatefulWidget {
const FAQScreen({super.key});
@override
State<FAQScreen> createState() => _FAQScreenState();
}
class _FAQScreenState extends State<FAQScreen> with UIMixin {
final List<Map<String, String>> faqs = [
{
"question": "How do I perform Check-in and Check-out?",
"answer":
"To Check-in, go to the Dashboard and tap the 'Check-in' button. For Check-out, return to the Dashboard and tap 'Check-out'. Ensure GPS and internet are enabled."
},
{
"question": "How do I login to the app?",
"answer":
"Enter your registered email and password on the login screen. If you forget your password, use the 'Forgot Password' option to reset it."
},
{
"question": "What is MPIN and how do I use it?",
"answer":
"MPIN is a 4-digit security PIN used for quick login and authorization. Set it under 'Settings > Security'. Use it instead of typing your password every time."
},
{
"question": "How do I log expenses?",
"answer":
"Go to the 'Expenses' section, click 'Add Expense', fill in the details like amount, category, and description, and then save. You can view all past expenses in the same section."
},
{
"question": "Can I edit or delete an expense?",
"answer":
"Yes, tap on an expense from the list and choose 'Edit' or 'Delete'. Changes are synced automatically with your account."
},
{
"question": "What if I face login issues?",
"answer":
"Ensure your internet is working and the app is updated. If problems persist, contact support via email or phone."
},
];
late List<bool> _expanded;
final TextEditingController searchController = TextEditingController();
String _searchQuery = "";
@override
void initState() {
super.initState();
_expanded = List.generate(faqs.length, (_) => false);
}
Widget _buildAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Navigator.pop(context),
),
MySpacing.width(8),
Expanded(
child: MyText.titleLarge('FAQ',
fontWeight: 700, color: Colors.black),
),
],
),
),
);
}
Widget _buildFAQCard(int index, Map<String, String> faq) {
final isExpanded = _expanded[index];
return GestureDetector(
onTap: () {
setState(() {
_expanded[index] = !isExpanded;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, 6),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.badge_help,
color: Colors.blue, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
faq["question"] ?? "",
fontWeight: 600,
color: Colors.black87,
fontSize: 14,
),
const SizedBox(height: 8),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: MyText.bodySmall(
faq["answer"] ?? "",
color: Colors.black54,
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
),
],
),
),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
color: Colors.grey[600],
),
],
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.help_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'No matching FAQs found.',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'Try adjusting your search or clear the search bar.',
color: Colors.grey,
),
],
),
);
}
@override
Widget build(BuildContext context) {
final filteredFaqs = faqs
.asMap()
.entries
.where((entry) =>
entry.value["question"]!
.toLowerCase()
.contains(_searchQuery.toLowerCase()) ||
entry.value["answer"]!
.toLowerCase()
.contains(_searchQuery.toLowerCase()))
.toList();
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
),
body: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(12.0),
child: SizedBox(
height: 40,
child: TextField(
controller: searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
setState(() {
_searchQuery = "";
});
},
);
},
),
hintText: 'Search FAQs...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
Expanded(
child: filteredFaqs.isEmpty
? _buildEmptyState()
: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Introductory text
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: MyText.bodyMedium(
'Here are some frequently asked questions to help you get started:',
fontWeight: 500,
color: Colors.black87,
),
),
...filteredFaqs
.map((entry) =>
_buildFAQCard(entry.key, entry.value))
.toList(),
const SizedBox(height: 24),
],
),
),
),
],
),
);
}
}

View File

@ -9,10 +9,9 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.dart';
import 'package:marco/controller/tenant/tenant_selection_controller.dart';
import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/helpers/services/tenant_service.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/view/support/support_screen.dart';
import 'package:marco/view/faq/faq_screen.dart';
class UserProfileBar extends StatefulWidget {
final bool isCondensed;
@ -27,21 +26,13 @@ class _UserProfileBarState extends State<UserProfileBar>
late EmployeeInfo employeeInfo;
bool _isLoading = true;
bool hasMpin = true;
late final TenantSelectionController _tenantController;
@override
void initState() {
super.initState();
_tenantController = Get.put(TenantSelectionController());
_initData();
}
@override
void dispose() {
Get.delete<TenantSelectionController>();
super.dispose();
}
Future<void> _initData() async {
employeeInfo = LocalStorage.getEmployeeInfo()!;
hasMpin = await LocalStorage.getIsMpin();
@ -54,7 +45,7 @@ class _UserProfileBarState extends State<UserProfileBar>
return Padding(
padding: const EdgeInsets.only(left: 14),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(5),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: AnimatedContainer(
@ -70,7 +61,7 @@ class _UserProfileBarState extends State<UserProfileBar>
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
@ -91,10 +82,6 @@ class _UserProfileBarState extends State<UserProfileBar>
_isLoading
? const _LoadingSection()
: _userProfileSection(isCondensed),
// --- SWITCH TENANT ROW BELOW AVATAR ---
if (!_isLoading && !isCondensed) _switchTenantRow(),
MySpacing.height(12),
Divider(
indent: 18,
@ -121,119 +108,6 @@ class _UserProfileBarState extends State<UserProfileBar>
);
}
/// Row widget to switch tenant with popup menu (button only)
Widget _switchTenantRow() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Obx(() {
if (_tenantController.isLoading.value) return _loadingTenantContainer();
final tenants = _tenantController.tenants;
if (tenants.isEmpty) return _noTenantContainer();
final selectedTenant = TenantService.currentTenant;
// Sort tenants: selected tenant first
final sortedTenants = List.of(tenants);
if (selectedTenant != null) {
sortedTenants.sort((a, b) {
if (a.id == selectedTenant.id) return -1;
if (b.id == selectedTenant.id) return 1;
return 0;
});
}
return PopupMenuButton<String>(
onSelected: (tenantId) =>
_tenantController.onTenantSelected(tenantId),
itemBuilder: (_) => sortedTenants.map((tenant) {
return PopupMenuItem(
value: tenant.id,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: tenant.logoImage),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
tenant.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: tenant.id == selectedTenant?.id
? FontWeight.bold
: FontWeight.w600,
color: tenant.id == selectedTenant?.id
? Colors.blueAccent
: Colors.black87,
),
),
),
if (tenant.id == selectedTenant?.id)
const Icon(Icons.check_circle,
color: Colors.blueAccent, size: 18),
],
),
);
}).toList(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.swap_horiz, color: Colors.blue.shade600),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
"Switch Organization",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.blue, fontWeight: FontWeight.bold),
),
),
),
Icon(Icons.arrow_drop_down, color: Colors.blue.shade600),
],
),
),
);
}),
);
}
Widget _loadingTenantContainer() => Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200, width: 1),
),
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
Widget _noTenantContainer() => Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200, width: 1),
),
child: MyText.bodyMedium(
"No tenants available",
color: Colors.blueAccent,
fontWeight: 600,
),
);
Widget _userProfileSection(bool condensed) {
final padding = MySpacing.fromLTRB(
condensed ? 16 : 26,
@ -306,20 +180,22 @@ class _UserProfileBarState extends State<UserProfileBar>
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.settings,
label: 'Settings',
icon: LucideIcons.badge_help,
label: 'Support',
onTap: _onSupportTap,
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.badge_help,
label: 'Support',
icon: LucideIcons.info,
label: 'FAQ', // <-- New FAQ menu item
onTap: _onFaqTap, // <-- Handle tap
),
SizedBox(height: spacingHeight),
_menuItemRow(
icon: LucideIcons.lock,
label: hasMpin ? 'Change MPIN' : 'Set MPIN',
iconColor: Colors.redAccent,
textColor: Colors.redAccent,
iconColor: contentTheme.primary,
textColor: contentTheme.primary,
onTap: _onMpinTap,
),
],
@ -327,6 +203,14 @@ class _UserProfileBarState extends State<UserProfileBar>
);
}
void _onFaqTap() {
Get.to(() => const FAQScreen());
}
void _onSupportTap() {
Get.to(() => const SupportScreen());
}
Widget _menuItemRow({
required IconData icon,
required String label,
@ -336,12 +220,12 @@ class _UserProfileBarState extends State<UserProfileBar>
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.grey.withOpacity(0.2), width: 1),
),
child: Row(
@ -397,11 +281,11 @@ class _UserProfileBarState extends State<UserProfileBar>
foregroundColor: Colors.white,
shadowColor: Colors.red.shade200,
padding: EdgeInsets.symmetric(
vertical: condensed ? 14 : 18,
horizontal: condensed ? 14 : 22,
vertical: condensed ? 9 : 12,
horizontal: condensed ? 6 : 16,
),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
),
),
),
@ -418,7 +302,7 @@ class _UserProfileBarState extends State<UserProfileBar>
Widget _buildLogoutDialog(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
elevation: 10,
backgroundColor: Colors.white,
child: Padding(
@ -462,7 +346,7 @@ class _UserProfileBarState extends State<UserProfileBar>
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
borderRadius: BorderRadius.circular(5)),
),
child: const Text("Logout"),
),

View File

@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/project/create_project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class CreateProjectBottomSheet extends StatefulWidget {
const CreateProjectBottomSheet({Key? key}) : super(key: key);
@override
State<CreateProjectBottomSheet> createState() =>
_CreateProjectBottomSheetState();
}
class _CreateProjectBottomSheetState extends State<CreateProjectBottomSheet> {
final _formKey = GlobalKey<FormState>();
final CreateProjectController controller = Get.put(CreateProjectController());
DateTime? _startDate;
DateTime? _endDate;
Future<void> _pickDate({required bool isStart}) async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
if (isStart) {
_startDate = picked;
controller.startDateCtrl.text =
DateFormat('yyyy-MM-dd').format(picked);
} else {
_endDate = picked;
controller.endDateCtrl.text = DateFormat('yyyy-MM-dd').format(picked);
}
});
}
}
Future<void> _handleSubmit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (_startDate == null || _endDate == null) {
showAppSnackbar(
title: "Error",
message: "Please select both start and end dates",
type: SnackbarType.error,
);
return;
}
if (controller.selectedStatus == null) {
showAppSnackbar(
title: "Error",
message: "Please select project status",
type: SnackbarType.error,
);
return;
}
// Call API
final success = await controller.createProject(
name: controller.nameCtrl.text.trim(),
shortName: controller.shortNameCtrl.text.trim(),
projectAddress: controller.addressCtrl.text.trim(),
contactPerson: controller.contactCtrl.text.trim(),
startDate: _startDate!,
endDate: _endDate!,
projectStatusId: controller.selectedStatus!.id,
);
if (success) Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Create Project",
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(16),
/// Project Name
LabeledInput(
label: "Project Name",
hint: "Enter project name",
controller: controller.nameCtrl,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Short Name
LabeledInput(
label: "Short Name",
hint: "Enter short name",
controller: controller.shortNameCtrl,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Project Address
LabeledInput(
label: "Project Address",
hint: "Enter project address",
controller: controller.addressCtrl,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Contact Person
LabeledInput(
label: "Contact Person",
hint: "Enter contact person",
controller: controller.contactCtrl,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Start Date
GestureDetector(
onTap: () => _pickDate(isStart: true),
child: AbsorbPointer(
child: LabeledInput(
label: "Start Date",
hint: "Select start date",
controller: controller.startDateCtrl,
validator: (value) =>
_startDate == null ? "Required" : null,
isRequired: true,
),
),
),
MySpacing.height(16),
/// End Date
GestureDetector(
onTap: () => _pickDate(isStart: false),
child: AbsorbPointer(
child: LabeledInput(
label: "End Date",
hint: "Select end date",
controller: controller.endDateCtrl,
validator: (value) => _endDate == null ? "Required" : null,
isRequired: true,
),
),
),
MySpacing.height(16),
/// Project Status using PopupMenuButton
Obx(() {
if (controller.statusList.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return LabeledDropdownPopup(
label: "Project Status",
hint: "Select status",
value: controller.selectedStatus?.name,
items: controller.statusList.map((e) => e.name).toList(),
onChanged: (selected) {
final status = controller.statusList
.firstWhere((s) => s.name == selected);
setState(() => controller.selectedStatus = status);
},
isRequired: true,
);
}),
MySpacing.height(16),
],
),
),
),
);
}
}
/// ----------------- LabeledInput -----------------
class LabeledInput extends StatelessWidget {
final String label;
final String hint;
final TextEditingController controller;
final String? Function(String?) validator;
final bool isRequired;
const LabeledInput({
Key? key,
required this.label,
required this.hint,
required this.controller,
required this.validator,
this.isRequired = false,
}) : super(key: key);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium(label),
if (isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
TextFormField(
controller: controller,
validator: validator,
decoration: InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: EdgeInsets.all(16),
),
),
],
);
}
/// ----------------- LabeledDropdownPopup -----------------
class LabeledDropdownPopup extends StatelessWidget {
final String label;
final String hint;
final String? value;
final List<String> items;
final ValueChanged<String> onChanged;
final bool isRequired;
LabeledDropdownPopup({
Key? key,
required this.label,
required this.hint,
required this.value,
required this.items,
required this.onChanged,
this.isRequired = false,
}) : super(key: key);
final GlobalKey _fieldKey = GlobalKey();
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium(label),
if (isRequired)
const Text(
" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
MySpacing.height(8),
GestureDetector(
key: _fieldKey,
onTap: () async {
// Get the position of the widget
final RenderBox box =
_fieldKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = box.localToGlobal(Offset.zero);
final RelativeRect position = RelativeRect.fromLTRB(
offset.dx,
offset.dy + box.size.height,
offset.dx + box.size.width,
offset.dy,
);
final selected = await showMenu<String>(
context: context,
position: position,
items: items
.map((item) => PopupMenuItem<String>(
value: item,
child: Text(item),
))
.toList(),
);
if (selected != null) onChanged(selected);
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: value ?? ""),
validator: (val) => isRequired && (val == null || val.isEmpty)
? "Required"
: null,
decoration: InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide:
BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: const EdgeInsets.all(16),
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
}

View File

@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:url_launcher/url_launcher.dart';
class SupportScreen extends StatefulWidget {
const SupportScreen({super.key});
@override
State<SupportScreen> createState() => _SupportScreenState();
}
class _SupportScreenState extends State<SupportScreen> with UIMixin {
final List<Map<String, dynamic>> contacts = [
{
"type": "email",
"label": "info@marcoaiot.com",
"subLabel": "Email us your queries",
"icon": LucideIcons.mail,
"action": "mailto:info@marcoaiot.com?subject=Support Request"
},
{
"type": "phone",
"label": "+91-8055099750",
"subLabel": "Call our support team",
"icon": LucideIcons.phone,
"action": "tel:+91-8055099750"
},
];
void _launchAction(String action) async {
final Uri uri = Uri.parse(action);
if (await canLaunchUrl(uri)) {
// Use LaunchMode.externalApplication for mailto/tel
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
} else {
// Fallback if no app found
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No app found to open this link.')),
);
}
}
Widget _buildAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Navigator.pop(context),
),
MySpacing.width(8),
Expanded(
child: MyText.titleLarge('Support',
fontWeight: 700, color: Colors.black),
),
],
),
),
);
}
Widget _buildContactCard(Map<String, dynamic> contact) {
return GestureDetector(
onTap: () => _launchAction(contact["action"]),
child: Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(contact["icon"], color: Colors.red, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
contact["label"],
fontWeight: 700,
color: Colors.black87,
fontSize: 16,
),
const SizedBox(height: 4),
MyText.bodySmall(
contact["subLabel"],
color: Colors.black54,
),
],
),
),
],
),
),
);
}
Widget _buildInfoCard(String title, String subtitle, IconData icon) {
return Container(
padding: const EdgeInsets.all(20),
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.red, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(title,
fontWeight: 700, color: Colors.black87, fontSize: 16),
const SizedBox(height: 4),
MyText.bodySmall(subtitle, color: Colors.black54),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
),
body: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
// Optional: Implement refresh logic
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: MyText.titleLarge(
"Need Help?",
fontWeight: 700,
color: Colors.red,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: MyText.bodySmall(
"Our support team is ready to assist you. Reach out via email or phone.",
color: Colors.grey[800],
),
),
const SizedBox(height: 24),
// Contact cards
...contacts.map((contact) => _buildContactCard(contact)),
const SizedBox(height: 16),
// Info card
_buildInfoCard(
"Working Hours",
"Monday - Friday: 9 AM - 6 PM\nSaturday: 10 AM - 2 PM",
LucideIcons.clock,
),
const SizedBox(height: 24),
],
),
),
),
),
);
}
}

View File

@ -1,572 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/utils/my_shadow.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_container.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/task_planning/daily_task_controller.dart';
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dailyTaskPlanning/task_action_buttons.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/controller/tenant/service_controller.dart';
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
class DailyProgressReportScreen extends StatefulWidget {
const DailyProgressReportScreen({super.key});
@override
State<DailyProgressReportScreen> createState() =>
_DailyProgressReportScreenState();
}
class TaskChartData {
final String label;
final num value;
final Color color;
TaskChartData(this.label, this.value, this.color);
}
class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
with UIMixin {
final DailyTaskController dailyTaskController =
Get.put(DailyTaskController());
final PermissionController permissionController =
Get.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(),
);
})
],
);
},
),
);
});
}
}

View File

@ -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(),
);
});
});
}
}

View File

@ -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;
}