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 // Default configuration for your application
defaultConfig { defaultConfig {
// Specify your unique Application ID. This identifies your app on Google Play. // 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 // Set minimum and target SDK versions based on Flutter's configuration
minSdk = 23 minSdk = 23
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart'; import 'package:marco/controller/my_controller.dart';
@ -79,6 +80,7 @@ class LoginController extends MyController {
enableRemoteLogging(); enableRemoteLogging();
logSafe("✅ Remote logging enabled after login."); logSafe("✅ Remote logging enabled after login.");
final fcmToken = await LocalStorage.getFcmToken(); final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) { if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!); final success = await AuthService.registerDeviceToken(fcmToken!);
@ -89,9 +91,9 @@ class LoginController extends MyController {
level: LogLevel.warning); 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) { } catch (e, stacktrace) {
logSafe("Exception during login", logSafe("Exception during login",

View File

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

View File

@ -73,7 +73,8 @@ class AddEmployeeController extends MyController {
controller: TextEditingController(), 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 // Prefill fields in edit mode
@ -87,7 +88,8 @@ class AddEmployeeController extends MyController {
editingEmployeeData?['phone_number'] ?? ''; editingEmployeeData?['phone_number'] ?? '';
selectedGender = editingEmployeeData?['gender'] != null selectedGender = editingEmployeeData?['gender'] != null
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender']) ? Gender.values
.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
: null; : null;
basicValidator.getController('email')?.text = basicValidator.getController('email')?.text =
@ -121,12 +123,24 @@ class AddEmployeeController extends MyController {
if (result != null) { if (result != null) {
roles = List<Map<String, dynamic>>.from(result); roles = List<Map<String, dynamic>>.from(result);
logSafe('Roles fetched successfully.'); 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(); update();
} else { } else {
logSafe('Failed to fetch roles: null result', level: LogLevel.error); logSafe('Failed to fetch roles: null result', level: LogLevel.error);
} }
} catch (e, st) { } 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 firstName = basicValidator.getController('first_name')?.text.trim();
final lastName = basicValidator.getController('last_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 { try {
// sanitize orgId before sending // sanitize orgId before sending
@ -216,7 +231,8 @@ class AddEmployeeController extends MyController {
showAppSnackbar( showAppSnackbar(
title: 'Permission Required', 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, type: SnackbarType.warning,
); );
return false; 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 { 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://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.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 getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects"; static const String getDashboardProjects = "/dashboard/projects";
///// Projects Module API Endpoints
static const String createProject = "/project";
// Attendance Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; 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_details_model.dart';
import 'package:marco/model/document/document_version_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/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/tenant/tenant_services_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; 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 /// Get Organizations assigned to a Project
static Future<OrganizationListResponse?> getAssignedOrganizations( static Future<OrganizationListResponse?> getAssignedOrganizations(
String projectId) async { String projectId) async {
@ -319,36 +372,6 @@ class ApiService {
return null; 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 { static Future<bool> postLogsApi(List<Map<String, dynamic>> logs) async {
const endpoint = "${ApiEndpoints.uploadLogs}"; const endpoint = "${ApiEndpoints.uploadLogs}";
logSafe("Posting logs... count=${logs.length}"); logSafe("Posting logs... count=${logs.length}");
@ -1761,19 +1784,18 @@ class ApiService {
return false; return false;
} }
static Future<List<dynamic>?> getDirectoryComments( static Future<List<dynamic>?> getDirectoryComments(
String contactId, { String contactId, {
bool active = true, bool active = true,
}) async { }) async {
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active";
final response = await _getRequest(url); final response = await _getRequest(url);
final data = response != null final data = response != null
? _parseResponse(response, label: 'Directory Comments') ? _parseResponse(response, label: 'Directory Comments')
: null; : null;
return data is List ? data : null;
}
return data is List ? data : null;
}
static Future<bool> updateContact( static Future<bool> updateContact(
String contactId, Map<String, dynamic> payload) async { String contactId, Map<String, dynamic> payload) async {

View File

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

View File

@ -1,9 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/controller/attendance/attendance_screen_controller.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_screen_controller.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/directory/directory_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 // Call method to handle team modifications and dashboard update
_handleDashboardUpdate(data); _handleDashboardUpdate(data);
break; 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 /// 🔹 Expenses
case 'Expenses_Modified': case 'Expenses_Modified':
_handleExpenseUpdated(data); _handleExpenseUpdated(data);
@ -128,23 +105,7 @@ class NotificationActionHandler {
/// ---------------------- HANDLERS ---------------------- /// ---------------------- 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) { static bool _isAttendanceAction(String? action) {
const validActions = { 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 ---------------------- /// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) { static void _handleDocumentModified(Map<String, dynamic> data) {
String entityTypeId; 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, pink,
green, green,
red, red,
brandRed; brandRed,
brandGreen;
Color get color { Color get color {
return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black; return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]
?['color']) ??
Colors.black;
} }
Color get onColor { 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 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 { class RightBarTheme {
@ -121,6 +128,7 @@ class ContentTheme {
final Color pink, onPink; final Color pink, onPink;
final Color red, onRed; final Color red, onRed;
final Color brandRed, onBrandRed; final Color brandRed, onBrandRed;
final Color brandGreen, onBrandGreen;
final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted; final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted;
final Color title; final Color title;
@ -130,7 +138,10 @@ class ContentTheme {
var c = AdminTheme.theme.contentTheme; var c = AdminTheme.theme.contentTheme;
return { return {
ContentThemeColor.primary: {'color': c.primary, 'onColor': c.onPrimary}, 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.success: {'color': c.success, 'onColor': c.onSuccess},
ContentThemeColor.info: {'color': c.info, 'onColor': c.onInfo}, ContentThemeColor.info: {'color': c.info, 'onColor': c.onInfo},
ContentThemeColor.warning: {'color': c.warning, 'onColor': c.onWarning}, ContentThemeColor.warning: {'color': c.warning, 'onColor': c.onWarning},
@ -139,14 +150,21 @@ class ContentTheme {
ContentThemeColor.dark: {'color': c.dark, 'onColor': c.onDark}, ContentThemeColor.dark: {'color': c.dark, 'onColor': c.onDark},
ContentThemeColor.pink: {'color': c.pink, 'onColor': c.onPink}, ContentThemeColor.pink: {'color': c.pink, 'onColor': c.onPink},
ContentThemeColor.red: {'color': c.red, 'onColor': c.onRed}, 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({ ContentTheme({
this.background = const Color(0xfffafbfe), this.background = const Color(0xfffafbfe),
this.onBackground = const Color(0xffF1F1F2), this.onBackground = const Color(0xffF1F1F2),
this.primary = const Color(0xff663399), this.primary = const Color(0xFF49BF3C),
this.onPrimary = const Color(0xffffffff), this.onPrimary = const Color(0xffffffff),
this.secondary = const Color(0xff6c757d), this.secondary = const Color(0xff6c757d),
this.onSecondary = const Color(0xffffffff), this.onSecondary = const Color(0xffffffff),
@ -178,12 +196,12 @@ class ContentTheme {
this.title = const Color(0xff6c757d), this.title = const Color(0xff6c757d),
this.disabled = const Color(0xffffffff), this.disabled = const Color(0xffffffff),
this.onDisabled = 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( static final ContentTheme lightContentTheme = ContentTheme(
primary: Color(0xff663399), primary: Color(0xFF49BF3C),
background: const Color(0xfffafbfe), background: const Color(0xfffafbfe),
onBackground: const Color(0xff313a46), onBackground: const Color(0xff313a46),
cardBorder: const Color(0xffe8ecf1), cardBorder: const Color(0xffe8ecf1),
@ -197,7 +215,7 @@ class ContentTheme {
); );
static final ContentTheme darkContentTheme = ContentTheme( static final ContentTheme darkContentTheme = ContentTheme(
primary: Color(0xff32BFAE), primary: Color(0xFF49BF3C),
background: const Color(0xff343a40), background: const Color(0xff343a40),
onBackground: const Color(0xffF1F1F2), onBackground: const Color(0xffF1F1F2),
disabled: const Color(0xff444d57), disabled: const Color(0xff444d57),
@ -236,9 +254,17 @@ class AdminTheme {
static void setTheme() { static void setTheme() {
theme = AdminTheme( theme = AdminTheme(
leftBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? LeftBarTheme.darkLeftBarTheme : LeftBarTheme.lightLeftBarTheme, leftBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark
topBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? TopBarTheme.darkTopBarTheme : TopBarTheme.lightTopBarTheme, ? LeftBarTheme.darkLeftBarTheme
rightBarTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? RightBarTheme.darkRightBarTheme : RightBarTheme.lightRightBarTheme, : LeftBarTheme.lightLeftBarTheme,
contentTheme: ThemeCustomizer.instance.theme == ThemeMode.dark ? ContentTheme.darkContentTheme : ContentTheme.lightContentTheme); 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:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class BaseBottomSheet extends StatelessWidget { class BaseBottomSheet extends StatefulWidget {
final String title; final String title;
final String? subtitle;
final Widget child; final Widget child;
final VoidCallback onCancel; final VoidCallback onCancel;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final bool isSubmitting; final bool isSubmitting;
final String submitText; final String submitText;
final Color submitColor; final Color? submitColor;
final IconData submitIcon; final IconData submitIcon;
final bool showButtons; final bool showButtons;
final Widget? bottomContent; final Widget? bottomContent;
@ -20,18 +22,26 @@ class BaseBottomSheet extends StatelessWidget {
required this.child, required this.child,
required this.onCancel, required this.onCancel,
required this.onSubmit, required this.onSubmit,
this.subtitle,
this.isSubmitting = false, this.isSubmitting = false,
this.submitText = 'Submit', this.submitText = 'Submit',
this.submitColor = Colors.indigo, this.submitColor,
this.submitIcon = Icons.check_circle_outline, this.submitIcon = Icons.check_circle_outline,
this.showButtons = true, this.showButtons = true,
this.bottomContent, this.bottomContent,
}); });
@override
State<BaseBottomSheet> createState() => _BaseBottomSheetState();
}
class _BaseBottomSheetState extends State<BaseBottomSheet> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final effectiveSubmitColor =
widget.submitColor ?? contentTheme.primary;
return SingleChildScrollView( return SingleChildScrollView(
padding: mediaQuery.viewInsets, padding: mediaQuery.viewInsets,
@ -50,33 +60,50 @@ class BaseBottomSheet extends StatelessWidget {
], ],
), ),
child: SafeArea( child: SafeArea(
// 👈 prevents overlap with nav bar
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(5), MySpacing.height(5),
Container( Center(
width: 40, child: Container(
height: 5, width: 40,
decoration: BoxDecoration( height: 5,
color: Colors.grey.shade300, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
MySpacing.height(12), 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), MySpacing.height(12),
child, widget.child,
MySpacing.height(12), MySpacing.height(12),
if (showButtons) ...[ if (widget.showButtons) ...[
Row( Row(
children: [ children: [
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: onCancel, onPressed: widget.onCancel,
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium( label: MyText.bodyMedium(
"Cancel", "Cancel",
@ -88,34 +115,40 @@ class BaseBottomSheet extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding:
const EdgeInsets.symmetric(vertical: 8),
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit, onPressed:
icon: Icon(submitIcon, color: Colors.white), widget.isSubmitting ? null : widget.onSubmit,
icon:
Icon(widget.submitIcon, color: Colors.white),
label: MyText.bodyMedium( label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText, widget.isSubmitting
? "Submitting..."
: widget.submitText,
color: Colors.white, color: Colors.white,
fontWeight: 600, fontWeight: 600,
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: submitColor, backgroundColor: effectiveSubmitColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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), 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/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/view/expense/expense_detail_screen.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController; 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 TextEditingController controller;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final VoidCallback onFilterTap; final VoidCallback onFilterTap;
@ -86,6 +87,11 @@ class SearchAndFilter extends StatelessWidget {
super.key, super.key,
}); });
@override
State<SearchAndFilter> createState() => _SearchAndFilterState();
}
class _SearchAndFilterState extends State<SearchAndFilter> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@ -96,8 +102,8 @@ class SearchAndFilter extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: 35, height: 35,
child: TextField( child: TextField(
controller: controller, controller: widget.controller,
onChanged: onChanged, onChanged: widget.onChanged,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: prefixIcon:
@ -109,6 +115,11 @@ class SearchAndFilter extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
@ -124,7 +135,7 @@ class SearchAndFilter extends StatelessWidget {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
const Icon(Icons.tune, color: Colors.black), const Icon(Icons.tune, color: Colors.black),
if (expenseController.isFilterApplied) if (widget.expenseController.isFilterApplied)
Positioned( Positioned(
top: -1, top: -1,
right: -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/helpers/utils/attendance_actions.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class AttendanceActionButton extends StatefulWidget { class AttendanceActionButton extends StatefulWidget {
final dynamic employee; final dynamic employee;
@ -84,6 +85,8 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
final controller = widget.attendanceController; final controller = widget.attendanceController;
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final selectedProjectId = projectController.selectedProject?.id; final selectedProjectId = projectController.selectedProject?.id;
final projectName =
projectController.selectedProject?.name ?? 'Unknown Project';
if (selectedProjectId == null) { if (selectedProjectId == null) {
showAppSnackbar( showAppSnackbar(
@ -108,8 +111,10 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
break; break;
case 1: case 1:
final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); final isOldCheckIn =
final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2);
final isOldCheckOut =
AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2);
if (widget.employee.checkOut == null && isOldCheckIn) { if (widget.employee.checkOut == null && isOldCheckIn) {
action = 2; action = 2;
@ -158,7 +163,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
actionText, actionText,
selectedTime: selectedTime, selectedTime: selectedTime,
checkInDate: widget.employee.checkIn, checkInDate: widget.employee.checkIn,
projectName: projectName,
); );
if (comment == null || comment.isEmpty) { if (comment == null || comment.isEmpty) {
controller.uploadingStates[uniqueLogKey]?.value = false; controller.uploadingStates[uniqueLogKey]?.value = false;
return; return;
@ -167,7 +174,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
String? markTime; String? markTime;
if (actionText == ButtonActions.requestRegularize) { if (actionText == ButtonActions.requestRegularize) {
selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); 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) { } else if (selectedTime != null) {
markTime = DateFormat("hh:mm a").format(selectedTime); markTime = DateFormat("hh:mm a").format(selectedTime);
} }
@ -205,13 +214,17 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
final controller = widget.attendanceController; final controller = widget.attendanceController;
final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; final isUploading =
controller.uploadingStates[uniqueLogKey]?.value ?? false;
final emp = widget.employee; final emp = widget.employee;
final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); final isYesterday =
final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut);
final isTodayApproved =
AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn);
final isApprovedButNotToday = final isApprovedButNotToday =
AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); AttendanceButtonHelper.isApprovedButNotToday(
emp.activity, isTodayApproved);
final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled(
isUploading: isUploading, isUploading: isUploading,
@ -288,15 +301,17 @@ class AttendanceActionButtonUI extends StatelessWidget {
if (buttonText.toLowerCase() == 'rejected') if (buttonText.toLowerCase() == 'rejected')
const Icon(Icons.close, size: 16, color: Colors.red), const Icon(Icons.close, size: 16, color: Colors.red),
if (buttonText.toLowerCase() == 'requested') 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'] if (['approved', 'rejected', 'requested']
.contains(buttonText.toLowerCase())) .contains(buttonText.toLowerCase()))
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Flexible(
child: Text( child: MyText.bodySmall(
buttonText, buttonText,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12), fontWeight: 500,
color: Colors.white,
), ),
), ),
], ],
@ -311,15 +326,18 @@ Future<String?> _showCommentBottomSheet(
String actionText, { String actionText, {
DateTime? selectedTime, DateTime? selectedTime,
DateTime? checkInDate, DateTime? checkInDate,
String? projectName,
}) async { }) async {
final commentController = TextEditingController(); final commentController = TextEditingController();
String? errorText; String? errorText;
// Prepare title String sheetTitle =
String sheetTitle = "Add Comment for ${capitalizeFirstLetter(actionText)}"; "Adding Comment for ${capitalizeFirstLetter(actionText)}";
if (selectedTime != null && checkInDate != null) { if (selectedTime != null && checkInDate != null) {
sheetTitle = 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>( return showModalBottomSheet<String>(
@ -342,30 +360,42 @@ Future<String?> _showCommentBottomSheet(
} }
return Padding( return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: BaseBottomSheet( child: BaseBottomSheet(
title: sheetTitle, // 👈 now showing full sentence as title title: sheetTitle,
subtitle: projectName,
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
onSubmit: submit, onSubmit: submit,
isSubmitting: false, isSubmitting: false,
submitText: 'Submit', submitText: 'Submit',
child: TextField( child: Column(
controller: commentController, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 4, children: [
decoration: InputDecoration( MyText.bodyMedium(
hintText: 'Type your comment here...', 'Add a comment to proceed',
border: OutlineInputBorder( fontWeight: 500,
borderRadius: BorderRadius.circular(8),
), ),
filled: true, const SizedBox(height: 8),
fillColor: Colors.grey.shade100, TextField(
errorText: errorText, controller: commentController,
), maxLines: 4,
onChanged: (_) { decoration: InputDecoration(
if (errorText != null) { hintText: 'Type your comment here...',
setModalState(() => errorText = null); border: OutlineInputBorder(
} borderRadius: BorderRadius.circular(8),
}, ),
filled: true,
fillColor: Colors.grey.shade100,
errorText: errorText,
),
onChanged: (_) {
if (errorText != null) {
setModalState(() => errorText = null);
}
},
),
],
), ),
), ),
); );
@ -375,6 +405,5 @@ Future<String?> _showCommentBottomSheet(
); );
} }
String capitalizeFirstLetter(String text) => String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); 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/widgets/my_text.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
class AttendanceFilterBottomSheet extends StatefulWidget { class AttendanceFilterBottomSheet extends StatefulWidget {
@ -39,78 +38,13 @@ class _AttendanceFilterBottomSheetState
final endDate = widget.controller.endDateAttendance; final endDate = widget.controller.endDateAttendance;
if (startDate != null && endDate != null) { if (startDate != null && endDate != null) {
final start = final start = DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
DateTimeUtils.formatDate(startDate, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy'); final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy');
return "$start - $end"; return "$start - $end";
} }
return "Date Range"; 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() { List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance); .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 // 🔹 Date Range only for attendanceLogs
if (tempSelectedTab == 'attendanceLogs') { if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([ widgets.addAll([
@ -237,7 +142,6 @@ class _AttendanceFilterBottomSheetState
onCancel: () => Navigator.pop(context), onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, { onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab, 'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}), }),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, 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

@ -72,40 +72,41 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
controller.enteredTags.assignAll(c.tags.map((e) => e.name)); controller.enteredTags.assignAll(c.tags.map((e) => e.name));
ever(controller.isInitialized, (bool ready) { ever(controller.isInitialized, (bool ready) {
if (ready) { if (ready) {
final projectIds = c.projectIds; // Buckets - map all
final bucketId = c.bucketIds.firstOrNull; if (c.bucketIds.isNotEmpty) {
final category = c.contactCategory?.name; final names = c.bucketIds.map((id) {
return controller.bucketsMap.entries
if (category != null) controller.selectedCategory.value = category; .firstWhereOrNull((e) => e.value == id)
if (projectIds != null) {
controller.selectedProjects.assignAll(
projectIds
.map((id) => controller.projectsMap.entries
.firstWhereOrNull((e) => e.value == id)
?.key)
.whereType<String>()
.toList(),
);
}
if (bucketId != null) {
final name = controller.bucketsMap.entries
.firstWhereOrNull((e) => e.value == bucketId)
?.key; ?.key;
if (name != null) controller.selectedBucket.value = name; }).whereType<String>().toList();
} controller.selectedBuckets.assignAll(names);
} }
}); // Projects and Category mapping - as before
} else { final projectIds = c.projectIds;
emailCtrls.add(TextEditingController()); if (projectIds != null) {
emailLabels.add('Office'.obs); controller.selectedProjects.assignAll(
phoneCtrls.add(TextEditingController()); projectIds
phoneLabels.add('Work'.obs); .map((id) => controller.projectsMap.entries
} .firstWhereOrNull((e) => e.value == id)
?.key)
.whereType<String>()
.toList(),
);
}
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 @override
void dispose() { 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() { void _handleSubmit() {
bool valid = formKey.currentState?.validate() ?? false; bool valid = formKey.currentState?.validate() ?? false;
if (controller.selectedBucket.value.isEmpty) { if (controller.selectedBuckets.isEmpty) {
bucketError.value = "Bucket is required"; bucketError.value = "Bucket is required";
valid = false; valid = false;
} else { } else {
@ -430,29 +546,14 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(16), MySpacing.height(16),
_textField("Organization", orgCtrl, required: true), _textField("Organization", orgCtrl, required: true),
MySpacing.height(16), MySpacing.height(16),
_labelWithStar("Bucket", required: true), _labelWithStar("Buckets", required: true),
MySpacing.height(8), MySpacing.height(8),
Stack( Stack(
children: [ children: [
_popupSelector(controller.selectedBucket, controller.buckets, _bucketMultiSelectField(),
"Choose Bucket"),
Positioned(
left: 0,
right: 0,
top: 56,
child: Obx(() => bucketError.value.isEmpty
? const SizedBox.shrink()
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(bucketError.value,
style: const TextStyle(
color: Colors.red, fontSize: 12)),
)),
),
], ],
), ),
MySpacing.height(24), MySpacing.height(12),
Obx(() => GestureDetector( Obx(() => GestureDetector(
onTap: () => showAdvanced.toggle(), onTap: () => showAdvanced.toggle(),
child: Row( child: Row(
@ -562,3 +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/add_employee_controller.dart';
import 'package:marco/controller/employee/employees_screen_controller.dart'; import 'package:marco/controller/employee/employees_screen_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
@ -24,8 +23,6 @@ class AddEmployeeBottomSheet extends StatefulWidget {
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet> class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin { with UIMixin {
late final AddEmployeeController _controller; late final AddEmployeeController _controller;
final OrganizationController _organizationController =
Get.put(OrganizationController());
// Local UI state // Local UI state
bool _hasApplicationAccess = false; bool _hasApplicationAccess = false;
@ -39,51 +36,56 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_orgFieldController = TextEditingController();
_joiningDateController = TextEditingController();
_genderController = TextEditingController();
_roleController = TextEditingController();
_controller = Get.put( _controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString());
AddEmployeeController(),
// Unique tag to avoid clashes, but stable for this widget instance
tag: UniqueKey().toString(),
);
_orgFieldController = TextEditingController(text: '');
_joiningDateController = TextEditingController(text: '');
_genderController = TextEditingController(text: '');
_roleController = TextEditingController(text: '');
// Prefill when editing
if (widget.employeeData != null) { if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData; _controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields(); _controller.prefillFields();
final orgId = widget.employeeData!['organizationId']; // Prepopulate hasApplicationAccess and email
if (orgId != null) { _hasApplicationAccess =
_controller.selectedOrganizationId = orgId; widget.employeeData?['hasApplicationAccess'] ?? false;
final selectedOrg = _organizationController.organizations final email = widget.employeeData?['email'];
.firstWhereOrNull((o) => o.id == orgId); if (email != null && email.toString().isNotEmpty) {
if (selectedOrg != null) { _controller.basicValidator.getController('email')?.text =
_organizationController.selectOrganization(selectedOrg); email.toString();
_orgFieldController.text = selectedOrg.name;
}
} }
// Trigger UI rebuild to reflect email & checkbox
setState(() {});
// Joining date
if (_controller.joiningDate != null) { if (_controller.joiningDate != null) {
_joiningDateController.text = _joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!); DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
} }
// Gender
if (_controller.selectedGender != null) { if (_controller.selectedGender != null) {
_genderController.text = _genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? ''; _controller.selectedGender!.name.capitalizeFirst ?? '';
} }
final roleName = _controller.roles.firstWhereOrNull( // Role
(r) => r['id'] == _controller.selectedRoleId)?['name'] ?? _controller.fetchRoles().then((_) {
''; if (_controller.selectedRoleId != null) {
_roleController.text = roleName; final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId,
)?['name'];
if (roleName != null) {
_roleController.text = roleName;
}
_controller.update();
}
});
} else { } else {
_orgFieldController.text = _organizationController.currentSelection; _controller.fetchRoles();
} }
} }
@ -102,7 +104,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
init: _controller, init: _controller,
builder: (_) { builder: (_) {
// Keep org field in sync with controller selection // Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet( return BaseBottomSheet(
title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee', title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
@ -135,30 +136,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.basicValidator.getValidation('last_name'), _controller.basicValidator.getValidation('last_name'),
), ),
MySpacing.height(16), 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'), _sectionLabel('Application Access'),
Row( Row(
children: [ children: [
@ -333,8 +310,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
return null; return null;
}, },
keyboardType: TextInputType.emailAddress, 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, context: context,
initialDate: _controller.joiningDate ?? DateTime.now(), initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000), firstDate: DateTime(2000),
lastDate: DateTime(2100), lastDate: DateTime.now(),
); );
if (picked != null) { if (picked != null) {
@ -479,13 +455,20 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
Future<void> _handleSubmit() async { Future<void> _handleSubmit() async {
final isValid = final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false; _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 || if (!isValid ||
_controller.joiningDate == null || _controller.joiningDate == null ||
_controller.selectedGender == null || _controller.selectedGender == null ||
_controller.selectedRoleId == null || _controller.selectedRoleId == null) {
_organizationController.currentSelection.isEmpty ||
_organizationController.currentSelection == 'All Organizations') {
showAppSnackbar( showAppSnackbar(
title: 'Missing Fields', title: 'Missing Fields',
message: 'Please complete all required 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 { void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>( final selected = await showMenu<Gender>(
context: context, context: context,

View File

@ -12,15 +12,17 @@ class EmployeeDetailsModel {
final String phoneNumber; final String phoneNumber;
final String? emergencyPhoneNumber; final String? emergencyPhoneNumber;
final String? emergencyContactPerson; final String? emergencyContactPerson;
final String? aadharNumber;
final bool isActive; final bool isActive;
final String? panNumber; final bool isRootUser;
final String? photo;
final String? applicationUserId;
final String jobRoleId;
final bool isSystem; final bool isSystem;
final String jobRole; 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({ EmployeeDetailsModel({
required this.id, required this.id,
required this.firstName, required this.firstName,
@ -35,14 +37,17 @@ class EmployeeDetailsModel {
required this.phoneNumber, required this.phoneNumber,
this.emergencyPhoneNumber, this.emergencyPhoneNumber,
this.emergencyContactPerson, this.emergencyContactPerson,
this.aadharNumber,
required this.isActive, required this.isActive,
this.panNumber, required this.isRootUser,
this.photo,
this.applicationUserId,
required this.jobRoleId,
required this.isSystem, required this.isSystem,
required this.jobRole, 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) { factory EmployeeDetailsModel.fromJson(Map<String, dynamic> json) {
@ -60,24 +65,20 @@ class EmployeeDetailsModel {
phoneNumber: json['phoneNumber'], phoneNumber: json['phoneNumber'],
emergencyPhoneNumber: json['emergencyPhoneNumber'], emergencyPhoneNumber: json['emergencyPhoneNumber'],
emergencyContactPerson: json['emergencyContactPerson'], emergencyContactPerson: json['emergencyContactPerson'],
aadharNumber: json['aadharNumber'],
isActive: json['isActive'], isActive: json['isActive'],
panNumber: json['panNumber'], isRootUser: json['isRootUser'],
photo: json['photo'],
applicationUserId: json['applicationUserId'],
jobRoleId: json['jobRoleId'],
isSystem: json['isSystem'], isSystem: json['isSystem'],
jobRole: json['jobRole'], 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() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
@ -93,14 +94,24 @@ class EmployeeDetailsModel {
'phoneNumber': phoneNumber, 'phoneNumber': phoneNumber,
'emergencyPhoneNumber': emergencyPhoneNumber, 'emergencyPhoneNumber': emergencyPhoneNumber,
'emergencyContactPerson': emergencyContactPerson, 'emergencyContactPerson': emergencyContactPerson,
'aadharNumber': aadharNumber,
'isActive': isActive, 'isActive': isActive,
'panNumber': panNumber, 'isRootUser': isRootUser,
'photo': photo,
'applicationUserId': applicationUserId,
'jobRoleId': jobRoleId,
'isSystem': isSystem, 'isSystem': isSystem,
'jobRole': jobRole, '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:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_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_snackbar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.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 /// Show bottom sheet wrapper
Future<T?> showAddExpenseBottomSheet<T>({ Future<T?> showAddExpenseBottomSheet<T>({
@ -18,10 +19,7 @@ Future<T?> showAddExpenseBottomSheet<T>({
Map<String, dynamic>? existingExpense, Map<String, dynamic>? existingExpense,
}) { }) {
return Get.bottomSheet<T>( return Get.bottomSheet<T>(
_AddExpenseBottomSheet( _AddExpenseBottomSheet(isEdit: isEdit, existingExpense: existingExpense),
isEdit: isEdit,
existingExpense: existingExpense,
),
isScrollControlled: true, isScrollControlled: true,
); );
} }
@ -48,95 +46,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx( return Obx(
@ -146,140 +55,44 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: widget.isEdit ? "Edit Expense" : "Add Expense", title: widget.isEdit ? "Edit Expense" : "Add Expense",
isSubmitting: controller.isSubmitting.value, isSubmitting: controller.isSubmitting.value,
onCancel: Get.back, onCancel: Get.back,
onSubmit: () { onSubmit: _handleSubmit,
if (_formKey.currentState!.validate() && _validateSelections()) {
controller.submitOrUpdateExpense();
} else {
_showError("Please fill all required fields correctly");
}
},
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildDropdownField<String>( _buildCreateProjectButton(),
icon: Icons.work_outline, _buildProjectDropdown(),
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,
),
dropdownKey: _projectDropdownKey,
),
_gap(), _gap(),
_buildExpenseTypeDropdown(),
_buildDropdownField<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey,
),
dropdownKey: _expenseTypeDropdownKey,
),
// Persons if required
if (controller.selectedExpenseType.value?.noOfPersonsRequired == if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true) ...[ true) ...[
_gap(), _gap(),
_buildTextFieldSection( _buildNumberField(
icon: Icons.people_outline, icon: Icons.people_outline,
title: "No. of Persons", title: "No. of Persons",
controller: controller.noOfPersonsController, controller: controller.noOfPersonsController,
hint: "Enter No. of Persons", hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
validator: Validators.requiredField, validator: Validators.requiredField,
), ),
], ],
_gap(), _gap(),
_buildPaymentModeDropdown(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
controller: controller.gstController,
hint: "Enter GST No.",
),
_gap(), _gap(),
_buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
),
_gap(),
_buildPaidBySection(), _buildPaidBySection(),
_gap(), _gap(),
_buildAmountField(),
_buildTextFieldSection(
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(), _gap(),
_buildSupplierField(),
_buildTextFieldSection(
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(), _gap(),
_buildTextFieldSection(
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(), _buildTransactionDateField(),
_gap(), _gap(),
_buildTransactionIdField(),
_gap(),
_buildLocationField(), _buildLocationField(),
_gap(), _gap(),
_buildAttachmentsSection(), _buildAttachmentsSection(),
_gap(), _gap(),
_buildDescriptionField(),
_buildTextFieldSection(
icon: Icons.description_outlined,
title: "Description",
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
validator: Validators.requiredField,
),
], ],
), ),
), ),
@ -288,102 +101,137 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
); );
} }
Widget _gap([double h = 16]) => MySpacing.height(h); /// 🟦 UI SECTION BUILDERS
Widget _buildDropdownField<T>({ Widget _buildCreateProjectButton() {
required IconData icon, return Align(
required String title, alignment: Alignment.centerRight,
required bool requiredField, child: TextButton.icon(
required String value, onPressed: () async {
required VoidCallback onTap, await Get.bottomSheet(const CreateProjectBottomSheet(),
required GlobalKey dropdownKey, isScrollControlled: true);
}) { await controller.fetchGlobalProjects();
return Column( },
crossAxisAlignment: CrossAxisAlignment.start, icon: const Icon(Icons.add, color: Colors.blue),
children: [ label: const Text(
SectionTitle(icon: icon, title: title, requiredField: requiredField), "Create Project",
MySpacing.height(6), style: TextStyle(color: Colors.blue, fontWeight: FontWeight.w600),
DropdownTile(key: dropdownKey, title: value, onTap: onTap), ),
], ),
); );
} }
Widget _buildTextFieldSection({ Widget _buildProjectDropdown() {
required IconData icon, return _buildDropdownField<String>(
required String title, icon: Icons.work_outline,
required TextEditingController controller, title: "Project",
String? hint, requiredField: true,
TextInputType? keyboardType, value: controller.selectedProject.value.isEmpty
FormFieldValidator<String>? validator, ? "Select Project"
int maxLines = 1, : controller.selectedProject.value,
}) { onTap: _showProjectSelector,
return Column( dropdownKey: _projectDropdownKey,
crossAxisAlignment: CrossAxisAlignment.start, );
children: [ }
SectionTitle(
icon: icon, title: title, requiredField: validator != null), Widget _buildExpenseTypeDropdown() {
MySpacing.height(6), return _buildDropdownField<ExpenseTypeModel>(
CustomTextField( icon: Icons.category_outlined,
controller: controller, title: "Expense Type",
hint: hint ?? "", requiredField: true,
keyboardType: value:
keyboardType ?? TextInputType.text, controller.selectedExpenseType.value?.name ?? "Select Expense Type",
validator: validator, onTap: () => _showOptionList(
maxLines: maxLines, controller.expenseTypes.toList(),
), (e) => e.name,
], (val) => controller.selectedExpenseType.value = val,
_expenseTypeDropdownKey,
),
dropdownKey: _expenseTypeDropdownKey,
);
}
Widget _buildPaymentModeDropdown() {
return _buildDropdownField<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value:
controller.selectedPaymentMode.value?.name ?? "Select Payment Mode",
onTap: () => _showOptionList(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
_paymentModeDropdownKey,
),
dropdownKey: _paymentModeDropdownKey,
); );
} }
Widget _buildPaidBySection() { Widget _buildPaidBySection() {
return Column( return _buildTileSelector(
crossAxisAlignment: CrossAxisAlignment.start, icon: Icons.person_outline,
children: [ title: "Paid By",
const SectionTitle( required: true,
icon: Icons.person_outline, title: "Paid By", requiredField: true), displayText: controller.selectedPaidBy.value == null
MySpacing.height(6), ? "Select Paid By"
GestureDetector( : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
onTap: _showEmployeeList, onTap: _showEmployeeList,
child: TileContainer( );
child: Row( }
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ Widget _buildAmountField() => _buildNumberField(
Text( icon: Icons.currency_rupee,
controller.selectedPaidBy.value == null title: "Amount",
? "Select Paid By" controller: controller.amountController,
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', hint: "Enter Amount",
style: const TextStyle(fontSize: 14), validator: (v) =>
), Validators.isNumeric(v ?? "") ? null : "Enter valid amount",
const Icon(Icons.arrow_drop_down, size: 22), );
],
), 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,
);
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) {
if (isRequired) {
if (v == null || v.isEmpty)
return "Transaction ID is required for this payment mode";
return Validators.transactionIdValidator(v);
}
return null;
},
requiredField: isRequired,
); );
} }
Widget _buildTransactionDateField() { Widget _buildTransactionDateField() {
return Column( return Obx(() => _buildTileSelector(
crossAxisAlignment: CrossAxisAlignment.start, icon: Icons.calendar_today,
children: [ title: "Transaction Date",
const SectionTitle( required: true,
icon: Icons.calendar_today, displayText: controller.selectedTransactionDate.value == null
title: "Transaction Date", ? "Select Transaction Date"
requiredField: true), : DateFormat('dd MMM yyyy')
MySpacing.height(6), .format(controller.selectedTransactionDate.value!),
GestureDetector(
onTap: () => controller.pickTransactionDate(context), onTap: () => controller.pickTransactionDate(context),
child: AbsorbPointer( ));
child: CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
validator: Validators.requiredField,
),
),
),
],
);
} }
Widget _buildLocationField() { Widget _buildLocationField() {
@ -426,40 +274,285 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SectionTitle( const SectionTitle(
icon: Icons.attach_file, title: "Attachments", requiredField: true), icon: Icons.attach_file,
title: "Attachments",
requiredField: true,
),
MySpacing.height(6), MySpacing.height(6),
AttachmentsSection( AttachmentsSection(
attachments: controller.attachments, attachments: controller.attachments,
existingAttachments: controller.existingAttachments, existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment, onRemoveNew: controller.removeAttachment,
onRemoveExisting: (item) async { onRemoveExisting: _confirmRemoveAttachment,
await showDialog(
context: context,
barrierDismissible: false,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
},
),
);
},
onAdd: controller.pickAttachments, 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,
builder: (_) => ConfirmDialog(
title: "Remove Attachment",
message: "Are you sure you want to remove this attachment?",
confirmText: "Remove",
icon: Icons.delete,
confirmColor: Colors.redAccent,
onConfirm: () async {
final index = controller.existingAttachments.indexOf(item);
if (index != -1) {
controller.existingAttachments[index]['isActive'] = false;
controller.existingAttachments.refresh();
}
showAppSnackbar(
title: 'Removed',
message: 'Attachment has been removed.',
type: SnackbarType.success,
);
},
),
);
}
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"), decoration: _inputDecoration("Enter transaction ID"),
), ),
MySpacing.height(16), MySpacing.height(16),
MySpacing.height(16),
MyText.labelMedium("Reimbursement Date"), MyText.labelMedium("Reimbursement Date"),
MySpacing.height(8), MySpacing.height(8),
GestureDetector( GestureDetector(
@ -165,12 +166,13 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
context: context, context: context,
initialDate: dateStr.value.isEmpty initialDate: dateStr.value.isEmpty
? DateTime.now() ? DateTime.now()
: DateFormat('yyyy-MM-dd').parse(dateStr.value), : DateFormat('dd MMM yyyy').parse(dateStr.value),
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime(2100), lastDate: DateTime(2100),
); );
if (picked != null) { if (picked != null) {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked); dateStr.value =
DateFormat('dd MMM yyyy').format(picked);
} }
}, },
child: AbsorbPointer( 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:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/services/auth_service.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/forgot_password_screen.dart';
import 'package:marco/view/auth/login_screen.dart'; import 'package:marco/view/auth/login_screen.dart';
import 'package:marco/view/auth/register_account_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/error_pages/error_500_screen.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart'; import 'package:marco/view/dashboard/dashboard_screen.dart';
import 'package:marco/view/Attendence/attendance_screen.dart'; import 'package:marco/view/Attendence/attendance_screen.dart';
import 'package:marco/view/taskPlanning/daily_task_planning.dart';
import 'package:marco/view/taskPlanning/daily_progress.dart';
import 'package:marco/view/employees/employees_screen.dart'; import 'package:marco/view/employees/employees_screen.dart';
import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/login_option_screen.dart';
import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart';
@ -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/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
RouteSettings? redirect(String? route) { RouteSettings? redirect(String? route) {
if (!AuthService.isLoggedIn) { return AuthService.isLoggedIn
if (route != '/auth/login-option') { ? null
return const RouteSettings(name: '/auth/login-option'); : RouteSettings(name: '/auth/login-option');
}
} else if (!TenantService.isTenantSelected) {
if (route != '/select-tenant') {
return const RouteSettings(name: '/select-tenant');
}
}
return null;
} }
} }
@ -49,10 +39,6 @@ getPageRoute() {
page: () => DashboardScreen(), // or your actual home screen page: () => DashboardScreen(), // or your actual home screen
middlewares: [AuthMiddleware()], middlewares: [AuthMiddleware()],
), ),
GetPage(
name: '/select-tenant',
page: () => const TenantSelectionScreen(),
middlewares: [AuthMiddleware()]),
// Dashboard // Dashboard
GetPage( GetPage(
@ -67,25 +53,17 @@ getPageRoute() {
name: '/dashboard/employees', name: '/dashboard/employees',
page: () => EmployeesScreen(), page: () => EmployeesScreen(),
middlewares: [AuthMiddleware()]), 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( GetPage(
name: '/dashboard/directory-main-page', name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(), page: () => DirectoryMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Expense // Expense
GetPage( GetPage(
name: '/dashboard/expense-main-page', name: '/dashboard/expense-main-page',
page: () => ExpenseMainScreen(), page: () => ExpenseMainScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Documents // Documents
GetPage( GetPage(
name: '/dashboard/document-main-page', name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(), page: () => UserDocumentsPage(),

View File

@ -94,7 +94,7 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
MaterialStateProperty.resolveWith<Color>( MaterialStateProperty.resolveWith<Color>(
(states) => (states) =>
states.contains(WidgetState.selected) states.contains(WidgetState.selected)
? contentTheme.brandRed ? contentTheme.primary
: Colors.white, : Colors.white,
), ),
checkColor: contentTheme.onPrimary, checkColor: contentTheme.onPrimary,
@ -128,7 +128,7 @@ class _EmailLoginFormState extends State<EmailLoginForm> with UIMixin {
elevation: 2, elevation: 2,
padding: MySpacing.xy(80, 16), padding: MySpacing.xy(80, 16),
borderRadiusAll: 10, borderRadiusAll: 10,
backgroundColor: contentTheme.brandRed, backgroundColor: contentTheme.primary,
child: MyText.labelLarge( child: MyText.labelLarge(
'Login', 'Login',
fontWeight: 700, 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_button.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart'; import 'package:marco/images.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
class ForgotPasswordScreen extends StatefulWidget { class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key}); const ForgotPasswordScreen({super.key});
@ -59,7 +60,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
_RedWaveBackground(brandRed: contentTheme.brandRed), WaveBackground(color: contentTheme.brandRed),
SafeArea( SafeArea(
child: Center( child: Center(
child: Column( child: Column(
@ -230,8 +231,8 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
borderRadiusAll: 10, borderRadiusAll: 10,
backgroundColor: _isLoading backgroundColor: _isLoading
? contentTheme.brandRed.withOpacity(0.6) ? contentTheme.primary.withOpacity(0.6)
: contentTheme.brandRed, : contentTheme.primary,
child: _isLoading child: _isLoading
? const SizedBox( ? const SizedBox(
height: 20, height: 20,
@ -253,68 +254,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
Widget _buildBackButton() { Widget _buildBackButton() {
return TextButton.icon( return TextButton.icon(
onPressed: () async => await LocalStorage.logout(), 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( label: MyText.bodyMedium(
'Back to Login', 'Back to Login',
color: contentTheme.brandRed, color: contentTheme.primary,
fontWeight: 600, fontWeight: 600,
fontSize: 14, 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/helpers/services/api_endpoints.dart';
import 'package:marco/view/auth/request_demo_bottom_sheet.dart'; import 'package:marco/view/auth/request_demo_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
enum LoginOption { email, otp } enum LoginOption { email, otp }
@ -55,7 +56,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => Dialog( builder: (_) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
insetPadding: const EdgeInsets.all(24), insetPadding: const EdgeInsets.all(24),
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@ -83,7 +84,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
option == LoginOption.email option == LoginOption.email
? EmailLoginForm() ? EmailLoginForm()
: const OTPLoginScreen(), : const OTPLoginScreen(),
], ],
), ),
@ -101,13 +102,14 @@ class _WelcomeScreenState extends State<WelcomeScreen>
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
_RedWaveBackground(brandRed: contentTheme.brandRed), WaveBackground(color: contentTheme.brandRed),
SafeArea( SafeArea(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: isNarrow ? double.infinity : 420), constraints: BoxConstraints(
maxWidth: isNarrow ? double.infinity : 420),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -166,7 +168,10 @@ class _WelcomeScreenState extends State<WelcomeScreen>
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
shape: BoxShape.circle, 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), child: Image.asset(Images.logoDark),
), ),
@ -199,7 +204,7 @@ class _WelcomeScreenState extends State<WelcomeScreen>
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orangeAccent, color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
child: MyText( child: MyText(
'BETA', 'BETA',
@ -230,9 +235,9 @@ class _WelcomeScreenState extends State<WelcomeScreen>
), ),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.brandRed, backgroundColor: contentTheme.brandGreen,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
elevation: 4, elevation: 4,
shadowColor: Colors.black26, 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), padding: MySpacing.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.02), color: theme.colorScheme.primary.withOpacity(0.02),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
border: Border.all( border: Border.all(
color: contentTheme.primary.withOpacity(0.5), color: contentTheme.primary.withOpacity(0.5),
), ),
@ -77,7 +77,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blueAccent, color: Colors.blueAccent,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
child: Text( child: Text(
'BETA', 'BETA',
@ -148,7 +148,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
value: controller.isChecked.value, value: controller.isChecked.value,
onChanged: controller.onChangeCheckBox, onChanged: controller.onChangeCheckBox,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
fillColor: MaterialStateProperty fillColor: MaterialStateProperty
.resolveWith<Color>( .resolveWith<Color>(
@ -192,8 +192,8 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
onPressed: controller.onLogin, onPressed: controller.onLogin,
elevation: 2, elevation: 2,
padding: MySpacing.xy(24, 16), padding: MySpacing.xy(24, 16),
borderRadiusAll: 16, borderRadiusAll: 5,
backgroundColor: Colors.blueAccent, backgroundColor:contentTheme.brandGreen,
child: MyText.labelMedium( child: MyText.labelMedium(
'Login', 'Login',
fontWeight: 600, fontWeight: 600,
@ -242,7 +242,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
return Material( return Material(
elevation: 2, elevation: 2,
shadowColor: contentTheme.secondary.withAlpha(30), shadowColor: contentTheme.secondary.withAlpha(30),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
child: TextFormField( child: TextFormField(
controller: controller, controller: controller,
validator: validator, validator: validator,
@ -255,7 +255,7 @@ class _LoginScreenState extends State<LoginScreen> with UIMixin {
filled: true, filled: true,
fillColor: theme.cardColor, fillColor: theme.cardColor,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
prefixIcon: Icon(icon, size: 18), 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/storage/local_storage.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/wave_background.dart';
class MPINAuthScreen extends StatefulWidget { class MPINAuthScreen extends StatefulWidget {
const MPINAuthScreen({super.key}); const MPINAuthScreen({super.key});
@ -51,7 +52,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
_RedWaveBackground(brandRed: contentTheme.brandRed), WaveBackground(color: contentTheme.brandRed),
SafeArea( SafeArea(
child: Center( child: Center(
child: Column( child: Column(
@ -110,7 +111,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
horizontal: 10, vertical: 4), horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orangeAccent, color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
child: MyText( child: MyText(
'BETA', 'BETA',
@ -145,7 +146,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(5),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black12, color: Colors.black12,
@ -264,7 +265,7 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
filled: true, filled: true,
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
), ),
@ -279,10 +280,10 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
onPressed: controller.isLoading.value ? null : controller.onSubmitMPIN, onPressed: controller.isLoading.value ? null : controller.onSubmitMPIN,
elevation: 2, elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
borderRadiusAll: 10, borderRadiusAll: 5,
backgroundColor: controller.isLoading.value backgroundColor: controller.isLoading.value
? contentTheme.brandRed.withOpacity(0.6) ? contentTheme.primary.withOpacity(0.6)
: contentTheme.brandRed, : contentTheme.primary,
child: controller.isLoading.value child: controller.isLoading.value
? const SizedBox( ? const SizedBox(
height: 20, 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, elevation: 2,
padding: MySpacing.xy(24, 16), padding: MySpacing.xy(24, 16),
borderRadiusAll: 10, borderRadiusAll: 10,
backgroundColor: isDisabled ? Colors.grey : contentTheme.brandRed, backgroundColor: isDisabled ? Colors.grey : contentTheme.primary,
child: controller.isSending.value child: controller.isSending.value
? SizedBox( ? SizedBox(
width: 20, width: 20,

View File

@ -166,10 +166,9 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
onChanged: (val) => setState(() => _agreed = val ?? false), onChanged: (val) => setState(() => _agreed = val ?? false),
fillColor: MaterialStateProperty.resolveWith((states) => fillColor: MaterialStateProperty.resolveWith((states) =>
states.contains(MaterialState.selected) states.contains(MaterialState.selected)
? contentTheme.brandRed ? contentTheme.primary
: Colors.white), : Colors.white),
checkColor: Colors.white, checkColor: Colors.white,
side: const BorderSide(color: Colors.red, width: 2),
), ),
Row( Row(
children: [ children: [
@ -179,7 +178,7 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
), ),
MyText( MyText(
'privacy policy & terms', 'privacy policy & terms',
color: contentTheme.brandRed, color: contentTheme.primary,
fontWeight: 600, fontWeight: 600,
), ),
], ],
@ -188,44 +187,44 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
OutlinedButton.icon( Expanded(
onPressed: () => Navigator.pop(context), child: ElevatedButton.icon(
icon: const Icon(Icons.arrow_back, color: Colors.red), onPressed: () => Navigator.pop(context),
label: MyText.bodyMedium("Back", color: Colors.red), icon: const Icon(Icons.close, color: Colors.white),
style: OutlinedButton.styleFrom( label: MyText.bodyMedium(
side: const BorderSide(color: Colors.red), "Cancel",
shape: RoundedRectangleBorder( color: Colors.white,
borderRadius: BorderRadius.circular(12), fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 8),
), ),
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 14),
), ),
), ),
ElevatedButton.icon( const SizedBox(width: 12),
onPressed: _loading ? null : _submitForm, Expanded(
icon: _loading child: ElevatedButton.icon(
? const SizedBox( onPressed: _loading ? null : _submitForm,
width: 18, icon:
height: 18, Icon(Icons.check_circle_outline, color: Colors.white),
child: CircularProgressIndicator( label: MyText.bodyMedium(
color: Colors.white, _loading ? "Submitting..." : "Submit",
strokeWidth: 2, color: Colors.white,
), fontWeight: 600,
) ),
: const Icon(Icons.check_circle_outline, style: ElevatedButton.styleFrom(
color: Colors.white), backgroundColor: contentTheme
label: _loading .primary,
? const SizedBox.shrink() shape: RoundedRectangleBorder(
: MyText.bodyMedium("Submit", color: Colors.white), borderRadius: BorderRadius.circular(12),
style: ElevatedButton.styleFrom( ),
backgroundColor: Colors.indigo, padding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 14),
), ),
), ),
], ],
@ -235,11 +234,11 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
child: TextButton.icon( child: TextButton.icon(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
icon: icon:
const Icon(Icons.arrow_back, size: 18, color: Colors.red), Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
label: MyText.bodySmall( label: MyText.bodySmall(
'Back to log in', 'Back to log in',
fontWeight: 600, fontWeight: 600,
color: contentTheme.brandRed, color: contentTheme.primary,
), ),
), ),
), ),
@ -291,19 +290,19 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16), const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[400]!), borderSide: BorderSide(color: Colors.grey[400]!),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[300]!), borderSide: BorderSide(color: Colors.grey[300]!),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: contentTheme.brandRed, width: 1.5), borderSide: BorderSide(color: contentTheme.brandRed, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(color: Colors.red), borderSide: const BorderSide(color: Colors.red),
), ),
), ),
@ -367,15 +366,15 @@ class _OrganizationFormState extends State<_OrganizationForm> with UIMixin {
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 16), horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[400]!), borderSide: BorderSide(color: Colors.grey[400]!),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey[300]!), borderSide: BorderSide(color: Colors.grey[300]!),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
borderSide: borderSide:
BorderSide(color: contentTheme.brandRed, width: 1.5), 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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.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/attendance_overview_chart.dart';
import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart';
import 'package:marco/view/layouts/layout.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 { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
static const String dashboardRoute = "/dashboard";
static const String employeesRoute = "/dashboard/employees"; static const String employeesRoute = "/dashboard/employees";
static const String projectsRoute = "/dashboard";
static const String attendanceRoute = "/dashboard/attendance"; 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 directoryMainPageRoute = "/dashboard/directory-main-page";
static const String expenseMainPageRoute = "/dashboard/expense-main-page"; static const String expenseMainPageRoute = "/dashboard/expense-main-page";
static const String documentMainPageRoute = "/dashboard/document-main-page";
@override @override
State<DashboardScreen> createState() => _DashboardScreenState(); State<DashboardScreen> createState() => _DashboardScreenState();
@ -37,7 +27,6 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> with UIMixin { class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final DashboardController dashboardController = final DashboardController dashboardController =
Get.put(DashboardController(), permanent: true); Get.put(DashboardController(), permanent: true);
final DynamicMenuController menuController = Get.put(DynamicMenuController());
bool hasMpin = true; bool hasMpin = true;
@ -62,82 +51,34 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
children: [ children: [
_buildDashboardStats(context), _buildDashboardStats(context),
MySpacing.height(24), MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.teamsOverview(),
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.tasksOverview(),
),
MySpacing.height(24),
_buildAttendanceChartSection(), _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 /// Attendance Chart Section
Widget _buildAttendanceChartSection() { Widget _buildAttendanceChartSection() {
return Obx(() { return GetBuilder<ProjectController>(
id: 'dashboard_controller',
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); builder: (projectController) {
final isProjectSelected = projectController.selectedProject != null;
if (!isAttendanceAllowed) { return Opacity(
// 🚫 Don't render anything if attendance menu is not allowed opacity: isProjectSelected ? 1.0 : 0.4,
return const SizedBox.shrink(); child: IgnorePointer(
} ignoring: !isProjectSelected,
child: ClipRRect(
return GetBuilder<ProjectController>( borderRadius: BorderRadius.circular(5),
id: 'dashboard_controller', child: SizedBox(
builder: (projectController) { height: 400,
final isProjectSelected = projectController.selectedProject != null; child: AttendanceDashboardChart(),
return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isProjectSelected,
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: AttendanceDashboardChart(),
),
), ),
), ),
); ),
}, );
); },
}); );
} }
/// No Project Assigned Message /// No Project Assigned Message
@ -165,116 +106,48 @@ 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 /// Dashboard Statistics Section
Widget _buildDashboardStats(BuildContext context) { Widget _buildDashboardStats(BuildContext context) {
return Obx(() { final stats = [
if (menuController.isLoading.value) { _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success,
return _buildLoadingSkeleton(context); DashboardScreen.attendanceRoute),
} _StatItem(LucideIcons.users, "Employees", contentTheme.warning,
DashboardScreen.employeesRoute),
_StatItem(LucideIcons.folder, "Directory", contentTheme.info,
DashboardScreen.directoryMainPageRoute),
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info,
DashboardScreen.expenseMainPageRoute),
];
if (menuController.hasError.value && menuController.menuItems.isEmpty) { final projectController = Get.find<ProjectController>();
return Padding( final isProjectSelected = projectController.selectedProject != null;
padding: const EdgeInsets.all(16),
child: Center(
child: MyText.bodySmall(
"Failed to load menus. Please try again later.",
color: Colors.red,
),
),
);
}
final stats = [ return Column(
_StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, crossAxisAlignment: CrossAxisAlignment.start,
DashboardScreen.attendanceRoute), children: [
_StatItem(LucideIcons.users, "Employees", contentTheme.warning, if (!isProjectSelected) _buildNoProjectMessage(),
DashboardScreen.employeesRoute), LayoutBuilder(
_StatItem(LucideIcons.logs, "Daily Task Planning", contentTheme.info, builder: (context, constraints) {
DashboardScreen.dailyTasksRoute), int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8);
_StatItem(LucideIcons.list_todo, "Daily Progress Report", double cardWidth =
contentTheme.info, DashboardScreen.dailyTasksProgressRoute), (constraints.maxWidth - (crossAxisCount - 1) * 6) /
_StatItem(LucideIcons.folder, "Directory", contentTheme.info, crossAxisCount;
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>(); return Wrap(
final isProjectSelected = projectController.selectedProject != null; spacing: 6,
runSpacing: 6,
return Column( alignment: WrapAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: stats
children: [ .map((stat) =>
if (!isProjectSelected) _buildNoProjectMessage(), _buildStatCard(stat, isProjectSelected, cardWidth))
LayoutBuilder( .toList()
builder: (context, constraints) { .cast<Widget>(),
// smaller width cards fit more in a row );
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8); },
double cardWidth = ),
(constraints.maxWidth - (crossAxisCount - 1) * 6) / ],
crossAxisCount; );
return Wrap(
spacing: 6,
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()
.cast<Widget>(),
);
},
),
],
);
});
} }
/// Stat Card (Compact + Small) /// 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/model/directory/contact_model.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:marco/helpers/utils/launcher_utils.dart';
import 'package:tab_indicator_styler/tab_indicator_styler.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart';
import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
@ -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/model/directory/add_contact_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
// HELPER: Delta to HTML conversion // HELPER: Delta to HTML conversion
String _convertDeltaToHtml(dynamic delta) { String _convertDeltaToHtml(dynamic delta) {
@ -69,7 +69,7 @@ class ContactDetailScreen extends StatefulWidget {
State<ContactDetailScreen> createState() => _ContactDetailScreenState(); State<ContactDetailScreen> createState() => _ContactDetailScreenState();
} }
class _ContactDetailScreenState extends State<ContactDetailScreen> { class _ContactDetailScreenState extends State<ContactDetailScreen> with UIMixin{
late final DirectoryController directoryController; late final DirectoryController directoryController;
late final ProjectController projectController; late final ProjectController projectController;
@ -190,16 +190,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
), ),
]), ]),
TabBar( TabBar(
labelColor: Colors.red, labelColor: Colors.black,
unselectedLabelColor: Colors.black, unselectedLabelColor: Colors.grey,
indicator: MaterialIndicator( indicatorColor: contentTheme.primary,
color: Colors.red,
height: 4,
topLeftRadius: 8,
topRightRadius: 8,
bottomLeftRadius: 8,
bottomRightRadius: 8,
),
tabs: const [ tabs: const [
Tab(text: "Details"), Tab(text: "Details"),
Tab(text: "Notes"), Tab(text: "Notes"),
@ -316,7 +309,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20, bottom: 20,
right: 20, right: 20,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
backgroundColor: Colors.red, backgroundColor: contentTheme.primary,
onPressed: () async { onPressed: () async {
final result = await Get.bottomSheet( final result = await Get.bottomSheet(
AddContactBottomSheet(existingContact: contact), AddContactBottomSheet(existingContact: contact),
@ -360,12 +353,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
[...activeComments, ...inactiveComments].reversed.toList(); [...activeComments, ...inactiveComments].reversed.toList();
final editingId = directoryController.editingCommentId.value; final editingId = directoryController.editingCommentId.value;
if (comments.isEmpty) {
return Center(
child: MyText.bodyLarge("No notes yet.", color: Colors.grey),
);
}
return Stack( return Stack(
children: [ children: [
MyRefreshIndicator( MyRefreshIndicator(
@ -377,14 +364,19 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
}, },
child: Padding( child: Padding(
padding: MySpacing.xy(12, 12), padding: MySpacing.xy(12, 12),
child: ListView.separated( child: comments.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? Center(
padding: const EdgeInsets.only(bottom: 100), child:
itemCount: comments.length, MyText.bodyLarge("No notes yet.", color: Colors.grey),
separatorBuilder: (_, __) => MySpacing.height(14), )
itemBuilder: (_, index) => : ListView.separated(
_buildCommentItem(comments[index], editingId, contactId), physics: const AlwaysScrollableScrollPhysics(),
), padding: const EdgeInsets.only(bottom: 100),
itemCount: comments.length,
separatorBuilder: (_, __) => MySpacing.height(14),
itemBuilder: (_, index) => _buildCommentItem(
comments[index], editingId, contactId),
),
), ),
), ),
if (editingId == null) if (editingId == null)
@ -392,7 +384,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
bottom: 20, bottom: 20,
right: 20, right: 20,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
backgroundColor: Colors.red, backgroundColor: contentTheme.primary,
onPressed: () async { onPressed: () async {
final result = await Get.bottomSheet( final result = await Get.bottomSheet(
AddCommentBottomSheet(contactId: contactId), 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/view/directory/manage_bucket_screen.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class DirectoryView extends StatefulWidget { class DirectoryView extends StatefulWidget {
@override @override
State<DirectoryView> createState() => _DirectoryViewState(); State<DirectoryView> createState() => _DirectoryViewState();
} }
class _DirectoryViewState extends State<DirectoryView> { class _DirectoryViewState extends State<DirectoryView> with UIMixin {
final DirectoryController controller = Get.find(); final DirectoryController controller = Get.find();
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final PermissionController permissionController = final PermissionController permissionController =
@ -126,7 +128,7 @@ class _DirectoryViewState extends State<DirectoryView> {
child: ElevatedButton( child: ElevatedButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo, backgroundColor: contentTheme.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -172,7 +174,7 @@ class _DirectoryViewState extends State<DirectoryView> {
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
heroTag: 'createContact', heroTag: 'createContact',
backgroundColor: Colors.red, backgroundColor: contentTheme.primary,
onPressed: _handleCreateContact, onPressed: _handleCreateContact,
icon: const Icon(Icons.person_add_alt_1, color: Colors.white), icon: const Icon(Icons.person_add_alt_1, color: Colors.white),
label: const Text("Add Contact", style: TextStyle(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, icon: Icon(Icons.tune,
size: 20, size: 20,
color: isFilterActive color: isFilterActive
? Colors.indigo ? contentTheme.primary
: Colors.black87), : Colors.black87),
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
@ -323,13 +325,13 @@ class _DirectoryViewState extends State<DirectoryView> {
PopupMenuItem<int>( PopupMenuItem<int>(
value: 2, value: 2,
child: Row( child: Row(
children: const [ children: [
Icon(Icons.add_box_outlined, Icon(Icons.add_box_outlined,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
SizedBox(width: 10), SizedBox(width: 10),
Expanded(child: Text("Create Bucket")), Expanded(child: Text("Create Bucket")),
Icon(Icons.chevron_right, Icon(Icons.chevron_right,
size: 20, color: Colors.red), size: 20, color: contentTheme.primary),
], ],
), ),
onTap: () { onTap: () {
@ -356,13 +358,13 @@ class _DirectoryViewState extends State<DirectoryView> {
PopupMenuItem<int>( PopupMenuItem<int>(
value: 1, value: 1,
child: Row( child: Row(
children: const [ children: [
Icon(Icons.label_outline, Icon(Icons.label_outline,
size: 20, color: Colors.black87), size: 20, color: Colors.black87),
SizedBox(width: 10), SizedBox(width: 10),
Expanded(child: Text("Manage Buckets")), Expanded(child: Text("Manage Buckets")),
Icon(Icons.chevron_right, Icon(Icons.chevron_right,
size: 20, color: Colors.red), size: 20, color: contentTheme.primary),
], ],
), ),
onTap: () { onTap: () {
@ -401,7 +403,7 @@ class _DirectoryViewState extends State<DirectoryView> {
const Expanded(child: Text('Show Deleted Contacts')), const Expanded(child: Text('Show Deleted Contacts')),
Switch.adaptive( Switch.adaptive(
value: !controller.isActive.value, value: !controller.isActive.value,
activeColor: Colors.indigo, activeColor: contentTheme.primary ,
onChanged: (val) { onChanged: (val) {
controller.isActive.value = !val; controller.isActive.value = !val;
controller.fetchContacts(active: !val); controller.fetchContacts(active: !val);
@ -424,7 +426,7 @@ class _DirectoryViewState extends State<DirectoryView> {
child: Obx(() { child: Obx(() {
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: _refreshDirectory, onRefresh: _refreshDirectory,
backgroundColor: Colors.indigo, backgroundColor: contentTheme.primary,
color: Colors.white, color: Colors.white,
child: controller.isLoading.value child: controller.isLoading.value
? ListView.separated( ? 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/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class NotesView extends StatelessWidget { class NotesView extends StatelessWidget {
final NotesController controller = Get.find(); 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/model/document/document_edit_bottom_sheet.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final String documentId; final String documentId;
@ -23,7 +24,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState(); State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
} }
class _DocumentDetailsPageState extends State<DocumentDetailsPage> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin {
final DocumentDetailsController controller = final DocumentDetailsController controller =
Get.find<DocumentDetailsController>(); Get.find<DocumentDetailsController>();
@ -155,7 +156,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (permissionController if (permissionController
.hasPermission(Permissions.modifyDocument)) .hasPermission(Permissions.modifyDocument))
IconButton( IconButton(
icon: const Icon(Icons.edit, color: Colors.red), icon: Icon(Icons.edit, color: contentTheme.primary),
onPressed: () async { onPressed: () async {
// existing bottom sheet flow // existing bottom sheet flow
await controller 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/permission_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart'; import 'package:marco/controller/document/document_details_controller.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class UserDocumentsPage extends StatefulWidget { class UserDocumentsPage extends StatefulWidget {
final String? entityId; final String? entityId;
@ -34,7 +35,7 @@ class UserDocumentsPage extends StatefulWidget {
State<UserDocumentsPage> createState() => _UserDocumentsPageState(); State<UserDocumentsPage> createState() => _UserDocumentsPageState();
} }
class _UserDocumentsPageState extends State<UserDocumentsPage> { class _UserDocumentsPageState extends State<UserDocumentsPage> with UIMixin {
final DocumentController docController = Get.put(DocumentController()); final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController = final PermissionController permissionController =
Get.find<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...', hintText: 'Search documents...',
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
@ -415,7 +421,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
const Expanded(child: Text('Show Deleted Documents')), const Expanded(child: Text('Show Deleted Documents')),
Switch.adaptive( Switch.adaptive(
value: docController.showInactive.value, value: docController.showInactive.value,
activeColor: Colors.indigo, activeColor: contentTheme.primary,
onChanged: (val) { onChanged: (val) {
docController.showInactive.value = val; docController.showInactive.value = val;
docController.fetchDocuments( docController.fetchDocuments(
@ -640,7 +646,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
color: Colors.white, color: Colors.white,
fontWeight: 600, fontWeight: 600,
), ),
backgroundColor: Colors.red, backgroundColor: contentTheme.primary,
) )
: null, : null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, 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/avatar.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
import 'package:marco/helpers/utils/launcher_utils.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/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/employees/add_employee_bottom_sheet.dart'; import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class EmployeeDetailPage extends StatefulWidget { class EmployeeDetailPage extends StatefulWidget {
final String employeeId; final String employeeId;
@ -27,11 +26,10 @@ class EmployeeDetailPage extends StatefulWidget {
State<EmployeeDetailPage> createState() => _EmployeeDetailPageState(); State<EmployeeDetailPage> createState() => _EmployeeDetailPageState();
} }
class _EmployeeDetailPageState extends State<EmployeeDetailPage> { class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
final EmployeesScreenController controller = final EmployeesScreenController controller =
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController _permissionController =
Get.find<PermissionController>();
@override @override
void initState() { void initState() {
@ -91,7 +89,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
value, value,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54, color: (isEmail || isPhone) ? contentTheme.primary : Colors.black54,
fontSize: 14, fontSize: 14,
decoration: (isEmail || isPhone) decoration: (isEmail || isPhone)
? TextDecoration.underline ? TextDecoration.underline
@ -251,8 +249,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.edit, icon:
size: 24, color: Colors.red), Icon(Icons.edit, size: 24, color: contentTheme.primary),
onPressed: () async { onPressed: () async {
final result = final result =
await showModalBottomSheet<Map<String, dynamic>>( await showModalBottomSheet<Map<String, dynamic>>(
@ -265,6 +263,9 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
'first_name': employee.firstName, 'first_name': employee.firstName,
'last_name': employee.lastName, 'last_name': employee.lastName,
'phone_number': employee.phoneNumber, 'phone_number': employee.phoneNumber,
'email': employee.email,
'hasApplicationAccess':
employee.hasApplicationAccess,
'gender': employee.gender.toLowerCase(), 'gender': employee.gender.toLowerCase(),
'job_role_id': employee.jobRoleId, 'job_role_id': employee.jobRoleId,
'joining_date': '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/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/helpers/utils/launcher_utils.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/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/view/employees/employee_profile_screen.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 { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -33,8 +31,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Get.find<PermissionController>(); Get.find<PermissionController>();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
final OrganizationController _organizationController =
Get.put(OrganizationController());
@override @override
void initState() { void initState() {
@ -47,44 +43,15 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Future<void> _initEmployees() async { Future<void> _initEmployees() async {
final projectId = Get.find<ProjectController>().selectedProject?.id; _employeeController.selectedProjectId = null;
final orgId = _organizationController.selectedOrganization.value?.id; await _employeeController.fetchAllEmployees();
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();
}
_filterEmployees(_searchController.text); _filterEmployees(_searchController.text);
} }
Future<void> _refreshEmployees() async { Future<void> _refreshEmployees() async {
try { try {
final projectId = Get.find<ProjectController>().selectedProject?.id; _employeeController.selectedProjectId = null;
final orgId = _organizationController.selectedOrganization.value?.id; await _employeeController.fetchAllEmployees();
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();
}
_filterEmployees(_searchController.text); _filterEmployees(_searchController.text);
_employeeController.update(['employee_screen_controller']); _employeeController.update(['employee_screen_controller']);
} catch (e, stackTrace) { } catch (e, stackTrace) {
@ -132,22 +99,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
if (result == null || result['success'] != true) return; 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(); await _refreshEmployees();
} }
@ -258,14 +210,14 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: contentTheme.primary,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)) color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))
], ],
), ),
child: const Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.add, color: Colors.white), Icon(Icons.add, color: Colors.white),
@ -287,43 +239,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Row( Row(
children: [ children: [
Expanded(child: _buildSearchField()), 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), hintStyle: const TextStyle(fontSize: 13, color: Colors.grey),
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300, width: 1), 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() { Widget _buildEmployeeList() {
return Obx(() { return Obx(() {
if (_employeeController.isLoading.value) { if (_employeeController.isLoading.value) {

View File

@ -38,7 +38,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
Get.back(); Get.back();
}, },
submitText: 'Submit', submitText: 'Submit',
submitColor: Colors.indigo,
submitIcon: Icons.check_circle_outline, submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView( child: SingleChildScrollView(
controller: scrollController, 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/widgets/expense/expense_main_components.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class ExpenseMainScreen extends StatefulWidget { class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key}); const ExpenseMainScreen({super.key});
@ -21,7 +21,7 @@ class ExpenseMainScreen extends StatefulWidget {
} }
class _ExpenseMainScreenState extends State<ExpenseMainScreen> class _ExpenseMainScreenState extends State<ExpenseMainScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin,UIMixin {
late TabController _tabController; late TabController _tabController;
final searchController = TextEditingController(); final searchController = TextEditingController();
final expenseController = Get.put(ExpenseController()); final expenseController = Get.put(ExpenseController());
@ -82,72 +82,75 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: ExpenseAppBar(projectController: projectController), appBar: ExpenseAppBar(projectController: projectController),
body: Column( body: Column(
children: [ children: [
// ---------------- TabBar ---------------- // ---------------- TabBar ----------------
Container( Container(
color: Colors.white, color: Colors.white,
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
labelColor: Colors.black, labelColor: Colors.black,
unselectedLabelColor: Colors.grey, unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red, indicatorColor: Colors.red,
tabs: const [ tabs: const [
Tab(text: "Current Month"), Tab(text: "Current Month"),
Tab(text: "History"), Tab(text: "History"),
],
),
),
// ---------------- Gray background for rest ----------------
Expanded(
child: Container(
color: Colors.grey[100], // Light gray background
child: Column(
children: [
// ---------------- Search ----------------
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// ---------------- TabBarView ----------------
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
], ],
), ),
), ),
),
],
),
floatingActionButton: // ---------------- Gray background for rest ----------------
permissionController.hasPermission(Permissions.expenseUpload) Expanded(
? FloatingActionButton( child: Container(
backgroundColor: Colors.red, color: Colors.grey[100], // Light gray background
onPressed: showAddExpenseBottomSheet, child: Column(
child: const Icon(Icons.add, color: Colors.white), children: [
) // ---------------- Search ----------------
: null, Padding(
); padding:
} const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// ---------------- TabBarView ----------------
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
],
),
),
),
],
),
floatingActionButton:
permissionController.hasPermission(Permissions.expenseUpload)
? FloatingActionButton.extended(
backgroundColor: contentTheme.primary,
onPressed: showAddExpenseBottomSheet,
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}) { Widget _buildExpenseList({required bool isHistory}) {
return Obx(() { 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/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/controller/auth/mpin_controller.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/view/employees/employee_profile_screen.dart';
import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/view/support/support_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/faq/faq_screen.dart';
class UserProfileBar extends StatefulWidget { class UserProfileBar extends StatefulWidget {
final bool isCondensed; final bool isCondensed;
@ -27,21 +26,13 @@ class _UserProfileBarState extends State<UserProfileBar>
late EmployeeInfo employeeInfo; late EmployeeInfo employeeInfo;
bool _isLoading = true; bool _isLoading = true;
bool hasMpin = true; bool hasMpin = true;
late final TenantSelectionController _tenantController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tenantController = Get.put(TenantSelectionController());
_initData(); _initData();
} }
@override
void dispose() {
Get.delete<TenantSelectionController>();
super.dispose();
}
Future<void> _initData() async { Future<void> _initData() async {
employeeInfo = LocalStorage.getEmployeeInfo()!; employeeInfo = LocalStorage.getEmployeeInfo()!;
hasMpin = await LocalStorage.getIsMpin(); hasMpin = await LocalStorage.getIsMpin();
@ -54,7 +45,7 @@ class _UserProfileBarState extends State<UserProfileBar>
return Padding( return Padding(
padding: const EdgeInsets.only(left: 14), padding: const EdgeInsets.only(left: 14),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(5),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: AnimatedContainer( child: AnimatedContainer(
@ -70,7 +61,7 @@ class _UserProfileBarState extends State<UserProfileBar>
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.06), color: Colors.black.withValues(alpha: 0.06),
@ -91,10 +82,6 @@ class _UserProfileBarState extends State<UserProfileBar>
_isLoading _isLoading
? const _LoadingSection() ? const _LoadingSection()
: _userProfileSection(isCondensed), : _userProfileSection(isCondensed),
// --- SWITCH TENANT ROW BELOW AVATAR ---
if (!_isLoading && !isCondensed) _switchTenantRow(),
MySpacing.height(12), MySpacing.height(12),
Divider( Divider(
indent: 18, 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) { Widget _userProfileSection(bool condensed) {
final padding = MySpacing.fromLTRB( final padding = MySpacing.fromLTRB(
condensed ? 16 : 26, condensed ? 16 : 26,
@ -306,20 +180,22 @@ class _UserProfileBarState extends State<UserProfileBar>
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),
_menuItemRow( _menuItemRow(
icon: LucideIcons.settings, icon: LucideIcons.badge_help,
label: 'Settings', label: 'Support',
onTap: _onSupportTap,
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),
_menuItemRow( _menuItemRow(
icon: LucideIcons.badge_help, icon: LucideIcons.info,
label: 'Support', label: 'FAQ', // <-- New FAQ menu item
onTap: _onFaqTap, // <-- Handle tap
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),
_menuItemRow( _menuItemRow(
icon: LucideIcons.lock, icon: LucideIcons.lock,
label: hasMpin ? 'Change MPIN' : 'Set MPIN', label: hasMpin ? 'Change MPIN' : 'Set MPIN',
iconColor: Colors.redAccent, iconColor: contentTheme.primary,
textColor: Colors.redAccent, textColor: contentTheme.primary,
onTap: _onMpinTap, 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({ Widget _menuItemRow({
required IconData icon, required IconData icon,
required String label, required String label,
@ -336,12 +220,12 @@ class _UserProfileBarState extends State<UserProfileBar>
}) { }) {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9), 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), border: Border.all(color: Colors.grey.withOpacity(0.2), width: 1),
), ),
child: Row( child: Row(
@ -397,11 +281,11 @@ class _UserProfileBarState extends State<UserProfileBar>
foregroundColor: Colors.white, foregroundColor: Colors.white,
shadowColor: Colors.red.shade200, shadowColor: Colors.red.shade200,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: condensed ? 14 : 18, vertical: condensed ? 9 : 12,
horizontal: condensed ? 14 : 22, horizontal: condensed ? 6 : 16,
), ),
shape: shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
), ),
), ),
), ),
@ -418,7 +302,7 @@ class _UserProfileBarState extends State<UserProfileBar>
Widget _buildLogoutDialog(BuildContext context) { Widget _buildLogoutDialog(BuildContext context) {
return Dialog( return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
elevation: 10, elevation: 10,
backgroundColor: Colors.white, backgroundColor: Colors.white,
child: Padding( child: Padding(
@ -462,7 +346,7 @@ class _UserProfileBarState extends State<UserProfileBar>
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)), borderRadius: BorderRadius.circular(5)),
), ),
child: const Text("Logout"), 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;
}