Merge pull request 'Vaibhav_Enhancement-#1129' (#73) from Vaibhav_Enhancement-#1129 into main
Reviewed-on: #73
This commit is contained in:
commit
5086b3be98
@ -15,7 +15,7 @@ import 'package:marco/model/employees/employee_model.dart';
|
|||||||
import 'package:marco/model/attendance/attendance_log_model.dart';
|
import 'package:marco/model/attendance/attendance_log_model.dart';
|
||||||
import 'package:marco/model/regularization_log_model.dart';
|
import 'package:marco/model/regularization_log_model.dart';
|
||||||
import 'package:marco/model/attendance/attendance_log_view_model.dart';
|
import 'package:marco/model/attendance/attendance_log_view_model.dart';
|
||||||
|
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
|
|
||||||
class AttendanceController extends GetxController {
|
class AttendanceController extends GetxController {
|
||||||
@ -26,9 +26,13 @@ class AttendanceController extends GetxController {
|
|||||||
List<AttendanceLogModel> attendanceLogs = [];
|
List<AttendanceLogModel> attendanceLogs = [];
|
||||||
List<RegularizationLogModel> regularizationLogs = [];
|
List<RegularizationLogModel> regularizationLogs = [];
|
||||||
List<AttendanceLogViewModel> attendenceLogsView = [];
|
List<AttendanceLogViewModel> attendenceLogsView = [];
|
||||||
|
// ------------------ Organizations ------------------
|
||||||
|
List<Organization> organizations = [];
|
||||||
|
Organization? selectedOrganization;
|
||||||
|
final isLoadingOrganizations = false.obs;
|
||||||
|
|
||||||
// States
|
// States
|
||||||
String selectedTab = 'Employee List';
|
String selectedTab = 'todaysAttendance';
|
||||||
DateTime? startDateAttendance;
|
DateTime? startDateAttendance;
|
||||||
DateTime? endDateAttendance;
|
DateTime? endDateAttendance;
|
||||||
|
|
||||||
@ -45,11 +49,16 @@ class AttendanceController extends GetxController {
|
|||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
_initializeDefaults();
|
_initializeDefaults();
|
||||||
|
|
||||||
|
// 🔹 Fetch organizations for the selected project
|
||||||
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
if (projectId != null) {
|
||||||
|
fetchOrganizations(projectId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializeDefaults() {
|
void _initializeDefaults() {
|
||||||
_setDefaultDateRange();
|
_setDefaultDateRange();
|
||||||
fetchProjects();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setDefaultDateRange() {
|
void _setDefaultDateRange() {
|
||||||
@ -104,29 +113,15 @@ class AttendanceController extends GetxController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchProjects() async {
|
Future<void> fetchTodaysAttendance(String? projectId) async {
|
||||||
isLoadingProjects.value = true;
|
|
||||||
|
|
||||||
final response = await ApiService.getProjects();
|
|
||||||
if (response != null && response.isNotEmpty) {
|
|
||||||
projects = response.map((e) => ProjectModel.fromJson(e)).toList();
|
|
||||||
logSafe("Projects fetched: ${projects.length}");
|
|
||||||
} else {
|
|
||||||
projects = [];
|
|
||||||
logSafe("Failed to fetch projects or no projects available.",
|
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingProjects.value = false;
|
|
||||||
update(['attendance_dashboard_controller']);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
|
||||||
if (projectId == null) return;
|
if (projectId == null) return;
|
||||||
|
|
||||||
isLoadingEmployees.value = true;
|
isLoadingEmployees.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getEmployeesByProject(projectId);
|
final response = await ApiService.getTodaysAttendance(
|
||||||
|
projectId,
|
||||||
|
organizationId: selectedOrganization?.id,
|
||||||
|
);
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
|
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
|
||||||
for (var emp in employees) {
|
for (var emp in employees) {
|
||||||
@ -141,6 +136,20 @@ class AttendanceController extends GetxController {
|
|||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> fetchOrganizations(String projectId) async {
|
||||||
|
isLoadingOrganizations.value = true;
|
||||||
|
final response = await ApiService.getAssignedOrganizations(projectId);
|
||||||
|
if (response != null) {
|
||||||
|
organizations = response.data;
|
||||||
|
logSafe("Organizations fetched: ${organizations.length}");
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to fetch organizations for project $projectId",
|
||||||
|
level: LogLevel.error);
|
||||||
|
}
|
||||||
|
isLoadingOrganizations.value = false;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------ Attendance Capture ------------------
|
// ------------------ Attendance Capture ------------------
|
||||||
|
|
||||||
Future<bool> captureAndUploadAttendance(
|
Future<bool> captureAndUploadAttendance(
|
||||||
@ -262,8 +271,12 @@ class AttendanceController extends GetxController {
|
|||||||
|
|
||||||
isLoadingAttendanceLogs.value = true;
|
isLoadingAttendanceLogs.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getAttendanceLogs(projectId,
|
final response = await ApiService.getAttendanceLogs(
|
||||||
dateFrom: dateFrom, dateTo: dateTo);
|
projectId,
|
||||||
|
dateFrom: dateFrom,
|
||||||
|
dateTo: dateTo,
|
||||||
|
organizationId: selectedOrganization?.id,
|
||||||
|
);
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
attendanceLogs =
|
attendanceLogs =
|
||||||
response.map((e) => AttendanceLogModel.fromJson(e)).toList();
|
response.map((e) => AttendanceLogModel.fromJson(e)).toList();
|
||||||
@ -306,7 +319,10 @@ class AttendanceController extends GetxController {
|
|||||||
|
|
||||||
isLoadingRegularizationLogs.value = true;
|
isLoadingRegularizationLogs.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getRegularizationLogs(projectId);
|
final response = await ApiService.getRegularizationLogs(
|
||||||
|
projectId,
|
||||||
|
organizationId: selectedOrganization?.id,
|
||||||
|
);
|
||||||
if (response != null) {
|
if (response != null) {
|
||||||
regularizationLogs =
|
regularizationLogs =
|
||||||
response.map((e) => RegularizationLogModel.fromJson(e)).toList();
|
response.map((e) => RegularizationLogModel.fromJson(e)).toList();
|
||||||
@ -354,14 +370,28 @@ class AttendanceController extends GetxController {
|
|||||||
Future<void> fetchProjectData(String? projectId) async {
|
Future<void> fetchProjectData(String? projectId) async {
|
||||||
if (projectId == null) return;
|
if (projectId == null) return;
|
||||||
|
|
||||||
await Future.wait([
|
await fetchOrganizations(projectId);
|
||||||
fetchEmployeesByProject(projectId),
|
|
||||||
fetchAttendanceLogs(projectId,
|
|
||||||
dateFrom: startDateAttendance, dateTo: endDateAttendance),
|
|
||||||
fetchRegularizationLogs(projectId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
logSafe("Project data fetched for project ID: $projectId");
|
// Call APIs depending on the selected tab only
|
||||||
|
switch (selectedTab) {
|
||||||
|
case 'todaysAttendance':
|
||||||
|
await fetchTodaysAttendance(projectId);
|
||||||
|
break;
|
||||||
|
case 'attendanceLogs':
|
||||||
|
await fetchAttendanceLogs(
|
||||||
|
projectId,
|
||||||
|
dateFrom: startDateAttendance,
|
||||||
|
dateTo: endDateAttendance,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'regularizationRequests':
|
||||||
|
await fetchRegularizationLogs(projectId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
"Project data fetched for project ID: $projectId, tab: $selectedTab");
|
||||||
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------ UI Interaction ------------------
|
// ------------------ UI Interaction ------------------
|
||||||
|
@ -79,7 +79,6 @@ 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!);
|
||||||
@ -90,9 +89,9 @@ class LoginController extends MyController {
|
|||||||
level: LogLevel.warning);
|
level: LogLevel.warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
logSafe("Login successful for user: ${loginData['username']}");
|
logSafe("Login successful for user: ${loginData['username']}");
|
||||||
Get.toNamed('/home');
|
|
||||||
|
Get.toNamed('/select_tenant');
|
||||||
}
|
}
|
||||||
} catch (e, stacktrace) {
|
} catch (e, stacktrace) {
|
||||||
logSafe("Exception during login",
|
logSafe("Exception during login",
|
||||||
|
@ -94,8 +94,9 @@ class AddContactController extends GetxController {
|
|||||||
required List<Map<String, String>> phones,
|
required List<Map<String, String>> phones,
|
||||||
required String address,
|
required String address,
|
||||||
required String description,
|
required String description,
|
||||||
|
String? designation,
|
||||||
}) async {
|
}) async {
|
||||||
if (isSubmitting.value) return;
|
if (isSubmitting.value) return;
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
|
|
||||||
final categoryId = categoriesMap[selectedCategory.value];
|
final categoryId = categoriesMap[selectedCategory.value];
|
||||||
@ -156,6 +157,8 @@ class AddContactController extends GetxController {
|
|||||||
if (phones.isNotEmpty) "contactPhones": phones,
|
if (phones.isNotEmpty) "contactPhones": phones,
|
||||||
if (address.trim().isNotEmpty) "address": address.trim(),
|
if (address.trim().isNotEmpty) "address": address.trim(),
|
||||||
if (description.trim().isNotEmpty) "description": description.trim(),
|
if (description.trim().isNotEmpty) "description": description.trim(),
|
||||||
|
if (designation != null && designation.trim().isNotEmpty)
|
||||||
|
"designation": designation.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
|
logSafe("${id != null ? 'Updating' : 'Creating'} contact");
|
||||||
|
@ -97,10 +97,13 @@ class DirectoryController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchCommentsForContact(String contactId) async {
|
Future<void> fetchCommentsForContact(String contactId,
|
||||||
|
{bool active = true}) async {
|
||||||
try {
|
try {
|
||||||
final data = await ApiService.getDirectoryComments(contactId);
|
final data =
|
||||||
logSafe("Fetched comments for contact $contactId: $data");
|
await ApiService.getDirectoryComments(contactId, active: active);
|
||||||
|
logSafe(
|
||||||
|
"Fetched ${active ? 'active' : 'inactive'} comments for contact $contactId: $data");
|
||||||
|
|
||||||
final comments =
|
final comments =
|
||||||
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
|
data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? [];
|
||||||
@ -112,7 +115,8 @@ class DirectoryController extends GetxController {
|
|||||||
contactCommentsMap[contactId]!.assignAll(comments);
|
contactCommentsMap[contactId]!.assignAll(comments);
|
||||||
contactCommentsMap[contactId]?.refresh();
|
contactCommentsMap[contactId]?.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("Error fetching comments for contact $contactId: $e",
|
logSafe(
|
||||||
|
"Error fetching ${active ? 'active' : 'inactive'} comments for contact $contactId: $e",
|
||||||
level: LogLevel.error);
|
level: LogLevel.error);
|
||||||
|
|
||||||
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
|
contactCommentsMap[contactId] ??= <DirectoryComment>[].obs;
|
||||||
@ -120,6 +124,80 @@ class DirectoryController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🗑️ Delete a comment (soft delete)
|
||||||
|
Future<void> deleteComment(String commentId, String contactId) async {
|
||||||
|
try {
|
||||||
|
logSafe("Deleting comment. id: $commentId");
|
||||||
|
|
||||||
|
final success = await ApiService.restoreContactComment(commentId, false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logSafe("Comment deleted successfully. id: $commentId");
|
||||||
|
|
||||||
|
// Refresh comments after deletion
|
||||||
|
await fetchCommentsForContact(contactId);
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Deleted",
|
||||||
|
message: "Comment deleted successfully.",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to delete comment via API. id: $commentId");
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to delete comment.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Delete comment failed: ${e.toString()}", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Something went wrong while deleting comment.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ♻️ Restore a previously deleted comment
|
||||||
|
Future<void> restoreComment(String commentId, String contactId) async {
|
||||||
|
try {
|
||||||
|
logSafe("Restoring comment. id: $commentId");
|
||||||
|
|
||||||
|
final success = await ApiService.restoreContactComment(commentId, true);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logSafe("Comment restored successfully. id: $commentId");
|
||||||
|
|
||||||
|
// Refresh comments after restore
|
||||||
|
await fetchCommentsForContact(contactId);
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Restored",
|
||||||
|
message: "Comment restored successfully.",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to restore comment via API. id: $commentId");
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to restore comment.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Restore comment failed: ${e.toString()}", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Something went wrong while restoring comment.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchBuckets() async {
|
Future<void> fetchBuckets() async {
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.getContactBucketList();
|
final response = await ApiService.getContactBucketList();
|
||||||
|
@ -107,6 +107,49 @@ class NotesController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> restoreOrDeleteNote(NoteModel note,
|
||||||
|
{bool restore = true}) async {
|
||||||
|
final action = restore ? "restore" : "delete";
|
||||||
|
|
||||||
|
try {
|
||||||
|
logSafe("Attempting to $action note id: ${note.id}");
|
||||||
|
|
||||||
|
final success = await ApiService.restoreContactComment(
|
||||||
|
note.id,
|
||||||
|
restore, // true = restore, false = delete
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
final index = notesList.indexWhere((n) => n.id == note.id);
|
||||||
|
if (index != -1) {
|
||||||
|
notesList[index] = note.copyWith(isActive: restore);
|
||||||
|
notesList.refresh();
|
||||||
|
}
|
||||||
|
showAppSnackbar(
|
||||||
|
title: restore ? "Restored" : "Deleted",
|
||||||
|
message: restore
|
||||||
|
? "Note has been restored successfully."
|
||||||
|
: "Note has been deleted successfully.",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message:
|
||||||
|
restore ? "Failed to restore note." : "Failed to delete note.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
logSafe("$action note failed: $e", error: e, stackTrace: st);
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Something went wrong while trying to $action the note.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void addNote(NoteModel note) {
|
void addNote(NoteModel note) {
|
||||||
notesList.insert(0, note);
|
notesList.insert(0, note);
|
||||||
logSafe("Note added to list");
|
logSafe("Note added to list");
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/my_controller.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:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_form_validator.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
|
|
||||||
enum Gender {
|
enum Gender {
|
||||||
male,
|
male,
|
||||||
@ -18,22 +18,26 @@ enum Gender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AddEmployeeController extends MyController {
|
class AddEmployeeController extends MyController {
|
||||||
Map<String, dynamic>? editingEmployeeData; // For edit mode
|
Map<String, dynamic>? editingEmployeeData;
|
||||||
|
|
||||||
List<PlatformFile> files = [];
|
// State
|
||||||
final MyFormValidator basicValidator = MyFormValidator();
|
final MyFormValidator basicValidator = MyFormValidator();
|
||||||
|
final List<PlatformFile> files = [];
|
||||||
|
final List<String> categories = [];
|
||||||
|
|
||||||
Gender? selectedGender;
|
Gender? selectedGender;
|
||||||
List<Map<String, dynamic>> roles = [];
|
List<Map<String, dynamic>> roles = [];
|
||||||
String? selectedRoleId;
|
String? selectedRoleId;
|
||||||
String selectedCountryCode = "+91";
|
String selectedCountryCode = '+91';
|
||||||
bool showOnline = true;
|
bool showOnline = true;
|
||||||
final List<String> categories = [];
|
|
||||||
DateTime? joiningDate;
|
DateTime? joiningDate;
|
||||||
|
String? selectedOrganizationId;
|
||||||
|
RxString selectedOrganizationName = RxString('');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
logSafe("Initializing AddEmployeeController...");
|
logSafe('Initializing AddEmployeeController...');
|
||||||
_initializeFields();
|
_initializeFields();
|
||||||
fetchRoles();
|
fetchRoles();
|
||||||
|
|
||||||
@ -45,29 +49,36 @@ class AddEmployeeController extends MyController {
|
|||||||
void _initializeFields() {
|
void _initializeFields() {
|
||||||
basicValidator.addField(
|
basicValidator.addField(
|
||||||
'first_name',
|
'first_name',
|
||||||
label: "First Name",
|
label: 'First Name',
|
||||||
required: true,
|
required: true,
|
||||||
controller: TextEditingController(),
|
controller: TextEditingController(),
|
||||||
);
|
);
|
||||||
basicValidator.addField(
|
basicValidator.addField(
|
||||||
'phone_number',
|
'phone_number',
|
||||||
label: "Phone Number",
|
label: 'Phone Number',
|
||||||
required: true,
|
required: true,
|
||||||
controller: TextEditingController(),
|
controller: TextEditingController(),
|
||||||
);
|
);
|
||||||
basicValidator.addField(
|
basicValidator.addField(
|
||||||
'last_name',
|
'last_name',
|
||||||
label: "Last Name",
|
label: 'Last Name',
|
||||||
required: true,
|
required: true,
|
||||||
controller: TextEditingController(),
|
controller: TextEditingController(),
|
||||||
);
|
);
|
||||||
logSafe("Fields initialized for first_name, phone_number, last_name.");
|
// Email is optional in controller; UI enforces when application access is checked
|
||||||
|
basicValidator.addField(
|
||||||
|
'email',
|
||||||
|
label: 'Email',
|
||||||
|
required: false,
|
||||||
|
controller: TextEditingController(),
|
||||||
|
);
|
||||||
|
|
||||||
|
logSafe('Fields initialized for first_name, phone_number, last_name, email.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefill fields in edit mode
|
// Prefill fields in edit mode
|
||||||
// In AddEmployeeController
|
|
||||||
void prefillFields() {
|
void prefillFields() {
|
||||||
logSafe("Prefilling data for editing...");
|
logSafe('Prefilling data for editing...');
|
||||||
basicValidator.getController('first_name')?.text =
|
basicValidator.getController('first_name')?.text =
|
||||||
editingEmployeeData?['first_name'] ?? '';
|
editingEmployeeData?['first_name'] ?? '';
|
||||||
basicValidator.getController('last_name')?.text =
|
basicValidator.getController('last_name')?.text =
|
||||||
@ -76,10 +87,12 @@ class AddEmployeeController extends MyController {
|
|||||||
editingEmployeeData?['phone_number'] ?? '';
|
editingEmployeeData?['phone_number'] ?? '';
|
||||||
|
|
||||||
selectedGender = editingEmployeeData?['gender'] != null
|
selectedGender = editingEmployeeData?['gender'] != null
|
||||||
? Gender.values
|
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
|
||||||
.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
basicValidator.getController('email')?.text =
|
||||||
|
editingEmployeeData?['email'] ?? '';
|
||||||
|
|
||||||
selectedRoleId = editingEmployeeData?['job_role_id'];
|
selectedRoleId = editingEmployeeData?['job_role_id'];
|
||||||
|
|
||||||
if (editingEmployeeData?['joining_date'] != null) {
|
if (editingEmployeeData?['joining_date'] != null) {
|
||||||
@ -91,92 +104,102 @@ class AddEmployeeController extends MyController {
|
|||||||
|
|
||||||
void setJoiningDate(DateTime date) {
|
void setJoiningDate(DateTime date) {
|
||||||
joiningDate = date;
|
joiningDate = date;
|
||||||
logSafe("Joining date selected: $date");
|
logSafe('Joining date selected: $date');
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onGenderSelected(Gender? gender) {
|
void onGenderSelected(Gender? gender) {
|
||||||
selectedGender = gender;
|
selectedGender = gender;
|
||||||
logSafe("Gender selected: ${gender?.name}");
|
logSafe('Gender selected: ${gender?.name}');
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchRoles() async {
|
Future<void> fetchRoles() async {
|
||||||
logSafe("Fetching roles...");
|
logSafe('Fetching roles...');
|
||||||
try {
|
try {
|
||||||
final result = await ApiService.getRoles();
|
final result = await ApiService.getRoles();
|
||||||
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.');
|
||||||
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",
|
logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st);
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onRoleSelected(String? roleId) {
|
void onRoleSelected(String? roleId) {
|
||||||
selectedRoleId = roleId;
|
selectedRoleId = roleId;
|
||||||
logSafe("Role selected: $roleId");
|
logSafe('Role selected: $roleId');
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create or update employee
|
// Create or update employee
|
||||||
Future<Map<String, dynamic>?> createOrUpdateEmployee() async {
|
Future<Map<String, dynamic>?> createOrUpdateEmployee({
|
||||||
|
String? email,
|
||||||
|
bool hasApplicationAccess = false,
|
||||||
|
}) async {
|
||||||
logSafe(editingEmployeeData != null
|
logSafe(editingEmployeeData != null
|
||||||
? "Starting employee update..."
|
? 'Starting employee update...'
|
||||||
: "Starting employee creation...");
|
: 'Starting employee creation...');
|
||||||
|
|
||||||
if (selectedGender == null || selectedRoleId == null) {
|
if (selectedGender == null || selectedRoleId == null) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Missing Fields",
|
title: 'Missing Fields',
|
||||||
message: "Please select both Gender and Role.",
|
message: 'Please select both Gender and Role.',
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
final phoneNumber = basicValidator.getController('phone_number')?.text.trim();
|
||||||
basicValidator.getController("phone_number")?.text.trim();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// sanitize orgId before sending
|
||||||
|
final String? orgId = (selectedOrganizationId != null &&
|
||||||
|
selectedOrganizationId!.trim().isNotEmpty)
|
||||||
|
? selectedOrganizationId
|
||||||
|
: null;
|
||||||
|
|
||||||
final response = await ApiService.createEmployee(
|
final response = await ApiService.createEmployee(
|
||||||
id: editingEmployeeData?['id'], // Pass id if editing
|
id: editingEmployeeData?['id'],
|
||||||
firstName: firstName!,
|
firstName: firstName!,
|
||||||
lastName: lastName!,
|
lastName: lastName!,
|
||||||
phoneNumber: phoneNumber!,
|
phoneNumber: phoneNumber!,
|
||||||
gender: selectedGender!.name,
|
gender: selectedGender!.name,
|
||||||
jobRoleId: selectedRoleId!,
|
jobRoleId: selectedRoleId!,
|
||||||
joiningDate: joiningDate?.toIso8601String() ?? "",
|
joiningDate: joiningDate?.toIso8601String() ?? '',
|
||||||
|
organizationId: orgId,
|
||||||
|
email: email,
|
||||||
|
hasApplicationAccess: hasApplicationAccess,
|
||||||
);
|
);
|
||||||
|
|
||||||
logSafe("Response: $response");
|
logSafe('Response: $response');
|
||||||
|
|
||||||
if (response != null && response['success'] == true) {
|
if (response != null && response['success'] == true) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Success",
|
title: 'Success',
|
||||||
message: editingEmployeeData != null
|
message: editingEmployeeData != null
|
||||||
? "Employee updated successfully!"
|
? 'Employee updated successfully!'
|
||||||
: "Employee created successfully!",
|
: 'Employee created successfully!',
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success,
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
} else {
|
} else {
|
||||||
logSafe("Failed operation", level: LogLevel.error);
|
logSafe('Failed operation', level: LogLevel.error);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logSafe("Error creating/updating employee",
|
logSafe('Error creating/updating employee',
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
}
|
}
|
||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: 'Error',
|
||||||
message: "Failed to save employee.",
|
message: 'Failed to save employee.',
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
@ -192,9 +215,8 @@ class AddEmployeeController extends MyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Permission Required",
|
title: 'Permission Required',
|
||||||
message:
|
message: 'Please allow Contacts permission from settings to pick a contact.',
|
||||||
"Please allow Contacts permission from settings to pick a contact.",
|
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@ -212,8 +234,8 @@ class AddEmployeeController extends MyController {
|
|||||||
await FlutterContacts.getContact(picked.id, withProperties: true);
|
await FlutterContacts.getContact(picked.id, withProperties: true);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: 'Error',
|
||||||
message: "Failed to load contact details.",
|
message: 'Failed to load contact details.',
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -221,8 +243,8 @@ class AddEmployeeController extends MyController {
|
|||||||
|
|
||||||
if (contact.phones.isEmpty) {
|
if (contact.phones.isEmpty) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "No Phone Number",
|
title: 'No Phone Number',
|
||||||
message: "Selected contact has no phone number.",
|
message: 'Selected contact has no phone number.',
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -236,8 +258,8 @@ class AddEmployeeController extends MyController {
|
|||||||
|
|
||||||
if (indiaPhones.isEmpty) {
|
if (indiaPhones.isEmpty) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "No Indian Number",
|
title: 'No Indian Number',
|
||||||
message: "Selected contact has no Indian (+91) phone number.",
|
message: 'Selected contact has no Indian (+91) phone number.',
|
||||||
type: SnackbarType.warning,
|
type: SnackbarType.warning,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -250,19 +272,20 @@ class AddEmployeeController extends MyController {
|
|||||||
selectedPhone = await showDialog<String>(
|
selectedPhone = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text("Choose an Indian number"),
|
title: const Text('Choose an Indian number'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: indiaPhones
|
children: indiaPhones
|
||||||
.map((p) => ListTile(
|
.map(
|
||||||
title: Text(p.number),
|
(p) => ListTile(
|
||||||
onTap: () => Navigator.of(ctx).pop(p.number),
|
title: Text(p.number),
|
||||||
))
|
onTap: () => Navigator.of(ctx).pop(p.number),
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedPhone == null) return;
|
if (selectedPhone == null) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,11 +298,11 @@ class AddEmployeeController extends MyController {
|
|||||||
phoneWithoutCountryCode;
|
phoneWithoutCountryCode;
|
||||||
update();
|
update();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logSafe("Error fetching contacts",
|
logSafe('Error fetching contacts',
|
||||||
level: LogLevel.error, error: e, stackTrace: st);
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: 'Error',
|
||||||
message: "Failed to fetch contacts.",
|
message: 'Failed to fetch contacts.',
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ class EmployeesScreenController extends GetxController {
|
|||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
fetchAllProjects().then((_) {
|
fetchAllProjects().then((_) {
|
||||||
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
if (projectId != null) {
|
if (projectId != null) {
|
||||||
@ -66,21 +66,26 @@ class EmployeesScreenController extends GetxController {
|
|||||||
update(['employee_screen_controller']);
|
update(['employee_screen_controller']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchAllEmployees() async {
|
Future<void> fetchAllEmployees({String? organizationId}) async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
update(['employee_screen_controller']);
|
update(['employee_screen_controller']);
|
||||||
|
|
||||||
await _handleApiCall(
|
await _handleApiCall(
|
||||||
ApiService.getAllEmployees,
|
() => ApiService.getAllEmployees(
|
||||||
|
organizationId: organizationId), // pass orgId to API
|
||||||
onSuccess: (data) {
|
onSuccess: (data) {
|
||||||
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
||||||
logSafe("All Employees fetched: ${employees.length} employees loaded.",
|
logSafe(
|
||||||
level: LogLevel.info);
|
"All Employees fetched: ${employees.length} employees loaded.",
|
||||||
|
level: LogLevel.info,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onEmpty: () {
|
onEmpty: () {
|
||||||
employees.clear();
|
employees.clear();
|
||||||
logSafe("No Employee data found or API call failed.",
|
logSafe(
|
||||||
level: LogLevel.warning);
|
"No Employee data found or API call failed",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -88,43 +93,22 @@ class EmployeesScreenController extends GetxController {
|
|||||||
update(['employee_screen_controller']);
|
update(['employee_screen_controller']);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchEmployeesByProject(String? projectId) async {
|
Future<void> fetchEmployeesByProject(String projectId,
|
||||||
if (projectId == null || projectId.isEmpty) {
|
{String? organizationId}) async {
|
||||||
logSafe("Project ID is required but was null or empty.",
|
if (projectId.isEmpty) return;
|
||||||
level: LogLevel.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
await _handleApiCall(
|
await _handleApiCall(
|
||||||
() => ApiService.getAllEmployeesByProject(projectId),
|
() => ApiService.getAllEmployeesByProject(projectId,
|
||||||
|
organizationId: organizationId),
|
||||||
onSuccess: (data) {
|
onSuccess: (data) {
|
||||||
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
||||||
|
|
||||||
for (var emp in employees) {
|
for (var emp in employees) {
|
||||||
uploadingStates[emp.id] = false.obs;
|
uploadingStates[emp.id] = false.obs;
|
||||||
}
|
}
|
||||||
|
|
||||||
logSafe(
|
|
||||||
"Employees fetched: ${employees.length} for project $projectId",
|
|
||||||
level: LogLevel.info,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onEmpty: () {
|
|
||||||
employees.clear();
|
|
||||||
logSafe(
|
|
||||||
"No employees found for project $projectId.",
|
|
||||||
level: LogLevel.warning,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (e) {
|
|
||||||
logSafe(
|
|
||||||
"Error fetching employees for project $projectId",
|
|
||||||
level: LogLevel.error,
|
|
||||||
error: e,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
onEmpty: () => employees.clear(),
|
||||||
);
|
);
|
||||||
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
@ -24,8 +24,12 @@ class DailyTaskController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RxBool isLoading = true.obs;
|
RxBool isLoading = true.obs;
|
||||||
|
RxBool isLoadingMore = false.obs;
|
||||||
Map<String, List<TaskModel>> groupedDailyTasks = {};
|
Map<String, List<TaskModel>> groupedDailyTasks = {};
|
||||||
|
// Pagination
|
||||||
|
int currentPage = 1;
|
||||||
|
int pageSize = 20;
|
||||||
|
bool hasMore = true;
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
@ -47,48 +51,49 @@ class DailyTaskController extends GetxController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchTaskData(String? projectId) async {
|
Future<void> fetchTaskData(
|
||||||
if (projectId == null) {
|
String projectId, {
|
||||||
logSafe("fetchTaskData: Skipped, projectId is null",
|
List<String>? serviceIds,
|
||||||
level: LogLevel.warning);
|
int pageNumber = 1,
|
||||||
return;
|
int pageSize = 20,
|
||||||
|
bool isLoadMore = false,
|
||||||
|
}) async {
|
||||||
|
if (!isLoadMore) {
|
||||||
|
isLoading.value = true;
|
||||||
|
currentPage = 1;
|
||||||
|
hasMore = true;
|
||||||
|
groupedDailyTasks.clear();
|
||||||
|
dailyTasks.clear();
|
||||||
|
} else {
|
||||||
|
isLoadingMore.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
final response = await ApiService.getDailyTasks(
|
final response = await ApiService.getDailyTasks(
|
||||||
projectId,
|
projectId,
|
||||||
dateFrom: startDateTask,
|
dateFrom: startDateTask,
|
||||||
dateTo: endDateTask,
|
dateTo: endDateTask,
|
||||||
|
serviceIds: serviceIds,
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
pageSize: pageSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
isLoading.value = false;
|
if (response != null && response.isNotEmpty) {
|
||||||
|
|
||||||
if (response != null) {
|
|
||||||
groupedDailyTasks.clear();
|
|
||||||
|
|
||||||
for (var taskJson in response) {
|
for (var taskJson in response) {
|
||||||
final task = TaskModel.fromJson(taskJson);
|
final task = TaskModel.fromJson(taskJson);
|
||||||
final assignmentDateKey =
|
final assignmentDateKey =
|
||||||
task.assignmentDate.toIso8601String().split('T')[0];
|
task.assignmentDate.toIso8601String().split('T')[0];
|
||||||
|
|
||||||
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
|
groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
|
dailyTasks = groupedDailyTasks.values.expand((list) => list).toList();
|
||||||
|
currentPage = pageNumber;
|
||||||
logSafe(
|
|
||||||
"Daily tasks fetched and grouped: ${dailyTasks.length} for project $projectId",
|
|
||||||
level: LogLevel.info,
|
|
||||||
);
|
|
||||||
|
|
||||||
update();
|
|
||||||
} else {
|
} else {
|
||||||
logSafe(
|
hasMore = false;
|
||||||
"Failed to fetch daily tasks for project $projectId",
|
|
||||||
level: LogLevel.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isLoading.value = false;
|
||||||
|
isLoadingMore.value = false;
|
||||||
|
|
||||||
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> selectDateRangeForTaskData(
|
Future<void> selectDateRangeForTaskData(
|
||||||
@ -119,17 +124,23 @@ class DailyTaskController extends GetxController {
|
|||||||
level: LogLevel.info,
|
level: LogLevel.info,
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.fetchTaskData(controller.selectedProjectId);
|
// ✅ 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({
|
void refreshTasksFromNotification({
|
||||||
required String projectId,
|
required String projectId,
|
||||||
required String taskAllocationId,
|
required String taskAllocationId,
|
||||||
}) async {
|
}) async {
|
||||||
// re-fetch tasks
|
// re-fetch tasks
|
||||||
await fetchTaskData(projectId);
|
await fetchTaskData(projectId);
|
||||||
|
|
||||||
update(); // rebuilds UI
|
|
||||||
}
|
|
||||||
|
|
||||||
|
update(); // rebuilds UI
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,7 +131,7 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch Infra details and then tasks per work area
|
/// Fetch Infra details and then tasks per work area
|
||||||
Future<void> fetchTaskData(String? projectId) async {
|
Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
|
||||||
if (projectId == null) {
|
if (projectId == null) {
|
||||||
logSafe("Project ID is null", level: LogLevel.warning);
|
logSafe("Project ID is null", level: LogLevel.warning);
|
||||||
return;
|
return;
|
||||||
@ -139,6 +139,7 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
// Fetch infra details
|
||||||
final infraResponse = await ApiService.getInfraDetails(projectId);
|
final infraResponse = await ApiService.getInfraDetails(projectId);
|
||||||
final infraData = infraResponse?['data'] as List<dynamic>?;
|
final infraData = infraResponse?['data'] as List<dynamic>?;
|
||||||
|
|
||||||
@ -159,11 +160,12 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
return Floor(
|
return Floor(
|
||||||
id: floorJson['id'],
|
id: floorJson['id'],
|
||||||
floorName: floorJson['floorName'],
|
floorName: floorJson['floorName'],
|
||||||
workAreas: (floorJson['workAreas'] as List<dynamic>).map((areaJson) {
|
workAreas:
|
||||||
|
(floorJson['workAreas'] as List<dynamic>).map((areaJson) {
|
||||||
return WorkArea(
|
return WorkArea(
|
||||||
id: areaJson['id'],
|
id: areaJson['id'],
|
||||||
areaName: areaJson['areaName'],
|
areaName: areaJson['areaName'],
|
||||||
workItems: [], // Initially empty, will fill after tasks API
|
workItems: [], // Will fill after tasks API
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
@ -182,13 +184,17 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Fetch tasks for each work area
|
// Fetch tasks for each work area, passing serviceId only if selected
|
||||||
await Future.wait(dailyTasks.expand((task) => task.buildings)
|
await Future.wait(dailyTasks
|
||||||
|
.expand((task) => task.buildings)
|
||||||
.expand((b) => b.floors)
|
.expand((b) => b.floors)
|
||||||
.expand((f) => f.workAreas)
|
.expand((f) => f.workAreas)
|
||||||
.map((area) async {
|
.map((area) async {
|
||||||
try {
|
try {
|
||||||
final taskResponse = await ApiService.getWorkItemsByWorkArea(area.id);
|
final taskResponse = await ApiService.getWorkItemsByWorkArea(
|
||||||
|
area.id,
|
||||||
|
// serviceId: serviceId, // <-- only pass if not null
|
||||||
|
);
|
||||||
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
|
final taskData = taskResponse?['data'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
area.workItems.addAll(taskData.map((taskJson) {
|
area.workItems.addAll(taskData.map((taskJson) {
|
||||||
@ -200,11 +206,13 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
? ActivityMaster.fromJson(taskJson['activityMaster'])
|
? ActivityMaster.fromJson(taskJson['activityMaster'])
|
||||||
: null,
|
: null,
|
||||||
workCategoryMaster: taskJson['workCategoryMaster'] != null
|
workCategoryMaster: taskJson['workCategoryMaster'] != null
|
||||||
? WorkCategoryMaster.fromJson(taskJson['workCategoryMaster'])
|
? WorkCategoryMaster.fromJson(
|
||||||
|
taskJson['workCategoryMaster'])
|
||||||
: null,
|
: null,
|
||||||
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
|
plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(),
|
||||||
completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
|
completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
|
||||||
todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(),
|
todaysAssigned:
|
||||||
|
(taskJson['todaysAssigned'] as num?)?.toDouble(),
|
||||||
description: taskJson['description'] as String?,
|
description: taskJson['description'] as String?,
|
||||||
taskDate: taskJson['taskDate'] != null
|
taskDate: taskJson['taskDate'] != null
|
||||||
? DateTime.tryParse(taskJson['taskDate'])
|
? DateTime.tryParse(taskJson['taskDate'])
|
||||||
@ -221,7 +229,8 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
logSafe("Fetched infra and tasks for project $projectId",
|
logSafe("Fetched infra and tasks for project $projectId",
|
||||||
level: LogLevel.info);
|
level: LogLevel.info);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack);
|
logSafe("Error fetching daily task data",
|
||||||
|
level: LogLevel.error, error: e, stackTrace: stack);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
update();
|
update();
|
||||||
|
52
lib/controller/tenant/organization_selection_controller.dart
Normal file
52
lib/controller/tenant/organization_selection_controller.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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";
|
||||||
|
}
|
43
lib/controller/tenant/service_controller.dart
Normal file
43
lib/controller/tenant/service_controller.dart
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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";
|
||||||
|
}
|
106
lib/controller/tenant/tenant_selection_controller.dart
Normal file
106
lib/controller/tenant/tenant_selection_controller.dart
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -14,7 +14,7 @@ class ApiEndpoints {
|
|||||||
// 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";
|
||||||
static const String getEmployeesByProject = "/attendance/project/team";
|
static const String getTodaysAttendance = "/attendance/project/team";
|
||||||
static const String getAttendanceLogs = "/attendance/project/log";
|
static const String getAttendanceLogs = "/attendance/project/log";
|
||||||
static const String getAttendanceLogView = "/attendance/log/attendance";
|
static const String getAttendanceLogView = "/attendance/log/attendance";
|
||||||
static const String getRegularizationLogs = "/attendance/regularize";
|
static const String getRegularizationLogs = "/attendance/regularize";
|
||||||
@ -25,7 +25,7 @@ class ApiEndpoints {
|
|||||||
static const String getAllEmployees = "/employee/list";
|
static const String getAllEmployees = "/employee/list";
|
||||||
static const String getEmployeesWithoutPermission = "/employee/basic";
|
static const String getEmployeesWithoutPermission = "/employee/basic";
|
||||||
static const String getRoles = "/roles/jobrole";
|
static const String getRoles = "/roles/jobrole";
|
||||||
static const String createEmployee = "/employee/manage-mobile";
|
static const String createEmployee = "/employee/app/manage";
|
||||||
static const String getEmployeeInfo = "/employee/profile/get";
|
static const String getEmployeeInfo = "/employee/profile/get";
|
||||||
static const String assignEmployee = "/employee/profile/get";
|
static const String assignEmployee = "/employee/profile/get";
|
||||||
static const String getAssignedProjects = "/project/assigned-projects";
|
static const String getAssignedProjects = "/project/assigned-projects";
|
||||||
@ -90,4 +90,8 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
/// Logs Module API Endpoints
|
/// Logs Module API Endpoints
|
||||||
static const String uploadLogs = "/log";
|
static const String uploadLogs = "/log";
|
||||||
|
|
||||||
|
static const String getAssignedOrganizations =
|
||||||
|
"/project/get/assigned/organization";
|
||||||
|
static const String getAssignedServices = "/Project/get/assigned/services";
|
||||||
}
|
}
|
||||||
|
@ -18,9 +18,10 @@ import 'package:marco/model/document/master_document_tags.dart';
|
|||||||
import 'package:marco/model/document/master_document_type_model.dart';
|
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/tenant/tenant_services_model.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const Duration timeout = Duration(seconds: 30);
|
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
static const Duration extendedTimeout = Duration(seconds: 60);
|
static const Duration extendedTimeout = Duration(seconds: 60);
|
||||||
|
|
||||||
@ -137,8 +138,9 @@ class ApiService {
|
|||||||
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
|
logSafe("Headers: ${_headers(token)}", level: LogLevel.debug);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response =
|
final response = await http
|
||||||
await http.get(uri, headers: _headers(token)).timeout(timeout);
|
.get(uri, headers: _headers(token))
|
||||||
|
.timeout(extendedTimeout);
|
||||||
|
|
||||||
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
|
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
|
||||||
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
|
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
|
||||||
@ -172,7 +174,7 @@ class ApiService {
|
|||||||
static Future<http.Response?> _postRequest(
|
static Future<http.Response?> _postRequest(
|
||||||
String endpoint,
|
String endpoint,
|
||||||
dynamic body, {
|
dynamic body, {
|
||||||
Duration customTimeout = timeout,
|
Duration customTimeout = extendedTimeout,
|
||||||
bool hasRetried = false,
|
bool hasRetried = false,
|
||||||
}) async {
|
}) async {
|
||||||
String? token = await _getToken();
|
String? token = await _getToken();
|
||||||
@ -206,7 +208,7 @@ class ApiService {
|
|||||||
String endpoint,
|
String endpoint,
|
||||||
dynamic body, {
|
dynamic body, {
|
||||||
Map<String, String>? additionalHeaders,
|
Map<String, String>? additionalHeaders,
|
||||||
Duration customTimeout = timeout,
|
Duration customTimeout = extendedTimeout,
|
||||||
bool hasRetried = false,
|
bool hasRetried = false,
|
||||||
}) async {
|
}) async {
|
||||||
String? token = await _getToken();
|
String? token = await _getToken();
|
||||||
@ -247,6 +249,106 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<http.Response?> _deleteRequest(
|
||||||
|
String endpoint, {
|
||||||
|
Map<String, String>? additionalHeaders,
|
||||||
|
Duration customTimeout = extendedTimeout,
|
||||||
|
bool hasRetried = false,
|
||||||
|
}) async {
|
||||||
|
String? token = await _getToken();
|
||||||
|
if (token == null) return null;
|
||||||
|
|
||||||
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint");
|
||||||
|
final headers = {
|
||||||
|
..._headers(token),
|
||||||
|
if (additionalHeaders != null) ...additionalHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
logSafe("DELETE $uri\nHeaders: $headers");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await http.delete(uri, headers: headers).timeout(customTimeout);
|
||||||
|
|
||||||
|
if (response.statusCode == 401 && !hasRetried) {
|
||||||
|
logSafe("Unauthorized DELETE. Attempting token refresh...");
|
||||||
|
if (await AuthService.refreshToken()) {
|
||||||
|
return await _deleteRequest(
|
||||||
|
endpoint,
|
||||||
|
additionalHeaders: additionalHeaders,
|
||||||
|
customTimeout: customTimeout,
|
||||||
|
hasRetried: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
logSafe("HTTP DELETE Exception: $e", level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Organizations assigned to a Project
|
||||||
|
static Future<OrganizationListResponse?> getAssignedOrganizations(
|
||||||
|
String projectId) async {
|
||||||
|
final endpoint = "${ApiEndpoints.getAssignedOrganizations}/$projectId";
|
||||||
|
logSafe("Fetching organizations assigned to projectId: $projectId");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(endpoint);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Assigned Organizations request failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonResponse =
|
||||||
|
_parseResponseForAllData(response, label: "Assigned Organizations");
|
||||||
|
|
||||||
|
if (jsonResponse != null) {
|
||||||
|
return OrganizationListResponse.fromJson(jsonResponse);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during getAssignedOrganizations: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}");
|
||||||
@ -868,8 +970,9 @@ class ApiService {
|
|||||||
|
|
||||||
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
|
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
|
||||||
|
|
||||||
final response =
|
final response = await http
|
||||||
await http.delete(uri, headers: _headers(token)).timeout(timeout);
|
.delete(uri, headers: _headers(token))
|
||||||
|
.timeout(extendedTimeout);
|
||||||
|
|
||||||
logSafe("DELETE expense response status: ${response.statusCode}");
|
logSafe("DELETE expense response status: ${response.statusCode}");
|
||||||
logSafe("DELETE expense response body: ${response.body}");
|
logSafe("DELETE expense response body: ${response.body}");
|
||||||
@ -1281,8 +1384,9 @@ class ApiService {
|
|||||||
|
|
||||||
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
|
logSafe("Sending DELETE request to $uri", level: LogLevel.debug);
|
||||||
|
|
||||||
final response =
|
final response = await http
|
||||||
await http.delete(uri, headers: _headers(token)).timeout(timeout);
|
.delete(uri, headers: _headers(token))
|
||||||
|
.timeout(extendedTimeout);
|
||||||
|
|
||||||
logSafe("DELETE bucket response status: ${response.statusCode}");
|
logSafe("DELETE bucket response status: ${response.statusCode}");
|
||||||
logSafe("DELETE bucket response body: ${response.body}");
|
logSafe("DELETE bucket response body: ${response.body}");
|
||||||
@ -1615,16 +1719,62 @@ class ApiService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getDirectoryComments(String contactId) async {
|
static Future<bool> restoreContactComment(
|
||||||
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId";
|
String commentId,
|
||||||
final response = await _getRequest(url);
|
bool isActive,
|
||||||
final data = response != null
|
) async {
|
||||||
? _parseResponse(response, label: 'Directory Comments')
|
final endpoint =
|
||||||
: null;
|
"${ApiEndpoints.updateDirectoryNotes}/$commentId?active=$isActive";
|
||||||
|
|
||||||
return data is List ? data : null;
|
logSafe(
|
||||||
|
"Updating comment active status. commentId: $commentId, isActive: $isActive");
|
||||||
|
logSafe("Sending request to $endpoint ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _deleteRequest(
|
||||||
|
endpoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Update comment failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Update comment response status: ${response.statusCode}");
|
||||||
|
logSafe("Update comment response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe(
|
||||||
|
"Comment active status updated successfully. commentId: $commentId");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe("Failed to update comment: ${json['message']}",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during updateComment API: ${e.toString()}",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<dynamic>?> getDirectoryComments(
|
||||||
|
String contactId, {
|
||||||
|
bool active = true,
|
||||||
|
}) async {
|
||||||
|
final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active";
|
||||||
|
final response = await _getRequest(url);
|
||||||
|
final data = response != null
|
||||||
|
? _parseResponse(response, label: 'Directory Comments')
|
||||||
|
: 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 {
|
||||||
try {
|
try {
|
||||||
@ -1733,23 +1883,49 @@ class ApiService {
|
|||||||
_getRequest(ApiEndpoints.getGlobalProjects).then((res) =>
|
_getRequest(ApiEndpoints.getGlobalProjects).then((res) =>
|
||||||
res != null ? _parseResponse(res, label: 'Global Projects') : null);
|
res != null ? _parseResponse(res, label: 'Global Projects') : null);
|
||||||
|
|
||||||
static Future<List<dynamic>?> getEmployeesByProject(String projectId) async =>
|
static Future<List<dynamic>?> getTodaysAttendance(
|
||||||
_getRequest(ApiEndpoints.getEmployeesByProject,
|
String projectId, {
|
||||||
queryParams: {"projectId": projectId})
|
String? organizationId,
|
||||||
.then((res) =>
|
}) async {
|
||||||
res != null ? _parseResponse(res, label: 'Employees') : null);
|
final query = {
|
||||||
|
"projectId": projectId,
|
||||||
|
if (organizationId != null) "organizationId": organizationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return _getRequest(ApiEndpoints.getTodaysAttendance, queryParams: query)
|
||||||
|
.then((res) =>
|
||||||
|
res != null ? _parseResponse(res, label: 'Employees') : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<dynamic>?> getRegularizationLogs(
|
||||||
|
String projectId, {
|
||||||
|
String? organizationId,
|
||||||
|
}) async {
|
||||||
|
final query = {
|
||||||
|
"projectId": projectId,
|
||||||
|
if (organizationId != null) "organizationId": organizationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return _getRequest(ApiEndpoints.getRegularizationLogs, queryParams: query)
|
||||||
|
.then((res) => res != null
|
||||||
|
? _parseResponse(res, label: 'Regularization Logs')
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getAttendanceLogs(
|
static Future<List<dynamic>?> getAttendanceLogs(
|
||||||
String projectId, {
|
String projectId, {
|
||||||
DateTime? dateFrom,
|
DateTime? dateFrom,
|
||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
|
String? organizationId,
|
||||||
}) async {
|
}) async {
|
||||||
final query = {
|
final query = {
|
||||||
"projectId": projectId,
|
"projectId": projectId,
|
||||||
if (dateFrom != null)
|
if (dateFrom != null)
|
||||||
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
|
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
|
||||||
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
|
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
|
||||||
|
if (organizationId != null) "organizationId": organizationId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then(
|
return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then(
|
||||||
(res) =>
|
(res) =>
|
||||||
res != null ? _parseResponse(res, label: 'Attendance Logs') : null);
|
res != null ? _parseResponse(res, label: 'Attendance Logs') : null);
|
||||||
@ -1759,13 +1935,6 @@ class ApiService {
|
|||||||
_getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) =>
|
_getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) =>
|
||||||
res != null ? _parseResponse(res, label: 'Log Details') : null);
|
res != null ? _parseResponse(res, label: 'Log Details') : null);
|
||||||
|
|
||||||
static Future<List<dynamic>?> getRegularizationLogs(String projectId) async =>
|
|
||||||
_getRequest(ApiEndpoints.getRegularizationLogs,
|
|
||||||
queryParams: {"projectId": projectId})
|
|
||||||
.then((res) => res != null
|
|
||||||
? _parseResponse(res, label: 'Regularization Logs')
|
|
||||||
: null);
|
|
||||||
|
|
||||||
static Future<bool> uploadAttendanceImage(
|
static Future<bool> uploadAttendanceImage(
|
||||||
String id,
|
String id,
|
||||||
String employeeId,
|
String employeeId,
|
||||||
@ -1859,11 +2028,15 @@ class ApiService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getAllEmployeesByProject(
|
static Future<List<dynamic>?> getAllEmployeesByProject(String projectId,
|
||||||
String projectId) async {
|
{String? organizationId}) async {
|
||||||
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
|
if (projectId.isEmpty) throw ArgumentError('projectId must not be empty');
|
||||||
|
|
||||||
final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId";
|
// Build the endpoint with optional organizationId query
|
||||||
|
var endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId";
|
||||||
|
if (organizationId != null && organizationId.isNotEmpty) {
|
||||||
|
endpoint += "?organizationId=$organizationId";
|
||||||
|
}
|
||||||
|
|
||||||
return _getRequest(endpoint).then(
|
return _getRequest(endpoint).then(
|
||||||
(res) => res != null
|
(res) => res != null
|
||||||
@ -1872,9 +2045,19 @@ class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getAllEmployees() async =>
|
static Future<List<dynamic>?> getAllEmployees(
|
||||||
_getRequest(ApiEndpoints.getAllEmployees).then((res) =>
|
{String? organizationId}) async {
|
||||||
res != null ? _parseResponse(res, label: 'All Employees') : null);
|
var endpoint = ApiEndpoints.getAllEmployees;
|
||||||
|
|
||||||
|
// Add organization filter if provided
|
||||||
|
if (organizationId != null && organizationId.isNotEmpty) {
|
||||||
|
endpoint += "?organizationId=$organizationId";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _getRequest(endpoint).then(
|
||||||
|
(res) => res != null ? _parseResponse(res, label: 'All Employees') : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<dynamic>?> getRoles() async =>
|
static Future<List<dynamic>?> getRoles() async =>
|
||||||
_getRequest(ApiEndpoints.getRoles).then(
|
_getRequest(ApiEndpoints.getRoles).then(
|
||||||
@ -1887,6 +2070,9 @@ class ApiService {
|
|||||||
required String gender,
|
required String gender,
|
||||||
required String jobRoleId,
|
required String jobRoleId,
|
||||||
required String joiningDate,
|
required String joiningDate,
|
||||||
|
String? email,
|
||||||
|
String? organizationId,
|
||||||
|
bool? hasApplicationAccess,
|
||||||
}) async {
|
}) async {
|
||||||
final body = {
|
final body = {
|
||||||
if (id != null) "id": id,
|
if (id != null) "id": id,
|
||||||
@ -1896,6 +2082,11 @@ class ApiService {
|
|||||||
"gender": gender,
|
"gender": gender,
|
||||||
"jobRoleId": jobRoleId,
|
"jobRoleId": jobRoleId,
|
||||||
"joiningDate": joiningDate,
|
"joiningDate": joiningDate,
|
||||||
|
if (email != null && email.isNotEmpty) "email": email,
|
||||||
|
if (organizationId != null && organizationId.isNotEmpty)
|
||||||
|
"organizationId": organizationId,
|
||||||
|
if (hasApplicationAccess != null)
|
||||||
|
"hasApplicationAccess": hasApplicationAccess,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _postRequest(
|
final response = await _postRequest(
|
||||||
@ -1929,16 +2120,32 @@ class ApiService {
|
|||||||
String projectId, {
|
String projectId, {
|
||||||
DateTime? dateFrom,
|
DateTime? dateFrom,
|
||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
|
List<String>? serviceIds,
|
||||||
|
int pageNumber = 1,
|
||||||
|
int pageSize = 20,
|
||||||
}) async {
|
}) async {
|
||||||
|
final filterBody = {
|
||||||
|
"serviceIds": serviceIds ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
final query = {
|
final query = {
|
||||||
"projectId": projectId,
|
"projectId": projectId,
|
||||||
|
"pageNumber": pageNumber.toString(),
|
||||||
|
"pageSize": pageSize.toString(),
|
||||||
if (dateFrom != null)
|
if (dateFrom != null)
|
||||||
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
|
"dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom),
|
||||||
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
|
if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo),
|
||||||
|
"filter": jsonEncode(filterBody),
|
||||||
};
|
};
|
||||||
return _getRequest(ApiEndpoints.getDailyTask, queryParams: query).then(
|
|
||||||
(res) =>
|
final uri =
|
||||||
res != null ? _parseResponse(res, label: 'Daily Tasks') : null);
|
Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query);
|
||||||
|
|
||||||
|
final response = await _getRequest(uri.toString());
|
||||||
|
|
||||||
|
return response != null
|
||||||
|
? _parseResponse(response, label: 'Daily Tasks')
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> reportTask({
|
static Future<bool> reportTask({
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:url_strategy/url_strategy.dart';
|
import 'package:url_strategy/url_strategy.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/services/auth_service.dart';
|
import 'package:marco/helpers/services/auth_service.dart';
|
||||||
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
|
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
|
||||||
import 'package:marco/helpers/services/device_info_service.dart';
|
import 'package:marco/helpers/services/device_info_service.dart';
|
||||||
import 'package:marco/helpers/theme/theme_customizer.dart';
|
import 'package:marco/helpers/theme/theme_customizer.dart';
|
||||||
import 'package:marco/helpers/theme/app_theme.dart';
|
import 'package:marco/helpers/theme/app_theme.dart';
|
||||||
@ -28,7 +27,7 @@ Future<void> initializeApp() async {
|
|||||||
await _handleAuthTokens();
|
await _handleAuthTokens();
|
||||||
await _setupTheme();
|
await _setupTheme();
|
||||||
await _setupControllers();
|
await _setupControllers();
|
||||||
await _setupFirebaseMessaging();
|
await _setupFirebaseMessaging();
|
||||||
|
|
||||||
_finalizeAppStyle();
|
_finalizeAppStyle();
|
||||||
|
|
||||||
@ -47,16 +46,9 @@ Future<void> initializeApp() async {
|
|||||||
Future<void> _setupUI() async {
|
Future<void> _setupUI() async {
|
||||||
setPathUrlStrategy();
|
setPathUrlStrategy();
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
logSafe("💡 UI setup completed with default system behavior.");
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
systemNavigationBarColor: Colors.transparent,
|
|
||||||
statusBarIconBrightness: Brightness.light,
|
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
|
||||||
));
|
|
||||||
logSafe("💡 UI setup completed.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _setupFirebase() async {
|
Future<void> _setupFirebase() async {
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
logSafe("💡 Firebase initialized.");
|
logSafe("💡 Firebase initialized.");
|
||||||
@ -126,7 +118,6 @@ Future<void> _setupFirebaseMessaging() async {
|
|||||||
logSafe("💡 Firebase Messaging initialized.");
|
logSafe("💡 Firebase Messaging initialized.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void _finalizeAppStyle() {
|
void _finalizeAppStyle() {
|
||||||
AppStyle.init();
|
AppStyle.init();
|
||||||
logSafe("💡 AppStyle initialized.");
|
logSafe("💡 AppStyle initialized.");
|
||||||
|
@ -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/login-mobile", data);
|
final responseData = await _post("/auth/app/login", data);
|
||||||
if (responseData == null)
|
if (responseData == null)
|
||||||
return {"error": "Network error. Please check your connection."};
|
return {"error": "Network error. Please check your connection."};
|
||||||
|
|
||||||
|
@ -11,19 +11,23 @@ import 'package:marco/helpers/services/auth_service.dart';
|
|||||||
import 'package:marco/helpers/services/api_endpoints.dart';
|
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||||
|
|
||||||
class PermissionService {
|
class PermissionService {
|
||||||
|
// In-memory cache keyed by user token
|
||||||
static final Map<String, Map<String, dynamic>> _userDataCache = {};
|
static final Map<String, Map<String, dynamic>> _userDataCache = {};
|
||||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||||
|
|
||||||
/// Fetches all user-related data (permissions, employee info, projects)
|
/// Fetches all user-related data (permissions, employee info, projects).
|
||||||
|
/// Uses in-memory cache for repeated token queries during session.
|
||||||
static Future<Map<String, dynamic>> fetchAllUserData(
|
static Future<Map<String, dynamic>> fetchAllUserData(
|
||||||
String token, {
|
String token, {
|
||||||
bool hasRetried = false,
|
bool hasRetried = false,
|
||||||
}) async {
|
}) async {
|
||||||
logSafe("Fetching user data...", );
|
logSafe("Fetching user data...");
|
||||||
|
|
||||||
if (_userDataCache.containsKey(token)) {
|
// Check for cached data before network request
|
||||||
logSafe("User data cache hit.", );
|
final cached = _userDataCache[token];
|
||||||
return _userDataCache[token]!;
|
if (cached != null) {
|
||||||
|
logSafe("User data cache hit.");
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final uri = Uri.parse("$_baseUrl/user/profile");
|
final uri = Uri.parse("$_baseUrl/user/profile");
|
||||||
@ -34,8 +38,8 @@ class PermissionService {
|
|||||||
final statusCode = response.statusCode;
|
final statusCode = response.statusCode;
|
||||||
|
|
||||||
if (statusCode == 200) {
|
if (statusCode == 200) {
|
||||||
logSafe("User data fetched successfully.");
|
final raw = json.decode(response.body);
|
||||||
final data = json.decode(response.body)['data'];
|
final data = raw['data'] as Map<String, dynamic>;
|
||||||
|
|
||||||
final result = {
|
final result = {
|
||||||
'permissions': _parsePermissions(data['featurePermissions']),
|
'permissions': _parsePermissions(data['featurePermissions']),
|
||||||
@ -43,10 +47,12 @@ class PermissionService {
|
|||||||
'projects': _parseProjectsInfo(data['projects']),
|
'projects': _parseProjectsInfo(data['projects']),
|
||||||
};
|
};
|
||||||
|
|
||||||
_userDataCache[token] = result;
|
_userDataCache[token] = result; // Cache it for future use
|
||||||
|
logSafe("User data fetched successfully.");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token expired, try refresh once then redirect on failure
|
||||||
if (statusCode == 401 && !hasRetried) {
|
if (statusCode == 401 && !hasRetried) {
|
||||||
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
|
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
|
||||||
|
|
||||||
@ -63,42 +69,43 @@ class PermissionService {
|
|||||||
throw Exception('Unauthorized. Token refresh failed.');
|
throw Exception('Unauthorized. Token refresh failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final error = json.decode(response.body)['message'] ?? 'Unknown error';
|
final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
|
||||||
logSafe("Failed to fetch user data: $error", level: LogLevel.warning);
|
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
|
||||||
throw Exception('Failed to fetch user data: $error');
|
throw Exception('Failed to fetch user data: $errorMsg');
|
||||||
} catch (e, stacktrace) {
|
} catch (e, stacktrace) {
|
||||||
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
|
||||||
rethrow;
|
rethrow; // Let the caller handle or report
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears auth data and redirects to login
|
/// Handles unauthorized/user sign out flow
|
||||||
static Future<void> _handleUnauthorized() async {
|
static Future<void> _handleUnauthorized() async {
|
||||||
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
|
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
|
||||||
|
|
||||||
await LocalStorage.removeToken('jwt_token');
|
await LocalStorage.removeToken('jwt_token');
|
||||||
await LocalStorage.removeToken('refresh_token');
|
await LocalStorage.removeToken('refresh_token');
|
||||||
await LocalStorage.setLoggedInUser(false);
|
await LocalStorage.setLoggedInUser(false);
|
||||||
Get.offAllNamed('/auth/login-option');
|
Get.offAllNamed('/auth/login-option');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts raw permission data into list of `UserPermission`
|
/// Robust model parsing for permissions
|
||||||
static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
|
static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
|
||||||
logSafe("Parsing user permissions...");
|
logSafe("Parsing user permissions...");
|
||||||
return permissions
|
return permissions
|
||||||
.map((id) => UserPermission.fromJson({'id': id}))
|
.map((perm) => UserPermission.fromJson({'id': perm}))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts raw employee JSON into `EmployeeInfo`
|
/// Robust model parsing for employee info
|
||||||
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic> data) {
|
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
|
||||||
logSafe("Parsing employee info...");
|
logSafe("Parsing employee info...");
|
||||||
|
if (data == null) throw Exception("Employee data missing");
|
||||||
return EmployeeInfo.fromJson(data);
|
return EmployeeInfo.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts raw projects JSON into list of `ProjectInfo`
|
/// Robust model parsing for projects list
|
||||||
static List<ProjectInfo> _parseProjectsInfo(List<dynamic> projects) {
|
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
|
||||||
logSafe("Parsing projects info...");
|
logSafe("Parsing projects info...");
|
||||||
|
if (projects == null) return [];
|
||||||
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
|
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,17 @@ class LocalStorage {
|
|||||||
static const String _isMpinKey = "isMpin";
|
static const String _isMpinKey = "isMpin";
|
||||||
static const String _fcmTokenKey = "fcm_token";
|
static const String _fcmTokenKey = "fcm_token";
|
||||||
static const String _menuStorageKey = "dynamic_menus";
|
static const String _menuStorageKey = "dynamic_menus";
|
||||||
|
// In LocalStorage
|
||||||
|
static const String _recentTenantKey = "recent_tenant_id";
|
||||||
|
|
||||||
|
static Future<bool> setRecentTenantId(String tenantId) =>
|
||||||
|
preferences.setString(_recentTenantKey, tenantId);
|
||||||
|
|
||||||
|
static String? getRecentTenantId() =>
|
||||||
|
_initialized ? preferences.getString(_recentTenantKey) : null;
|
||||||
|
|
||||||
|
static Future<bool> removeRecentTenantId() =>
|
||||||
|
preferences.remove(_recentTenantKey);
|
||||||
|
|
||||||
static SharedPreferences? _preferencesInstance;
|
static SharedPreferences? _preferencesInstance;
|
||||||
static bool _initialized = false;
|
static bool _initialized = false;
|
||||||
@ -76,7 +87,8 @@ class LocalStorage {
|
|||||||
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
||||||
|
|
||||||
// ================== User Permissions ==================
|
// ================== User Permissions ==================
|
||||||
static Future<bool> setUserPermissions(List<UserPermission> permissions) async {
|
static Future<bool> setUserPermissions(
|
||||||
|
List<UserPermission> permissions) async {
|
||||||
final jsonList = permissions.map((e) => e.toJson()).toList();
|
final jsonList = permissions.map((e) => e.toJson()).toList();
|
||||||
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
@ -94,8 +106,8 @@ class LocalStorage {
|
|||||||
preferences.remove(_userPermissionsKey);
|
preferences.remove(_userPermissionsKey);
|
||||||
|
|
||||||
// ================== Employee Info ==================
|
// ================== Employee Info ==================
|
||||||
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) =>
|
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) => preferences
|
||||||
preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
||||||
|
|
||||||
static EmployeeInfo? getEmployeeInfo() {
|
static EmployeeInfo? getEmployeeInfo() {
|
||||||
if (!_initialized) return null;
|
if (!_initialized) return null;
|
||||||
@ -135,6 +147,7 @@ class LocalStorage {
|
|||||||
await removeMpinToken();
|
await removeMpinToken();
|
||||||
await removeIsMpin();
|
await removeIsMpin();
|
||||||
await removeMenus();
|
await removeMenus();
|
||||||
|
await removeRecentTenantId();
|
||||||
await preferences.remove("mpin_verified");
|
await preferences.remove("mpin_verified");
|
||||||
await preferences.remove(_languageKey);
|
await preferences.remove(_languageKey);
|
||||||
await preferences.remove(_themeCustomizerKey);
|
await preferences.remove(_themeCustomizerKey);
|
||||||
|
152
lib/helpers/services/tenant_service.dart
Normal file
152
lib/helpers/services/tenant_service.dart
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -24,8 +24,8 @@ class AttendanceActionColors {
|
|||||||
ButtonActions.rejected: Colors.orange,
|
ButtonActions.rejected: Colors.orange,
|
||||||
ButtonActions.approved: Colors.green,
|
ButtonActions.approved: Colors.green,
|
||||||
ButtonActions.requested: Colors.yellow,
|
ButtonActions.requested: Colors.yellow,
|
||||||
ButtonActions.approve: Colors.blueAccent,
|
ButtonActions.approve: Colors.green,
|
||||||
ButtonActions.reject: Colors.pink,
|
ButtonActions.reject: Colors.red,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|
||||||
|
|
||||||
class AttendanceDashboardChart extends StatelessWidget {
|
class AttendanceDashboardChart extends StatelessWidget {
|
||||||
AttendanceDashboardChart({Key? key}) : super(key: key);
|
AttendanceDashboardChart({Key? key}) : super(key: key);
|
||||||
@ -46,13 +45,9 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
Color(0xFF64B5F6), // Blue 300 (repeat)
|
Color(0xFF64B5F6), // Blue 300 (repeat)
|
||||||
];
|
];
|
||||||
|
|
||||||
static final Map<String, Color> _roleColorMap = {};
|
|
||||||
|
|
||||||
Color _getRoleColor(String role) {
|
Color _getRoleColor(String role) {
|
||||||
return _roleColorMap.putIfAbsent(
|
final index = role.hashCode.abs() % _flatColors.length;
|
||||||
role,
|
return _flatColors[index];
|
||||||
() => _flatColors[_roleColorMap.length % _flatColors.length],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -62,12 +57,9 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isChartView = _controller.attendanceIsChartView.value;
|
final isChartView = _controller.attendanceIsChartView.value;
|
||||||
final selectedRange = _controller.attendanceSelectedRange.value;
|
final selectedRange = _controller.attendanceSelectedRange.value;
|
||||||
final isLoading = _controller.isAttendanceLoading.value;
|
|
||||||
|
|
||||||
final filteredData = _getFilteredData();
|
final filteredData = _getFilteredData();
|
||||||
if (isLoading) {
|
|
||||||
return SkeletonLoaders.buildLoadingSkeleton();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: _containerDecoration,
|
decoration: _containerDecoration,
|
||||||
@ -106,7 +98,7 @@ class AttendanceDashboardChart extends StatelessWidget {
|
|||||||
|
|
||||||
BoxDecoration get _containerDecoration => BoxDecoration(
|
BoxDecoration get _containerDecoration => BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.grey.withOpacity(0.05),
|
color: Colors.grey.withOpacity(0.05),
|
||||||
@ -164,7 +156,7 @@ class _Header extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
ToggleButtons(
|
ToggleButtons(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderColor: Colors.grey,
|
borderColor: Colors.grey,
|
||||||
fillColor: Colors.blueAccent.withOpacity(0.15),
|
fillColor: Colors.blueAccent.withOpacity(0.15),
|
||||||
selectedBorderColor: Colors.blueAccent,
|
selectedBorderColor: Colors.blueAccent,
|
||||||
@ -208,7 +200,7 @@ class _Header extends StatelessWidget {
|
|||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: selectedRange == label
|
color: selectedRange == label
|
||||||
? Colors.blueAccent
|
? Colors.blueAccent
|
||||||
@ -283,7 +275,7 @@ class _AttendanceChart extends StatelessWidget {
|
|||||||
height: 600,
|
height: 600,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade50,
|
color: Colors.blueGrey.shade50,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -311,7 +303,7 @@ class _AttendanceChart extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade50,
|
color: Colors.blueGrey.shade50,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: SfCartesianChart(
|
child: SfCartesianChart(
|
||||||
tooltipBehavior: TooltipBehavior(enable: true, shared: true),
|
tooltipBehavior: TooltipBehavior(enable: true, shared: true),
|
||||||
@ -387,7 +379,7 @@ class _AttendanceTable extends StatelessWidget {
|
|||||||
height: 300,
|
height: 300,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade50,
|
color: Colors.grey.shade50,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -409,7 +401,7 @@ class _AttendanceTable extends StatelessWidget {
|
|||||||
height: 300,
|
height: 300,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
color: Colors.grey.shade50,
|
color: Colors.grey.shade50,
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@ -461,7 +453,7 @@ class _RolePill extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withOpacity(0.15),
|
color: color.withOpacity(0.15),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: MyText.labelSmall(role, fontWeight: 500),
|
child: MyText.labelSmall(role, fontWeight: 500),
|
||||||
);
|
);
|
||||||
|
@ -1,277 +1,393 @@
|
|||||||
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:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
// Assuming these exist in the project
|
||||||
|
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_card.dart';
|
import 'package:marco/helpers/widgets/my_card.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_text.dart'; // import MyText
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class DashboardOverviewWidgets {
|
class DashboardOverviewWidgets {
|
||||||
static final DashboardController dashboardController =
|
static final DashboardController dashboardController =
|
||||||
Get.find<DashboardController>();
|
Get.find<DashboardController>();
|
||||||
|
|
||||||
static const _titleTextStyle = TextStyle(
|
// Text styles
|
||||||
|
static const _titleStyle = TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black87,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _subtitleStyle = TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.black54,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _metricStyle = TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.black87,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _percentStyle = TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const _subtitleTextStyle = TextStyle(
|
static final NumberFormat _comma = NumberFormat.decimalPattern();
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.grey,
|
|
||||||
);
|
|
||||||
|
|
||||||
static const _infoNumberTextStyle = TextStyle(
|
// Colors
|
||||||
fontSize: 20,
|
static const Color _primaryA = Color(0xFF1565C0); // Blue
|
||||||
fontWeight: FontWeight.bold,
|
static const Color _accentA = Color(0xFF2E7D32); // Green
|
||||||
color: Colors.black87,
|
static const Color _warnA = Color(0xFFC62828); // Red
|
||||||
);
|
static const Color _muted = Color(0xFF9E9E9E); // Grey
|
||||||
|
static const Color _hint = Color(0xFFBDBDBD); // Light Grey
|
||||||
|
static const Color _bgSoft = Color(0xFFF7F8FA); // Light background
|
||||||
|
|
||||||
static const _infoNumberGreenTextStyle = TextStyle(
|
// --- TEAMS OVERVIEW ---
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
);
|
|
||||||
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
|
|
||||||
|
|
||||||
/// Teams Overview Card without chart, labels & values in rows
|
|
||||||
static Widget teamsOverview() {
|
static Widget teamsOverview() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (dashboardController.isTeamsLoading.value) {
|
if (dashboardController.isTeamsLoading.value) {
|
||||||
return _loadingSkeletonCard("Teams");
|
return _skeletonCard(title: "Teams");
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = dashboardController.totalEmployees.value;
|
final total = dashboardController.totalEmployees.value;
|
||||||
final inToday = dashboardController.inToday.value;
|
final inToday = dashboardController.inToday.value.clamp(0, total);
|
||||||
|
final percent = total > 0 ? inToday / total : 0.0;
|
||||||
|
|
||||||
return LayoutBuilder(
|
final hasData = total > 0;
|
||||||
builder: (context, constraints) {
|
final data = hasData
|
||||||
final cardWidth = constraints.maxWidth > 400
|
? [
|
||||||
? (constraints.maxWidth / 2) - 10
|
_ChartData('In Today', inToday.toDouble(), _accentA),
|
||||||
: constraints.maxWidth;
|
_ChartData('Total', total.toDouble(), _muted),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
_ChartData('No Data', 1.0, _hint),
|
||||||
|
];
|
||||||
|
|
||||||
return SizedBox(
|
return _MetricCard(
|
||||||
width: cardWidth,
|
icon: Icons.group,
|
||||||
child: MyCard(
|
iconColor: _primaryA,
|
||||||
borderRadiusAll: 16,
|
title: "Teams",
|
||||||
paddingAll: 20,
|
subtitle: hasData ? "Attendance today" : "Awaiting data",
|
||||||
child: Column(
|
chart: _SemiDonutChart(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
percentLabel: "${(percent * 100).toInt()}%",
|
||||||
children: [
|
data: data,
|
||||||
Row(
|
startAngle: 270,
|
||||||
children: [
|
endAngle: 90,
|
||||||
const Icon(Icons.group,
|
showLegend: false,
|
||||||
color: Colors.blueAccent, size: 26),
|
),
|
||||||
MySpacing.width(8),
|
footer: _SingleColumnKpis(
|
||||||
MyText("Teams", style: _titleTextStyle),
|
stats: {
|
||||||
],
|
"In Today": _comma.format(inToday),
|
||||||
),
|
"Total": _comma.format(total),
|
||||||
MySpacing.height(16),
|
},
|
||||||
// Labels in one row
|
colors: {
|
||||||
Row(
|
"In Today": _accentA,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
"Total": _muted,
|
||||||
children: [
|
},
|
||||||
MyText("Total Employees", style: _subtitleTextStyle),
|
),
|
||||||
MyText("In Today", style: _subtitleTextStyle),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(4),
|
|
||||||
// Values in one row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText(_commaFormatter.format(total),
|
|
||||||
style: _infoNumberTextStyle),
|
|
||||||
MyText(_commaFormatter.format(inToday),
|
|
||||||
style: _infoNumberGreenTextStyle.copyWith(
|
|
||||||
color: Colors.green[700])),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tasks Overview Card
|
// --- TASKS OVERVIEW ---
|
||||||
static Widget tasksOverview() {
|
static Widget tasksOverview() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (dashboardController.isTasksLoading.value) {
|
if (dashboardController.isTasksLoading.value) {
|
||||||
return _loadingSkeletonCard("Tasks");
|
return _skeletonCard(title: "Tasks");
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = dashboardController.totalTasks.value;
|
final total = dashboardController.totalTasks.value;
|
||||||
final completed = dashboardController.completedTasks.value;
|
final completed =
|
||||||
final remaining = total - completed;
|
dashboardController.completedTasks.value.clamp(0, total);
|
||||||
final double percent = total > 0 ? completed / total : 0.0;
|
final remaining = (total - completed).clamp(0, total);
|
||||||
|
final percent = total > 0 ? completed / total : 0.0;
|
||||||
|
|
||||||
// Task colors
|
final hasData = total > 0;
|
||||||
const completedColor = Color(0xFF64B5F6);
|
final data = hasData
|
||||||
const remainingColor =Color(0xFFE57373);
|
? [
|
||||||
|
_ChartData('Completed', completed.toDouble(), _primaryA),
|
||||||
|
_ChartData('Remaining', remaining.toDouble(), _warnA),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
_ChartData('No Data', 1.0, _hint),
|
||||||
|
];
|
||||||
|
|
||||||
final List<_ChartData> pieData = [
|
return _MetricCard(
|
||||||
_ChartData('Completed', completed.toDouble(), completedColor),
|
icon: Icons.task_alt,
|
||||||
_ChartData('Remaining', remaining.toDouble(), remainingColor),
|
iconColor: _primaryA,
|
||||||
];
|
title: "Tasks",
|
||||||
|
subtitle: hasData ? "Completion status" : "Awaiting data",
|
||||||
return LayoutBuilder(
|
chart: _SemiDonutChart(
|
||||||
builder: (context, constraints) {
|
percentLabel: "${(percent * 100).toInt()}%",
|
||||||
final cardWidth =
|
data: data,
|
||||||
constraints.maxWidth < 300 ? constraints.maxWidth : 300.0;
|
startAngle: 270,
|
||||||
|
endAngle: 90,
|
||||||
return SizedBox(
|
showLegend: false,
|
||||||
width: cardWidth,
|
),
|
||||||
child: MyCard(
|
footer: _SingleColumnKpis(
|
||||||
borderRadiusAll: 16,
|
stats: {
|
||||||
paddingAll: 20,
|
"Completed": _comma.format(completed),
|
||||||
child: Column(
|
"Remaining": _comma.format(remaining),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
},
|
||||||
children: [
|
colors: {
|
||||||
// Icon + Title
|
"Completed": _primaryA,
|
||||||
Row(
|
"Remaining": _warnA,
|
||||||
children: [
|
},
|
||||||
const Icon(Icons.task_alt,
|
),
|
||||||
color: completedColor, size: 26),
|
|
||||||
MySpacing.width(8),
|
|
||||||
MyText("Tasks", style: _titleTextStyle),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
|
||||||
|
|
||||||
// Main Row: Bigger Pie Chart + Full-Color Info Boxes
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// Pie Chart Column (Bigger)
|
|
||||||
SizedBox(
|
|
||||||
height: 140,
|
|
||||||
width: 140,
|
|
||||||
child: SfCircularChart(
|
|
||||||
annotations: <CircularChartAnnotation>[
|
|
||||||
CircularChartAnnotation(
|
|
||||||
widget: MyText(
|
|
||||||
"${(percent * 100).toInt()}%",
|
|
||||||
style: _infoNumberGreenTextStyle.copyWith(
|
|
||||||
fontSize: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
series: <PieSeries<_ChartData, String>>[
|
|
||||||
PieSeries<_ChartData, String>(
|
|
||||||
dataSource: pieData,
|
|
||||||
xValueMapper: (_ChartData data, _) =>
|
|
||||||
data.category,
|
|
||||||
yValueMapper: (_ChartData data, _) => data.value,
|
|
||||||
pointColorMapper: (_ChartData data, _) =>
|
|
||||||
data.color,
|
|
||||||
dataLabelSettings:
|
|
||||||
const DataLabelSettings(isVisible: false),
|
|
||||||
radius: '100%',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(16),
|
|
||||||
|
|
||||||
// Info Boxes Column (Full Color)
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_infoBoxFullColor(
|
|
||||||
"Completed", completed, completedColor),
|
|
||||||
MySpacing.height(8),
|
|
||||||
_infoBoxFullColor(
|
|
||||||
"Remaining", remaining, remainingColor),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full-color info box
|
// Skeleton card
|
||||||
static Widget _infoBoxFullColor(String label, int value, Color bgColor) {
|
static Widget _skeletonCard({required String title}) {
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: bgColor, // full color
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
MyText(_commaFormatter.format(value),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
)),
|
|
||||||
MySpacing.height(2),
|
|
||||||
MyText(label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white, // text in white for contrast
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loading Skeleton Card
|
|
||||||
static Widget _loadingSkeletonCard(String title) {
|
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
final cardWidth =
|
final width = constraints.maxWidth.clamp(220.0, 480.0);
|
||||||
constraints.maxWidth < 200 ? constraints.maxWidth : 200.0;
|
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: cardWidth,
|
width: width,
|
||||||
child: MyCard(
|
child: MyCard(
|
||||||
borderRadiusAll: 16,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 20,
|
paddingAll: 16,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_loadingBar(width: 100),
|
_Skeleton.line(width: 120, height: 16),
|
||||||
MySpacing.height(12),
|
MySpacing.height(12),
|
||||||
_loadingBar(width: 80),
|
_Skeleton.line(width: 80, height: 12),
|
||||||
MySpacing.height(12),
|
MySpacing.height(16),
|
||||||
_loadingBar(width: double.infinity, height: 12),
|
_Skeleton.block(height: 120),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_Skeleton.line(width: double.infinity, height: 12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Widget _loadingBar(
|
// --- METRIC CARD with chart on left, stats on right ---
|
||||||
{double width = double.infinity, double height = 16}) {
|
class _MetricCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget chart;
|
||||||
|
final Widget footer;
|
||||||
|
|
||||||
|
const _MetricCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.chart,
|
||||||
|
required this.footer,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
final maxW = constraints.maxWidth;
|
||||||
|
final clampedW = maxW.clamp(260.0, 560.0);
|
||||||
|
final dense = clampedW < 340;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: clampedW,
|
||||||
|
child: MyCard(
|
||||||
|
borderRadiusAll: 5,
|
||||||
|
paddingAll: dense ? 14 : 16,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: icon + title + subtitle
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_IconBadge(icon: icon, color: iconColor),
|
||||||
|
MySpacing.width(10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText(title,
|
||||||
|
style: DashboardOverviewWidgets._titleStyle),
|
||||||
|
MySpacing.height(2),
|
||||||
|
MyText(subtitle,
|
||||||
|
style: DashboardOverviewWidgets._subtitleStyle),
|
||||||
|
MySpacing.height(12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Body: chart left, stats right
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: SizedBox(
|
||||||
|
height: dense ? 120 : 150,
|
||||||
|
child: chart,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: footer, // Stats stacked vertically
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SINGLE COLUMN KPIs (stacked vertically) ---
|
||||||
|
class _SingleColumnKpis extends StatelessWidget {
|
||||||
|
final Map<String, String> stats;
|
||||||
|
final Map<String, Color>? colors;
|
||||||
|
|
||||||
|
const _SingleColumnKpis({required this.stats, this.colors});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: stats.entries.map((entry) {
|
||||||
|
final color = colors != null && colors!.containsKey(entry.key)
|
||||||
|
? colors![entry.key]!
|
||||||
|
: DashboardOverviewWidgets._metricStyle.color;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText(entry.key, style: DashboardOverviewWidgets._subtitleStyle),
|
||||||
|
MyText(entry.value,
|
||||||
|
style: DashboardOverviewWidgets._metricStyle
|
||||||
|
.copyWith(color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SEMI DONUT CHART ---
|
||||||
|
class _SemiDonutChart extends StatelessWidget {
|
||||||
|
final String percentLabel;
|
||||||
|
final List<_ChartData> data;
|
||||||
|
final int startAngle;
|
||||||
|
final int endAngle;
|
||||||
|
final bool showLegend;
|
||||||
|
|
||||||
|
const _SemiDonutChart({
|
||||||
|
required this.percentLabel,
|
||||||
|
required this.data,
|
||||||
|
required this.startAngle,
|
||||||
|
required this.endAngle,
|
||||||
|
this.showLegend = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get _hasData =>
|
||||||
|
data.isNotEmpty &&
|
||||||
|
data.any((d) => d.color != DashboardOverviewWidgets._hint);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final chartData = _hasData
|
||||||
|
? data
|
||||||
|
: [_ChartData('No Data', 1.0, DashboardOverviewWidgets._hint)];
|
||||||
|
|
||||||
|
return SfCircularChart(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
centerY: '65%', // pull donut up
|
||||||
|
legend: Legend(isVisible: showLegend && _hasData),
|
||||||
|
annotations: <CircularChartAnnotation>[
|
||||||
|
CircularChartAnnotation(
|
||||||
|
widget: Center(
|
||||||
|
child: MyText(percentLabel, style: DashboardOverviewWidgets._percentStyle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
series: <DoughnutSeries<_ChartData, String>>[
|
||||||
|
DoughnutSeries<_ChartData, String>(
|
||||||
|
dataSource: chartData,
|
||||||
|
xValueMapper: (d, _) => d.category,
|
||||||
|
yValueMapper: (d, _) => d.value,
|
||||||
|
pointColorMapper: (d, _) => d.color,
|
||||||
|
startAngle: startAngle,
|
||||||
|
endAngle: endAngle,
|
||||||
|
radius: '80%',
|
||||||
|
innerRadius: '65%',
|
||||||
|
strokeWidth: 0,
|
||||||
|
dataLabelSettings: const DataLabelSettings(isVisible: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ICON BADGE ---
|
||||||
|
class _IconBadge extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _IconBadge({required this.icon, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: DashboardOverviewWidgets._bgSoft,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 22),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SKELETON ---
|
||||||
|
class _Skeleton {
|
||||||
|
static Widget line({double width = double.infinity, double height = 14}) {
|
||||||
return Container(
|
return Container(
|
||||||
height: height,
|
|
||||||
width: width,
|
width: width,
|
||||||
|
height: height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Widget block({double height = 120}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CHART DATA ---
|
||||||
class _ChartData {
|
class _ChartData {
|
||||||
final String category;
|
final String category;
|
||||||
final double value;
|
final double value;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
_ChartData(this.category, this.value, this.color);
|
_ChartData(this.category, this.value, this.color);
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import 'package:syncfusion_flutter_charts/charts.dart';
|
|||||||
import 'package:marco/model/dashboard/project_progress_model.dart';
|
import 'package:marco/model/dashboard/project_progress_model.dart';
|
||||||
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|
||||||
|
|
||||||
class ProjectProgressChart extends StatelessWidget {
|
class ProjectProgressChart extends StatelessWidget {
|
||||||
final List<ChartTaskData> data;
|
final List<ChartTaskData> data;
|
||||||
@ -50,13 +49,9 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
];
|
];
|
||||||
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
|
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
|
||||||
|
|
||||||
static final Map<String, Color> _taskColorMap = {};
|
|
||||||
|
|
||||||
Color _getTaskColor(String taskName) {
|
Color _getTaskColor(String taskName) {
|
||||||
return _taskColorMap.putIfAbsent(
|
final index = taskName.hashCode % _flatColors.length;
|
||||||
taskName,
|
return _flatColors[index];
|
||||||
() => _flatColors[_taskColorMap.length % _flatColors.length],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -66,12 +61,11 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isChartView = controller.projectIsChartView.value;
|
final isChartView = controller.projectIsChartView.value;
|
||||||
final selectedRange = controller.projectSelectedRange.value;
|
final selectedRange = controller.projectSelectedRange.value;
|
||||||
final isLoading = controller.isProjectLoading.value;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.grey.withOpacity(0.04),
|
color: Colors.grey.withOpacity(0.04),
|
||||||
@ -94,13 +88,11 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) => AnimatedSwitcher(
|
builder: (context, constraints) => AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: isLoading
|
child: data.isEmpty
|
||||||
? SkeletonLoaders.buildLoadingSkeleton()
|
? _buildNoDataMessage()
|
||||||
: data.isEmpty
|
: isChartView
|
||||||
? _buildNoDataMessage()
|
? _buildChart(constraints.maxHeight)
|
||||||
: isChartView
|
: _buildTable(constraints.maxHeight, screenWidth),
|
||||||
? _buildChart(constraints.maxHeight)
|
|
||||||
: _buildTable(constraints.maxHeight, screenWidth),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -129,7 +121,7 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
ToggleButtons(
|
ToggleButtons(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderColor: Colors.grey,
|
borderColor: Colors.grey,
|
||||||
fillColor: Colors.blueAccent.withOpacity(0.15),
|
fillColor: Colors.blueAccent.withOpacity(0.15),
|
||||||
selectedBorderColor: Colors.blueAccent,
|
selectedBorderColor: Colors.blueAccent,
|
||||||
@ -182,7 +174,7 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
selectedRange == label ? FontWeight.w600 : FontWeight.normal,
|
selectedRange == label ? FontWeight.w600 : FontWeight.normal,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: selectedRange == label
|
color: selectedRange == label
|
||||||
? Colors.blueAccent
|
? Colors.blueAccent
|
||||||
@ -206,7 +198,7 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade50,
|
color: Colors.blueGrey.shade50,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: SfCartesianChart(
|
child: SfCartesianChart(
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
tooltipBehavior: TooltipBehavior(enable: true),
|
||||||
@ -280,7 +272,7 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
color: Colors.grey.shade50,
|
color: Colors.grey.shade50,
|
||||||
),
|
),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
@ -332,7 +324,7 @@ class ProjectProgressChart extends StatelessWidget {
|
|||||||
height: height > 280 ? 280 : height,
|
height: height > 280 ? 280 : height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade50,
|
color: Colors.blueGrey.shade50,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
@ -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/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
class ConfirmDialog extends StatelessWidget {
|
class ConfirmDialog extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@ -115,7 +115,11 @@ class _ContentView extends StatelessWidget {
|
|||||||
Navigator.pop(context, true); // close on success
|
Navigator.pop(context, true); // close on success
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Show error, dialog stays open
|
// Show error, dialog stays open
|
||||||
Get.snackbar("Error", "Failed to delete. Try again.");
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to delete. Try again.",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
106
lib/helpers/widgets/tenant/organization_selector.dart
Normal file
106
lib/helpers/widgets/tenant/organization_selector.dart
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
114
lib/helpers/widgets/tenant/service_selector.dart
Normal file
114
lib/helpers/widgets/tenant/service_selector.dart
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/model/tenant/tenant_services_model.dart';
|
||||||
|
import 'package:marco/controller/tenant/service_controller.dart';
|
||||||
|
|
||||||
|
class ServiceSelector extends StatelessWidget {
|
||||||
|
final ServiceController controller;
|
||||||
|
|
||||||
|
/// Called whenever a new service is selected (including "All Services")
|
||||||
|
final Future<void> Function(Service?)? onSelectionChanged;
|
||||||
|
|
||||||
|
/// Optional height for the selector
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
const ServiceSelector({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.onSelectionChanged,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _popupSelector({
|
||||||
|
required String currentValue,
|
||||||
|
required List<String> items,
|
||||||
|
}) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
onSelected: items.isEmpty
|
||||||
|
? null
|
||||||
|
: (name) async {
|
||||||
|
Service? service = name == "All Services"
|
||||||
|
? null
|
||||||
|
: controller.services.firstWhere((e) => e.name == name);
|
||||||
|
|
||||||
|
controller.selectService(service);
|
||||||
|
|
||||||
|
if (onSelectionChanged != null) {
|
||||||
|
await onSelectionChanged!(service);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) {
|
||||||
|
if (items.isEmpty || items.length == 1 && items[0] == "All Services") {
|
||||||
|
return [
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
enabled: false,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
"No services found",
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.map((e) => PopupMenuItem<String>(value: e, child: MyText(e)))
|
||||||
|
.toList();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
height: height,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
currentValue.isEmpty ? "No services found" : currentValue,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
if (controller.isLoadingServices.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final serviceNames = controller.services.isEmpty
|
||||||
|
? <String>[]
|
||||||
|
: <String>[
|
||||||
|
"All Services",
|
||||||
|
...controller.services.map((e) => e.name).toList(),
|
||||||
|
];
|
||||||
|
|
||||||
|
final currentValue =
|
||||||
|
controller.services.isEmpty ? "" : controller.currentSelection;
|
||||||
|
|
||||||
|
return _popupSelector(
|
||||||
|
currentValue: currentValue,
|
||||||
|
items: serviceNames,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,57 +1,114 @@
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class AttendanceLogViewModel {
|
class Employee {
|
||||||
final DateTime? activityTime;
|
final String id;
|
||||||
final String? imageUrl;
|
final String firstName;
|
||||||
final String? comment;
|
final String lastName;
|
||||||
final String? thumbPreSignedUrl;
|
final String? photo;
|
||||||
final String? preSignedUrl;
|
final String jobRoleId;
|
||||||
final String? longitude;
|
final String jobRoleName;
|
||||||
final String? latitude;
|
|
||||||
final int? activity;
|
|
||||||
|
|
||||||
AttendanceLogViewModel({
|
Employee({
|
||||||
this.activityTime,
|
required this.id,
|
||||||
this.imageUrl,
|
required this.firstName,
|
||||||
this.comment,
|
required this.lastName,
|
||||||
this.thumbPreSignedUrl,
|
this.photo,
|
||||||
this.preSignedUrl,
|
required this.jobRoleId,
|
||||||
this.longitude,
|
required this.jobRoleName,
|
||||||
this.latitude,
|
|
||||||
required this.activity,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AttendanceLogViewModel.fromJson(Map<String, dynamic> json) {
|
factory Employee.fromJson(Map<String, dynamic> json) {
|
||||||
return AttendanceLogViewModel(
|
return Employee(
|
||||||
activityTime: json['activityTime'] != null
|
id: json['id'],
|
||||||
? DateTime.tryParse(json['activityTime'])
|
firstName: json['firstName'] ?? '',
|
||||||
: null,
|
lastName: json['lastName'] ?? '',
|
||||||
imageUrl: json['imageUrl']?.toString(),
|
photo: json['photo']?.toString(),
|
||||||
comment: json['comment']?.toString(),
|
jobRoleId: json['jobRoleId'] ?? '',
|
||||||
thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(),
|
jobRoleName: json['jobRoleName'] ?? '',
|
||||||
preSignedUrl: json['preSignedUrl']?.toString(),
|
|
||||||
longitude: json['longitude']?.toString(),
|
|
||||||
latitude: json['latitude']?.toString(),
|
|
||||||
activity: json['activity'] ?? 0,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'activityTime': activityTime?.toIso8601String(),
|
'id': id,
|
||||||
'imageUrl': imageUrl,
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'photo': photo,
|
||||||
|
'jobRoleId': jobRoleId,
|
||||||
|
'jobRoleName': jobRoleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttendanceLogViewModel {
|
||||||
|
final String id;
|
||||||
|
final String? comment;
|
||||||
|
final Employee employee;
|
||||||
|
final DateTime? activityTime;
|
||||||
|
final int activity;
|
||||||
|
final String? photo;
|
||||||
|
final String? thumbPreSignedUrl;
|
||||||
|
final String? preSignedUrl;
|
||||||
|
final String? longitude;
|
||||||
|
final String? latitude;
|
||||||
|
final DateTime? updatedOn;
|
||||||
|
final Employee? updatedByEmployee;
|
||||||
|
final String? documentId;
|
||||||
|
|
||||||
|
AttendanceLogViewModel({
|
||||||
|
required this.id,
|
||||||
|
this.comment,
|
||||||
|
required this.employee,
|
||||||
|
this.activityTime,
|
||||||
|
required this.activity,
|
||||||
|
this.photo,
|
||||||
|
this.thumbPreSignedUrl,
|
||||||
|
this.preSignedUrl,
|
||||||
|
this.longitude,
|
||||||
|
this.latitude,
|
||||||
|
this.updatedOn,
|
||||||
|
this.updatedByEmployee,
|
||||||
|
this.documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AttendanceLogViewModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AttendanceLogViewModel(
|
||||||
|
id: json['id'],
|
||||||
|
comment: json['comment']?.toString(),
|
||||||
|
employee: Employee.fromJson(json['employee']),
|
||||||
|
activityTime: json['activityTime'] != null ? DateTime.tryParse(json['activityTime']) : null,
|
||||||
|
activity: json['activity'] ?? 0,
|
||||||
|
photo: json['photo']?.toString(),
|
||||||
|
thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(),
|
||||||
|
preSignedUrl: json['preSignedUrl']?.toString(),
|
||||||
|
longitude: json['longitude']?.toString(),
|
||||||
|
latitude: json['latitude']?.toString(),
|
||||||
|
updatedOn: json['updatedOn'] != null ? DateTime.tryParse(json['updatedOn']) : null,
|
||||||
|
updatedByEmployee: json['updatedByEmployee'] != null ? Employee.fromJson(json['updatedByEmployee']) : null,
|
||||||
|
documentId: json['documentId']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
'comment': comment,
|
'comment': comment,
|
||||||
|
'employee': employee.toJson(),
|
||||||
|
'activityTime': activityTime?.toIso8601String(),
|
||||||
|
'activity': activity,
|
||||||
|
'photo': photo,
|
||||||
'thumbPreSignedUrl': thumbPreSignedUrl,
|
'thumbPreSignedUrl': thumbPreSignedUrl,
|
||||||
'preSignedUrl': preSignedUrl,
|
'preSignedUrl': preSignedUrl,
|
||||||
'longitude': longitude,
|
'longitude': longitude,
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
'activity': activity,
|
'updatedOn': updatedOn?.toIso8601String(),
|
||||||
|
'updatedByEmployee': updatedByEmployee?.toJson(),
|
||||||
|
'documentId': documentId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get formattedDate => activityTime != null
|
String? get formattedDate =>
|
||||||
? DateFormat('yyyy-MM-dd').format(activityTime!)
|
activityTime != null ? DateFormat('yyyy-MM-dd').format(activityTime!) : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
String? get formattedTime =>
|
String? get formattedTime =>
|
||||||
activityTime != null ? DateFormat('hh:mm a').format(activityTime!) : null;
|
activityTime != null ? DateFormat('hh:mm a').format(activityTime!) : null;
|
||||||
|
@ -193,7 +193,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
|||||||
controller.uploadingStates[uniqueLogKey]?.value = false;
|
controller.uploadingStates[uniqueLogKey]?.value = false;
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await controller.fetchEmployeesByProject(selectedProjectId);
|
await controller.fetchTodaysAttendance(selectedProjectId);
|
||||||
await controller.fetchAttendanceLogs(selectedProjectId);
|
await controller.fetchAttendanceLogs(selectedProjectId);
|
||||||
await controller.fetchRegularizationLogs(selectedProjectId);
|
await controller.fetchRegularizationLogs(selectedProjectId);
|
||||||
await controller.fetchProjectData(selectedProjectId);
|
await controller.fetchProjectData(selectedProjectId);
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
import 'package:marco/controller/attendance/attendance_screen_controller.dart';
|
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';
|
||||||
|
|
||||||
class AttendanceFilterBottomSheet extends StatefulWidget {
|
class AttendanceFilterBottomSheet extends StatefulWidget {
|
||||||
final AttendanceController controller;
|
final AttendanceController controller;
|
||||||
@ -36,14 +37,80 @@ class _AttendanceFilterBottomSheetState
|
|||||||
String getLabelText() {
|
String getLabelText() {
|
||||||
final startDate = widget.controller.startDateAttendance;
|
final startDate = widget.controller.startDateAttendance;
|
||||||
final endDate = widget.controller.endDateAttendance;
|
final endDate = widget.controller.endDateAttendance;
|
||||||
|
|
||||||
if (startDate != null && endDate != null) {
|
if (startDate != null && endDate != null) {
|
||||||
final start = DateFormat('dd/MM/yyyy').format(startDate);
|
final start =
|
||||||
final end = DateFormat('dd/MM/yyyy').format(endDate);
|
DateTimeUtils.formatDate(startDate, '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);
|
||||||
@ -61,7 +128,7 @@ class _AttendanceFilterBottomSheetState
|
|||||||
|
|
||||||
final List<Widget> widgets = [
|
final List<Widget> widgets = [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: MyText.titleSmall("View", fontWeight: 600),
|
child: MyText.titleSmall("View", fontWeight: 600),
|
||||||
@ -82,11 +149,41 @@ class _AttendanceFilterBottomSheetState
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 🔹 Organization filter
|
||||||
|
widgets.addAll([
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 12),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: MyText.titleSmall("Choose Organization", fontWeight: 600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(() {
|
||||||
|
if (widget.controller.isLoadingOrganizations.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
} else if (widget.controller.organizations.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
"No organizations found",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildOrganizationSelector(context);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 🔹 Date Range only for attendanceLogs
|
||||||
if (tempSelectedTab == 'attendanceLogs') {
|
if (tempSelectedTab == 'attendanceLogs') {
|
||||||
widgets.addAll([
|
widgets.addAll([
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 12, bottom: 4),
|
padding: const EdgeInsets.only(top: 12, bottom: 4),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: MyText.titleSmall("Date Range", fontWeight: 600),
|
child: MyText.titleSmall("Date Range", fontWeight: 600),
|
||||||
@ -99,7 +196,7 @@ class _AttendanceFilterBottomSheetState
|
|||||||
context,
|
context,
|
||||||
widget.controller,
|
widget.controller,
|
||||||
);
|
);
|
||||||
setState(() {}); // rebuild UI after date range is updated
|
setState(() {});
|
||||||
},
|
},
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -136,9 +233,11 @@ class _AttendanceFilterBottomSheetState
|
|||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
child: BaseBottomSheet(
|
child: BaseBottomSheet(
|
||||||
title: "Attendance Filter",
|
title: "Attendance Filter",
|
||||||
|
submitText: "Apply",
|
||||||
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,
|
||||||
|
@ -1,18 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/utils/attendance_actions.dart';
|
|
||||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
|
|
||||||
class AttendanceLogViewButton extends StatelessWidget {
|
class AttendanceLogViewButton extends StatefulWidget {
|
||||||
final dynamic employee;
|
final dynamic employee;
|
||||||
final dynamic attendanceController;
|
final dynamic attendanceController;
|
||||||
|
|
||||||
const AttendanceLogViewButton({
|
const AttendanceLogViewButton({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.employee,
|
required this.employee,
|
||||||
required this.attendanceController,
|
required this.attendanceController,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AttendanceLogViewButton> createState() =>
|
||||||
|
_AttendanceLogViewButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
|
||||||
Future<void> _openGoogleMaps(
|
Future<void> _openGoogleMaps(
|
||||||
BuildContext context, double lat, double lon) async {
|
BuildContext context, double lat, double lon) async {
|
||||||
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
|
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon';
|
||||||
@ -49,7 +56,8 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showLogsBottomSheet(BuildContext context) async {
|
void _showLogsBottomSheet(BuildContext context) async {
|
||||||
await attendanceController.fetchLogsView(employee.id.toString());
|
await widget.attendanceController
|
||||||
|
.fetchLogsView(widget.employee.id.toString());
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@ -58,157 +66,238 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) => BaseBottomSheet(
|
builder: (context) {
|
||||||
title: "Attendance Log",
|
Map<int, bool> expandedDescription = {};
|
||||||
onCancel: () => Navigator.pop(context),
|
|
||||||
onSubmit: () => Navigator.pop(context),
|
return BaseBottomSheet(
|
||||||
showButtons: false,
|
title: "Attendance Log",
|
||||||
child: attendanceController.attendenceLogsView.isEmpty
|
onCancel: () => Navigator.pop(context),
|
||||||
? Padding(
|
onSubmit: () => Navigator.pop(context),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
showButtons: false,
|
||||||
child: Column(
|
child: widget.attendanceController.attendenceLogsView.isEmpty
|
||||||
children: const [
|
? Padding(
|
||||||
Icon(Icons.info_outline, size: 40, color: Colors.grey),
|
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||||
SizedBox(height: 8),
|
child: Column(
|
||||||
Text("No attendance logs available."),
|
children: [
|
||||||
],
|
Icon(Icons.info_outline, size: 40, color: Colors.grey),
|
||||||
),
|
SizedBox(height: 8),
|
||||||
)
|
MyText.bodySmall("No attendance logs available."),
|
||||||
: ListView.separated(
|
],
|
||||||
shrinkWrap: true,
|
),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
)
|
||||||
itemCount: attendanceController.attendenceLogsView.length,
|
: StatefulBuilder(
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
builder: (context, setStateSB) {
|
||||||
itemBuilder: (_, index) {
|
return ListView.separated(
|
||||||
final log = attendanceController.attendenceLogsView[index];
|
shrinkWrap: true,
|
||||||
return Container(
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
decoration: BoxDecoration(
|
itemCount:
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
widget.attendanceController.attendenceLogsView.length,
|
||||||
borderRadius: BorderRadius.circular(12),
|
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||||
boxShadow: [
|
itemBuilder: (_, index) {
|
||||||
BoxShadow(
|
final log = widget
|
||||||
color: Colors.black.withOpacity(0.05),
|
.attendanceController.attendenceLogsView[index];
|
||||||
blurRadius: 6,
|
|
||||||
offset: const Offset(0, 2),
|
return Container(
|
||||||
)
|
decoration: BoxDecoration(
|
||||||
],
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
),
|
borderRadius: BorderRadius.circular(12),
|
||||||
padding: const EdgeInsets.all(8),
|
boxShadow: [
|
||||||
child: Column(
|
BoxShadow(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: Colors.black.withOpacity(0.05),
|
||||||
children: [
|
blurRadius: 6,
|
||||||
Row(
|
offset: const Offset(0, 2),
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
)
|
||||||
children: [
|
],
|
||||||
Expanded(
|
),
|
||||||
flex: 3,
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: Icon + Date + Time
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_getLogIcon(log),
|
||||||
children: [
|
const SizedBox(width: 12),
|
||||||
_getLogIcon(log),
|
MyText.bodyLarge(
|
||||||
const SizedBox(width: 10),
|
(log.formattedDate != null &&
|
||||||
Column(
|
log.formattedDate!.isNotEmpty)
|
||||||
crossAxisAlignment:
|
? DateTimeUtils.convertUtcToLocal(
|
||||||
CrossAxisAlignment.start,
|
log.formattedDate!,
|
||||||
children: [
|
format: 'd MMM yyyy',
|
||||||
MyText.bodyLarge(
|
)
|
||||||
log.formattedDate ?? '-',
|
: '-',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
"Time: ${log.formattedTime ?? '-'}",
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(width: 12),
|
||||||
Row(
|
MyText.bodySmall(
|
||||||
crossAxisAlignment:
|
log.formattedTime != null
|
||||||
CrossAxisAlignment.start,
|
? "Time: ${log.formattedTime}"
|
||||||
children: [
|
: "",
|
||||||
if (log.latitude != null &&
|
color: Colors.grey[700],
|
||||||
log.longitude != null)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
final lat = double.tryParse(
|
|
||||||
log.latitude.toString()) ??
|
|
||||||
0.0;
|
|
||||||
final lon = double.tryParse(
|
|
||||||
log.longitude.toString()) ??
|
|
||||||
0.0;
|
|
||||||
if (lat >= -90 &&
|
|
||||||
lat <= 90 &&
|
|
||||||
lon >= -180 &&
|
|
||||||
lon <= 180) {
|
|
||||||
_openGoogleMaps(
|
|
||||||
context, lat, lon);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Invalid location coordinates')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Padding(
|
|
||||||
padding:
|
|
||||||
EdgeInsets.only(right: 8.0),
|
|
||||||
child: Icon(Icons.location_on,
|
|
||||||
size: 18, color: Colors.blue),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: MyText.bodyMedium(
|
|
||||||
log.comment?.isNotEmpty == true
|
|
||||||
? log.comment
|
|
||||||
: "No description provided",
|
|
||||||
fontWeight: 500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(width: 16),
|
const Divider(height: 1, color: Colors.grey),
|
||||||
if (log.thumbPreSignedUrl != null)
|
// Middle Row: Image + Text (Done by, Description & Location)
|
||||||
GestureDetector(
|
Row(
|
||||||
onTap: () {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (log.preSignedUrl != null) {
|
children: [
|
||||||
_showImageDialog(
|
// Image Column
|
||||||
context, log.preSignedUrl!);
|
if (log.thumbPreSignedUrl != null)
|
||||||
}
|
GestureDetector(
|
||||||
},
|
onTap: () {
|
||||||
child: ClipRRect(
|
if (log.preSignedUrl != null) {
|
||||||
borderRadius: BorderRadius.circular(8),
|
_showImageDialog(
|
||||||
child: Image.network(
|
context, log.preSignedUrl!);
|
||||||
log.thumbPreSignedUrl!,
|
}
|
||||||
height: 60,
|
},
|
||||||
width: 60,
|
child: ClipRRect(
|
||||||
fit: BoxFit.cover,
|
borderRadius: BorderRadius.circular(8),
|
||||||
errorBuilder: (context, error, stackTrace) {
|
child: Image.network(
|
||||||
return const Icon(Icons.broken_image,
|
log.thumbPreSignedUrl!,
|
||||||
size: 20, color: Colors.grey);
|
height: 60,
|
||||||
},
|
width: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) =>
|
||||||
|
const Icon(Icons.broken_image,
|
||||||
|
size: 40, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (log.thumbPreSignedUrl != null)
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Text Column
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Done by
|
||||||
|
if (log.updatedByEmployee != null)
|
||||||
|
MyText.bodySmall(
|
||||||
|
"By: ${log.updatedByEmployee!.firstName} ${log.updatedByEmployee!.lastName}",
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if (log.latitude != null &&
|
||||||
|
log.longitude != null)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
final lat = double.tryParse(
|
||||||
|
log.latitude
|
||||||
|
.toString()) ??
|
||||||
|
0.0;
|
||||||
|
final lon = double.tryParse(
|
||||||
|
log.longitude
|
||||||
|
.toString()) ??
|
||||||
|
0.0;
|
||||||
|
if (lat >= -90 &&
|
||||||
|
lat <= 90 &&
|
||||||
|
lon >= -180 &&
|
||||||
|
lon <= 180) {
|
||||||
|
_openGoogleMaps(
|
||||||
|
context, lat, lon);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: MyText.bodySmall(
|
||||||
|
"Invalid location coordinates")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.location_on,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.blue),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
"View Location",
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration:
|
||||||
|
TextDecoration.underline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Description with label and more/less using MyText
|
||||||
|
if (log.comment != null &&
|
||||||
|
log.comment!.isNotEmpty)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Description: ${log.comment!}",
|
||||||
|
maxLines: expandedDescription[
|
||||||
|
index] ==
|
||||||
|
true
|
||||||
|
? null
|
||||||
|
: 2,
|
||||||
|
overflow: expandedDescription[
|
||||||
|
index] ==
|
||||||
|
true
|
||||||
|
? TextOverflow.visible
|
||||||
|
: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (log.comment!.length > 100)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setStateSB(() {
|
||||||
|
expandedDescription[
|
||||||
|
index] =
|
||||||
|
!(expandedDescription[
|
||||||
|
index] ==
|
||||||
|
true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
expandedDescription[
|
||||||
|
index] ==
|
||||||
|
true
|
||||||
|
? "less"
|
||||||
|
: "more",
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Description: No description provided",
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
)
|
),
|
||||||
else
|
],
|
||||||
const Icon(Icons.broken_image,
|
),
|
||||||
size: 20, color: Colors.grey),
|
);
|
||||||
],
|
},
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,16 +308,16 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => _showLogsBottomSheet(context),
|
onPressed: () => _showLogsBottomSheet(context),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn],
|
backgroundColor: Colors.indigo,
|
||||||
textStyle: const TextStyle(fontSize: 12),
|
textStyle: const TextStyle(fontSize: 12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
),
|
),
|
||||||
child: const FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: MyText.bodySmall(
|
||||||
"View",
|
"View",
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(fontSize: 12, color: Colors.white),
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -249,7 +338,7 @@ class AttendanceLogViewButton extends StatelessWidget {
|
|||||||
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
final logDay = DateTime(logDate.year, logDate.month, logDate.day);
|
final logDay = DateTime(logDate.year, logDate.month, logDate.day);
|
||||||
final yesterday = today.subtract(Duration(days: 1));
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
isTodayOrYesterday = (logDay == today) || (logDay == yesterday);
|
isTodayOrYesterday = (logDay == today) || (logDay == yesterday);
|
||||||
}
|
}
|
||||||
|
106
lib/model/attendance/organization_per_project_list_model.dart
Normal file
106
lib/model/attendance/organization_per_project_list_model.dart
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
class OrganizationListResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<Organization> data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final String timestamp;
|
||||||
|
|
||||||
|
OrganizationListResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory OrganizationListResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OrganizationListResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: (json['data'] as List<dynamic>?)
|
||||||
|
?.map((e) => Organization.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 Organization {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final String contactPerson;
|
||||||
|
final String address;
|
||||||
|
final String contactNumber;
|
||||||
|
final int sprid;
|
||||||
|
final String createdAt;
|
||||||
|
final dynamic createdBy;
|
||||||
|
final dynamic updatedBy;
|
||||||
|
final dynamic updatedAt;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
Organization({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
required this.contactPerson,
|
||||||
|
required this.address,
|
||||||
|
required this.contactNumber,
|
||||||
|
required this.sprid,
|
||||||
|
required this.createdAt,
|
||||||
|
this.createdBy,
|
||||||
|
this.updatedBy,
|
||||||
|
this.updatedAt,
|
||||||
|
required this.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Organization.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Organization(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
email: json['email'] ?? '',
|
||||||
|
contactPerson: json['contactPerson'] ?? '',
|
||||||
|
address: json['address'] ?? '',
|
||||||
|
contactNumber: json['contactNumber'] ?? '',
|
||||||
|
sprid: json['sprid'] ?? 0,
|
||||||
|
createdAt: json['createdAt'] ?? '',
|
||||||
|
createdBy: json['createdBy'],
|
||||||
|
updatedBy: json['updatedBy'],
|
||||||
|
updatedAt: json['updatedAt'],
|
||||||
|
isActive: json['isActive'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'contactPerson': contactPerson,
|
||||||
|
'address': address,
|
||||||
|
'contactNumber': contactNumber,
|
||||||
|
'sprid': sprid,
|
||||||
|
'createdAt': createdAt,
|
||||||
|
'createdBy': createdBy,
|
||||||
|
'updatedBy': updatedBy,
|
||||||
|
'updatedAt': updatedAt,
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -249,7 +249,7 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader("Add Comment", Icons.comment_outlined),
|
_buildSectionHeader("Add Note", Icons.comment_outlined),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
validator:
|
validator:
|
||||||
|
@ -65,7 +65,7 @@ class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(12),
|
MySpacing.height(12),
|
||||||
Center(child: MyText.titleMedium("Add Comment", fontWeight: 700)),
|
Center(child: MyText.titleMedium("Add Note", fontWeight: 700)),
|
||||||
MySpacing.height(24),
|
MySpacing.height(24),
|
||||||
CommentEditorCard(
|
CommentEditorCard(
|
||||||
controller: quillController,
|
controller: quillController,
|
||||||
|
@ -24,6 +24,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
|
|
||||||
final nameCtrl = TextEditingController();
|
final nameCtrl = TextEditingController();
|
||||||
final orgCtrl = TextEditingController();
|
final orgCtrl = TextEditingController();
|
||||||
|
final designationCtrl = TextEditingController();
|
||||||
final addrCtrl = TextEditingController();
|
final addrCtrl = TextEditingController();
|
||||||
final descCtrl = TextEditingController();
|
final descCtrl = TextEditingController();
|
||||||
final tagCtrl = TextEditingController();
|
final tagCtrl = TextEditingController();
|
||||||
@ -49,6 +50,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
if (c != null) {
|
if (c != null) {
|
||||||
nameCtrl.text = c.name;
|
nameCtrl.text = c.name;
|
||||||
orgCtrl.text = c.organization;
|
orgCtrl.text = c.organization;
|
||||||
|
designationCtrl.text = c.designation ?? '';
|
||||||
addrCtrl.text = c.address;
|
addrCtrl.text = c.address;
|
||||||
descCtrl.text = c.description;
|
descCtrl.text = c.description;
|
||||||
|
|
||||||
@ -109,6 +111,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
nameCtrl.dispose();
|
nameCtrl.dispose();
|
||||||
orgCtrl.dispose();
|
orgCtrl.dispose();
|
||||||
|
designationCtrl.dispose();
|
||||||
addrCtrl.dispose();
|
addrCtrl.dispose();
|
||||||
descCtrl.dispose();
|
descCtrl.dispose();
|
||||||
tagCtrl.dispose();
|
tagCtrl.dispose();
|
||||||
@ -118,6 +121,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _labelWithStar(String label, {bool required = false}) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium(label),
|
||||||
|
if (required)
|
||||||
|
const Text(
|
||||||
|
" *",
|
||||||
|
style: TextStyle(color: Colors.red, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
InputDecoration _inputDecoration(String hint) => InputDecoration(
|
InputDecoration _inputDecoration(String hint) => InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
@ -145,7 +162,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.labelMedium(label),
|
_labelWithStar(label, required: required),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: ctrl,
|
controller: ctrl,
|
||||||
@ -386,6 +403,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
phones: phones,
|
phones: phones,
|
||||||
address: addrCtrl.text.trim(),
|
address: addrCtrl.text.trim(),
|
||||||
description: descCtrl.text.trim(),
|
description: descCtrl.text.trim(),
|
||||||
|
designation: designationCtrl.text.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,12 +430,12 @@ 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),
|
||||||
MyText.labelMedium("Select Bucket"),
|
_labelWithStar("Bucket", required: true),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
_popupSelector(controller.selectedBucket, controller.buckets,
|
_popupSelector(controller.selectedBucket, controller.buckets,
|
||||||
"Select Bucket"),
|
"Choose Bucket"),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@ -477,19 +495,63 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
|||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text("Add Phone"),
|
label: const Text("Add Phone"),
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
Obx(() => showAdvanced.value
|
||||||
MyText.labelMedium("Category"),
|
? Column(
|
||||||
MySpacing.height(8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_popupSelector(controller.selectedCategory,
|
children: [
|
||||||
controller.categories, "Select Category"),
|
// ✅ Move Designation field here
|
||||||
MySpacing.height(16),
|
_textField("Designation", designationCtrl),
|
||||||
MyText.labelMedium("Tags"),
|
MySpacing.height(16),
|
||||||
MySpacing.height(8),
|
|
||||||
_tagInput(),
|
_dynamicList(
|
||||||
MySpacing.height(16),
|
emailCtrls,
|
||||||
_textField("Address", addrCtrl),
|
emailLabels,
|
||||||
MySpacing.height(16),
|
"Email",
|
||||||
_textField("Description", descCtrl),
|
["Office", "Personal", "Other"],
|
||||||
|
TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
emailCtrls.add(TextEditingController());
|
||||||
|
emailLabels.add("Office".obs);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Add Email"),
|
||||||
|
),
|
||||||
|
_dynamicList(
|
||||||
|
phoneCtrls,
|
||||||
|
phoneLabels,
|
||||||
|
"Phone",
|
||||||
|
["Work", "Mobile", "Other"],
|
||||||
|
TextInputType.phone,
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
phoneCtrls.add(TextEditingController());
|
||||||
|
phoneLabels.add("Work".obs);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Add Phone"),
|
||||||
|
),
|
||||||
|
MyText.labelMedium("Category"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_popupSelector(
|
||||||
|
controller.selectedCategory,
|
||||||
|
controller.categories,
|
||||||
|
"Choose Category",
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Tags"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_tagInput(),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_textField("Address", addrCtrl),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_textField("Description", descCtrl,
|
||||||
|
maxLines: 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink()),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink()),
|
: const SizedBox.shrink()),
|
||||||
|
@ -2,6 +2,7 @@ class ContactModel {
|
|||||||
final String id;
|
final String id;
|
||||||
final List<String>? projectIds;
|
final List<String>? projectIds;
|
||||||
final String name;
|
final String name;
|
||||||
|
final String? designation;
|
||||||
final List<ContactPhone> contactPhones;
|
final List<ContactPhone> contactPhones;
|
||||||
final List<ContactEmail> contactEmails;
|
final List<ContactEmail> contactEmails;
|
||||||
final ContactCategory? contactCategory;
|
final ContactCategory? contactCategory;
|
||||||
@ -15,6 +16,7 @@ class ContactModel {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.projectIds,
|
required this.projectIds,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
this.designation,
|
||||||
required this.contactPhones,
|
required this.contactPhones,
|
||||||
required this.contactEmails,
|
required this.contactEmails,
|
||||||
required this.contactCategory,
|
required this.contactCategory,
|
||||||
@ -30,6 +32,7 @@ class ContactModel {
|
|||||||
id: json['id'],
|
id: json['id'],
|
||||||
projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(),
|
projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(),
|
||||||
name: json['name'],
|
name: json['name'],
|
||||||
|
designation: json['designation'],
|
||||||
contactPhones: (json['contactPhones'] as List)
|
contactPhones: (json['contactPhones'] as List)
|
||||||
.map((e) => ContactPhone.fromJson(e))
|
.map((e) => ContactPhone.fromJson(e))
|
||||||
.toList(),
|
.toList(),
|
||||||
@ -48,6 +51,7 @@ class ContactModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ContactPhone {
|
class ContactPhone {
|
||||||
final String id;
|
final String id;
|
||||||
final String label;
|
final String label;
|
||||||
|
@ -79,7 +79,7 @@ class NoteModel {
|
|||||||
required this.contactId,
|
required this.contactId,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
});
|
});
|
||||||
NoteModel copyWith({String? note}) => NoteModel(
|
NoteModel copyWith({String? note, bool? isActive}) => NoteModel(
|
||||||
id: id,
|
id: id,
|
||||||
note: note ?? this.note,
|
note: note ?? this.note,
|
||||||
contactName: contactName,
|
contactName: contactName,
|
||||||
@ -89,7 +89,7 @@ class NoteModel {
|
|||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
updatedBy: updatedBy,
|
updatedBy: updatedBy,
|
||||||
contactId: contactId,
|
contactId: contactId,
|
||||||
isActive: isActive,
|
isActive: isActive ?? this.isActive,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory NoteModel.fromJson(Map<String, dynamic> json) {
|
factory NoteModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
@ -393,6 +393,7 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
|
|||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
value == null || value.trim().isEmpty ? "Required" : null,
|
value == null || value.trim().isEmpty ? "Required" : null,
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -564,6 +565,7 @@ class LabeledInput extends StatelessWidget {
|
|||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String? Function(String?) validator;
|
final String? Function(String?) validator;
|
||||||
final bool isRequired;
|
final bool isRequired;
|
||||||
|
final int maxLines;
|
||||||
|
|
||||||
const LabeledInput({
|
const LabeledInput({
|
||||||
Key? key,
|
Key? key,
|
||||||
@ -572,6 +574,7 @@ class LabeledInput extends StatelessWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
required this.validator,
|
required this.validator,
|
||||||
this.isRequired = false,
|
this.isRequired = false,
|
||||||
|
this.maxLines = 1,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -594,6 +597,7 @@ class LabeledInput extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
decoration: _inputDecoration(context, hint),
|
decoration: _inputDecoration(context, hint),
|
||||||
|
maxLines: maxLines,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -34,6 +34,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
|
|||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
title: 'Filter Documents',
|
title: 'Filter Documents',
|
||||||
|
submitText: 'Apply',
|
||||||
showButtons: hasFilters,
|
showButtons: hasFilters,
|
||||||
onCancel: () => Get.back(),
|
onCancel: () => Get.back(),
|
||||||
onSubmit: () {
|
onSubmit: () {
|
||||||
@ -108,7 +109,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: MyText(
|
child: MyText(
|
||||||
"Uploaded On",
|
"Upload Date",
|
||||||
style: MyTextStyle.bodyMedium(
|
style: MyTextStyle.bodyMedium(
|
||||||
color:
|
color:
|
||||||
docController.isUploadedAt.value
|
docController.isUploadedAt.value
|
||||||
@ -139,7 +140,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: MyText(
|
child: MyText(
|
||||||
"Updated On",
|
"Update Date",
|
||||||
style: MyTextStyle.bodyMedium(
|
style: MyTextStyle.bodyMedium(
|
||||||
color: !docController
|
color: !docController
|
||||||
.isUploadedAt.value
|
.isUploadedAt.value
|
||||||
@ -165,7 +166,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
|
|||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
return _dateButton(
|
return _dateButton(
|
||||||
label: docController.startDate.value == null
|
label: docController.startDate.value == null
|
||||||
? 'Start Date'
|
? 'From Date'
|
||||||
: DateTimeUtils.formatDate(
|
: DateTimeUtils.formatDate(
|
||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
docController.startDate.value!),
|
docController.startDate.value!),
|
||||||
@ -191,7 +192,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
|
|||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
return _dateButton(
|
return _dateButton(
|
||||||
label: docController.endDate.value == null
|
label: docController.endDate.value == null
|
||||||
? 'End Date'
|
? 'To Date'
|
||||||
: DateTimeUtils.formatDate(
|
: DateTimeUtils.formatDate(
|
||||||
DateTime.parse(
|
DateTime.parse(
|
||||||
docController.endDate.value!),
|
docController.endDate.value!),
|
||||||
@ -222,39 +223,35 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
|
|||||||
_multiSelectField(
|
_multiSelectField(
|
||||||
label: "Uploaded By",
|
label: "Uploaded By",
|
||||||
items: filterData.uploadedBy,
|
items: filterData.uploadedBy,
|
||||||
fallback: "Select Uploaded By",
|
fallback: "Choose Uploaded By",
|
||||||
selectedValues: docController.selectedUploadedBy,
|
selectedValues: docController.selectedUploadedBy,
|
||||||
),
|
),
|
||||||
_multiSelectField(
|
_multiSelectField(
|
||||||
label: "Category",
|
label: "Category",
|
||||||
items: filterData.documentCategory,
|
items: filterData.documentCategory,
|
||||||
fallback: "Select Category",
|
fallback: "Choose Category",
|
||||||
selectedValues: docController.selectedCategory,
|
selectedValues: docController.selectedCategory,
|
||||||
),
|
),
|
||||||
_multiSelectField(
|
_multiSelectField(
|
||||||
label: "Type",
|
label: "Type",
|
||||||
items: filterData.documentType,
|
items: filterData.documentType,
|
||||||
fallback: "Select Type",
|
fallback: "Choose Type",
|
||||||
selectedValues: docController.selectedType,
|
selectedValues: docController.selectedType,
|
||||||
),
|
),
|
||||||
_multiSelectField(
|
_multiSelectField(
|
||||||
label: "Tag",
|
label: "Tag",
|
||||||
items: filterData.documentTag,
|
items: filterData.documentTag,
|
||||||
fallback: "Select Tag",
|
fallback: "Choose Tag",
|
||||||
selectedValues: docController.selectedTag,
|
selectedValues: docController.selectedTag,
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- Document Status ---
|
// --- Document Status ---
|
||||||
_buildField(
|
_buildField(
|
||||||
"Select Document Status",
|
" Document Status",
|
||||||
Obx(() {
|
Obx(() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: MySpacing.all(12),
|
padding: MySpacing.all(12),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
@ -1,19 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
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/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_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
||||||
|
|
||||||
class AddEmployeeBottomSheet extends StatefulWidget {
|
class AddEmployeeBottomSheet extends StatefulWidget {
|
||||||
final Map<String, dynamic>? employeeData;
|
final Map<String, dynamic>? employeeData;
|
||||||
AddEmployeeBottomSheet({this.employeeData});
|
const AddEmployeeBottomSheet({super.key, this.employeeData});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
|
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
|
||||||
@ -22,28 +24,88 @@ 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
|
||||||
|
bool _hasApplicationAccess = false;
|
||||||
|
|
||||||
|
// Local read-only controllers to avoid recreating TextEditingController in build
|
||||||
|
late final TextEditingController _orgFieldController;
|
||||||
|
late final TextEditingController _joiningDateController;
|
||||||
|
late final TextEditingController _genderController;
|
||||||
|
late final TextEditingController _roleController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_controller = Get.put(
|
_controller = Get.put(
|
||||||
AddEmployeeController(),
|
AddEmployeeController(),
|
||||||
tag: UniqueKey().toString(),
|
// 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'];
|
||||||
|
if (orgId != null) {
|
||||||
|
_controller.selectedOrganizationId = orgId;
|
||||||
|
|
||||||
|
final selectedOrg = _organizationController.organizations
|
||||||
|
.firstWhereOrNull((o) => o.id == orgId);
|
||||||
|
if (selectedOrg != null) {
|
||||||
|
_organizationController.selectOrganization(selectedOrg);
|
||||||
|
_orgFieldController.text = selectedOrg.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller.joiningDate != null) {
|
||||||
|
_joiningDateController.text =
|
||||||
|
DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller.selectedGender != null) {
|
||||||
|
_genderController.text =
|
||||||
|
_controller.selectedGender!.name.capitalizeFirst ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final roleName = _controller.roles.firstWhereOrNull(
|
||||||
|
(r) => r['id'] == _controller.selectedRoleId)?['name'] ??
|
||||||
|
'';
|
||||||
|
_roleController.text = roleName;
|
||||||
|
} else {
|
||||||
|
_orgFieldController.text = _organizationController.currentSelection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_orgFieldController.dispose();
|
||||||
|
_joiningDateController.dispose();
|
||||||
|
_genderController.dispose();
|
||||||
|
_roleController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GetBuilder<AddEmployeeController>(
|
return GetBuilder<AddEmployeeController>(
|
||||||
init: _controller,
|
init: _controller,
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
|
// 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',
|
||||||
onCancel: () => Navigator.pop(context),
|
onCancel: () => Navigator.pop(context),
|
||||||
onSubmit: _handleSubmit,
|
onSubmit: _handleSubmit,
|
||||||
child: Form(
|
child: Form(
|
||||||
@ -51,11 +113,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_sectionLabel("Personal Info"),
|
_sectionLabel('Personal Info'),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_inputWithIcon(
|
_inputWithIcon(
|
||||||
label: "First Name",
|
label: 'First Name',
|
||||||
hint: "e.g., John",
|
hint: 'e.g., John',
|
||||||
icon: Icons.person,
|
icon: Icons.person,
|
||||||
controller:
|
controller:
|
||||||
_controller.basicValidator.getController('first_name')!,
|
_controller.basicValidator.getController('first_name')!,
|
||||||
@ -64,8 +126,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_inputWithIcon(
|
_inputWithIcon(
|
||||||
label: "Last Name",
|
label: 'Last Name',
|
||||||
hint: "e.g., Doe",
|
hint: 'e.g., Doe',
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
controller:
|
controller:
|
||||||
_controller.basicValidator.getController('last_name')!,
|
_controller.basicValidator.getController('last_name')!,
|
||||||
@ -73,37 +135,91 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
_controller.basicValidator.getValidation('last_name'),
|
_controller.basicValidator.getValidation('last_name'),
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_sectionLabel("Joining Details"),
|
_sectionLabel('Organization'),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showOrganizationPopup(context),
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextFormField(
|
||||||
|
readOnly: true,
|
||||||
|
controller: _orgFieldController,
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null ||
|
||||||
|
val.trim().isEmpty ||
|
||||||
|
val == 'All Organizations') {
|
||||||
|
return 'Organization is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration:
|
||||||
|
_inputDecoration('Select Organization').copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.expand_more),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(24),
|
||||||
|
_sectionLabel('Application Access'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _hasApplicationAccess,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() => _hasApplicationAccess = val ?? false);
|
||||||
|
},
|
||||||
|
fillColor:
|
||||||
|
WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return Colors.indigo;
|
||||||
|
}
|
||||||
|
return Colors.white;
|
||||||
|
}),
|
||||||
|
side: WidgetStateBorderSide.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return BorderSide.none;
|
||||||
|
}
|
||||||
|
return const BorderSide(
|
||||||
|
color: Colors.black,
|
||||||
|
width: 2,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
checkColor: Colors.white,
|
||||||
|
),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'Has Application Access',
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
_buildEmailField(),
|
||||||
|
MySpacing.height(12),
|
||||||
|
_sectionLabel('Joining Details'),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_buildDatePickerField(
|
_buildDatePickerField(
|
||||||
label: "Joining Date",
|
label: 'Joining Date',
|
||||||
value: _controller.joiningDate != null
|
controller: _joiningDateController,
|
||||||
? DateFormat("dd MMM yyyy")
|
hint: 'Select Joining Date',
|
||||||
.format(_controller.joiningDate!)
|
|
||||||
: "",
|
|
||||||
hint: "Select Joining Date",
|
|
||||||
onTap: () => _pickJoiningDate(context),
|
onTap: () => _pickJoiningDate(context),
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_sectionLabel("Contact Details"),
|
_sectionLabel('Contact Details'),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_buildPhoneInput(context),
|
_buildPhoneInput(context),
|
||||||
MySpacing.height(24),
|
MySpacing.height(24),
|
||||||
_sectionLabel("Other Details"),
|
_sectionLabel('Other Details'),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_buildDropdownField(
|
_buildDropdownField(
|
||||||
label: "Gender",
|
label: 'Gender',
|
||||||
value: _controller.selectedGender?.name.capitalizeFirst ?? '',
|
controller: _genderController,
|
||||||
hint: "Select Gender",
|
hint: 'Select Gender',
|
||||||
onTap: () => _showGenderPopup(context),
|
onTap: () => _showGenderPopup(context),
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_buildDropdownField(
|
_buildDropdownField(
|
||||||
label: "Role",
|
label: 'Role',
|
||||||
value: _controller.roles.firstWhereOrNull((role) =>
|
controller: _roleController,
|
||||||
role['id'] == _controller.selectedRoleId)?['name'] ??
|
hint: 'Select Role',
|
||||||
"",
|
|
||||||
hint: "Select Role",
|
|
||||||
onTap: () => _showRolePopup(context),
|
onTap: () => _showRolePopup(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -114,96 +230,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _requiredLabel(String text) {
|
// UI Pieces
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
MyText.labelMedium(text),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Text("*", style: TextStyle(color: Colors.red)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDatePickerField({
|
|
||||||
required String label,
|
|
||||||
required String value,
|
|
||||||
required String hint,
|
|
||||||
required VoidCallback onTap,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_requiredLabel(label),
|
|
||||||
MySpacing.height(8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: AbsorbPointer(
|
|
||||||
child: TextFormField(
|
|
||||||
readOnly: true,
|
|
||||||
controller: TextEditingController(text: value),
|
|
||||||
validator: (val) {
|
|
||||||
if (val == null || val.trim().isEmpty) {
|
|
||||||
return "$label is required";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
decoration: _inputDecoration(hint).copyWith(
|
|
||||||
suffixIcon: const Icon(Icons.calendar_today),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickJoiningDate(BuildContext context) async {
|
|
||||||
final picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: _controller.joiningDate ?? DateTime.now(),
|
|
||||||
firstDate: DateTime(2000),
|
|
||||||
lastDate: DateTime(2100),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (picked != null) {
|
|
||||||
_controller.setJoiningDate(picked);
|
|
||||||
_controller.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSubmit() async {
|
|
||||||
final isValid =
|
|
||||||
_controller.basicValidator.formKey.currentState?.validate() ?? false;
|
|
||||||
|
|
||||||
if (!isValid ||
|
|
||||||
_controller.joiningDate == null ||
|
|
||||||
_controller.selectedGender == null ||
|
|
||||||
_controller.selectedRoleId == null) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Missing Fields",
|
|
||||||
message: "Please complete all required fields.",
|
|
||||||
type: SnackbarType.warning,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _controller.createOrUpdateEmployee();
|
|
||||||
|
|
||||||
if (result != null && result['success'] == true) {
|
|
||||||
final employeeController = Get.find<EmployeesScreenController>();
|
|
||||||
final projectId = employeeController.selectedProjectId;
|
|
||||||
|
|
||||||
if (projectId == null) {
|
|
||||||
await employeeController.fetchAllEmployees();
|
|
||||||
} else {
|
|
||||||
await employeeController.fetchEmployeesByProject(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
employeeController.update(['employee_screen_controller']);
|
|
||||||
|
|
||||||
Navigator.pop(context, result['data']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sectionLabel(String title) => Column(
|
Widget _sectionLabel(String title) => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -214,116 +241,12 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _inputWithIcon({
|
Widget _requiredLabel(String text) {
|
||||||
required String label,
|
return Row(
|
||||||
required String hint,
|
|
||||||
required IconData icon,
|
|
||||||
required TextEditingController controller,
|
|
||||||
required String? Function(String?)? validator,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
_requiredLabel(label),
|
MyText.labelMedium(text),
|
||||||
MySpacing.height(8),
|
const SizedBox(width: 4),
|
||||||
TextFormField(
|
const Text('*', style: TextStyle(color: Colors.red)),
|
||||||
controller: controller,
|
|
||||||
validator: (val) {
|
|
||||||
if (val == null || val.trim().isEmpty) {
|
|
||||||
return "$label is required";
|
|
||||||
}
|
|
||||||
return validator?.call(val);
|
|
||||||
},
|
|
||||||
decoration: _inputDecoration(hint).copyWith(
|
|
||||||
prefixIcon: Icon(icon, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPhoneInput(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_requiredLabel("Phone Number"),
|
|
||||||
MySpacing.height(8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
),
|
|
||||||
child: const Text("+91"),
|
|
||||||
),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller:
|
|
||||||
_controller.basicValidator.getController('phone_number'),
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return "Phone Number is required";
|
|
||||||
}
|
|
||||||
if (value.trim().length != 10) {
|
|
||||||
return "Phone Number must be exactly 10 digits";
|
|
||||||
}
|
|
||||||
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
|
||||||
return "Enter a valid 10-digit number";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
inputFormatters: [
|
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
|
||||||
LengthLimitingTextInputFormatter(10),
|
|
||||||
],
|
|
||||||
decoration: _inputDecoration("e.g., 9876543210").copyWith(
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: const Icon(Icons.contacts),
|
|
||||||
onPressed: () => _controller.pickContact(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDropdownField({
|
|
||||||
required String label,
|
|
||||||
required String value,
|
|
||||||
required String hint,
|
|
||||||
required VoidCallback onTap,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_requiredLabel(label),
|
|
||||||
MySpacing.height(8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: AbsorbPointer(
|
|
||||||
child: TextFormField(
|
|
||||||
readOnly: true,
|
|
||||||
controller: TextEditingController(text: value),
|
|
||||||
validator: (val) {
|
|
||||||
if (val == null || val.trim().isEmpty) {
|
|
||||||
return "$label is required";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
decoration: _inputDecoration(hint).copyWith(
|
|
||||||
suffixIcon: const Icon(Icons.expand_more),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -350,20 +273,298 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _inputWithIcon({
|
||||||
|
required String label,
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String? Function(String?)? validator,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_requiredLabel(label),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return validator?.call(val);
|
||||||
|
},
|
||||||
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
|
prefixIcon: Icon(icon, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailField() {
|
||||||
|
final emailController = _controller.basicValidator.getController('email') ??
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium('Email'),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
if (_hasApplicationAccess)
|
||||||
|
const Text('*', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextFormField(
|
||||||
|
controller: emailController,
|
||||||
|
validator: (val) {
|
||||||
|
if (_hasApplicationAccess) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return 'Email is required for application users';
|
||||||
|
}
|
||||||
|
final email = val.trim();
|
||||||
|
if (!RegExp(r'^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$')
|
||||||
|
.hasMatch(email)) {
|
||||||
|
return 'Enter a valid email address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDatePickerField({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String hint,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_requiredLabel(label),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextFormField(
|
||||||
|
readOnly: true,
|
||||||
|
controller: controller,
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdownField({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String hint,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_requiredLabel(label),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextFormField(
|
||||||
|
readOnly: true,
|
||||||
|
controller: controller,
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.trim().isEmpty) {
|
||||||
|
return '$label is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: _inputDecoration(hint).copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.expand_more),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhoneInput(BuildContext context) {
|
||||||
|
final phoneController =
|
||||||
|
_controller.basicValidator.getController('phone_number');
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_requiredLabel('Phone Number'),
|
||||||
|
MySpacing.height(8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: const Text('+91'),
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: phoneController,
|
||||||
|
validator: (value) {
|
||||||
|
final v = value?.trim() ?? '';
|
||||||
|
if (v.isEmpty) return 'Phone Number is required';
|
||||||
|
if (v.length != 10)
|
||||||
|
return 'Phone Number must be exactly 10 digits';
|
||||||
|
if (!RegExp(r'^\d{10}$').hasMatch(v)) {
|
||||||
|
return 'Enter a valid 10-digit number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
],
|
||||||
|
decoration: _inputDecoration('e.g., 9876543210').copyWith(
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.contacts),
|
||||||
|
onPressed: () => _controller.pickContact(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
Future<void> _pickJoiningDate(BuildContext context) async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _controller.joiningDate ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
_controller.setJoiningDate(picked);
|
||||||
|
_joiningDateController.text = DateFormat('dd MMM yyyy').format(picked);
|
||||||
|
_controller.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
final isValid =
|
||||||
|
_controller.basicValidator.formKey.currentState?.validate() ?? false;
|
||||||
|
|
||||||
|
if (!isValid ||
|
||||||
|
_controller.joiningDate == null ||
|
||||||
|
_controller.selectedGender == null ||
|
||||||
|
_controller.selectedRoleId == null ||
|
||||||
|
_organizationController.currentSelection.isEmpty ||
|
||||||
|
_organizationController.currentSelection == 'All Organizations') {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Missing Fields',
|
||||||
|
message: 'Please complete all required fields.',
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _controller.createOrUpdateEmployee(
|
||||||
|
email: _controller.basicValidator.getController('email')?.text.trim(),
|
||||||
|
hasApplicationAccess: _hasApplicationAccess,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result['success'] == true) {
|
||||||
|
final employeeController = Get.find<EmployeesScreenController>();
|
||||||
|
final projectId = employeeController.selectedProjectId;
|
||||||
|
|
||||||
|
if (projectId == null) {
|
||||||
|
await employeeController.fetchAllEmployees();
|
||||||
|
} else {
|
||||||
|
await employeeController.fetchEmployeesByProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
employeeController.update(['employee_screen_controller']);
|
||||||
|
if (mounted) Navigator.pop(context, result['data']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
position: _popupMenuPosition(context),
|
position: _popupMenuPosition(context),
|
||||||
items: Gender.values.map((gender) {
|
items: Gender.values
|
||||||
return PopupMenuItem<Gender>(
|
.map(
|
||||||
value: gender,
|
(gender) => PopupMenuItem<Gender>(
|
||||||
child: Text(gender.name.capitalizeFirst!),
|
value: gender,
|
||||||
);
|
child: Text(gender.name.capitalizeFirst!),
|
||||||
}).toList(),
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
_controller.onGenderSelected(selected);
|
_controller.onGenderSelected(selected);
|
||||||
|
_genderController.text = selected.name.capitalizeFirst ?? '';
|
||||||
_controller.update();
|
_controller.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -372,16 +573,22 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
|||||||
final selected = await showMenu<String>(
|
final selected = await showMenu<String>(
|
||||||
context: context,
|
context: context,
|
||||||
position: _popupMenuPosition(context),
|
position: _popupMenuPosition(context),
|
||||||
items: _controller.roles.map((role) {
|
items: _controller.roles
|
||||||
return PopupMenuItem<String>(
|
.map(
|
||||||
value: role['id'],
|
(role) => PopupMenuItem<String>(
|
||||||
child: Text(role['name']),
|
value: role['id'],
|
||||||
);
|
child: Text(role['name']),
|
||||||
}).toList(),
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
_controller.onRoleSelected(selected);
|
_controller.onRoleSelected(selected);
|
||||||
|
final roleName = _controller.roles
|
||||||
|
.firstWhereOrNull((r) => r['id'] == selected)?['name'] ??
|
||||||
|
'';
|
||||||
|
_roleController.text = roleName;
|
||||||
_controller.update();
|
_controller.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,51 +1,65 @@
|
|||||||
class GlobalProjectModel {
|
class GlobalProjectModel {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String projectAddress;
|
final String projectAddress;
|
||||||
final String contactPerson;
|
final String contactPerson;
|
||||||
final DateTime startDate;
|
final DateTime? startDate;
|
||||||
final DateTime endDate;
|
final DateTime? endDate;
|
||||||
final int teamSize;
|
final int teamSize;
|
||||||
final String projectStatusId;
|
final String projectStatusId;
|
||||||
final String? tenantId;
|
final String? tenantId;
|
||||||
|
|
||||||
GlobalProjectModel({
|
GlobalProjectModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.projectAddress,
|
required this.projectAddress,
|
||||||
required this.contactPerson,
|
required this.contactPerson,
|
||||||
required this.startDate,
|
this.startDate,
|
||||||
required this.endDate,
|
this.endDate,
|
||||||
required this.teamSize,
|
required this.teamSize,
|
||||||
required this.projectStatusId,
|
required this.projectStatusId,
|
||||||
this.tenantId,
|
this.tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory GlobalProjectModel.fromJson(Map<String, dynamic> json) {
|
factory GlobalProjectModel.fromJson(Map<String, dynamic> json) {
|
||||||
return GlobalProjectModel(
|
return GlobalProjectModel(
|
||||||
id: json['id'] ?? '',
|
id: json['id'] ?? '',
|
||||||
name: json['name'] ?? '',
|
name: json['name'] ?? '',
|
||||||
projectAddress: json['projectAddress'] ?? '',
|
projectAddress: json['projectAddress'] ?? '',
|
||||||
contactPerson: json['contactPerson'] ?? '',
|
contactPerson: json['contactPerson'] ?? '',
|
||||||
startDate: DateTime.parse(json['startDate']),
|
startDate: _parseDate(json['startDate']),
|
||||||
endDate: DateTime.parse(json['endDate']),
|
endDate: _parseDate(json['endDate']),
|
||||||
teamSize: json['teamSize'] ?? 0, // ✅ SAFER
|
teamSize: json['teamSize'] is int
|
||||||
projectStatusId: json['projectStatusId'] ?? '',
|
? json['teamSize']
|
||||||
tenantId: json['tenantId'],
|
: int.tryParse(json['teamSize']?.toString() ?? '0') ?? 0,
|
||||||
);
|
projectStatusId: json['projectStatusId'] ?? '',
|
||||||
|
tenantId: json['tenantId'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'projectAddress': projectAddress,
|
||||||
|
'contactPerson': contactPerson,
|
||||||
|
'startDate': startDate?.toIso8601String(),
|
||||||
|
'endDate': endDate?.toIso8601String(),
|
||||||
|
'teamSize': teamSize,
|
||||||
|
'projectStatusId': projectStatusId,
|
||||||
|
'tenantId': tenantId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null || value.toString().trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return DateTime.parse(value.toString());
|
||||||
|
} catch (e) {
|
||||||
|
print('⚠️ Failed to parse date "$value": $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
'projectAddress': projectAddress,
|
|
||||||
'contactPerson': contactPerson,
|
|
||||||
'startDate': startDate.toIso8601String(),
|
|
||||||
'endDate': endDate.toIso8601String(),
|
|
||||||
'teamSize': teamSize,
|
|
||||||
'projectStatusId': projectStatusId,
|
|
||||||
'tenantId': tenantId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,8 +3,8 @@ class ProjectModel {
|
|||||||
final String name;
|
final String name;
|
||||||
final String projectAddress;
|
final String projectAddress;
|
||||||
final String contactPerson;
|
final String contactPerson;
|
||||||
final DateTime startDate;
|
final DateTime? startDate;
|
||||||
final DateTime endDate;
|
final DateTime? endDate;
|
||||||
final int teamSize;
|
final int teamSize;
|
||||||
final double completedWork;
|
final double completedWork;
|
||||||
final double plannedWork;
|
final double plannedWork;
|
||||||
@ -16,8 +16,8 @@ class ProjectModel {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.projectAddress,
|
required this.projectAddress,
|
||||||
required this.contactPerson,
|
required this.contactPerson,
|
||||||
required this.startDate,
|
this.startDate,
|
||||||
required this.endDate,
|
this.endDate,
|
||||||
required this.teamSize,
|
required this.teamSize,
|
||||||
required this.completedWork,
|
required this.completedWork,
|
||||||
required this.plannedWork,
|
required this.plannedWork,
|
||||||
@ -25,36 +25,30 @@ class ProjectModel {
|
|||||||
this.tenantId,
|
this.tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Factory method to create an instance of ProjectModel from a JSON object
|
|
||||||
factory ProjectModel.fromJson(Map<String, dynamic> json) {
|
factory ProjectModel.fromJson(Map<String, dynamic> json) {
|
||||||
return ProjectModel(
|
return ProjectModel(
|
||||||
id: json['id'],
|
id: json['id']?.toString() ?? '',
|
||||||
name: json['name'],
|
name: json['name']?.toString() ?? '',
|
||||||
projectAddress: json['projectAddress'],
|
projectAddress: json['projectAddress']?.toString() ?? '',
|
||||||
contactPerson: json['contactPerson'],
|
contactPerson: json['contactPerson']?.toString() ?? '',
|
||||||
startDate: DateTime.parse(json['startDate']),
|
startDate: _parseDate(json['startDate']),
|
||||||
endDate: DateTime.parse(json['endDate']),
|
endDate: _parseDate(json['endDate']),
|
||||||
teamSize: json['teamSize'],
|
teamSize: _parseInt(json['teamSize']),
|
||||||
completedWork: json['completedWork'] != null
|
completedWork: _parseDouble(json['completedWork']),
|
||||||
? (json['completedWork'] as num).toDouble()
|
plannedWork: _parseDouble(json['plannedWork']),
|
||||||
: 0.0,
|
projectStatusId: json['projectStatusId']?.toString() ?? '',
|
||||||
plannedWork: json['plannedWork'] != null
|
tenantId: json['tenantId']?.toString(),
|
||||||
? (json['plannedWork'] as num).toDouble()
|
|
||||||
: 0.0,
|
|
||||||
projectStatusId: json['projectStatusId'],
|
|
||||||
tenantId: json['tenantId'],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to convert the ProjectModel instance back to a JSON object
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'projectAddress': projectAddress,
|
'projectAddress': projectAddress,
|
||||||
'contactPerson': contactPerson,
|
'contactPerson': contactPerson,
|
||||||
'startDate': startDate.toIso8601String(),
|
'startDate': startDate?.toIso8601String(),
|
||||||
'endDate': endDate.toIso8601String(),
|
'endDate': endDate?.toIso8601String(),
|
||||||
'teamSize': teamSize,
|
'teamSize': teamSize,
|
||||||
'completedWork': completedWork,
|
'completedWork': completedWork,
|
||||||
'plannedWork': plannedWork,
|
'plannedWork': plannedWork,
|
||||||
@ -62,4 +56,30 @@ class ProjectModel {
|
|||||||
'tenantId': tenantId,
|
'tenantId': tenantId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null || value.toString().trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return DateTime.parse(value.toString());
|
||||||
|
} catch (e) {
|
||||||
|
print('⚠️ Failed to parse date: $value');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseInt(dynamic value) {
|
||||||
|
if (value == null) return 0;
|
||||||
|
if (value is int) return value;
|
||||||
|
return int.tryParse(value.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _parseDouble(dynamic value) {
|
||||||
|
if (value == null) return 0.0;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
return double.tryParse(value.toString()) ?? 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
109
lib/model/tenant/tenant_list_model.dart
Normal file
109
lib/model/tenant/tenant_list_model.dart
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
78
lib/model/tenant/tenant_services_model.dart
Normal file
78
lib/model/tenant/tenant_services_model.dart
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +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';
|
||||||
@ -19,13 +20,21 @@ 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) {
|
||||||
return AuthService.isLoggedIn
|
if (!AuthService.isLoggedIn) {
|
||||||
? null
|
if (route != '/auth/login-option') {
|
||||||
: RouteSettings(name: '/auth/login-option');
|
return const RouteSettings(name: '/auth/login-option');
|
||||||
|
}
|
||||||
|
} else if (!TenantService.isTenantSelected) {
|
||||||
|
if (route != '/select-tenant') {
|
||||||
|
return const RouteSettings(name: '/select-tenant');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +49,10 @@ 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,12 +80,12 @@ getPageRoute() {
|
|||||||
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(),
|
||||||
|
@ -11,7 +11,6 @@ import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|||||||
import 'package:marco/model/attendance/log_details_view.dart';
|
import 'package:marco/model/attendance/log_details_view.dart';
|
||||||
import 'package:marco/model/attendance/attendence_action_button.dart';
|
import 'package:marco/model/attendance/attendence_action_button.dart';
|
||||||
import 'package:marco/helpers/utils/attendance_actions.dart';
|
import 'package:marco/helpers/utils/attendance_actions.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
|
||||||
|
|
||||||
class AttendanceLogsTab extends StatefulWidget {
|
class AttendanceLogsTab extends StatefulWidget {
|
||||||
final AttendanceController controller;
|
final AttendanceController controller;
|
||||||
@ -94,16 +93,6 @@ class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
|
|||||||
} else {
|
} else {
|
||||||
priority = 5;
|
priority = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Use AppLogger instead of print
|
|
||||||
logSafe(
|
|
||||||
"[AttendanceLogs] Priority calculated "
|
|
||||||
"name=${employee.name}, activity=${employee.activity}, "
|
|
||||||
"checkIn=${employee.checkIn}, checkOut=${employee.checkOut}, "
|
|
||||||
"buttonText=$text, priority=$priority",
|
|
||||||
level: LogLevel.debug,
|
|
||||||
);
|
|
||||||
|
|
||||||
return priority;
|
return priority;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +47,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
|||||||
|
|
||||||
Future<void> _loadData(String projectId) async {
|
Future<void> _loadData(String projectId) async {
|
||||||
try {
|
try {
|
||||||
|
attendanceController.selectedTab = 'todaysAttendance';
|
||||||
await attendanceController.loadAttendanceData(projectId);
|
await attendanceController.loadAttendanceData(projectId);
|
||||||
attendanceController.update(['attendance_dashboard_controller']);
|
attendanceController.update(['attendance_dashboard_controller']);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -56,7 +57,24 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
|||||||
|
|
||||||
Future<void> _refreshData() async {
|
Future<void> _refreshData() async {
|
||||||
final projectId = projectController.selectedProjectId.value;
|
final projectId = projectController.selectedProjectId.value;
|
||||||
if (projectId.isNotEmpty) await _loadData(projectId);
|
if (projectId.isEmpty) return;
|
||||||
|
|
||||||
|
// Call only the relevant API for current tab
|
||||||
|
switch (selectedTab) {
|
||||||
|
case 'todaysAttendance':
|
||||||
|
await attendanceController.fetchTodaysAttendance(projectId);
|
||||||
|
break;
|
||||||
|
case 'attendanceLogs':
|
||||||
|
await attendanceController.fetchAttendanceLogs(
|
||||||
|
projectId,
|
||||||
|
dateFrom: attendanceController.startDateAttendance,
|
||||||
|
dateTo: attendanceController.endDateAttendance,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'regularizationRequests':
|
||||||
|
await attendanceController.fetchRegularizationLogs(projectId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar() {
|
Widget _buildAppBar() {
|
||||||
@ -195,15 +213,26 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
|||||||
final selectedProjectId =
|
final selectedProjectId =
|
||||||
projectController.selectedProjectId.value;
|
projectController.selectedProjectId.value;
|
||||||
final selectedView = result['selectedTab'] as String?;
|
final selectedView = result['selectedTab'] as String?;
|
||||||
|
final selectedOrgId =
|
||||||
|
result['selectedOrganization'] as String?;
|
||||||
|
|
||||||
|
if (selectedOrgId != null) {
|
||||||
|
attendanceController.selectedOrganization =
|
||||||
|
attendanceController.organizations
|
||||||
|
.firstWhere((o) => o.id == selectedOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedProjectId.isNotEmpty) {
|
if (selectedProjectId.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
await attendanceController
|
await attendanceController.fetchTodaysAttendance(
|
||||||
.fetchEmployeesByProject(selectedProjectId);
|
selectedProjectId,
|
||||||
await attendanceController
|
);
|
||||||
.fetchAttendanceLogs(selectedProjectId);
|
await attendanceController.fetchAttendanceLogs(
|
||||||
await attendanceController
|
selectedProjectId,
|
||||||
.fetchRegularizationLogs(selectedProjectId);
|
);
|
||||||
|
await attendanceController.fetchRegularizationLogs(
|
||||||
|
selectedProjectId,
|
||||||
|
);
|
||||||
await attendanceController
|
await attendanceController
|
||||||
.fetchProjectData(selectedProjectId);
|
.fetchProjectData(selectedProjectId);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@ -214,6 +243,11 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
|||||||
|
|
||||||
if (selectedView != null && selectedView != selectedTab) {
|
if (selectedView != null && selectedView != selectedTab) {
|
||||||
setState(() => selectedTab = selectedView);
|
setState(() => selectedTab = selectedView);
|
||||||
|
attendanceController.selectedTab = selectedView;
|
||||||
|
if (selectedProjectId.isNotEmpty) {
|
||||||
|
await attendanceController
|
||||||
|
.fetchProjectData(selectedProjectId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -13,7 +13,6 @@ import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart';
|
|||||||
import 'package:marco/helpers/widgets/dashbaord/project_progress_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/controller/dynamicMenu/dynamic_menu_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|
||||||
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
|
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
|
||||||
|
|
||||||
class DashboardScreen extends StatefulWidget {
|
class DashboardScreen extends StatefulWidget {
|
||||||
@ -85,12 +84,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
/// Project Progress Chart Section
|
/// Project Progress Chart Section
|
||||||
Widget _buildProjectProgressChartSection() {
|
Widget _buildProjectProgressChartSection() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (dashboardController.isProjectLoading.value) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: SkeletonLoaders.chartSkeletonLoader(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dashboardController.projectChartData.isEmpty) {
|
if (dashboardController.projectChartData.isEmpty) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
@ -102,7 +96,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 400,
|
height: 400,
|
||||||
child: ProjectProgressChart(
|
child: ProjectProgressChart(
|
||||||
@ -116,14 +110,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
/// Attendance Chart Section
|
/// Attendance Chart Section
|
||||||
Widget _buildAttendanceChartSection() {
|
Widget _buildAttendanceChartSection() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (menuController.isLoading.value) {
|
|
||||||
// ✅ Show Skeleton Loader Instead of CircularProgressIndicator
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: SkeletonLoaders
|
|
||||||
.chartSkeletonLoader(), // <-- using the skeleton we built
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance");
|
final isAttendanceAllowed = menuController.isMenuAllowed("Attendance");
|
||||||
|
|
||||||
@ -141,7 +127,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !isProjectSelected,
|
ignoring: !isProjectSelected,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 400,
|
height: 400,
|
||||||
child: AttendanceDashboardChart(),
|
child: AttendanceDashboardChart(),
|
||||||
@ -198,7 +184,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
width: width,
|
width: width,
|
||||||
height: 100,
|
height: 100,
|
||||||
paddingAll: 5,
|
paddingAll: 5,
|
||||||
borderRadiusAll: 10,
|
borderRadiusAll: 5,
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@ -304,12 +290,12 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
ignoring: !isEnabled,
|
ignoring: !isEnabled,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _handleStatCardTap(statItem, isEnabled),
|
onTap: () => _handleStatCardTap(statItem, isEnabled),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: MyCard.bordered(
|
child: MyCard.bordered(
|
||||||
width: width,
|
width: width,
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
paddingAll: 4,
|
paddingAll: 4,
|
||||||
borderRadiusAll: 6,
|
borderRadiusAll: 5,
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
@ -16,6 +16,7 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart';
|
|||||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
|
||||||
// HELPER: Delta to HTML conversion
|
// HELPER: Delta to HTML conversion
|
||||||
String _convertDeltaToHtml(dynamic delta) {
|
String _convertDeltaToHtml(dynamic delta) {
|
||||||
@ -81,8 +82,11 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
projectController = Get.find<ProjectController>();
|
projectController = Get.find<ProjectController>();
|
||||||
contactRx = widget.contact.obs;
|
contactRx = widget.contact.obs;
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
directoryController.fetchCommentsForContact(contactRx.value.id);
|
await directoryController.fetchCommentsForContact(contactRx.value.id,
|
||||||
|
active: true);
|
||||||
|
await directoryController.fetchCommentsForContact(contactRx.value.id,
|
||||||
|
active: false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to controller's allContacts and update contact if changed
|
// Listen to controller's allContacts and update contact if changed
|
||||||
@ -169,10 +173,10 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Avatar(
|
Avatar(
|
||||||
firstName: firstName,
|
firstName: firstName,
|
||||||
lastName: lastName,
|
lastName: lastName,
|
||||||
size: 35,
|
size: 35,
|
||||||
backgroundColor: Colors.indigo),
|
),
|
||||||
MySpacing.width(12),
|
MySpacing.width(12),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -198,7 +202,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
),
|
),
|
||||||
tabs: const [
|
tabs: const [
|
||||||
Tab(text: "Details"),
|
Tab(text: "Details"),
|
||||||
Tab(text: "Comments"),
|
Tab(text: "Notes"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -340,51 +344,48 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
Widget _buildCommentsTab() {
|
Widget _buildCommentsTab() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final contactId = contactRx.value.id;
|
final contactId = contactRx.value.id;
|
||||||
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final comments = directoryController
|
// Get active and inactive comments
|
||||||
|
final activeComments = directoryController
|
||||||
.getCommentsForContact(contactId)
|
.getCommentsForContact(contactId)
|
||||||
.reversed
|
.where((c) => c.isActive)
|
||||||
.toList();
|
.toList();
|
||||||
|
final inactiveComments = directoryController
|
||||||
|
.getCommentsForContact(contactId)
|
||||||
|
.where((c) => !c.isActive)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Combine both and keep the same sorting (recent first)
|
||||||
|
final comments =
|
||||||
|
[...activeComments, ...inactiveComments].reversed.toList();
|
||||||
final 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(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await directoryController.fetchCommentsForContact(contactId);
|
await directoryController.fetchCommentsForContact(contactId,
|
||||||
|
active: true);
|
||||||
|
await directoryController.fetchCommentsForContact(contactId,
|
||||||
|
active: false);
|
||||||
},
|
},
|
||||||
child: comments.isEmpty
|
child: Padding(
|
||||||
? ListView(
|
padding: MySpacing.xy(12, 12),
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
child: ListView.separated(
|
||||||
children: [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
SizedBox(
|
padding: const EdgeInsets.only(bottom: 100),
|
||||||
height: Get.height * 0.6,
|
itemCount: comments.length,
|
||||||
child: Center(
|
separatorBuilder: (_, __) => MySpacing.height(14),
|
||||||
child: MyText.bodyLarge(
|
itemBuilder: (_, index) =>
|
||||||
"No comments yet.",
|
_buildCommentItem(comments[index], editingId, contactId),
|
||||||
color: Colors.grey,
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Padding(
|
|
||||||
padding: MySpacing.xy(12, 12),
|
|
||||||
child: ListView.separated(
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.only(bottom: 100),
|
|
||||||
itemCount: comments.length,
|
|
||||||
separatorBuilder: (_, __) => MySpacing.height(14),
|
|
||||||
itemBuilder: (_, index) => _buildCommentItem(
|
|
||||||
comments[index],
|
|
||||||
editingId,
|
|
||||||
contactId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (editingId == null)
|
if (editingId == null)
|
||||||
Positioned(
|
Positioned(
|
||||||
@ -398,15 +399,15 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
);
|
);
|
||||||
if (result == true) {
|
if (result == true) {
|
||||||
await directoryController
|
await directoryController.fetchCommentsForContact(contactId,
|
||||||
.fetchCommentsForContact(contactId);
|
active: true);
|
||||||
|
await directoryController.fetchCommentsForContact(contactId,
|
||||||
|
active: false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.add_comment, color: Colors.white),
|
icon: const Icon(Icons.add_comment, color: Colors.white),
|
||||||
label: const Text(
|
label: const Text("Add Note",
|
||||||
"Add Comment",
|
style: TextStyle(color: Colors.white)),
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -419,6 +420,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
final initials = comment.createdBy.firstName.isNotEmpty
|
final initials = comment.createdBy.firstName.isNotEmpty
|
||||||
? comment.createdBy.firstName[0].toUpperCase()
|
? comment.createdBy.firstName[0].toUpperCase()
|
||||||
: "?";
|
: "?";
|
||||||
|
|
||||||
final decodedDelta = HtmlToDelta().convert(comment.note);
|
final decodedDelta = HtmlToDelta().convert(comment.note);
|
||||||
final quillController = isEditing
|
final quillController = isEditing
|
||||||
? quill.QuillController(
|
? quill.QuillController(
|
||||||
@ -427,58 +429,144 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return AnimatedContainer(
|
return Container(
|
||||||
duration: const Duration(milliseconds: 300),
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
padding: MySpacing.xy(8, 7),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isEditing ? Colors.indigo[50] : Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
color: isEditing ? Colors.indigo : Colors.grey.shade300,
|
boxShadow: [
|
||||||
width: 1.2,
|
BoxShadow(
|
||||||
),
|
color: Colors.black.withOpacity(0.03),
|
||||||
boxShadow: const [
|
blurRadius: 6,
|
||||||
BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// 🧑 Header
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Avatar(firstName: initials, lastName: '', size: 36),
|
Avatar(
|
||||||
MySpacing.width(12),
|
firstName: initials,
|
||||||
|
lastName: '',
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium("By: ${comment.createdBy.firstName}",
|
// Full name on top
|
||||||
fontWeight: 600, color: Colors.indigo[800]),
|
Text(
|
||||||
MySpacing.height(4),
|
"${comment.createdBy.firstName} ${comment.createdBy.lastName}",
|
||||||
MyText.bodySmall(
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Job Role
|
||||||
|
if (comment.createdBy.jobRoleName?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
comment.createdBy.jobRoleName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.indigo[600],
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Timestamp
|
||||||
|
Text(
|
||||||
DateTimeUtils.convertUtcToLocal(
|
DateTimeUtils.convertUtcToLocal(
|
||||||
comment.createdAt.toString(),
|
comment.createdAt.toString(),
|
||||||
format: 'dd MMM yyyy, hh:mm a',
|
format: 'dd MMM yyyy, hh:mm a',
|
||||||
),
|
),
|
||||||
color: Colors.grey[600],
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
// ⚡ Action buttons
|
||||||
isEditing ? Icons.close : Icons.edit,
|
Row(
|
||||||
size: 20,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: Colors.indigo,
|
children: [
|
||||||
),
|
if (!comment.isActive)
|
||||||
onPressed: () {
|
IconButton(
|
||||||
directoryController.editingCommentId.value =
|
icon: const Icon(Icons.restore,
|
||||||
isEditing ? null : comment.id;
|
size: 18, color: Colors.green),
|
||||||
},
|
tooltip: "Restore",
|
||||||
|
splashRadius: 18,
|
||||||
|
onPressed: () async {
|
||||||
|
await Get.dialog(
|
||||||
|
ConfirmDialog(
|
||||||
|
title: "Restore Note",
|
||||||
|
message:
|
||||||
|
"Are you sure you want to restore this note?",
|
||||||
|
confirmText: "Restore",
|
||||||
|
confirmColor: Colors.green,
|
||||||
|
icon: Icons.restore,
|
||||||
|
onConfirm: () async {
|
||||||
|
await directoryController.restoreComment(
|
||||||
|
comment.id, contactId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (comment.isActive) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined,
|
||||||
|
size: 18, color: Colors.indigo),
|
||||||
|
tooltip: "Edit",
|
||||||
|
splashRadius: 18,
|
||||||
|
onPressed: () {
|
||||||
|
directoryController.editingCommentId.value =
|
||||||
|
isEditing ? null : comment.id;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
size: 18, color: Colors.red),
|
||||||
|
tooltip: "Delete",
|
||||||
|
splashRadius: 18,
|
||||||
|
onPressed: () async {
|
||||||
|
await Get.dialog(
|
||||||
|
ConfirmDialog(
|
||||||
|
title: "Delete Note",
|
||||||
|
message:
|
||||||
|
"Are you sure you want to delete this note?",
|
||||||
|
confirmText: "Delete",
|
||||||
|
confirmColor: Colors.red,
|
||||||
|
icon: Icons.delete_forever,
|
||||||
|
onConfirm: () async {
|
||||||
|
await directoryController.deleteComment(
|
||||||
|
comment.id, contactId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// 📝 Comment Content
|
||||||
if (isEditing && quillController != null)
|
if (isEditing && quillController != null)
|
||||||
CommentEditorCard(
|
CommentEditorCard(
|
||||||
controller: quillController,
|
controller: quillController,
|
||||||
@ -499,7 +587,15 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
|||||||
"body": html.Style(
|
"body": html.Style(
|
||||||
margin: html.Margins.zero,
|
margin: html.Margins.zero,
|
||||||
padding: html.HtmlPaddings.zero,
|
padding: html.HtmlPaddings.zero,
|
||||||
fontSize: html.FontSize.medium,
|
fontSize: html.FontSize(14),
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
"p": html.Style(
|
||||||
|
margin: html.Margins.only(bottom: 6),
|
||||||
|
lineHeight: const html.LineHeight(1.4),
|
||||||
|
),
|
||||||
|
"strong": html.Style(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -10,16 +10,36 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/view/directory/directory_view.dart';
|
import 'package:marco/view/directory/directory_view.dart';
|
||||||
import 'package:marco/view/directory/notes_view.dart';
|
import 'package:marco/view/directory/notes_view.dart';
|
||||||
|
|
||||||
class DirectoryMainScreen extends StatelessWidget {
|
class DirectoryMainScreen extends StatefulWidget {
|
||||||
DirectoryMainScreen({super.key});
|
const DirectoryMainScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DirectoryMainScreen> createState() => _DirectoryMainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DirectoryMainScreenState extends State<DirectoryMainScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
final DirectoryController controller = Get.put(DirectoryController());
|
final DirectoryController controller = Get.put(DirectoryController());
|
||||||
final NotesController notesController = Get.put(NotesController());
|
final NotesController notesController = Get.put(NotesController());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(72),
|
preferredSize: const Size.fromHeight(72),
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
@ -79,116 +99,34 @@ class DirectoryMainScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
// ---------------- TabBar ----------------
|
||||||
// Toggle between Directory and Notes
|
Container(
|
||||||
Padding(
|
color: Colors.white,
|
||||||
padding: MySpacing.fromLTRB(8, 12, 8, 5),
|
child: TabBar(
|
||||||
child: Obx(() {
|
controller: _tabController,
|
||||||
final isNotesView = controller.isNotesView.value;
|
labelColor: Colors.black,
|
||||||
|
unselectedLabelColor: Colors.grey,
|
||||||
return Container(
|
indicatorColor: Colors.red,
|
||||||
padding: EdgeInsets.all(2),
|
tabs: const [
|
||||||
decoration: BoxDecoration(
|
Tab(text: "Directory"),
|
||||||
color: const Color(0xFFF0F0F0),
|
Tab(text: "Notes"),
|
||||||
borderRadius: BorderRadius.circular(10),
|
],
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => controller.isNotesView.value = false,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 6, horizontal: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: !isNotesView
|
|
||||||
? Colors.red
|
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.contacts,
|
|
||||||
size: 16,
|
|
||||||
color: !isNotesView
|
|
||||||
? Colors.white
|
|
||||||
: Colors.grey),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Directory',
|
|
||||||
style: TextStyle(
|
|
||||||
color: !isNotesView
|
|
||||||
? Colors.white
|
|
||||||
: Colors.grey,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => controller.isNotesView.value = true,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 6, horizontal: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color:
|
|
||||||
isNotesView ? Colors.red : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.notes,
|
|
||||||
size: 16,
|
|
||||||
color: isNotesView
|
|
||||||
? Colors.white
|
|
||||||
: Colors.grey),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Notes',
|
|
||||||
style: TextStyle(
|
|
||||||
color: isNotesView
|
|
||||||
? Colors.white
|
|
||||||
: Colors.grey,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Main View
|
// ---------------- TabBarView ----------------
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Obx(() =>
|
child: TabBarView(
|
||||||
controller.isNotesView.value ? NotesView() : DirectoryView()),
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
DirectoryView(),
|
||||||
|
NotesView(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -144,15 +144,38 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey),
|
||||||
|
MySpacing.height(18),
|
||||||
|
MyText.titleMedium(
|
||||||
|
'No matching contacts found.',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
MySpacing.height(10),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Try adjusting your filters or refresh to reload.',
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.grey[100],
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
heroTag: 'createContact',
|
heroTag: 'createContact',
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
onPressed: _handleCreateContact,
|
onPressed: _handleCreateContact,
|
||||||
child: 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)),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -195,11 +218,11 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -217,7 +240,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.tune,
|
icon: Icon(Icons.tune,
|
||||||
@ -262,14 +285,14 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: PopupMenuButton<int>(
|
child: PopupMenuButton<int>(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon: const Icon(Icons.more_vert,
|
icon: const Icon(Icons.more_vert,
|
||||||
size: 20, color: Colors.black87),
|
size: 20, color: Colors.black87),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
List<PopupMenuEntry<int>> menuItems = [];
|
List<PopupMenuEntry<int>> menuItems = [];
|
||||||
@ -375,7 +398,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
const Icon(Icons.visibility_off_outlined,
|
const Icon(Icons.visibility_off_outlined,
|
||||||
size: 20, color: Colors.black87),
|
size: 20, color: Colors.black87),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Expanded(child: Text('Show Inactive')),
|
const Expanded(child: Text('Show Deleted Contacts')),
|
||||||
Switch.adaptive(
|
Switch.adaptive(
|
||||||
value: !controller.isActive.value,
|
value: !controller.isActive.value,
|
||||||
activeColor: Colors.indigo,
|
activeColor: Colors.indigo,
|
||||||
@ -412,27 +435,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
|||||||
SkeletonLoaders.contactSkeletonCard(),
|
SkeletonLoaders.contactSkeletonCard(),
|
||||||
)
|
)
|
||||||
: controller.filteredContacts.isEmpty
|
: controller.filteredContacts.isEmpty
|
||||||
? ListView(
|
? _buildEmptyState()
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height:
|
|
||||||
MediaQuery.of(context).size.height * 0.6,
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.contact_page_outlined,
|
|
||||||
size: 60, color: Colors.grey),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
MyText.bodyMedium('No contacts found.',
|
|
||||||
fontWeight: 500),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: MySpacing.only(
|
padding: MySpacing.only(
|
||||||
|
@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/widgets/avatar.dart';
|
import 'package:marco/helpers/widgets/avatar.dart';
|
||||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
|
import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
|
||||||
class NotesView extends StatelessWidget {
|
class NotesView extends StatelessWidget {
|
||||||
final NotesController controller = Get.find();
|
final NotesController controller = Get.find();
|
||||||
@ -71,6 +72,28 @@ class NotesView extends StatelessWidget {
|
|||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey),
|
||||||
|
MySpacing.height(18),
|
||||||
|
MyText.titleMedium(
|
||||||
|
'No matching notes found.',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
MySpacing.height(10),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Try adjusting your filters or refresh to reload.',
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
@ -94,17 +117,17 @@ class NotesView extends StatelessWidget {
|
|||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -121,25 +144,19 @@ class NotesView extends StatelessWidget {
|
|||||||
if (notes.isEmpty) {
|
if (notes.isEmpty) {
|
||||||
return MyRefreshIndicator(
|
return MyRefreshIndicator(
|
||||||
onRefresh: _refreshNotes,
|
onRefresh: _refreshNotes,
|
||||||
child: ListView(
|
child: LayoutBuilder(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
builder: (context, constraints) {
|
||||||
children: [
|
return SingleChildScrollView(
|
||||||
SizedBox(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
height: MediaQuery.of(context).size.height * 0.6,
|
child: ConstrainedBox(
|
||||||
child: Center(
|
constraints:
|
||||||
child: Column(
|
BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Center(
|
||||||
children: [
|
child: _buildEmptyState(),
|
||||||
const Icon(Icons.note_alt_outlined,
|
|
||||||
size: 60, color: Colors.grey),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
MyText.bodyMedium('No notes found.',
|
|
||||||
fontWeight: 500),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -193,7 +210,7 @@ class NotesView extends StatelessWidget {
|
|||||||
isEditing ? Colors.indigo : Colors.grey.shade300,
|
isEditing ? Colors.indigo : Colors.grey.shade300,
|
||||||
width: 1.1,
|
width: 1.1,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black12,
|
color: Colors.black12,
|
||||||
@ -228,17 +245,83 @@ class NotesView extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
/// Edit / Delete / Restore Icons
|
||||||
isEditing ? Icons.close : Icons.edit,
|
if (!note.isActive)
|
||||||
color: Colors.indigo,
|
IconButton(
|
||||||
size: 20,
|
icon: const Icon(Icons.restore,
|
||||||
|
color: Colors.green, size: 20),
|
||||||
|
tooltip: "Restore",
|
||||||
|
padding: EdgeInsets
|
||||||
|
.zero,
|
||||||
|
onPressed: () async {
|
||||||
|
await Get.dialog(
|
||||||
|
ConfirmDialog(
|
||||||
|
title: "Restore Note",
|
||||||
|
message:
|
||||||
|
"Are you sure you want to restore this note?",
|
||||||
|
confirmText: "Restore",
|
||||||
|
confirmColor: Colors.green,
|
||||||
|
icon: Icons.restore,
|
||||||
|
onConfirm: () async {
|
||||||
|
await controller.restoreOrDeleteNote(
|
||||||
|
note,
|
||||||
|
restore: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
barrierDismissible: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
/// Edit Icon
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isEditing ? Icons.close : Icons.edit,
|
||||||
|
color: Colors.indigo,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets
|
||||||
|
.zero,
|
||||||
|
constraints:
|
||||||
|
const BoxConstraints(),
|
||||||
|
onPressed: () {
|
||||||
|
controller.editingNoteId.value =
|
||||||
|
isEditing ? null : note.id;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: 6),
|
||||||
|
/// Delete Icon
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
color: Colors.redAccent, size: 20),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () async {
|
||||||
|
await Get.dialog(
|
||||||
|
ConfirmDialog(
|
||||||
|
title: "Delete Note",
|
||||||
|
message:
|
||||||
|
"Are you sure you want to delete this note?",
|
||||||
|
confirmText: "Delete",
|
||||||
|
confirmColor: Colors.redAccent,
|
||||||
|
icon: Icons.delete_forever,
|
||||||
|
onConfirm: () async {
|
||||||
|
await controller
|
||||||
|
.restoreOrDeleteNote(note,
|
||||||
|
restore: false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
barrierDismissible: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onPressed: () {
|
|
||||||
controller.editingNoteId.value =
|
|
||||||
isEditing ? null : note.id;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.06),
|
color: Colors.black.withOpacity(0.06),
|
||||||
@ -191,7 +191,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.vertical(top: Radius.circular(20)),
|
BorderRadius.vertical(top: Radius.circular(5)),
|
||||||
),
|
),
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return DocumentEditBottomSheet(
|
return DocumentEditBottomSheet(
|
||||||
@ -247,7 +247,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
),
|
),
|
||||||
@ -281,7 +281,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
),
|
),
|
||||||
@ -378,7 +378,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
margin: const EdgeInsets.only(right: 6, bottom: 6),
|
margin: const EdgeInsets.only(right: 6, bottom: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.shade100,
|
color: Colors.blue.shade100,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(
|
||||||
label,
|
label,
|
||||||
|
@ -67,7 +67,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDocumentTile(DocumentItem doc) {
|
Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) {
|
||||||
final uploadDate =
|
final uploadDate =
|
||||||
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
|
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
|
||||||
|
|
||||||
@ -79,15 +79,16 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
if (showDateHeader)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
Padding(
|
||||||
child: MyText.bodySmall(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
uploadDate,
|
child: MyText.bodySmall(
|
||||||
fontSize: 13,
|
uploadDate,
|
||||||
fontWeight: 500,
|
fontSize: 13,
|
||||||
color: Colors.grey,
|
fontWeight: 500,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// 👉 Navigate to details page
|
// 👉 Navigate to details page
|
||||||
@ -98,7 +99,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.05),
|
color: Colors.black.withOpacity(0.05),
|
||||||
@ -114,7 +115,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.shade50,
|
color: Colors.blue.shade50,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.description, color: Colors.blue),
|
child: const Icon(Icons.description, color: Colors.blue),
|
||||||
),
|
),
|
||||||
@ -190,7 +191,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
if (result == true) {
|
if (result == true) {
|
||||||
debugPrint("✅ Document deleted and removed from list");
|
debugPrint("✅ Document deleted and removed from list");
|
||||||
}
|
}
|
||||||
} else if (value == "activate") {
|
} else if (value == "restore") {
|
||||||
// existing activate flow (unchanged)
|
// existing activate flow (unchanged)
|
||||||
final success = await docController.toggleDocumentActive(
|
final success = await docController.toggleDocumentActive(
|
||||||
doc.id,
|
doc.id,
|
||||||
@ -201,14 +202,14 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Reactivated",
|
title: "Restored",
|
||||||
message: "Document reactivated successfully",
|
message: "Document reastored successfully",
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Failed to reactivate document",
|
message: "Failed to restore document",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -226,8 +227,8 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
permissionController
|
permissionController
|
||||||
.hasPermission(Permissions.modifyDocument))
|
.hasPermission(Permissions.modifyDocument))
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: "activate",
|
value: "restore",
|
||||||
child: Text("Activate"),
|
child: Text("Restore"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -307,11 +308,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -331,7 +332,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
@ -347,7 +348,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.vertical(top: Radius.circular(20)),
|
BorderRadius.vertical(top: Radius.circular(5)),
|
||||||
),
|
),
|
||||||
builder: (_) => UserDocumentFilterBottomSheet(
|
builder: (_) => UserDocumentFilterBottomSheet(
|
||||||
entityId: resolvedEntityId,
|
entityId: resolvedEntityId,
|
||||||
@ -382,14 +383,14 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: PopupMenuButton<int>(
|
child: PopupMenuButton<int>(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon:
|
icon:
|
||||||
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
|
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem<int>(
|
const PopupMenuItem<int>(
|
||||||
@ -411,7 +412,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
const Icon(Icons.visibility_off_outlined,
|
const Icon(Icons.visibility_off_outlined,
|
||||||
size: 20, color: Colors.black87),
|
size: 20, color: Colors.black87),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Expanded(child: Text('Show Inactive')),
|
const Expanded(child: Text('Show Deleted Documents')),
|
||||||
Switch.adaptive(
|
Switch.adaptive(
|
||||||
value: docController.showInactive.value,
|
value: docController.showInactive.value,
|
||||||
activeColor: Colors.indigo,
|
activeColor: Colors.indigo,
|
||||||
@ -439,24 +440,24 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
Widget _buildStatusHeader() {
|
Widget _buildStatusHeader() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isInactive = docController.showInactive.value;
|
final isInactive = docController.showInactive.value;
|
||||||
|
if (!isInactive) return const SizedBox.shrink(); // hide when active
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
color: isInactive ? Colors.red.shade50 : Colors.green.shade50,
|
color: Colors.red.shade50,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isInactive ? Icons.visibility_off : Icons.check_circle,
|
Icons.visibility_off,
|
||||||
color: isInactive ? Colors.red : Colors.green,
|
color: Colors.red,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
isInactive
|
"Showing Deleted Documents",
|
||||||
? "Showing Inactive Documents"
|
|
||||||
: "Showing Active Documents",
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isInactive ? Colors.red : Colors.green,
|
color: Colors.red,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -535,7 +536,21 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
...docs.map(_buildDocumentTile),
|
...docs.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final doc = entry.value;
|
||||||
|
|
||||||
|
final currentDate = DateFormat("dd MMM yyyy")
|
||||||
|
.format(doc.uploadedAt.toLocal());
|
||||||
|
final prevDate = index > 0
|
||||||
|
? DateFormat("dd MMM yyyy").format(
|
||||||
|
docs[index - 1].uploadedAt.toLocal())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final showDateHeader = currentDate != prevDate;
|
||||||
|
|
||||||
|
return _buildDocumentTile(doc, showDateHeader);
|
||||||
|
}),
|
||||||
if (docController.isLoading.value)
|
if (docController.isLoading.value)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.all(12),
|
padding: EdgeInsets.all(12),
|
||||||
@ -609,8 +624,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
|||||||
reset: true,
|
reset: true,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Get.snackbar(
|
showAppSnackbar(
|
||||||
"Error", "Upload failed, please try again");
|
title: "Error",
|
||||||
|
message: "Upload failed, please try again",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -153,7 +153,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
shadowColor: Colors.black12,
|
shadowColor: Colors.black12,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -16,6 +16,8 @@ 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});
|
||||||
@ -31,6 +33,8 @@ 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() {
|
||||||
@ -44,13 +48,19 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
|
|
||||||
Future<void> _initEmployees() async {
|
Future<void> _initEmployees() async {
|
||||||
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
final orgId = _organizationController.selectedOrganization.value?.id;
|
||||||
|
|
||||||
|
if (projectId != null) {
|
||||||
|
await _organizationController.fetchOrganizations(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
if (_employeeController.isAllEmployeeSelected.value) {
|
if (_employeeController.isAllEmployeeSelected.value) {
|
||||||
_employeeController.selectedProjectId = null;
|
_employeeController.selectedProjectId = null;
|
||||||
await _employeeController.fetchAllEmployees();
|
await _employeeController.fetchAllEmployees(organizationId: orgId);
|
||||||
} else if (projectId != null) {
|
} else if (projectId != null) {
|
||||||
_employeeController.selectedProjectId = projectId;
|
_employeeController.selectedProjectId = projectId;
|
||||||
await _employeeController.fetchEmployeesByProject(projectId);
|
await _employeeController.fetchEmployeesByProject(projectId,
|
||||||
|
organizationId: orgId);
|
||||||
} else {
|
} else {
|
||||||
_employeeController.clearEmployees();
|
_employeeController.clearEmployees();
|
||||||
}
|
}
|
||||||
@ -61,14 +71,16 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
Future<void> _refreshEmployees() async {
|
Future<void> _refreshEmployees() async {
|
||||||
try {
|
try {
|
||||||
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
final projectId = Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
final orgId = _organizationController.selectedOrganization.value?.id;
|
||||||
final allSelected = _employeeController.isAllEmployeeSelected.value;
|
final allSelected = _employeeController.isAllEmployeeSelected.value;
|
||||||
|
|
||||||
_employeeController.selectedProjectId = allSelected ? null : projectId;
|
_employeeController.selectedProjectId = allSelected ? null : projectId;
|
||||||
|
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
await _employeeController.fetchAllEmployees();
|
await _employeeController.fetchAllEmployees(organizationId: orgId);
|
||||||
} else if (projectId != null) {
|
} else if (projectId != null) {
|
||||||
await _employeeController.fetchEmployeesByProject(projectId);
|
await _employeeController.fetchEmployeesByProject(projectId,
|
||||||
|
organizationId: orgId);
|
||||||
} else {
|
} else {
|
||||||
_employeeController.clearEmployees();
|
_employeeController.clearEmployees();
|
||||||
}
|
}
|
||||||
@ -267,12 +279,51 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
|
|
||||||
Widget _buildSearchAndActionRow() {
|
Widget _buildSearchAndActionRow() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: MySpacing.x(flexSpacing),
|
padding: MySpacing.x(15),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _buildSearchField()),
|
// Search Field Row
|
||||||
const SizedBox(width: 8),
|
Row(
|
||||||
_buildPopupMenu(),
|
children: [
|
||||||
|
Expanded(child: _buildSearchField()),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildPopupMenu(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Organization Selector Row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OrganizationSelector(
|
||||||
|
controller: _organizationController,
|
||||||
|
height: 36,
|
||||||
|
onSelectionChanged: (org) async {
|
||||||
|
// Make sure the selectedOrganization is updated immediately
|
||||||
|
_organizationController.selectOrganization(org);
|
||||||
|
|
||||||
|
final projectId =
|
||||||
|
Get.find<ProjectController>().selectedProject?.id;
|
||||||
|
|
||||||
|
if (_employeeController.isAllEmployeeSelected.value) {
|
||||||
|
await _employeeController.fetchAllEmployees(
|
||||||
|
organizationId: _organizationController
|
||||||
|
.selectedOrganization.value?.id);
|
||||||
|
} else if (projectId != null) {
|
||||||
|
await _employeeController.fetchEmployeesByProject(
|
||||||
|
projectId,
|
||||||
|
organizationId: _organizationController
|
||||||
|
.selectedOrganization.value?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_employeeController.update(['employee_screen_controller']);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -21,6 +21,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:marco/model/employees/employee_info.dart';
|
import 'package:marco/model/employees/employee_info.dart';
|
||||||
import 'package:timeline_tile/timeline_tile.dart';
|
import 'package:timeline_tile/timeline_tile.dart';
|
||||||
|
|
||||||
class ExpenseDetailScreen extends StatefulWidget {
|
class ExpenseDetailScreen extends StatefulWidget {
|
||||||
final String expenseId;
|
final String expenseId;
|
||||||
const ExpenseDetailScreen({super.key, required this.expenseId});
|
const ExpenseDetailScreen({super.key, required this.expenseId});
|
||||||
@ -105,7 +106,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|||||||
constraints: const BoxConstraints(maxWidth: 520),
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
child: Card(
|
child: Card(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10)),
|
borderRadius: BorderRadius.circular(5)),
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@ -123,14 +124,12 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
_InvoiceDocuments(documents: expense.documents),
|
_InvoiceDocuments(documents: expense.documents),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
_InvoiceTotals(
|
_InvoiceTotals(
|
||||||
expense: expense,
|
expense: expense,
|
||||||
formattedAmount: formattedAmount,
|
formattedAmount: formattedAmount,
|
||||||
statusColor: statusColor,
|
statusColor: statusColor,
|
||||||
),
|
),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -160,7 +159,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return FloatingActionButton(
|
return FloatingActionButton.extended(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final editData = {
|
final editData = {
|
||||||
'id': expense.id,
|
'id': expense.id,
|
||||||
@ -197,8 +196,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|||||||
await controller.fetchExpenseDetails();
|
await controller.fetchExpenseDetails();
|
||||||
},
|
},
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
tooltip: 'Edit Expense',
|
icon: const Icon(Icons.edit),
|
||||||
child: const Icon(Icons.edit),
|
label: MyText.bodyMedium(
|
||||||
|
"Edit Expense", fontWeight: 600, color: Colors.white),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
bottomNavigationBar: Obx(() {
|
bottomNavigationBar: Obx(() {
|
||||||
@ -271,7 +271,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|||||||
minimumSize: const Size(100, 40),
|
minimumSize: const Size(100, 40),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||||
backgroundColor: buttonColor,
|
backgroundColor: buttonColor,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
|
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
|
||||||
@ -280,7 +280,7 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(5))),
|
||||||
builder: (context) => ReimbursementBottomSheet(
|
builder: (context) => ReimbursementBottomSheet(
|
||||||
expenseId: expense.id,
|
expenseId: expense.id,
|
||||||
statusId: next.id,
|
statusId: next.id,
|
||||||
@ -470,7 +470,7 @@ class _InvoiceHeader extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withOpacity(0.15),
|
color: statusColor.withOpacity(0.15),
|
||||||
borderRadius: BorderRadius.circular(8)),
|
borderRadius: BorderRadius.circular(5)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -604,7 +604,7 @@ class _InvoiceDocuments extends StatelessWidget {
|
|||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
color: Colors.grey.shade100,
|
color: Colors.grey.shade100,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -679,7 +679,8 @@ class InvoiceLogs extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2),
|
beforeLineStyle:
|
||||||
|
LineStyle(color: Colors.grey.shade300, thickness: 2),
|
||||||
endChild: Padding(
|
endChild: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -698,17 +699,20 @@ class InvoiceLogs extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.access_time, size: 14, color: Colors.grey[600]),
|
Icon(Icons.access_time,
|
||||||
|
size: 14, color: Colors.grey[600]),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
MyText.bodySmall(formattedDate, color: Colors.grey[700]),
|
MyText.bodySmall(formattedDate,
|
||||||
|
color: Colors.grey[700]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.shade50,
|
color: Colors.blue.shade50,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(
|
||||||
log.action,
|
log.action,
|
||||||
|
@ -408,4 +408,5 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -20,8 +20,9 @@ class ExpenseMainScreen extends StatefulWidget {
|
|||||||
State<ExpenseMainScreen> createState() => _ExpenseMainScreenState();
|
State<ExpenseMainScreen> createState() => _ExpenseMainScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
class _ExpenseMainScreenState extends State<ExpenseMainScreen>
|
||||||
bool isHistoryView = false;
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
final searchController = TextEditingController();
|
final searchController = TextEditingController();
|
||||||
final expenseController = Get.put(ExpenseController());
|
final expenseController = Get.put(ExpenseController());
|
||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
@ -30,9 +31,16 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
expenseController.fetchExpenses();
|
expenseController.fetchExpenses();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshExpenses() async {
|
Future<void> _refreshExpenses() async {
|
||||||
await expenseController.fetchExpenses();
|
await expenseController.fetchExpenses();
|
||||||
}
|
}
|
||||||
@ -49,7 +57,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ExpenseModel> _getFilteredExpenses() {
|
List<ExpenseModel> _getFilteredExpenses({required bool isHistory}) {
|
||||||
final query = searchController.text.trim().toLowerCase();
|
final query = searchController.text.trim().toLowerCase();
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
@ -61,7 +69,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
|||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) => b.transactionDate.compareTo(a.transactionDate));
|
..sort((a, b) => b.transactionDate.compareTo(a.transactionDate));
|
||||||
|
|
||||||
return isHistoryView
|
return isHistory
|
||||||
? filtered
|
? filtered
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.transactionDate.isBefore(DateTime(now.year, now.month)))
|
e.transactionDate.isBefore(DateTime(now.year, now.month)))
|
||||||
@ -72,89 +80,121 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
|||||||
e.transactionDate.year == now.year)
|
e.transactionDate.year == now.year)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@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: SafeArea(
|
body: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
// ---------------- TabBar ----------------
|
||||||
SearchAndFilter(
|
Container(
|
||||||
controller: searchController,
|
color: Colors.white,
|
||||||
onChanged: (_) => setState(() {}),
|
child: TabBar(
|
||||||
onFilterTap: _openFilterBottomSheet,
|
controller: _tabController,
|
||||||
expenseController: expenseController,
|
labelColor: Colors.black,
|
||||||
),
|
unselectedLabelColor: Colors.grey,
|
||||||
ToggleButtonsRow(
|
indicatorColor: Colors.red,
|
||||||
isHistoryView: isHistoryView,
|
tabs: const [
|
||||||
onToggle: (v) => setState(() => isHistoryView = v),
|
Tab(text: "Current Month"),
|
||||||
),
|
Tab(text: "History"),
|
||||||
Expanded(
|
],
|
||||||
child: Obx(() {
|
),
|
||||||
// Loader while fetching first time
|
|
||||||
if (expenseController.isLoading.value &&
|
|
||||||
expenseController.expenses.isEmpty) {
|
|
||||||
return SkeletonLoaders.expenseListSkeletonLoader();
|
|
||||||
}
|
|
||||||
|
|
||||||
final filteredList = _getFilteredExpenses();
|
|
||||||
|
|
||||||
return MyRefreshIndicator(
|
|
||||||
onRefresh: _refreshExpenses,
|
|
||||||
child: filteredList.isEmpty
|
|
||||||
? ListView(
|
|
||||||
physics:
|
|
||||||
const AlwaysScrollableScrollPhysics(),
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height: MediaQuery.of(context).size.height * 0.5,
|
|
||||||
child: Center(
|
|
||||||
child: MyText.bodyMedium(
|
|
||||||
expenseController.errorMessage.isNotEmpty
|
|
||||||
? expenseController.errorMessage.value
|
|
||||||
: "No expenses found",
|
|
||||||
color:
|
|
||||||
expenseController.errorMessage.isNotEmpty
|
|
||||||
? Colors.red
|
|
||||||
: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: NotificationListener<ScrollNotification>(
|
|
||||||
onNotification: (scrollInfo) {
|
|
||||||
if (scrollInfo.metrics.pixels ==
|
|
||||||
scrollInfo.metrics.maxScrollExtent &&
|
|
||||||
!expenseController.isLoading.value) {
|
|
||||||
expenseController.loadMoreExpenses();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: ExpenseList(
|
|
||||||
expenseList: filteredList,
|
|
||||||
onViewDetail: () =>
|
|
||||||
expenseController.fetchExpenses(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// ✅ FAB only if user has expenseUpload permission
|
// ---------------- Gray background for rest ----------------
|
||||||
floatingActionButton:
|
Expanded(
|
||||||
permissionController.hasPermission(Permissions.expenseUpload)
|
child: Container(
|
||||||
? FloatingActionButton(
|
color: Colors.grey[100], // Light gray background
|
||||||
backgroundColor: Colors.red,
|
child: Column(
|
||||||
onPressed: showAddExpenseBottomSheet,
|
children: [
|
||||||
child: const Icon(Icons.add, color: Colors.white),
|
// ---------------- Search ----------------
|
||||||
)
|
Padding(
|
||||||
: null,
|
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(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
onPressed: showAddExpenseBottomSheet,
|
||||||
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Widget _buildExpenseList({required bool isHistory}) {
|
||||||
|
return Obx(() {
|
||||||
|
if (expenseController.isLoading.value &&
|
||||||
|
expenseController.expenses.isEmpty) {
|
||||||
|
return SkeletonLoaders.expenseListSkeletonLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredList = _getFilteredExpenses(isHistory: isHistory);
|
||||||
|
|
||||||
|
return MyRefreshIndicator(
|
||||||
|
onRefresh: _refreshExpenses,
|
||||||
|
child: filteredList.isEmpty
|
||||||
|
? ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.5,
|
||||||
|
child: Center(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
expenseController.errorMessage.isNotEmpty
|
||||||
|
? expenseController.errorMessage.value
|
||||||
|
: "No expenses found",
|
||||||
|
color: expenseController.errorMessage.isNotEmpty
|
||||||
|
? Colors.red
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (scrollInfo) {
|
||||||
|
if (scrollInfo.metrics.pixels ==
|
||||||
|
scrollInfo.metrics.maxScrollExtent &&
|
||||||
|
!expenseController.isLoading.value) {
|
||||||
|
expenseController.loadMoreExpenses();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ExpenseList(
|
||||||
|
expenseList: filteredList,
|
||||||
|
onViewDetail: () => expenseController.fetchExpenses(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@ -133,12 +133,48 @@ class _LayoutState extends State<Layout> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: Image.asset(
|
child: Stack(
|
||||||
Images.logoDark,
|
clipBehavior: Clip.none,
|
||||||
height: 50,
|
children: [
|
||||||
width: 50,
|
Image.asset(
|
||||||
fit: BoxFit.contain,
|
Images.logoDark,
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
if (isBetaEnvironment)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.deepPurple,
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(6), // capsule shape
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white, width: 1.2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'B',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@ -218,21 +254,6 @@ class _LayoutState extends State<Layout> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isBetaEnvironment)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(left: 8),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.deepPurple,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: MyText.bodySmall(
|
|
||||||
'BETA',
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
@ -268,7 +289,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(5),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
child: _buildProjectList(context, isMobile),
|
child: _buildProjectList(context, isMobile),
|
||||||
),
|
),
|
||||||
@ -285,7 +306,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
return Card(
|
return Card(
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@ -297,7 +318,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
width: 50,
|
width: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@ -343,11 +364,11 @@ class _LayoutState extends State<Layout> {
|
|||||||
right: 16,
|
right: 16,
|
||||||
child: Material(
|
child: Material(
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
child: _buildProjectList(context, isMobile),
|
child: _buildProjectList(context, isMobile),
|
||||||
@ -397,7 +418,7 @@ class _LayoutState extends State<Layout> {
|
|||||||
? Colors.blueAccent.withOpacity(0.1)
|
? Colors.blueAccent.withOpacity(0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
visualDensity: const VisualDensity(vertical: -4),
|
visualDensity: const VisualDensity(vertical: -4),
|
||||||
);
|
);
|
||||||
|
@ -9,7 +9,10 @@ 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/tenant/tenant_selection_screen.dart';
|
||||||
|
|
||||||
class UserProfileBar extends StatefulWidget {
|
class UserProfileBar extends StatefulWidget {
|
||||||
final bool isCondensed;
|
final bool isCondensed;
|
||||||
@ -24,13 +27,21 @@ 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();
|
||||||
@ -80,6 +91,10 @@ 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,
|
||||||
@ -106,6 +121,119 @@ 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,
|
||||||
|
@ -17,6 +17,8 @@ import 'package:marco/model/dailyTaskPlanning/task_action_buttons.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.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/controller/tenant/service_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
|
||||||
|
|
||||||
class DailyProgressReportScreen extends StatefulWidget {
|
class DailyProgressReportScreen extends StatefulWidget {
|
||||||
const DailyProgressReportScreen({super.key});
|
const DailyProgressReportScreen({super.key});
|
||||||
@ -41,28 +43,51 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
final PermissionController permissionController =
|
final PermissionController permissionController =
|
||||||
Get.find<PermissionController>();
|
Get.find<PermissionController>();
|
||||||
final ProjectController projectController = Get.find<ProjectController>();
|
final ProjectController projectController = Get.find<ProjectController>();
|
||||||
|
final ServiceController serviceController = Get.put(ServiceController());
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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;
|
final initialProjectId = projectController.selectedProjectId.value;
|
||||||
if (initialProjectId.isNotEmpty) {
|
if (initialProjectId.isNotEmpty) {
|
||||||
dailyTaskController.selectedProjectId = initialProjectId;
|
dailyTaskController.selectedProjectId = initialProjectId;
|
||||||
dailyTaskController.fetchTaskData(initialProjectId);
|
dailyTaskController.fetchTaskData(initialProjectId);
|
||||||
|
serviceController.fetchServices(initialProjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
ever<String>(
|
// Update when project changes
|
||||||
projectController.selectedProjectId,
|
ever<String>(projectController.selectedProjectId, (newProjectId) async {
|
||||||
(newProjectId) async {
|
if (newProjectId.isNotEmpty &&
|
||||||
if (newProjectId.isNotEmpty &&
|
newProjectId != dailyTaskController.selectedProjectId) {
|
||||||
newProjectId != dailyTaskController.selectedProjectId) {
|
dailyTaskController.selectedProjectId = newProjectId;
|
||||||
dailyTaskController.selectedProjectId = newProjectId;
|
await dailyTaskController.fetchTaskData(newProjectId);
|
||||||
await dailyTaskController.fetchTaskData(newProjectId);
|
await serviceController.fetchServices(newProjectId);
|
||||||
dailyTaskController.update(['daily_progress_report_controller']);
|
dailyTaskController.update(['daily_progress_report_controller']);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -131,8 +156,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
child: MyRefreshIndicator(
|
child: MyRefreshIndicator(
|
||||||
onRefresh: _refreshData,
|
onRefresh: _refreshData,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
physics:
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
const AlwaysScrollableScrollPhysics(),
|
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: GetBuilder<DailyTaskController>(
|
child: GetBuilder<DailyTaskController>(
|
||||||
@ -143,6 +167,29 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
MySpacing.height(flexSpacing),
|
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(),
|
_buildActionBar(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: MySpacing.x(8),
|
padding: MySpacing.x(8),
|
||||||
@ -299,10 +346,12 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
final isLoading = dailyTaskController.isLoading.value;
|
final isLoading = dailyTaskController.isLoading.value;
|
||||||
final groupedTasks = dailyTaskController.groupedDailyTasks;
|
final groupedTasks = dailyTaskController.groupedDailyTasks;
|
||||||
|
|
||||||
if (isLoading) {
|
// Initial loading skeleton
|
||||||
|
if (isLoading && dailyTaskController.currentPage == 1) {
|
||||||
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
|
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No tasks
|
||||||
if (groupedTasks.isEmpty) {
|
if (groupedTasks.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(
|
||||||
@ -315,23 +364,33 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
final sortedDates = groupedTasks.keys.toList()
|
final sortedDates = groupedTasks.keys.toList()
|
||||||
..sort((a, b) => b.compareTo(a));
|
..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(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 10,
|
borderRadiusAll: 10,
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||||
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
child: ListView.separated(
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
itemCount: sortedDates.length,
|
itemCount: sortedDates.length + 1, // +1 for loading indicator
|
||||||
separatorBuilder: (_, __) => Column(
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Divider(color: Colors.grey.withOpacity(0.3), thickness: 1),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
itemBuilder: (context, dateIndex) {
|
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 dateKey = sortedDates[dateIndex];
|
||||||
final tasksForDate = groupedTasks[dateKey]!;
|
final tasksForDate = groupedTasks[dateKey]!;
|
||||||
final date = DateTime.tryParse(dateKey);
|
final date = DateTime.tryParse(dateKey);
|
||||||
@ -367,7 +426,6 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
return Column(
|
return Column(
|
||||||
children: tasksForDate.asMap().entries.map((entry) {
|
children: tasksForDate.asMap().entries.map((entry) {
|
||||||
final task = entry.value;
|
final task = entry.value;
|
||||||
final index = entry.key;
|
|
||||||
|
|
||||||
final activityName =
|
final activityName =
|
||||||
task.workItem?.activityMaster?.activityName ?? 'N/A';
|
task.workItem?.activityMaster?.activityName ?? 'N/A';
|
||||||
@ -385,134 +443,121 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
|||||||
? (completed / planned).clamp(0.0, 1.0)
|
? (completed / planned).clamp(0.0, 1.0)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
final parentTaskID = task.id;
|
final parentTaskID = task.id;
|
||||||
return Column(
|
|
||||||
children: [
|
return Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
child: MyContainer(
|
||||||
child: MyContainer(
|
paddingAll: 12,
|
||||||
paddingAll: 12,
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
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: [
|
children: [
|
||||||
MyText.bodyMedium(activityName,
|
Container(
|
||||||
fontWeight: 600),
|
height: 5,
|
||||||
const SizedBox(height: 2),
|
decoration: BoxDecoration(
|
||||||
MyText.bodySmall(location,
|
color: Colors.grey[300],
|
||||||
color: Colors.grey),
|
borderRadius: BorderRadius.circular(6),
|
||||||
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),
|
FractionallySizedBox(
|
||||||
MyText.bodySmall(
|
widthFactor: progress,
|
||||||
"Completed: $completed / $planned",
|
child: Container(
|
||||||
fontWeight: 600,
|
height: 5,
|
||||||
color: Colors.black87,
|
decoration: BoxDecoration(
|
||||||
),
|
color: progress >= 1.0
|
||||||
const SizedBox(height: 6),
|
? Colors.green
|
||||||
Stack(
|
: progress >= 0.5
|
||||||
children: [
|
? Colors.amber
|
||||||
Container(
|
: Colors.red,
|
||||||
height: 5,
|
borderRadius: BorderRadius.circular(6),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (index != tasksForDate.length - 1)
|
),
|
||||||
Divider(
|
|
||||||
color: Colors.grey.withOpacity(0.2),
|
|
||||||
thickness: 1,
|
|
||||||
height: 1),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
@ -13,6 +13,8 @@ import 'package:marco/model/dailyTaskPlanning/assign_task_bottom_sheet .dart';
|
|||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.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/controller/tenant/service_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
|
||||||
|
|
||||||
class DailyTaskPlanningScreen extends StatefulWidget {
|
class DailyTaskPlanningScreen extends StatefulWidget {
|
||||||
DailyTaskPlanningScreen({super.key});
|
DailyTaskPlanningScreen({super.key});
|
||||||
@ -29,23 +31,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
final PermissionController permissionController =
|
final PermissionController permissionController =
|
||||||
Get.put(PermissionController());
|
Get.put(PermissionController());
|
||||||
final ProjectController projectController = Get.find<ProjectController>();
|
final ProjectController projectController = Get.find<ProjectController>();
|
||||||
|
final ServiceController serviceController = Get.put(ServiceController());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Initial fetch if a project is already selected
|
|
||||||
final projectId = projectController.selectedProjectId.value;
|
final projectId = projectController.selectedProjectId.value;
|
||||||
if (projectId.isNotEmpty) {
|
if (projectId.isNotEmpty) {
|
||||||
dailyTaskPlanningController.fetchTaskData(projectId);
|
dailyTaskPlanningController.fetchTaskData(projectId);
|
||||||
|
serviceController.fetchServices(projectId); // <-- Fetch services here
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive fetch on project ID change
|
|
||||||
ever<String>(
|
ever<String>(
|
||||||
projectController.selectedProjectId,
|
projectController.selectedProjectId,
|
||||||
(newProjectId) {
|
(newProjectId) {
|
||||||
if (newProjectId.isNotEmpty) {
|
if (newProjectId.isNotEmpty) {
|
||||||
dailyTaskPlanningController.fetchTaskData(newProjectId);
|
dailyTaskPlanningController.fetchTaskData(newProjectId);
|
||||||
|
serviceController
|
||||||
|
.fetchServices(newProjectId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -143,6 +147,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
MySpacing.height(flexSpacing),
|
||||||
Padding(
|
Padding(
|
||||||
padding: MySpacing.x(8),
|
padding: MySpacing.x(8),
|
||||||
|
391
lib/view/tenant/tenant_selection_screen.dart
Normal file
391
lib/view/tenant/tenant_selection_screen.dart
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/services/api_endpoints.dart';
|
||||||
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/images.dart';
|
||||||
|
import 'package:marco/controller/tenant/tenant_selection_controller.dart';
|
||||||
|
|
||||||
|
class TenantSelectionScreen extends StatefulWidget {
|
||||||
|
const TenantSelectionScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TenantSelectionScreen> createState() => _TenantSelectionScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
||||||
|
with UIMixin, SingleTickerProviderStateMixin {
|
||||||
|
late final TenantSelectionController _controller;
|
||||||
|
late final AnimationController _logoAnimController;
|
||||||
|
late final Animation<double> _logoAnimation;
|
||||||
|
final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = Get.put(TenantSelectionController());
|
||||||
|
_logoAnimController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
);
|
||||||
|
_logoAnimation = CurvedAnimation(
|
||||||
|
parent: _logoAnimController,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
);
|
||||||
|
_logoAnimController.forward();
|
||||||
|
|
||||||
|
// 🔥 Tell controller this is tenant selection screen
|
||||||
|
_controller.loadTenants(fromTenantSelectionScreen: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_logoAnimController.dispose();
|
||||||
|
Get.delete<TenantSelectionController>();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTenantSelected(String tenantId) async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
await _controller.onTenantSelected(tenantId);
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
_RedWaveBackground(brandRed: contentTheme.brandRed),
|
||||||
|
SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_AnimatedLogo(animation: _logoAnimation),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const _WelcomeTexts(),
|
||||||
|
if (_isBetaEnvironment) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const _BetaBadge(),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
// Tenant list directly reacts to controller
|
||||||
|
TenantCardList(
|
||||||
|
controller: _controller,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
onTenantSelected: _onTenantSelected,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedLogo extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
const _AnimatedLogo({required this.animation});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: animation,
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Image.asset(Images.logoDark),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomeTexts extends StatelessWidget {
|
||||||
|
const _WelcomeTexts();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
MyText(
|
||||||
|
"Welcome",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.black87,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText(
|
||||||
|
"Please select which dashboard you want to explore!.",
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.black54,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BetaBadge extends StatelessWidget {
|
||||||
|
const _BetaBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orangeAccent,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: MyText(
|
||||||
|
'BETA',
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TenantCardList extends StatelessWidget {
|
||||||
|
final TenantSelectionController controller;
|
||||||
|
final bool isLoading;
|
||||||
|
final Function(String tenantId) onTenantSelected;
|
||||||
|
|
||||||
|
const TenantCardList({
|
||||||
|
required this.controller,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.onTenantSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
if (controller.isLoading.value || isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.tenants.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: MyText(
|
||||||
|
"No dashboards available for your account.",
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.black54,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.tenants.length == 1) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
...controller.tenants.map(
|
||||||
|
(tenant) => _TenantCard(
|
||||||
|
tenant: tenant,
|
||||||
|
onTap: () => onTenantSelected(tenant.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => Get.back(),
|
||||||
|
icon: const Icon(Icons.arrow_back,
|
||||||
|
size: 20, color: Colors.redAccent),
|
||||||
|
label: MyText(
|
||||||
|
'Back to Login',
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TenantCard extends StatelessWidget {
|
||||||
|
final dynamic tenant;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _TenantCard({required this.tenant, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: Card(
|
||||||
|
elevation: 3,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: TenantLogo(logoImage: tenant.logoImage),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText(
|
||||||
|
tenant.name,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
MyText(
|
||||||
|
"Industry: ${tenant.industry?.name ?? "-"}",
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
size: 24,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TenantLogo extends StatelessWidget {
|
||||||
|
final String? logoImage;
|
||||||
|
const TenantLogo({required this.logoImage});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (logoImage == null || logoImage!.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (logoImage!.startsWith("data:image")) {
|
||||||
|
try {
|
||||||
|
final base64Str = logoImage!.split(',').last;
|
||||||
|
final bytes = base64Decode(base64Str);
|
||||||
|
return Image.memory(bytes, fit: BoxFit.cover);
|
||||||
|
} catch (_) {
|
||||||
|
return Center(
|
||||||
|
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Image.network(
|
||||||
|
logoImage!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Center(
|
||||||
|
child: Icon(Icons.business, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RedWaveBackground extends StatelessWidget {
|
||||||
|
final Color brandRed;
|
||||||
|
const _RedWaveBackground({required this.brandRed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _WavePainter(brandRed),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WavePainter extends CustomPainter {
|
||||||
|
final Color brandRed;
|
||||||
|
|
||||||
|
_WavePainter(this.brandRed);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint1 = Paint()
|
||||||
|
..shader = LinearGradient(
|
||||||
|
colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
|
||||||
|
|
||||||
|
final path1 = Path()
|
||||||
|
..moveTo(0, size.height * 0.2)
|
||||||
|
..quadraticBezierTo(size.width * 0.25, size.height * 0.05,
|
||||||
|
size.width * 0.5, size.height * 0.15)
|
||||||
|
..quadraticBezierTo(
|
||||||
|
size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1)
|
||||||
|
..lineTo(size.width, 0)
|
||||||
|
..lineTo(0, 0)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(path1, paint1);
|
||||||
|
|
||||||
|
final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15);
|
||||||
|
final path2 = Path()
|
||||||
|
..moveTo(0, size.height * 0.25)
|
||||||
|
..quadraticBezierTo(
|
||||||
|
size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2)
|
||||||
|
..lineTo(size.width, 0)
|
||||||
|
..lineTo(0, 0)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(path2, paint2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user