Merge pull request 'Vaibhav_Enhancement-#1129' (#73) from Vaibhav_Enhancement-#1129 into main

Reviewed-on: #73
This commit is contained in:
vaibhav.surve 2025-09-30 09:09:35 +00:00
commit 5086b3be98
63 changed files with 4452 additions and 1663 deletions

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

@ -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({

View File

@ -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.");

View File

@ -83,7 +83,7 @@ class AuthService {
logSafe("Login payload (raw): $data"); logSafe("Login payload (raw): $data");
logSafe("Login payload (JSON): ${jsonEncode(data)}"); logSafe("Login payload (JSON): ${jsonEncode(data)}");
final responseData = await _post("/auth/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."};

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/helpers/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;
} }

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

@ -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: [

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -408,4 +408,5 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
], ],
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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