Compare commits

..

1 Commits

Author SHA1 Message Date
0e9c2e0055 implementation of security service 2025-11-26 10:10:54 +05:30
64 changed files with 4796 additions and 9727 deletions

View File

@ -1,41 +1,37 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/widgets/my_image_compressor.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
import 'package:on_field_work/model/attendance/attendance_log_model.dart';
import 'package:on_field_work/model/attendance/attendance_log_view_model.dart';
import 'package:on_field_work/model/attendance/attendance_model.dart';
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/project_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/attendance/attendance_log_model.dart';
import 'package:on_field_work/model/regularization_log_model.dart';
import 'package:on_field_work/model/attendance/attendance_log_view_model.dart';
import 'package:on_field_work/model/attendance/organization_per_project_list_model.dart';
import 'package:on_field_work/controller/project_controller.dart';
class AttendanceController extends GetxController {
// ------------------ Data Models ------------------
final List<AttendanceModel> attendances = <AttendanceModel>[];
final List<ProjectModel> projects = <ProjectModel>[];
final List<EmployeeModel> employees = <EmployeeModel>[];
final List<AttendanceLogModel> attendanceLogs = <AttendanceLogModel>[];
final List<RegularizationLogModel> regularizationLogs =
<RegularizationLogModel>[];
final List<AttendanceLogViewModel> attendenceLogsView =
<AttendanceLogViewModel>[];
List<AttendanceModel> attendances = [];
List<ProjectModel> projects = [];
List<EmployeeModel> employees = [];
List<AttendanceLogModel> attendanceLogs = [];
List<RegularizationLogModel> regularizationLogs = [];
List<AttendanceLogViewModel> attendenceLogsView = [];
// ------------------ Organizations ------------------
final List<Organization> organizations = <Organization>[];
List<Organization> organizations = [];
Organization? selectedOrganization;
final RxBool isLoadingOrganizations = false.obs;
final isLoadingOrganizations = false.obs;
// ------------------ States ------------------
String selectedTab = 'todaysAttendance';
@ -46,17 +42,16 @@ class AttendanceController extends GetxController {
final Rx<DateTime> endDateAttendance =
DateTime.now().subtract(const Duration(days: 1)).obs;
final RxBool isLoading = true.obs;
final RxBool isLoadingProjects = true.obs;
final RxBool isLoadingEmployees = true.obs;
final RxBool isLoadingAttendanceLogs = true.obs;
final RxBool isLoadingRegularizationLogs = true.obs;
final RxBool isLoadingLogView = true.obs;
final isLoading = true.obs;
final isLoadingProjects = true.obs;
final isLoadingEmployees = true.obs;
final isLoadingAttendanceLogs = true.obs;
final isLoadingRegularizationLogs = true.obs;
final isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs;
final RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
final RxBool showPendingOnly = false.obs;
final RxString searchQuery = ''.obs;
final searchQuery = ''.obs;
@override
void onInit() {
@ -69,43 +64,35 @@ class AttendanceController extends GetxController {
}
void _setDefaultDateRange() {
final DateTime today = DateTime.now();
final today = DateTime.now();
startDateAttendance.value = today.subtract(const Duration(days: 7));
endDateAttendance.value = today.subtract(const Duration(days: 1));
logSafe(
'Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
"Default date range set: ${startDateAttendance.value} to ${endDateAttendance.value}");
}
// ------------------ Computed Filters ------------------
List<EmployeeModel> get filteredEmployees {
final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return employees;
if (searchQuery.value.isEmpty) return employees;
return employees
.where(
(EmployeeModel e) => e.name.toLowerCase().contains(query),
)
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<AttendanceLogModel> get filteredLogs {
final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return attendanceLogs;
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where(
(AttendanceLogModel log) => log.name.toLowerCase().contains(query),
)
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
List<RegularizationLogModel> get filteredRegularizationLogs {
final String query = searchQuery.value.trim().toLowerCase();
if (query.isEmpty) return regularizationLogs;
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where(
(RegularizationLogModel log) =>
log.name.toLowerCase().contains(query),
)
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
@ -113,16 +100,13 @@ class AttendanceController extends GetxController {
Future<void> refreshDataFromNotification({String? projectId}) async {
projectId ??= Get.find<ProjectController>().selectedProject?.id;
if (projectId == null) {
logSafe(
'No project selected for attendance refresh from notification',
level: LogLevel.warning,
);
logSafe("No project selected for attendance refresh from notification",
level: LogLevel.warning);
return;
}
await fetchProjectData(projectId);
logSafe(
'Attendance data refreshed from notification for project $projectId',
);
"Attendance data refreshed from notification for project $projectId");
}
Future<void> fetchTodaysAttendance(String? projectId) async {
@ -130,35 +114,19 @@ class AttendanceController extends GetxController {
isLoadingEmployees.value = true;
final List<dynamic>? response = await ApiService.getTodaysAttendance(
final response = await ApiService.getTodaysAttendance(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) {
employees
..clear()
..addAll(
response
.map<EmployeeModel>(
(dynamic e) => EmployeeModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
for (final EmployeeModel emp in employees) {
employees = response.map((e) => EmployeeModel.fromJson(e)).toList();
for (var emp in employees) {
uploadingStates[emp.id] = false.obs;
}
logSafe(
'Employees fetched: ${employees.length} for project $projectId',
);
logSafe("Employees fetched: ${employees.length} for project $projectId");
} else {
logSafe(
'Failed to fetch employees for project $projectId',
level: LogLevel.error,
);
logSafe("Failed to fetch employees for project $projectId",
level: LogLevel.error);
}
isLoadingEmployees.value = false;
@ -167,22 +135,14 @@ class AttendanceController extends GetxController {
Future<void> fetchOrganizations(String projectId) async {
isLoadingOrganizations.value = true;
// Keep original return type inference from your ApiService
final response = await ApiService.getAssignedOrganizations(projectId);
if (response != null) {
organizations
..clear()
..addAll(response.data);
logSafe('Organizations fetched: ${organizations.length}');
organizations = response.data;
logSafe("Organizations fetched: ${organizations.length}");
} else {
logSafe(
'Failed to fetch organizations for project $projectId',
level: LogLevel.error,
);
logSafe("Failed to fetch organizations for project $projectId",
level: LogLevel.error);
}
isLoadingOrganizations.value = false;
update();
}
@ -192,43 +152,61 @@ class AttendanceController extends GetxController {
String id,
String employeeId,
String projectId, {
String comment = 'Marked via mobile app',
String comment = "Marked via mobile app",
required int action,
bool imageCapture = true,
String? markTime,
String? date,
}) async {
try {
_setUploading(employeeId, true);
uploadingStates[employeeId]?.value = true;
final XFile? image = await _captureAndPrepareImage(
employeeId: employeeId,
imageCapture: imageCapture,
);
if (imageCapture && image == null) {
return false;
XFile? image;
if (imageCapture) {
image = await ImagePicker()
.pickImage(source: ImageSource.camera, imageQuality: 80);
if (image == null) {
logSafe("Image capture cancelled.", level: LogLevel.warning);
return false;
}
final timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(image.path));
final compressedBytes =
await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) {
logSafe("Image compression failed.", level: LogLevel.error);
return false;
}
final compressedFile = await saveCompressedImageToFile(compressedBytes);
image = XFile(compressedFile.path);
}
final Position? position = await _getCurrentPositionSafely();
if (position == null) return false;
if (!await _handleLocationPermission()) return false;
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
final String imageName = imageCapture
? ApiService.generateImageName(
employeeId,
employees.length + 1,
)
: '';
final imageName = imageCapture
? ApiService.generateImageName(employeeId, employees.length + 1)
: "";
final DateTime effectiveDate =
_resolveEffectiveDateForAction(action, employeeId);
final now = DateTime.now();
DateTime effectiveDate = now;
final DateTime now = DateTime.now();
final String formattedMarkTime =
markTime ?? DateFormat('hh:mm a').format(now);
final String formattedDate =
if (action == 1) {
final log = attendanceLogs.firstWhereOrNull(
(log) => log.employeeId == employeeId && log.checkOut == null,
);
if (log?.checkIn != null) effectiveDate = log!.checkIn!;
}
final formattedMarkTime = markTime ?? DateFormat('hh:mm a').format(now);
final formattedDate =
date ?? DateFormat('yyyy-MM-dd').format(effectiveDate);
final bool result = await ApiService.uploadAttendanceImage(
final result = await ApiService.uploadAttendanceImage(
id,
employeeId,
image,
@ -243,99 +221,15 @@ class AttendanceController extends GetxController {
date: formattedDate,
);
if (result) {
logSafe(
'Attendance uploaded for $employeeId, action: $action, date: $formattedDate',
);
if (Get.isRegistered<DashboardController>()) {
final DashboardController dashboardController =
Get.find<DashboardController>();
await dashboardController.fetchTodaysAttendance(projectId);
}
}
logSafe(
"Attendance uploaded for $employeeId, action: $action, date: $formattedDate");
return result;
} catch (e, stacktrace) {
logSafe(
'Error uploading attendance',
level: LogLevel.error,
error: e,
stackTrace: stacktrace,
);
logSafe("Error uploading attendance",
level: LogLevel.error, error: e, stackTrace: stacktrace);
return false;
} finally {
_setUploading(employeeId, false);
}
}
Future<XFile?> _captureAndPrepareImage({
required String employeeId,
required bool imageCapture,
}) async {
if (!imageCapture) return null;
final XFile? rawImage = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (rawImage == null) {
logSafe(
'Image capture cancelled.',
level: LogLevel.warning,
);
return null;
}
final File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: File(rawImage.path),
);
final List<int>? compressedBytes =
await compressImageToUnder100KB(timestampedFile);
if (compressedBytes == null) {
logSafe(
'Image compression failed.',
level: LogLevel.error,
);
return null;
}
// FIX: convert List<int> -> Uint8List
final Uint8List compressedUint8List = Uint8List.fromList(compressedBytes);
final File compressedFile =
await saveCompressedImageToFile(compressedUint8List);
return XFile(compressedFile.path);
}
Future<Position?> _getCurrentPositionSafely() async {
final bool permissionGranted = await _handleLocationPermission();
if (!permissionGranted) return null;
return Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
}
DateTime _resolveEffectiveDateForAction(int action, String employeeId) {
final DateTime now = DateTime.now();
if (action != 1) return now;
final AttendanceLogModel? log = attendanceLogs.firstWhereOrNull(
(AttendanceLogModel log) =>
log.employeeId == employeeId && log.checkOut == null,
);
return log?.checkIn ?? now;
}
void _setUploading(String employeeId, bool value) {
final RxBool? state = uploadingStates[employeeId];
if (state != null) {
state.value = value;
} else {
uploadingStates[employeeId] = value.obs;
uploadingStates[employeeId]?.value = false;
}
}
@ -345,19 +239,14 @@ class AttendanceController extends GetxController {
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
logSafe(
'Location permissions are denied',
level: LogLevel.warning,
);
logSafe('Location permissions are denied', level: LogLevel.warning);
return false;
}
}
if (permission == LocationPermission.deniedForever) {
logSafe(
'Location permissions are permanently denied',
level: LogLevel.error,
);
logSafe('Location permissions are permanently denied',
level: LogLevel.error);
return false;
}
@ -365,40 +254,25 @@ class AttendanceController extends GetxController {
}
// ------------------ Attendance Logs ------------------
Future<void> fetchAttendanceLogs(
String? projectId, {
DateTime? dateFrom,
DateTime? dateTo,
}) async {
Future<void> fetchAttendanceLogs(String? projectId,
{DateTime? dateFrom, DateTime? dateTo}) async {
if (projectId == null) return;
isLoadingAttendanceLogs.value = true;
final List<dynamic>? response = await ApiService.getAttendanceLogs(
final response = await ApiService.getAttendanceLogs(
projectId,
dateFrom: dateFrom,
dateTo: dateTo,
organizationId: selectedOrganization?.id,
);
if (response != null) {
attendanceLogs
..clear()
..addAll(
response
.map<AttendanceLogModel>(
(dynamic e) => AttendanceLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe('Attendance logs fetched: ${attendanceLogs.length}');
attendanceLogs =
response.map((e) => AttendanceLogModel.fromJson(e)).toList();
logSafe("Attendance logs fetched: ${attendanceLogs.length}");
} else {
logSafe(
'Failed to fetch attendance logs for project $projectId',
level: LogLevel.error,
);
logSafe("Failed to fetch attendance logs for project $projectId",
level: LogLevel.error);
}
isLoadingAttendanceLogs.value = false;
@ -406,37 +280,25 @@ class AttendanceController extends GetxController {
}
Map<String, List<AttendanceLogModel>> groupLogsByCheckInDate() {
final Map<String, List<AttendanceLogModel>> groupedLogs =
<String, List<AttendanceLogModel>>{};
final groupedLogs = <String, List<AttendanceLogModel>>{};
for (final AttendanceLogModel logItem in attendanceLogs) {
final String checkInDate = logItem.checkIn != null
for (var logItem in attendanceLogs) {
final checkInDate = logItem.checkIn != null
? DateFormat('dd MMM yyyy').format(logItem.checkIn!)
: 'Unknown';
groupedLogs.putIfAbsent(
checkInDate,
() => <AttendanceLogModel>[],
)..add(logItem);
groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem);
}
final List<MapEntry<String, List<AttendanceLogModel>>> sortedEntries =
groupedLogs.entries.toList()
..sort(
(MapEntry<String, List<AttendanceLogModel>> a,
MapEntry<String, List<AttendanceLogModel>> b) {
if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1;
final sortedEntries = groupedLogs.entries.toList()
..sort((a, b) {
if (a.key == 'Unknown') return 1;
if (b.key == 'Unknown') return -1;
final dateA = DateFormat('dd MMM yyyy').parse(a.key);
final dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA);
});
final DateTime dateA = DateFormat('dd MMM yyyy').parse(a.key);
final DateTime dateB = DateFormat('dd MMM yyyy').parse(b.key);
return dateB.compareTo(dateA);
},
);
return Map<String, List<AttendanceLogModel>>.fromEntries(
sortedEntries,
);
return Map<String, List<AttendanceLogModel>>.fromEntries(sortedEntries);
}
// ------------------ Regularization Logs ------------------
@ -445,31 +307,17 @@ class AttendanceController extends GetxController {
isLoadingRegularizationLogs.value = true;
final List<dynamic>? response = await ApiService.getRegularizationLogs(
final response = await ApiService.getRegularizationLogs(
projectId,
organizationId: selectedOrganization?.id,
);
if (response != null) {
regularizationLogs
..clear()
..addAll(
response
.map<RegularizationLogModel>(
(dynamic e) => RegularizationLogModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
logSafe(
'Regularization logs fetched: ${regularizationLogs.length}',
);
regularizationLogs =
response.map((e) => RegularizationLogModel.fromJson(e)).toList();
logSafe("Regularization logs fetched: ${regularizationLogs.length}");
} else {
logSafe(
'Failed to fetch regularization logs for project $projectId',
level: LogLevel.error,
);
logSafe("Failed to fetch regularization logs for project $projectId",
level: LogLevel.error);
}
isLoadingRegularizationLogs.value = false;
@ -482,33 +330,16 @@ class AttendanceController extends GetxController {
isLoadingLogView.value = true;
final List<dynamic>? response = await ApiService.getAttendanceLogView(id);
final response = await ApiService.getAttendanceLogView(id);
if (response != null) {
attendenceLogsView
..clear()
..addAll(
response
.map<AttendanceLogViewModel>(
(dynamic e) => AttendanceLogViewModel.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
);
attendenceLogsView.sort(
(AttendanceLogViewModel a, AttendanceLogViewModel b) =>
(b.activityTime ?? DateTime(2000))
.compareTo(a.activityTime ?? DateTime(2000)),
);
logSafe('Attendance log view fetched for ID: $id');
attendenceLogsView =
response.map((e) => AttendanceLogViewModel.fromJson(e)).toList();
attendenceLogsView.sort((a, b) => (b.activityTime ?? DateTime(2000))
.compareTo(a.activityTime ?? DateTime(2000)));
logSafe("Attendance log view fetched for ID: $id");
} else {
logSafe(
'Failed to fetch attendance log view for ID $id',
level: LogLevel.error,
);
logSafe("Failed to fetch attendance log view for ID $id",
level: LogLevel.error);
}
isLoadingLogView.value = false;
@ -544,19 +375,16 @@ class AttendanceController extends GetxController {
}
logSafe(
'Project data fetched for project ID: $projectId, tab: $selectedTab',
);
"Project data fetched for project ID: $projectId, tab: $selectedTab");
update();
}
// ------------------ UI Interaction ------------------
Future<void> selectDateRangeForAttendance(
BuildContext context,
AttendanceController controller,
) async {
final DateTime today = DateTime.now();
BuildContext context, AttendanceController controller) async {
final today = DateTime.now();
final DateTimeRange? picked = await showDateRangePicker(
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2022),
lastDate: today.subtract(const Duration(days: 1)),
@ -571,8 +399,7 @@ class AttendanceController extends GetxController {
endDateAttendance.value = picked.end;
logSafe(
'Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}',
);
"Date range selected: ${startDateAttendance.value} to ${endDateAttendance.value}");
await controller.fetchAttendanceLogs(
Get.find<ProjectController>().selectedProject?.id,

View File

@ -7,201 +7,191 @@ import 'package:on_field_work/model/dashboard/pending_expenses_model.dart';
import 'package:on_field_work/model/dashboard/expense_type_report_model.dart';
import 'package:on_field_work/model/dashboard/monthly_expence_model.dart';
import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
class DashboardController extends GetxController {
// Dependencies
final ProjectController projectController = Get.put(ProjectController());
// =========================
// Attendance overview
// =========================
final RxList<Map<String, dynamic>> roleWiseData =
<Map<String, dynamic>>[].obs;
final RxString attendanceSelectedRange = '15D'.obs;
final RxBool attendanceIsChartView = true.obs;
final RxBool isAttendanceLoading = false.obs;
// =========================
// 1. STATE VARIABLES
// Project progress overview
// =========================
final RxList<ChartTaskData> projectChartData = <ChartTaskData>[].obs;
final RxString projectSelectedRange = '15D'.obs;
final RxBool projectIsChartView = true.obs;
final RxBool isProjectLoading = false.obs;
// Attendance
final roleWiseData = <Map<String, dynamic>>[].obs;
final attendanceSelectedRange = '15D'.obs;
final attendanceIsChartView = true.obs;
final isAttendanceLoading = false.obs;
// =========================
// Projects overview
// =========================
final RxInt totalProjects = 0.obs;
final RxInt ongoingProjects = 0.obs;
final RxBool isProjectsLoading = false.obs;
// Project Progress
final projectChartData = <ChartTaskData>[].obs;
final projectSelectedRange = '15D'.obs;
final projectIsChartView = true.obs;
final isProjectLoading = false.obs;
// =========================
// Tasks overview
// =========================
final RxInt totalTasks = 0.obs;
final RxInt completedTasks = 0.obs;
final RxBool isTasksLoading = false.obs;
// Overview Counts
final totalProjects = 0.obs;
final ongoingProjects = 0.obs;
final isProjectsLoading = false.obs;
// =========================
// Teams overview
// =========================
final RxInt totalEmployees = 0.obs;
final RxInt inToday = 0.obs;
final RxBool isTeamsLoading = false.obs;
final totalTasks = 0.obs;
final completedTasks = 0.obs;
final isTasksLoading = false.obs;
final totalEmployees = 0.obs;
final inToday = 0.obs;
final isTeamsLoading = false.obs;
// Expenses & Reports
final isPendingExpensesLoading = false.obs;
final pendingExpensesData = Rx<PendingExpensesData?>(null);
final isExpenseTypeReportLoading = false.obs;
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
final expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final expenseReportEndDate = DateTime.now().obs;
final isMonthlyExpenseLoading = false.obs;
final monthlyExpenseList = <MonthlyExpenseData>[].obs;
final selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
final selectedMonthsCount = 12.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
// Teams/Employees
final isLoadingEmployees = true.obs;
final employees = <EmployeeModel>[].obs;
final uploadingStates = <String, RxBool>{}.obs;
// Collection
final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants
// Common ranges
final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = {
'7D': 7,
'15D': 15,
'30D': 30,
'3M': 90,
'6M': 180
};
// Inject ProjectController
final ProjectController projectController = Get.put(ProjectController());
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
// =========================
// 2. COMPUTED PROPERTIES
// Expense Category Report
// =========================
final RxBool isExpenseTypeReportLoading = false.obs;
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
Rx<ExpenseTypeReportData?>(null);
final Rx<DateTime> expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs;
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
// =========================
// Monthly Expense Report
// =========================
final RxBool isMonthlyExpenseLoading = false.obs;
final RxList<MonthlyExpenseData> monthlyExpenseList =
<MonthlyExpenseData>[].obs;
// =========================
// Monthly Expense Report Filters
// =========================
final Rx<MonthlyExpenseDuration> selectedMonthlyExpenseDuration =
MonthlyExpenseDuration.twelveMonths.obs;
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
final RxInt selectedMonthsCount = 12.obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
// DSO Calculation Constants
static const double _w0_30 = 15.0;
static const double _w30_60 = 45.0;
static const double _w60_90 = 75.0;
static const double _w90_plus = 105.0;
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
double get calculatedDSO {
final data = collectionOverviewData.value;
if (data == null || data.totalDueAmount == 0) return 0.0;
// Debug print to verify
print('Selected: ${type?.name ?? "All Types"}');
final double weightedDue = (data.bucket0To30Amount * _w0_30) +
(data.bucket30To60Amount * _w30_60) +
(data.bucket60To90Amount * _w60_90) +
(data.bucket90PlusAmount * _w90_plus);
return weightedDue / data.totalDueAmount;
if (type == null) {
fetchMonthlyExpenses();
} else {
fetchMonthlyExpenses(categoryId: type.id);
}
}
// =========================
// 3. LIFECYCLE
// =========================
@override
void onInit() {
super.onInit();
logSafe('DashboardController initialized', level: LogLevel.info);
// Project Selection Listener
logSafe(
'DashboardController initialized with project ID: ${projectController.selectedProjectId.value}',
level: LogLevel.info,
);
fetchAllDashboardData();
// React to project change
ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) {
fetchAllDashboardData();
fetchTodaysAttendance(id);
}
fetchAllDashboardData();
});
// Expense Report Date Listener
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
if (projectController.selectedProjectId.value.isNotEmpty) {
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
}
fetchExpenseTypeReport(
startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value,
);
});
// Chart Range Listeners
// React to range changes
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress());
}
// =========================
// 4. USER ACTIONS
// Helper Methods
// =========================
void updateAttendanceRange(String range) =>
attendanceSelectedRange.value = range;
void updateProjectRange(String range) => projectSelectedRange.value = range;
void toggleAttendanceChartView(bool isChart) =>
attendanceIsChartView.value = isChart;
void toggleProjectChartView(bool isChart) =>
projectIsChartView.value = isChart;
void updateSelectedExpenseType(ExpenseTypeModel? type) {
selectedExpenseType.value = type;
fetchMonthlyExpenses(categoryId: type?.id);
}
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration;
// Efficient Map lookup instead of Switch
const durationMap = {
MonthlyExpenseDuration.oneMonth: 1,
MonthlyExpenseDuration.threeMonths: 3,
MonthlyExpenseDuration.sixMonths: 6,
MonthlyExpenseDuration.twelveMonths: 12,
MonthlyExpenseDuration.all: 0,
};
selectedMonthsCount.value = durationMap[duration] ?? 12;
fetchMonthlyExpenses();
}
Future<void> refreshDashboard() => fetchAllDashboardData();
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
Future<void> refreshProjects() => fetchProjectProgress();
Future<void> refreshTasks() async {
final id = projectController.selectedProjectId.value;
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
}
// =========================
// 5. DATA FETCHING (API)
// =========================
/// Wrapper to reduce try-finally boilerplate for loading states
Future<void> _executeApiCall(
RxBool loader, Future<void> Function() apiLogic) async {
loader.value = true;
try {
await apiLogic();
} finally {
loader.value = false;
int _getDaysFromRange(String range) {
switch (range) {
case '7D':
return 7;
case '15D':
return 15;
case '30D':
return 30;
case '3M':
return 90;
case '6M':
return 180;
default:
return 7;
}
}
int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value);
int getProjectDays() => _getDaysFromRange(projectSelectedRange.value);
void updateAttendanceRange(String range) {
attendanceSelectedRange.value = range;
logSafe('Attendance range updated to $range', level: LogLevel.debug);
}
void updateProjectRange(String range) {
projectSelectedRange.value = range;
logSafe('Project range updated to $range', level: LogLevel.debug);
}
void toggleAttendanceChartView(bool isChart) {
attendanceIsChartView.value = isChart;
logSafe('Attendance chart view toggled to: $isChart',
level: LogLevel.debug);
}
void toggleProjectChartView(bool isChart) {
projectIsChartView.value = isChart;
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
}
// =========================
// Manual Refresh Methods
// =========================
Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
await fetchAllDashboardData();
}
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
Future<void> refreshTasks() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
}
Future<void> refreshProjects() async => fetchProjectProgress();
// =========================
// Fetch All Dashboard Data
// =========================
Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
if (projectId.isEmpty) {
logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning);
return;
}
await Future.wait([
fetchRoleWiseAttendance(),
@ -214,150 +204,248 @@ class DashboardController extends GetxController {
endDate: expenseReportEndDate.value,
),
fetchMonthlyExpenses(),
fetchMasterData(),
fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
fetchMasterData()
]);
}
Future<void> fetchCollectionOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration;
await _executeApiCall(isCollectionOverviewLoading, () async {
final response =
await ApiService.getCollectionOverview(projectId: projectId);
collectionOverviewData.value =
(response?.success == true) ? response!.data : null;
});
}
// Set months count based on selection
switch (duration) {
case MonthlyExpenseDuration.oneMonth:
selectedMonthsCount.value = 1;
break;
case MonthlyExpenseDuration.threeMonths:
selectedMonthsCount.value = 3;
break;
case MonthlyExpenseDuration.sixMonths:
selectedMonthsCount.value = 6;
break;
case MonthlyExpenseDuration.twelveMonths:
selectedMonthsCount.value = 12;
break;
case MonthlyExpenseDuration.all:
selectedMonthsCount.value = 0; // 0 = All months in your API
break;
}
Future<void> fetchTodaysAttendance(String projectId) async {
await _executeApiCall(isLoadingEmployees, () async {
final response = await ApiService.getAttendanceForDashboard(projectId);
if (response != null) {
employees.value = response;
for (var emp in employees) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
}
}
});
// Re-fetch updated data
fetchMonthlyExpenses();
}
Future<void> fetchMasterData() async {
try {
final data = await ApiService.getMasterExpenseTypes();
if (data is List) {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
expenseTypes.value =
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (_) {}
} catch (e) {
logSafe('Error fetching master data', level: LogLevel.error, error: e);
}
}
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
await _executeApiCall(isMonthlyExpenseLoading, () async {
try {
isMonthlyExpenseLoading.value = true;
int months = selectedMonthsCount.value;
logSafe(
'Fetching Monthly Expense Report for last $months months'
'${categoryId != null ? ' (categoryId: $categoryId)' : ''}',
level: LogLevel.info,
);
final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId,
months: selectedMonthsCount.value,
months: months,
);
monthlyExpenseList.value =
(response?.success == true) ? response!.data : [];
});
}
Future<void> fetchPurchaseInvoiceOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isPurchaseInvoiceLoading, () async {
final response = await ApiService.getPurchaseInvoiceOverview(
projectId: projectId,
);
purchaseInvoiceOverviewData.value =
(response?.success == true) ? response!.data : null;
});
if (response != null && response.success) {
monthlyExpenseList.value = response.data;
logSafe('Monthly Expense Report fetched successfully.',
level: LogLevel.info);
} else {
monthlyExpenseList.clear();
logSafe('Failed to fetch Monthly Expense Report.',
level: LogLevel.error);
}
} catch (e, st) {
monthlyExpenseList.clear();
logSafe('Error fetching Monthly Expense Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isMonthlyExpenseLoading.value = false;
}
}
Future<void> fetchPendingExpenses() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isPendingExpensesLoading, () async {
final response = await ApiService.getPendingExpensesApi(projectId: id);
pendingExpensesData.value =
(response?.success == true) ? response!.data : null;
});
try {
isPendingExpensesLoading.value = true;
final response =
await ApiService.getPendingExpensesApi(projectId: projectId);
if (response != null && response.success) {
pendingExpensesData.value = response.data;
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
} else {
pendingExpensesData.value = null;
logSafe('Failed to fetch pending expenses.', level: LogLevel.error);
}
} catch (e, st) {
pendingExpensesData.value = null;
logSafe('Error fetching pending expenses',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isPendingExpensesLoading.value = false;
}
}
// =========================
// API Calls
// =========================
Future<void> fetchRoleWiseAttendance() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isAttendanceLoading, () async {
final response = await ApiService.getDashboardAttendanceOverview(
id, getAttendanceDays());
roleWiseData.value =
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
});
try {
isAttendanceLoading.value = true;
final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays());
if (response != null) {
roleWiseData.value =
response.map((e) => Map<String, dynamic>.from(e)).toList();
logSafe('Attendance overview fetched successfully.',
level: LogLevel.info);
} else {
roleWiseData.clear();
logSafe('Failed to fetch attendance overview: response is null.',
level: LogLevel.error);
}
} catch (e, st) {
roleWiseData.clear();
logSafe('Error fetching attendance overview',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isAttendanceLoading.value = false;
}
}
Future<void> fetchExpenseTypeReport(
{required DateTime startDate, required DateTime endDate}) async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
Future<void> fetchExpenseTypeReport({
required DateTime startDate,
required DateTime endDate,
}) async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isExpenseTypeReportLoading.value = true;
await _executeApiCall(isExpenseTypeReportLoading, () async {
final response = await ApiService.getExpenseTypeReportApi(
projectId: id,
projectId: projectId,
startDate: startDate,
endDate: endDate,
);
expenseTypeReportData.value =
(response?.success == true) ? response!.data : null;
});
if (response != null && response.success) {
expenseTypeReportData.value = response.data;
logSafe('Expense Category Report fetched successfully.',
level: LogLevel.info);
} else {
expenseTypeReportData.value = null;
logSafe('Failed to fetch Expense Category Report.', level: LogLevel.error);
}
} catch (e, st) {
expenseTypeReportData.value = null;
logSafe('Error fetching Expense Category Report',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isExpenseTypeReportLoading.value = false;
}
}
Future<void> fetchProjectProgress() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isProjectLoading, () async {
try {
isProjectLoading.value = true;
final response = await ApiService.getProjectProgress(
projectId: id, days: getProjectDays());
if (response?.success == true) {
projectChartData.value = response!.data
.map((d) => ChartTaskData.fromProjectData(d))
.toList();
projectId: projectId, days: getProjectDays());
if (response != null && response.success) {
projectChartData.value =
response.data.map((d) => ChartTaskData.fromProjectData(d)).toList();
logSafe('Project progress data mapped for chart', level: LogLevel.info);
} else {
projectChartData.clear();
logSafe('Failed to fetch project progress', level: LogLevel.error);
}
});
} catch (e, st) {
projectChartData.clear();
logSafe('Error fetching project progress',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isProjectLoading.value = false;
}
}
Future<void> fetchDashboardTasks({required String projectId}) async {
await _executeApiCall(isTasksLoading, () async {
if (projectId.isEmpty) return;
try {
isTasksLoading.value = true;
final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response?.success == true) {
totalTasks.value = response!.data?.totalTasks ?? 0;
if (response != null && response.success) {
totalTasks.value = response.data?.totalTasks ?? 0;
completedTasks.value = response.data?.completedTasks ?? 0;
logSafe('Dashboard tasks fetched', level: LogLevel.info);
} else {
totalTasks.value = 0;
completedTasks.value = 0;
logSafe('Failed to fetch tasks', level: LogLevel.error);
}
});
} catch (e, st) {
totalTasks.value = 0;
completedTasks.value = 0;
logSafe('Error fetching tasks',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTasksLoading.value = false;
}
}
Future<void> fetchDashboardTeams({required String projectId}) async {
await _executeApiCall(isTeamsLoading, () async {
if (projectId.isEmpty) return;
try {
isTeamsLoading.value = true;
final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response?.success == true) {
totalEmployees.value = response!.data?.totalEmployees ?? 0;
if (response != null && response.success) {
totalEmployees.value = response.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0;
logSafe('Dashboard teams fetched', level: LogLevel.info);
} else {
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Failed to fetch teams', level: LogLevel.error);
}
});
} catch (e, st) {
totalEmployees.value = 0;
inToday.value = 0;
logSafe('Error fetching teams',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isTeamsLoading.value = false;
}
}
}

View File

@ -142,8 +142,8 @@ class DocumentController extends GetxController {
);
if (response != null && response.success) {
if (response.data?.data.isNotEmpty ?? false) {
documents.addAll(response.data!.data);
if (response.data.data.isNotEmpty) {
documents.addAll(response.data.data);
pageNumber.value++;
} else {
hasMore.value = false;

View File

@ -281,7 +281,6 @@ class PaymentRequestDetailController extends GetxController {
String? tdsPercentage,
}) async {
isLoading.value = true;
try {
final success = await ApiService.updateExpensePaymentRequestStatusApi(
paymentRequestId: _requestId,
@ -296,14 +295,25 @@ class PaymentRequestDetailController extends GetxController {
);
if (success) {
// Controller refreshes the data but does not show snackbars.
showAppSnackbar(
title: 'Success',
message: 'Payment submitted successfully',
type: SnackbarType.success);
await fetchPaymentRequestDetail();
paymentRequestController.fetchPaymentRequests();
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status. Please try again.',
type: SnackbarType.error);
}
return success;
} catch (e) {
// Controller returns false on error; UI will show the snackbar.
showAppSnackbar(
title: 'Error',
message: 'Something went wrong: $e',
type: SnackbarType.error);
return false;
} finally {
isLoading.value = false;

View File

@ -1,48 +0,0 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
class InfraProjectController extends GetxController {
final projects = <ProjectData>[].obs;
final isLoading = false.obs;
final searchQuery = ''.obs;
// Filtered list
List<ProjectData> get filteredProjects {
final q = searchQuery.value.trim().toLowerCase();
if (q.isEmpty) return projects;
return projects.where((p) {
return (p.name?.toLowerCase().contains(q) ?? false) ||
(p.shortName?.toLowerCase().contains(q) ?? false) ||
(p.projectAddress?.toLowerCase().contains(q) ?? false) ||
(p.contactPerson?.toLowerCase().contains(q) ?? false);
}).toList();
}
// Fetch Projects
Future<void> fetchProjects({int pageNumber = 1, int pageSize = 20}) async {
try {
isLoading.value = true;
final response = await ApiService.getInfraProjectsList(
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.data != null) {
projects.assignAll(response.data!.data ?? []);
} else {
projects.clear();
}
} catch (e) {
rethrow;
} finally {
isLoading.value = false;
}
}
void updateSearch(String query) {
searchQuery.value = query;
}
}

View File

@ -1,38 +0,0 @@
import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
class InfraProjectDetailsController extends GetxController {
final String projectId;
InfraProjectDetailsController({required this.projectId});
var isLoading = true.obs;
var projectDetails = Rxn<ProjectData>();
var errorMessage = ''.obs;
@override
void onInit() {
super.onInit();
fetchProjectDetails();
}
Future<void> fetchProjectDetails() async {
try {
isLoading.value = true;
final response = await ApiService.getInfraProjectDetails(projectId: projectId);
if (response != null && response.success == true && response.data != null) {
projectDetails.value = response.data;
isLoading.value = false;
} else {
errorMessage.value = response?.message ?? "Failed to load project details";
}
} catch (e) {
errorMessage.value = "Error fetching project details: $e";
} finally {
isLoading.value = false;
}
}
}

View File

@ -15,9 +15,6 @@ class PermissionController extends GetxController {
Timer? _refreshTimer;
var isLoading = true.obs;
/// NEW: reactive flag to signal permissions are loaded
var permissionsLoaded = false.obs;
@override
void onInit() {
super.onInit();
@ -55,10 +52,6 @@ class PermissionController extends GetxController {
_updateState(userData);
await _storeData();
logSafe("Data loaded and state updated successfully.");
// NEW: mark permissions as loaded
permissionsLoaded.value = true;
} catch (e, stacktrace) {
logSafe("Error loading data from API",
level: LogLevel.error, error: e, stackTrace: stacktrace);
@ -110,7 +103,7 @@ class PermissionController extends GetxController {
}
void _startAutoRefresh() {
_refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async {
_refreshTimer = Timer.periodic(Duration(minutes: 30), (timer) async {
logSafe("Auto-refresh triggered.");
final token = await _getAuthToken();
if (token?.isNotEmpty ?? false) {
@ -124,6 +117,8 @@ class PermissionController extends GetxController {
bool hasPermission(String permissionId) {
final hasPerm = permissions.any((p) => p.id == permissionId);
// logSafe("Checking permission $permissionId: $hasPerm",
// level: LogLevel.debug);
return hasPerm;
}

View File

@ -63,34 +63,26 @@ class AddServiceProjectJobController extends GetxController {
return;
}
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
isLoading.value = true;
final jobId = await ApiService.createServiceProjectJobApi(
final success = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(),
description: descCtrl.text.trim(),
projectId: projectId,
branchId: selectedBranch.value?.id,
assignees: selectedAssignees // payload mapping
.map((e) => {"employeeId": e.id, "isActive": true})
.toList(),
branchId: selectedBranch.value?.id,
assignees: assigneeIds.map((id) => {"id": id}).toList(),
startDate: startDate.value!,
dueDate: dueDate.value!,
tags: enteredTags
.map((tag) => {"id": null, "name": tag, "isActive": true})
.toList(),
tags: enteredTags.map((tag) => {"name": tag}).toList(),
);
isLoading.value = false;
if (jobId != null) {
if (success) {
if (Get.isRegistered<ServiceProjectDetailsController>()) {
final detailsCtrl = Get.find<ServiceProjectDetailsController>();
// 🔥 1. Refresh job LIST
detailsCtrl.refreshJobsAfterAdd();
// 🔥 2. Refresh job DETAILS (FULL DATA - including tags and assignees)
await detailsCtrl.fetchJobDetail(jobId);
Get.find<ServiceProjectDetailsController>().refreshJobsAfterAdd();
}
Get.back();

View File

@ -6,12 +6,10 @@ import 'package:on_field_work/model/service_project/service_project_job_detail_m
import 'package:geolocator/geolocator.dart';
import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart';
import 'package:on_field_work/model/service_project/job_allocation_model.dart';
import 'package:on_field_work/model/service_project/job_status_response.dart';
import 'package:on_field_work/model/service_project/job_comments.dart';
import 'dart:convert';
import 'dart:io';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
class ServiceProjectDetailsController extends GetxController {
// -------------------- Observables --------------------
@ -30,8 +28,6 @@ class ServiceProjectDetailsController extends GetxController {
var errorMessage = ''.obs;
var jobErrorMessage = ''.obs;
var jobDetailErrorMessage = ''.obs;
final ImagePicker picker = ImagePicker();
var isProcessingAttachment = false.obs;
// Pagination
var pageNumber = 1;
@ -45,16 +41,7 @@ class ServiceProjectDetailsController extends GetxController {
var isTeamLoading = false.obs;
var teamErrorMessage = ''.obs;
var filteredJobList = <JobEntity>[].obs;
// -------------------- Job Status --------------------
// With this:
var jobStatusList = <JobStatus>[].obs;
var selectedJobStatus = Rx<JobStatus?>(null);
var isJobStatusLoading = false.obs;
var jobStatusErrorMessage = ''.obs;
// -------------------- Job Comments --------------------
var jobComments = <CommentItem>[].obs;
var isCommentsLoading = false.obs;
var commentsErrorMessage = ''.obs;
// -------------------- Lifecycle --------------------
@override
void onInit() {
@ -123,41 +110,6 @@ class ServiceProjectDetailsController extends GetxController {
}
}
Future<void> fetchJobStatus({required String statusId}) async {
if (projectId.value.isEmpty) {
jobStatusErrorMessage.value = "Invalid project ID";
return;
}
isJobStatusLoading.value = true;
jobStatusErrorMessage.value = '';
try {
final statuses = await ApiService.getMasterJobStatus(
projectId: projectId.value,
statusId: statusId,
);
if (statuses != null && statuses.isNotEmpty) {
jobStatusList.value = statuses;
// Keep previously selected if exists, else pick first
selectedJobStatus.value = statuses.firstWhere(
(status) => status.id == selectedJobStatus.value?.id,
orElse: () => statuses.first,
);
print("Job Status List: ${jobStatusList.map((e) => e.name).toList()}");
} else {
jobStatusErrorMessage.value = "No job statuses found";
}
} catch (e) {
jobStatusErrorMessage.value = "Error fetching job status: $e";
} finally {
isJobStatusLoading.value = false;
}
}
Future<void> fetchProjectDetail() async {
if (projectId.value.isEmpty) {
errorMessage.value = "Invalid project ID";
@ -319,91 +271,6 @@ class ServiceProjectDetailsController extends GetxController {
}
}
Future<void> fetchJobComments({bool refresh = false}) async {
if (jobDetail.value?.data?.id == null) {
commentsErrorMessage.value = "Invalid job ID";
return;
}
if (refresh) pageNumber = 1;
isCommentsLoading.value = true;
commentsErrorMessage.value = '';
try {
final response = await ApiService.getJobCommentList(
jobTicketId: jobDetail.value!.data!.id!,
pageNumber: pageNumber,
pageSize: pageSize,
);
if (response != null && response.data != null) {
final newComments = response.data?.data ?? [];
if (refresh || pageNumber == 1) {
jobComments.value = newComments;
} else {
jobComments.addAll(newComments);
}
hasMoreJobs.value =
(response.data?.totalEntities ?? 0) > (pageNumber * pageSize);
if (hasMoreJobs.value) pageNumber++;
} else {
commentsErrorMessage.value =
response?.message ?? "Failed to fetch comments";
}
} catch (e) {
commentsErrorMessage.value = "Error fetching comments: $e";
} finally {
isCommentsLoading.value = false;
}
}
Future<bool> addJobComment({
required String jobId,
required String comment,
List<File>? files,
}) async {
try {
List<Map<String, dynamic>> attachments = [];
if (files != null && files.isNotEmpty) {
for (final file in files) {
final bytes = await file.readAsBytes();
final base64Data = base64Encode(bytes);
final mimeType =
lookupMimeType(file.path) ?? "application/octet-stream";
attachments.add({
"fileName": file.path.split('/').last,
"base64Data": base64Data,
"contentType": mimeType,
"fileSize": bytes.length,
"description": "",
"isActive": true,
});
}
}
final success = await ApiService.addJobComment(
jobTicketId: jobId,
comment: comment,
attachments: attachments,
);
if (success) {
await fetchJobDetail(jobId);
refresh();
}
return success;
} catch (e) {
print("Error adding comment: $e");
return false;
}
}
/// Tag In / Tag Out for a job with proper payload
Future<void> updateJobAttendance({
required String jobId,

View File

@ -1,9 +1,9 @@
class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.onfieldwork.com/api";
static const String baseUrl = "https://api.onfieldwork.com/api";
static const String getMasterCurrencies = "/Master/currencies/list";
@ -36,10 +36,6 @@ class ApiEndpoints {
"/Dashboard/expense/monthly";
static const String getExpenseTypeReport = "/Dashboard/expense/type";
static const String getPendingExpenses = "/Dashboard/expense/pendings";
static const String getCollectionOverview = "/dashboard/collection-overview";
static const String getPurchaseInvoiceOverview =
"/dashboard/purchase-invoice-overview";
///// Projects Module API Endpoints
static const String createProject = "/project";
@ -48,7 +44,6 @@ class ApiEndpoints {
static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize";
@ -157,14 +152,4 @@ class ApiEndpoints {
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
static const String getTeamRoles = "/master/team-roles/list";
static const String getServiceProjectBranches = "/serviceproject/branch/list";
static const String getMasterJobStatus = "/Master/job-status/list";
static const String addJobComment = "/ServiceProject/job/add/comment";
static const String getJobCommentList = "/ServiceProject/job/comment/list";
// Infra Project Module API Endpoints
static const String getInfraProjectsList = "/project/list";
static const String getInfraProjectDetail = "/project/details";
}

View File

@ -40,14 +40,7 @@ import 'package:on_field_work/model/service_project/service_project_job_detail_m
import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart';
import 'package:on_field_work/model/service_project/job_allocation_model.dart';
import 'package:on_field_work/model/service_project/service_project_branches_model.dart';
import 'package:on_field_work/model/service_project/job_status_response.dart';
import 'package:on_field_work/model/service_project/job_comments.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
import 'package:on_field_work/helpers/services/security_service.dart';
class ApiService {
static const bool enableLogs = true;
@ -173,7 +166,7 @@ class ApiService {
.timeout(extendedTimeout);
logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug);
logSafe("Response Body: ${response.body}", level: LogLevel.debug);
logSafe("Encrypted Response: ${response.body}", level: LogLevel.debug);
if (response.statusCode == 401 && !hasRetried) {
logSafe("Unauthorized (401). Attempting token refresh...",
@ -194,6 +187,14 @@ class ApiService {
await LocalStorage.logout();
}
// *********** DECRYPT HERE *************
final decrypted = await SecurityService.decryptResponse(response.body);
if (decrypted != null) {
return http.Response(jsonEncode(decrypted), response.statusCode);
}
// *************************************
return response;
} catch (e) {
logSafe("HTTP GET Exception: $e", level: LogLevel.error);
@ -227,6 +228,17 @@ class ApiService {
customTimeout: customTimeout, hasRetried: true);
}
}
logSafe("Encrypted Response: ${response.body}", level: LogLevel.debug);
// *********** DECRYPT HERE *************
final decrypted = await SecurityService.decryptResponse(response.body);
if (decrypted != null) {
return http.Response(jsonEncode(decrypted), response.statusCode);
}
// *************************************
return response;
} catch (e) {
logSafe("HTTP POST Exception: $e", level: LogLevel.error);
@ -319,274 +331,6 @@ class ApiService {
}
}
/// ============================================
/// GET PURCHASE INVOICE OVERVIEW (Dashboard)
/// ============================================
static Future<PurchaseInvoiceOverviewResponse?> getPurchaseInvoiceOverview({
String? projectId,
}) async {
try {
final queryParams = <String, String>{};
if (projectId != null && projectId.isNotEmpty) {
queryParams['projectId'] = projectId;
}
final response = await _getRequest(
ApiEndpoints.getPurchaseInvoiceOverview,
queryParams: queryParams,
);
if (response == null) {
_log("getPurchaseInvoiceOverview: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson = _parseResponseForAllData(
response,
label: "PurchaseInvoiceOverview",
);
if (parsedJson == null) return null;
return PurchaseInvoiceOverviewResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getPurchaseInvoiceOverview: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// ============================================
/// GET COLLECTION OVERVIEW (Dashboard)
/// ============================================
static Future<CollectionOverviewResponse?> getCollectionOverview({
String? projectId,
}) async {
try {
// Build query params (only add projectId if not null)
final queryParams = <String, String>{};
if (projectId != null && projectId.isNotEmpty) {
queryParams['projectId'] = projectId;
}
final response = await _getRequest(
ApiEndpoints.getCollectionOverview,
queryParams: queryParams,
);
if (response == null) {
_log("getCollectionOverview: No response from server",
level: LogLevel.error);
return null;
}
// Parse full JSON (success, message, data, etc.)
final parsedJson =
_parseResponseForAllData(response, label: "CollectionOverview");
if (parsedJson == null) return null;
return CollectionOverviewResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getCollectionOverview: $e\n$stack",
level: LogLevel.error);
return null;
}
}
// Infra Project Module APIs
/// ================================
/// GET INFRA PROJECT DETAILS
/// ================================
static Future<ProjectDetailsResponse?> getInfraProjectDetails({
required String projectId,
}) async {
final endpoint = "${ApiEndpoints.getInfraProjectDetail}/$projectId";
try {
final response = await _getRequest(endpoint);
if (response == null) {
_log("getInfraProjectDetails: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "InfraProjectDetails");
if (parsedJson == null) return null;
return ProjectDetailsResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getInfraProjectDetails: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// ================================
/// GET INFRA PROJECTS LIST
/// ================================
static Future<ProjectsResponse?> getInfraProjectsList({
int pageSize = 20,
int pageNumber = 1,
String searchString = "",
}) async {
final queryParams = {
"pageSize": pageSize.toString(),
"pageNumber": pageNumber.toString(),
"searchString": searchString,
};
try {
final response = await _getRequest(
ApiEndpoints.getInfraProjectsList,
queryParams: queryParams,
);
if (response == null) {
_log("getInfraProjectsList: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "InfraProjectsList");
if (parsedJson == null) return null;
return ProjectsResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getInfraProjectsList: $e\n$stack",
level: LogLevel.error);
return null;
}
}
static Future<JobCommentResponse?> getJobCommentList({
required String jobTicketId,
int pageNumber = 1,
int pageSize = 20,
}) async {
final queryParams = {
'jobTicketId': jobTicketId,
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
};
try {
final response = await _getRequest(
ApiEndpoints.getJobCommentList,
queryParams: queryParams,
);
if (response == null) {
_log("getJobCommentList: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "JobCommentList");
if (parsedJson == null) return null;
return JobCommentResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getJobCommentList: $e\n$stack", level: LogLevel.error);
return null;
}
}
static Future<bool> addJobComment({
required String jobTicketId,
required String comment,
List<Map<String, dynamic>> attachments = const [],
}) async {
final body = {
"jobTicketId": jobTicketId,
"comment": comment,
"attachments": attachments,
};
try {
final response = await _postRequest(
ApiEndpoints.addJobComment,
body,
);
if (response == null) {
_log("addJobComment: No response from server", level: LogLevel.error);
return false;
}
// Handle 201 Created as success manually
if (response.statusCode == 201) {
_log("AddJobComment: Comment added successfully (201).",
level: LogLevel.info);
return true;
}
// Otherwise fallback to existing _parseResponse
final parsed = _parseResponse(response, label: "AddJobComment");
if (parsed != null && parsed['success'] == true) {
_log("AddJobComment: Comment added successfully.",
level: LogLevel.info);
return true;
} else {
_log(
"AddJobComment failed: ${parsed?['message'] ?? 'Unknown error'}",
level: LogLevel.error,
);
return false;
}
} catch (e, stack) {
_log("Exception in addJobComment: $e\n$stack", level: LogLevel.error);
return false;
}
}
static Future<List<JobStatus>?> getMasterJobStatus({
required String statusId,
required String projectId,
}) async {
final queryParams = {
'statusId': statusId,
'projectId': projectId,
};
try {
final response = await _getRequest(
ApiEndpoints.getMasterJobStatus,
queryParams: queryParams,
);
if (response == null) {
_log("getMasterJobStatus: No response received.");
return null;
}
final parsedJson =
_parseResponseForAllData(response, label: "MasterJobStatus");
if (parsedJson == null) return null;
// Directly parse JobStatus list
final dataList = (parsedJson['data'] as List<dynamic>?)
?.map((e) => JobStatus.fromJson(e))
.toList();
return dataList;
} catch (e, stack) {
_log("Exception in getMasterJobStatus: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// Fetch Service Project Branches with full response
static Future<ServiceProjectBranchesResponse?> getServiceProjectBranchesFull({
required String projectId,
@ -871,7 +615,8 @@ class ApiService {
return null;
}
static Future<String?> createServiceProjectJobApi({
/// Create a new Service Project Job
static Future<bool> createServiceProjectJobApi({
required String title,
required String description,
required String projectId,
@ -898,22 +643,32 @@ class ApiService {
try {
final response = await _postRequest(endpoint, body);
if (response == null) return null;
final json = jsonDecode(response.body);
if (json['success'] == true) {
final jobId = json['data']?['id'];
logSafe("Service Project Job created successfully: $jobId");
return jobId;
if (response == null) {
logSafe("Create Service Project Job failed: null response",
level: LogLevel.error);
return false;
}
return null;
logSafe(
"Create Service Project Job response status: ${response.statusCode}");
logSafe("Create Service Project Job response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Service Project Job created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createServiceProjectJobApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return null;
return false;
}
}
@ -3473,30 +3228,6 @@ class ApiService {
res != null ? _parseResponse(res, label: 'Employees') : null);
}
static Future<List<EmployeeModel>?> getAttendanceForDashboard(
String projectId) async {
String endpoint = ApiEndpoints.getAttendanceForDashboard.replaceFirst(
':projectId',
projectId,
);
final res = await _getRequest(endpoint);
if (res == null) return null;
final data = _parseResponse(res, label: 'Dashboard Attendance');
if (data == null) return null;
// Wrap single object in a list if needed
if (data is Map<String, dynamic>) {
return [EmployeeModel.fromJson(data)];
} else if (data is List) {
return data.map((e) => EmployeeModel.fromJson(e)).toList();
}
return null;
}
static Future<List<dynamic>?> getRegularizationLogs(
String projectId, {
String? organizationId,

View File

@ -0,0 +1,41 @@
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
class SecurityService {
// Same 32-byte key
static const _keyBase64 = "Your32ByteBase64KeyGoesHere/1234567890+XY=";
static Future<dynamic> decryptResponse(String encryptedBase64) async {
final algorithm = AesGcm.with256bits();
// 1. Decode Key and Data
final secretKey = await algorithm.newSecretKeyFromBytes(base64.decode(_keyBase64));
final encryptedBytes = base64.decode(encryptedBase64);
// 2. Extract Parts: [Nonce 12] + [Ciphertext] + [Tag 16]
// The "SecretBox" class helps us organize these parts
final nonce = encryptedBytes.sublist(0, 12);
final ciphertext = encryptedBytes.sublist(12, encryptedBytes.length - 16);
final mac = encryptedBytes.sublist(encryptedBytes.length - 16);
final secretBox = SecretBox(
ciphertext,
nonce: nonce,
mac: Mac(mac),
);
// 3. Decrypt
try {
final decryptedBytes = await algorithm.decrypt(
secretBox,
secretKey: secretKey,
);
final decryptedString = utf8.decode(decryptedBytes);
return json.decode(decryptedString);
} catch (e) {
print("Decryption failed: $e");
return null;
}
}
}

View File

@ -63,9 +63,6 @@ class ThemeController extends GetxController {
await Future.delayed(const Duration(milliseconds: 600));
showApplied.value = false;
// Navigate to dashboard after applying theme
Get.offAllNamed('/dashboard');
}
}

View File

@ -163,9 +163,6 @@ class MenuItems {
/// Service Projects
static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b";
/// Infrastructure Projects
static const String infraProjects = "5fab4b88-c9a0-417b-aca2-130980fdb0cf";
}
/// Contains all job status IDs used across the application.

View File

@ -3,228 +3,95 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class CustomAppBar extends StatefulWidget
with UIMixin
implements PreferredSizeWidget {
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final String? projectName; // If passed, show static text
final String? projectName;
final VoidCallback? onBackPressed;
final Color? backgroundColor;
CustomAppBar({
const CustomAppBar({
super.key,
required this.title,
this.projectName,
this.onBackPressed,
this.backgroundColor,
});
@override
Size get preferredSize => const Size.fromHeight(72);
@override
State<CustomAppBar> createState() => _CustomAppBarState();
}
class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
final ProjectController projectController = Get.find();
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
void _toggleDropdown() {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
} else {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
OverlayEntry _createOverlayEntry() {
final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => GestureDetector(
onTap: () {
_toggleDropdown();
},
behavior: HitTestBehavior.translucent,
child: Stack(
children: [
Positioned(
left: offset.dx + 16,
top: offset.dy + size.height,
width: size.width - 32,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(5),
child: Container(
height: MediaQuery.of(context).size.height * 0.33,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: "Search project...",
isDense: true,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5)),
),
),
Expanded(
child: ListView.builder(
itemCount: projectController.projects.length,
itemBuilder: (_, index) {
final project = projectController.projects[index];
return RadioListTile<String>(
dense: true,
value: project.id,
groupValue:
projectController.selectedProjectId.value,
onChanged: (v) {
if (v != null) {
projectController.updateSelectedProject(v);
_toggleDropdown();
}
},
title: Text(project.name),
);
},
),
),
],
),
),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final Color effectiveBackgroundColor =
widget.backgroundColor ?? contentTheme.primary;
const Color onPrimaryColor = Colors.white;
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.black,
size: 20,
),
onPressed: onBackPressed ?? () => Get.back(),
),
MySpacing.width(5),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// TITLE
MyText.titleLarge(
title,
fontWeight: 700,
color: Colors.black,
),
final bool showDropdown = widget.projectName == null;
MySpacing.height(2),
return AppBar(
backgroundColor: effectiveBackgroundColor,
elevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 0,
shadowColor: Colors.transparent,
leading: Padding(
padding: MySpacing.only(left: 16),
child: IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: onPrimaryColor,
size: 20,
),
onPressed: widget.onBackPressed ?? () => Get.back(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
title: Padding(
padding: MySpacing.only(right: 16, left: 8),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge(
widget.title,
fontWeight: 800,
color: onPrimaryColor,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
MySpacing.height(3),
showDropdown
? CompositedTransformTarget(
link: _layerLink,
child: GestureDetector(
onTap: _toggleDropdown,
child: Row(
children: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
MySpacing.width(4),
Flexible(
child: Obx(() {
final projectName = projectController
.selectedProject?.name ??
'Select Project';
return MyText.bodySmall(
projectName,
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}),
),
MySpacing.width(2),
const Icon(Icons.keyboard_arrow_down,
size: 18, color: onPrimaryColor),
],
),
),
)
: Row(
// PROJECT NAME ROW
GetBuilder<ProjectController>(
builder: (projectController) {
// NEW LOGIC simple and safe
final displayProjectName =
projectName ??
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
const Icon(
Icons.work_outline,
size: 14,
color: Colors.grey,
),
MySpacing.width(4),
Flexible(
Expanded(
child: MyText.bodySmall(
widget.projectName!,
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
displayProjectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
maxLines: 1,
color: Colors.grey[700],
),
),
],
),
],
);
},
),
],
),
),
),
],
),
),
actions: [
Padding(
padding: MySpacing.only(right: 16),
child: IconButton(
icon: const Icon(Icons.home, color: onPrimaryColor, size: 24),
onPressed: () => Get.offAllNamed('/dashboard'),
],
),
),
],
),
);
}
@override
void dispose() {
_overlayEntry?.remove();
super.dispose();
}
Size get preferredSize => const Size.fromHeight(72);
}

View File

@ -1,426 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class CollectionsHealthWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final DashboardController controller = Get.find<DashboardController>();
return Obx(() {
final data = controller.collectionOverviewData.value;
final isLoading = controller.isCollectionOverviewLoading.value;
// Loading state
if (isLoading) {
return Container(
decoration: _boxDecoration(), // Maintain the outer box decoration
padding: const EdgeInsets.all(16.0),
child: SkeletonLoaders.collectionHealthSkeleton(),
);
}
// No data
if (data == null) {
return Container(
decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0),
child: Center(
child: MyText.bodyMedium('No collection overview data available.'),
),
);
}
// Data available
final double totalDue = data.totalDueAmount;
final double totalCollected = data.totalCollectedAmount;
final double pendingPercentage = data.pendingPercentage / 100.0;
final double dsoDays = controller.calculatedDSO;
return Container(
decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildHeader(),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
flex: 5,
child: _buildLeftChartSection(
totalDue: totalDue,
pendingPercentage: pendingPercentage,
totalCollected: totalCollected,
),
),
const SizedBox(width: 16),
Expanded(
flex: 4,
child: _buildRightMetricsSection(
data: data,
dsoDays: dsoDays,
),
),
],
),
const SizedBox(height: 20),
_buildAgingAnalysis(data: data),
],
),
);
});
}
// ==============================
// HEADER
// ==============================
Widget _buildHeader() {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Collections Health Overview', fontWeight: 700),
const SizedBox(height: 2),
MyText.bodySmall('View your collection health data.',
color: Colors.grey),
],
),
),
],
);
}
// ==============================
// LEFT SECTION (GAUGE + SUMMARY)
// ==============================
Widget _buildLeftChartSection({
required double totalDue,
required double pendingPercentage,
required double totalCollected,
}) {
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
String collectedPercentStr =
((1 - pendingPercentage) * 100).toStringAsFixed(0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: [
_GaugeChartPlaceholder(
backgroundColor: Colors.white,
pendingPercentage: pendingPercentage,
),
const SizedBox(width: 12),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodyLarge(
'${totalDue.toStringAsFixed(0)} DUE',
fontWeight: 700,
),
const SizedBox(height: 4),
MyText.bodySmall(
'• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)',
color: Colors.black54,
),
MyText.bodySmall(
'${totalCollected.toStringAsFixed(0)} Collected',
color: Colors.black54,
),
],
),
),
],
),
],
);
}
// ==============================
// RIGHT METRICS SECTION
// ==============================
Widget _buildRightMetricsSection({
required CollectionOverviewData data,
required double dsoDays,
}) {
final String topClientName = data.topClient?.name ?? 'N/A';
final double topClientBalance = data.topClientBalance;
return Column(
children: <Widget>[
_buildMetricCard(
title: 'Top Client Balance',
value: topClientName,
subValue: '${topClientBalance.toStringAsFixed(0)}',
valueColor: Colors.red,
isDetailed: true,
),
const SizedBox(height: 10),
_buildMetricCard(
title: 'Total Collected (YTD)',
value: '${data.totalCollectedAmount.toStringAsFixed(0)}',
subValue: 'Collected',
valueColor: Colors.green,
isDetailed: false,
),
],
);
}
Widget _buildMetricCard({
required String title,
required String value,
required String subValue,
required Color valueColor,
required bool isDetailed,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodySmall(title, color: Colors.black54),
const SizedBox(height: 2),
if (isDetailed) ...[
MyText.bodySmall(value, fontWeight: 600),
MyText.bodyMedium(subValue, color: valueColor, fontWeight: 700),
] else
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
MyText.bodySmall(value, fontWeight: 600),
MyText.bodySmall(subValue, color: valueColor, fontWeight: 600),
],
),
],
),
);
}
// ==============================
// AGING ANALYSIS
// ==============================
Widget _buildAgingAnalysis({required CollectionOverviewData data}) {
final buckets = [
AgingBucketData('0-30 Days', data.bucket0To30Amount, Colors.green,
data.bucket0To30Invoices),
AgingBucketData('30-60 Days', data.bucket30To60Amount, Colors.orange,
data.bucket30To60Invoices),
AgingBucketData('60-90 Days', data.bucket60To90Amount,
Colors.red.shade300, data.bucket60To90Invoices),
AgingBucketData('> 90 Days', data.bucket90PlusAmount, Colors.red,
data.bucket90PlusInvoices),
];
final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Outstanding Collections Aging Analysis',
fontWeight: 700),
MyText.bodySmall(
'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}',
color: Colors.black54),
const SizedBox(height: 10),
_AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding),
const SizedBox(height: 15),
Wrap(
spacing: 12,
runSpacing: 8,
children: buckets
.map((bucket) => _buildAgingLegendItem(bucket.title,
bucket.amount, bucket.color, bucket.invoiceCount))
.toList(),
),
],
);
}
Widget _buildAgingLegendItem(
String title, double amount, Color color, int count) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
MyText.bodySmall(
'$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'),
],
);
}
BoxDecoration _boxDecoration() {
return BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
);
}
}
// =====================================================================
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
// =====================================================================
// Gauge Chart
class _GaugeChartPlaceholder extends StatelessWidget {
final Color backgroundColor;
final double pendingPercentage;
const _GaugeChartPlaceholder({
required this.backgroundColor,
required this.pendingPercentage,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 120,
height: 80,
child: Stack(
children: [
CustomPaint(
size: const Size(120, 70),
painter: _SemiCirclePainter(
canvasColor: backgroundColor,
pendingPercentage: pendingPercentage,
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FittedBox(
child: MyText.bodySmall('RISK LEVEL', fontWeight: 600),
),
),
),
],
),
);
}
}
class _SemiCirclePainter extends CustomPainter {
final Color canvasColor;
final double pendingPercentage;
_SemiCirclePainter(
{required this.canvasColor, required this.pendingPercentage});
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromCircle(
center: Offset(size.width / 2, size.height),
radius: size.width / 2,
);
const double arc = 3.14159;
final double pendingSweep = arc * pendingPercentage;
final double collectedSweep = arc * (1 - pendingPercentage);
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.1)
..strokeWidth = 10
..style = PaintingStyle.stroke;
canvas.drawArc(rect, arc, arc, false, backgroundPaint);
final pendingPaint = Paint()
..strokeWidth = 10
..style = PaintingStyle.stroke
..shader = const LinearGradient(
colors: [Colors.orange, Colors.red],
).createShader(rect);
canvas.drawArc(rect, arc, pendingSweep, false, pendingPaint);
final collectedPaint = Paint()
..color = Colors.green
..strokeWidth = 10
..style = PaintingStyle.stroke;
canvas.drawArc(
rect, arc + pendingSweep, collectedSweep, false, collectedPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// AGING BUCKET
class AgingBucketData {
final String title;
final double amount;
final Color color;
final int invoiceCount; // ADDED
// UPDATED CONSTRUCTOR
AgingBucketData(this.title, this.amount, this.color, this.invoiceCount);
}
class _AgingStackedBar extends StatelessWidget {
final List<AgingBucketData> buckets;
final double totalOutstanding;
const _AgingStackedBar({
required this.buckets,
required this.totalOutstanding,
});
@override
Widget build(BuildContext context) {
if (totalOutstanding == 0) {
return Container(
height: 16,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: MyText.bodySmall('No Outstanding Collections',
color: Colors.black54),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Row(
children: buckets.where((b) => b.amount > 0).map((bucket) {
final flexValue = bucket.amount / totalOutstanding;
return Expanded(
flex: (flexValue * 1000).toInt(),
child: Container(height: 16, color: bucket.color),
);
}).toList(),
),
);
}
}

View File

@ -1,717 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class CompactPurchaseInvoiceDashboard extends StatelessWidget {
const CompactPurchaseInvoiceDashboard({super.key});
@override
Widget build(BuildContext context) {
final DashboardController controller = Get.find();
// Use Obx to reactively listen to data changes
return Obx(() {
final data = controller.purchaseInvoiceOverviewData.value;
// Show loading state while API call is in progress
if (controller.isPurchaseInvoiceLoading.value) {
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
}
// Show empty state if no data
if (data == null || data.totalInvoices == 0) {
return Center(
child: MyText.bodySmall('No purchase invoices found.'),
);
}
// Convert API response to internal PurchaseInvoiceData list
final invoices = (data.projectBreakdown ?? [])
.map((project) => PurchaseInvoiceData(
id: project.id ?? '',
title: project.name ?? 'Unknown',
proformaInvoiceAmount: project.totalValue ?? 0.0,
supplierName: data.topSupplier?.name ?? 'N/A',
projectName: project.name ?? 'Unknown',
statusName: 'Unknown', // API might have status if needed
))
.toList();
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics);
});
}
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
const double spacing = 16.0;
const double smallSpacing = 8.0;
return Container(
padding: const EdgeInsets.all(spacing),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const _DashboardHeader(),
const SizedBox(height: spacing),
_TotalValueCard(
totalProformaAmount: metrics.totalProformaAmount,
totalCount: metrics.totalCount,
),
const SizedBox(height: spacing),
_CondensedMetricsRow(
draftCount: metrics.draftCount,
avgInvoiceValue: metrics.avgInvoiceValue,
topSupplierName: metrics.topSupplierName,
spacing: smallSpacing,
),
const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing),
const _SectionTitle('Status Breakdown by Value'),
const SizedBox(height: smallSpacing),
_StatusDonutChart(
statusBuckets: metrics.statusBuckets,
totalAmount: metrics.totalProformaAmount,
),
const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing),
const _SectionTitle('Top Projects by Proforma Value'),
const SizedBox(height: smallSpacing),
_ProjectBreakdown(
projects: metrics.projectBuckets.take(3).toList(),
totalAmount: metrics.totalProformaAmount,
spacing: smallSpacing,
),
],
),
);
}
}
/// Container object used internally
class PurchaseInvoiceDashboardData {
final List<PurchaseInvoiceData> invoices;
final PurchaseInvoiceMetrics metrics;
const PurchaseInvoiceDashboardData({
required this.invoices,
required this.metrics,
});
}
/// =======================
/// DATA MODELS
/// =======================
class PurchaseInvoiceData {
final String id;
final String title;
final double proformaInvoiceAmount;
final String supplierName;
final String projectName;
final String statusName;
const PurchaseInvoiceData({
required this.id,
required this.title,
required this.proformaInvoiceAmount,
required this.supplierName,
required this.projectName,
required this.statusName,
});
factory PurchaseInvoiceData.fromJson(Map<String, dynamic> json) {
final supplier = json['supplier'] as Map<String, dynamic>? ?? const {};
final project = json['project'] as Map<String, dynamic>? ?? const {};
final status = json['status'] as Map<String, dynamic>? ?? const {};
return PurchaseInvoiceData(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
proformaInvoiceAmount:
(json['proformaInvoiceAmount'] as num?)?.toDouble() ?? 0.0,
supplierName: supplier['name']?.toString() ?? 'Unknown Supplier',
projectName: project['name']?.toString() ?? 'Unknown Project',
statusName: status['displayName']?.toString() ?? 'Unknown',
);
}
}
class StatusBucketData {
final String title;
final double amount;
final Color color;
final int count;
const StatusBucketData({
required this.title,
required this.amount,
required this.color,
required this.count,
});
}
class ProjectMetricData {
final String name;
final double amount;
const ProjectMetricData({
required this.name,
required this.amount,
});
}
class PurchaseInvoiceMetrics {
final double totalProformaAmount;
final int totalCount;
final int draftCount;
final String topSupplierName;
final double topSupplierAmount;
final List<StatusBucketData> statusBuckets;
final List<ProjectMetricData> projectBuckets;
final double avgInvoiceValue;
const PurchaseInvoiceMetrics({
required this.totalProformaAmount,
required this.totalCount,
required this.draftCount,
required this.topSupplierName,
required this.topSupplierAmount,
required this.statusBuckets,
required this.projectBuckets,
required this.avgInvoiceValue,
});
}
/// =======================
/// METRICS CALCULATOR
/// =======================
class PurchaseInvoiceMetricsCalculator {
PurchaseInvoiceMetrics calculate(List<PurchaseInvoiceData> invoices) {
final double totalProformaAmount =
invoices.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
final int totalCount = invoices.length;
final int draftCount =
invoices.where((item) => item.statusName == 'Draft').length;
final Map<String, double> supplierTotals = <String, double>{};
for (final invoice in invoices) {
supplierTotals.update(
invoice.supplierName,
(value) => value + invoice.proformaInvoiceAmount,
ifAbsent: () => invoice.proformaInvoiceAmount,
);
}
final MapEntry<String, double>? topSupplierEntry = supplierTotals
.entries.isEmpty
? null
: supplierTotals.entries.reduce((a, b) => a.value > b.value ? a : b);
final String topSupplierName = topSupplierEntry?.key ?? 'N/A';
final double topSupplierAmount = topSupplierEntry?.value ?? 0.0;
final Map<String, double> projectTotals = <String, double>{};
for (final invoice in invoices) {
projectTotals.update(
invoice.projectName,
(value) => value + invoice.proformaInvoiceAmount,
ifAbsent: () => invoice.proformaInvoiceAmount,
);
}
final List<ProjectMetricData> projectBuckets = projectTotals.entries
.map((e) => ProjectMetricData(name: e.key, amount: e.value))
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
final Map<String, List<PurchaseInvoiceData>> statusGroups =
<String, List<PurchaseInvoiceData>>{};
for (final invoice in invoices) {
statusGroups.putIfAbsent(
invoice.statusName,
() => <PurchaseInvoiceData>[],
);
statusGroups[invoice.statusName]!.add(invoice);
}
final List<StatusBucketData> statusBuckets = statusGroups.entries.map(
(entry) {
final double statusTotal = entry.value
.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
return StatusBucketData(
title: entry.key,
amount: statusTotal,
color: getColorForStatus(entry.key),
count: entry.value.length,
);
},
).toList();
final double avgInvoiceValue =
totalCount > 0 ? totalProformaAmount / totalCount : 0.0;
return PurchaseInvoiceMetrics(
totalProformaAmount: totalProformaAmount,
totalCount: totalCount,
draftCount: draftCount,
topSupplierName: topSupplierName,
topSupplierAmount: topSupplierAmount,
statusBuckets: statusBuckets,
projectBuckets: projectBuckets,
avgInvoiceValue: avgInvoiceValue,
);
}
}
/// =======================
/// UTILITIES
/// =======================
Color _getProjectColor(String name) {
final int hash = name.hashCode;
const List<Color> colors = <Color>[
Color(0xFF42A5F5), // Blue
Color(0xFF66BB6A), // Green
Color(0xFFFFA726), // Orange
Color(0xFFEC407A), // Pink
Color(0xFF7E57C2), // Deep Purple
Color(0xFF26C6DA), // Cyan
Color(0xFFFFEE58), // Yellow
];
return colors[hash.abs() % colors.length];
}
Color getColorForStatus(String status) {
switch (status) {
case 'Draft':
return Colors.blueGrey;
case 'Pending Approval':
return Colors.orange;
case 'Approved':
return Colors.green;
case 'Paid':
return Colors.blue;
default:
return Colors.grey;
}
}
/// =======================
/// REDESIGNED INTERNAL UI WIDGETS
/// =======================
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return MyText.bodySmall(
title,
color: Colors.grey.shade700,
fontWeight: 700,
letterSpacing: 0.5,
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader();
@override
Widget build(BuildContext context) {
return Row(mainAxisAlignment: MainAxisAlignment.start, children: [
Expanded(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
MyText.bodyMedium(
'Purchase Invoice ',
fontWeight: 700,
),
SizedBox(height: 2),
MyText.bodySmall(
'View your purchase invoice data.',
color: Colors.grey,
),
]))
]);
}
}
// Total Value Card - Refined Style
class _TotalValueCard extends StatelessWidget {
final double totalProformaAmount;
final int totalCount;
const _TotalValueCard({
required this.totalProformaAmount,
required this.totalCount,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD), // Lighter Blue
borderRadius: BorderRadius.circular(5),
border: Border.all(color: const Color(0xFFBBDEFB), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodySmall(
'TOTAL PROFORMA VALUE (₹)',
color: Colors.blue.shade800,
fontWeight: 700,
letterSpacing: 1.0,
),
Icon(
Icons.account_balance_wallet_outlined,
color: Colors.blue.shade700,
size: 20,
),
],
),
MySpacing.height(8),
MyText.bodyMedium(
totalProformaAmount.toStringAsFixed(0),
),
MySpacing.height(4),
MyText.bodySmall(
'Over $totalCount Total Invoices',
color: Colors.blueGrey.shade600,
fontWeight: 500,
),
],
),
);
}
}
// Condensed Metrics Row - Replaces the GridView
class _CondensedMetricsRow extends StatelessWidget {
final int draftCount;
final double avgInvoiceValue;
final String topSupplierName;
final double spacing;
const _CondensedMetricsRow({
required this.draftCount,
required this.avgInvoiceValue,
required this.topSupplierName,
required this.spacing,
});
@override
Widget build(BuildContext context) {
// Only showing 3 key metrics in a row for a tighter feel
return Row(
children: [
Expanded(
child: _CondensedMetricCard(
title: 'Drafts',
value: draftCount.toString(),
caption: 'To Complete',
color: Colors.orange.shade700,
icon: Icons.edit_note_outlined,
),
),
SizedBox(width: spacing),
Expanded(
child: _CondensedMetricCard(
title: 'Avg. Value',
value: '${avgInvoiceValue.toStringAsFixed(0)}',
caption: 'Per Invoice',
color: Colors.purple.shade700,
icon: Icons.calculate_outlined,
),
),
SizedBox(width: spacing),
Expanded(
child: _CondensedMetricCard(
title: 'Top Supplier',
value: topSupplierName,
caption: 'By Value',
color: Colors.green.shade700,
icon: Icons.business_center_outlined,
),
),
],
);
}
}
// Condensed Metric Card - Small, impactful display
class _CondensedMetricCard extends StatelessWidget {
final String title;
final String value;
final String caption;
final Color color;
final IconData icon;
const _CondensedMetricCard({
required this.title,
required this.value,
required this.caption,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: color.withOpacity(0.15), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 4),
Expanded(
child: MyText.bodySmall(
title,
overflow: TextOverflow.ellipsis,
color: color,
fontWeight: 700,
),
),
],
),
MySpacing.height(6),
MyText.bodyMedium(
value,
overflow: TextOverflow.ellipsis,
fontWeight: 800,
),
MyText.bodySmall(
caption,
color: Colors.grey.shade500,
fontWeight: 500,
),
],
),
);
}
}
// Status Breakdown (Donut Chart + Legend) - Stronger Visualization
class _StatusDonutChart extends StatelessWidget {
final List<StatusBucketData> statusBuckets;
final double totalAmount;
const _StatusDonutChart({
required this.statusBuckets,
required this.totalAmount,
});
@override
Widget build(BuildContext context) {
final List<StatusBucketData> activeBuckets = statusBuckets
.where((b) => b.amount > 0)
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
if (activeBuckets.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: MyText.bodySmall(
'No active invoices to display status breakdown.',
color: Colors.grey.shade500,
),
);
}
// Determine the percentage of the largest bucket for the center text
final double mainPercentage =
totalAmount > 0 ? activeBuckets.first.amount / totalAmount : 0.0;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Simulated Donut Chart (Center Focus)
Container(
width: 120,
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: activeBuckets.first.color.withOpacity(0.5), width: 6),
color: activeBuckets.first.color.withOpacity(0.05),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyText.bodySmall(
activeBuckets.first.title,
color: activeBuckets.first.color,
fontWeight: 700,
),
MyText.bodyMedium(
'${(mainPercentage * 100).toStringAsFixed(0)}%',
),
],
),
),
const SizedBox(width: 16),
// Legend/Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: activeBuckets.map((bucket) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: bucket.color,
shape: BoxShape.circle,
),
),
Expanded(
child: MyText.bodySmall(
'${bucket.title} (${bucket.count})',
color: Colors.grey.shade800,
fontWeight: 500,
),
),
MyText.bodySmall(
'${bucket.amount.toStringAsFixed(0)}',
fontWeight: 700,
color: bucket.color.withOpacity(0.9),
),
],
),
);
}).toList(),
),
),
],
);
}
}
// Project Breakdown - Denser and with clearer value
class _ProjectBreakdown extends StatelessWidget {
final List<ProjectMetricData> projects;
final double totalAmount;
final double spacing;
const _ProjectBreakdown({
required this.projects,
required this.totalAmount,
required this.spacing,
});
@override
Widget build(BuildContext context) {
if (projects.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: MyText.bodySmall(
'No project data available.',
color: Colors.grey.shade500,
),
);
}
return Column(
children: projects.map((project) {
final double percentage =
totalAmount > 0 ? (project.amount / totalAmount) : 0.0;
final Color color = _getProjectColor(project.name);
final String percentageText = (percentage * 100).toStringAsFixed(1);
return Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
project.name,
overflow: TextOverflow.ellipsis,
fontWeight: 600,
),
const SizedBox(height: 2),
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
value: percentage,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 4, // Smaller bar height
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MyText.bodyMedium(
'${project.amount.toStringAsFixed(0)}',
fontWeight: 700,
color: color.withOpacity(0.9),
),
MyText.bodySmall(
'$percentageText%',
fontWeight: 500,
color: Colors.grey.shade600,
),
],
),
],
),
);
}).toList(),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
class PillTabBar extends StatelessWidget {
final TabController controller;
final List<String> tabs;
final Color selectedColor;
final Color unselectedColor;
final Color indicatorColor;
final double height;
final ValueChanged<int>? onTap;
const PillTabBar({
Key? key,
required this.controller,
required this.tabs,
this.selectedColor = Colors.blue,
this.unselectedColor = Colors.grey,
this.indicatorColor = Colors.blueAccent,
this.height = 48,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// Dynamic horizontal padding between tabs
final screenWidth = MediaQuery.of(context).size.width;
final tabSpacing = (screenWidth / (tabs.length * 12)).clamp(8.0, 24.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(height / 2),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: controller,
indicator: BoxDecoration(
color: indicatorColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(height / 2),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: EdgeInsets.symmetric(
horizontal: tabSpacing / 2,
vertical: 4,
),
labelColor: selectedColor,
unselectedLabelColor: unselectedColor,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
),
tabs: tabs
.map(
(text) => Tab(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: tabSpacing),
child: Text(
text,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
),
)
.toList(),
onTap: onTap,
),
),
);
}
}

View File

@ -1,510 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'package:on_field_work/controller/service_project/service_project_details_screen_controller.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/model/service_project/job_comments.dart';
import 'package:on_field_work/helpers/widgets/image_viewer_dialog.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/time_stamp_image_helper.dart';
class AddCommentWidget extends StatefulWidget {
final String jobId;
final String jobTicketId;
const AddCommentWidget({
super.key,
required this.jobId,
required this.jobTicketId,
});
@override
State<AddCommentWidget> createState() => _AddCommentWidgetState();
}
class _AddCommentWidgetState extends State<AddCommentWidget> {
final TextEditingController _controller = TextEditingController();
final List<File> _selectedFiles = [];
final ServiceProjectDetailsController controller =
Get.find<ServiceProjectDetailsController>();
bool isSubmitting = false;
bool isProcessingAttachment = false;
@override
void initState() {
super.initState();
controller.fetchJobComments(refresh: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// --- PICK MULTIPLE FILES ---
Future<void> _pickFiles() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
allowMultiple: true,
);
if (result != null) {
setState(() {
_selectedFiles.addAll(
result.paths.whereType<String>().map((path) => File(path)));
});
}
} catch (e) {
Get.snackbar("Error", "Failed to pick files: $e");
}
}
// --- PICK IMAGE FROM CAMERA ---
Future<void> _pickFromCamera() async {
try {
final pickedFile =
await controller.picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
setState(() {
controller.isProcessingAttachment.value =
true; // optional: show loading
});
File imageFile = File(pickedFile.path);
// Add timestamp to the captured image
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
setState(() {
_selectedFiles.add(timestampedFile);
});
}
} catch (e) {
Get.snackbar("Camera error", "$e",
backgroundColor: Colors.red.shade200, colorText: Colors.white);
} finally {
setState(() {
controller.isProcessingAttachment.value = false;
});
}
}
// --- SUBMIT COMMENT ---
Future<void> _submitComment() async {
if (_controller.text.trim().isEmpty && _selectedFiles.isEmpty) return;
setState(() => isSubmitting = true);
final success = await controller.addJobComment(
jobId: widget.jobId,
comment: _controller.text.trim(),
files: _selectedFiles,
);
setState(() => isSubmitting = false);
if (success) {
_controller.clear();
_selectedFiles.clear();
FocusScope.of(context).unfocus();
await controller.fetchJobComments(refresh: true);
}
}
// --- HELPER: CHECK IF FILE IS IMAGE ---
bool _isImage(String? fileName) {
if (fileName == null) return false;
final ext = fileName.split('.').last.toLowerCase();
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].contains(ext);
}
// --- SELECTED FILES PREVIEW ---
// --- SELECTED FILES PREVIEW (styled like expense attachments) ---
Widget _buildSelectedFiles() {
if (_selectedFiles.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _selectedFiles.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final file = _selectedFiles[index];
final fileName = file.path.split('/').last;
final isImage = _isImage(fileName);
return GestureDetector(
onTap: isImage
? () {
// Show image preview
Get.to(() => ImageViewerDialog(
imageSources: _selectedFiles.toList(),
initialIndex: _selectedFiles
.where((f) => _isImage(f.path.split('/').last))
.toList()
.indexOf(file),
captions: _selectedFiles
.where((f) => _isImage(f.path.split('/').last))
.map((f) => f.path.split('/').last)
.toList(),
));
}
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isImage ? Colors.teal.shade50 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isImage ? Colors.teal.shade100 : Colors.grey.shade300,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isImage
? Icons.insert_photo_outlined
: Icons.insert_drive_file_outlined,
size: 16,
color:
isImage ? Colors.teal.shade700 : Colors.grey.shade700,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: Text(
fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isImage
? Colors.teal.shade700
: Colors.grey.shade700,
),
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: () => setState(() => _selectedFiles.removeAt(index)),
child: Icon(
Icons.close,
size: 14,
color:
isImage ? Colors.teal.shade700 : Colors.grey.shade700,
),
),
],
),
),
);
},
),
);
}
// --- BUILD SINGLE COMMENT ITEM ---
Widget _buildCommentItem(CommentItem comment) {
final firstName = comment.createdBy?.firstName ?? '';
final lastName = comment.createdBy?.lastName ?? '';
final formattedDate = comment.createdAt != null
? DateTimeUtils.convertUtcToLocal(comment.createdAt!,
format: 'dd MMM yyyy hh:mm a')
: "Just now";
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: firstName, lastName: lastName, size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
"$firstName $lastName".trim().isNotEmpty
? "$firstName $lastName"
: "Unknown User",
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 14),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6.0),
child: Text("",
style: TextStyle(fontSize: 14, color: Colors.grey)),
),
Icon(Icons.access_time, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(formattedDate,
style:
TextStyle(fontSize: 13, color: Colors.grey[600])),
],
),
const SizedBox(height: 4),
if (comment.comment != null && comment.comment!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
comment.comment!,
style:
const TextStyle(fontSize: 14, color: Colors.black87),
),
),
if (comment.attachments != null &&
comment.attachments!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: comment.attachments!.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final attachment = comment.attachments![index];
final isImage = _isImage(attachment.fileName);
final imageAttachments = comment.attachments!
.where((a) => _isImage(a.fileName))
.toList();
final imageIndex =
imageAttachments.indexOf(attachment);
return GestureDetector(
onTap: isImage
? () {
Get.to(() => ImageViewerDialog(
imageSources: imageAttachments
.map((a) => a.preSignedUrl ?? "")
.toList(),
initialIndex: imageIndex,
captions: imageAttachments
.map((a) => a.fileName ?? "")
.toList(),
));
}
: null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isImage
? Colors.teal.shade50
: Colors.purple.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isImage
? Colors.teal.shade100
: Colors.purple.shade100),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isImage
? Icons.insert_photo_outlined
: Icons.insert_drive_file_outlined,
size: 16,
color: isImage
? Colors.teal.shade700
: Colors.purple.shade700,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 100),
child: Text(
attachment.fileName ?? "Attachment",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isImage
? Colors.teal.shade700
: Colors.purple.shade700,
),
),
),
],
),
),
);
},
),
),
),
],
),
),
],
),
);
}
// --- COMMENT LIST ---
Widget _buildCommentList() {
return Obx(() {
if (controller.isCommentsLoading.value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Column(
children: [
const CircularProgressIndicator(strokeWidth: 3),
MySpacing.height(12),
MyText.bodyMedium("Loading comments...",
color: Colors.grey.shade600),
],
),
),
);
}
if (controller.jobComments.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Column(
children: [
Icon(Icons.chat_bubble_outline,
size: 40, color: Colors.grey.shade400),
MySpacing.height(8),
MyText.bodyMedium("No comments yet.",
color: Colors.grey.shade600),
MyText.bodySmall("Be the first to post a comment.",
color: Colors.grey.shade500),
],
),
),
);
}
return Column(
children: controller.jobComments.map(_buildCommentItem).toList(),
);
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(12),
TextField(
controller: _controller,
maxLines: 3,
decoration: InputDecoration(
hintText: "Type your comment here...",
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blue, width: 2),
),
),
),
MySpacing.height(10),
_buildSelectedFiles(),
MySpacing.height(10),
Row(
children: [
// Attach file
IconButton(
onPressed: isSubmitting ? null : _pickFiles,
icon: const Icon(Icons.attach_file, size: 24, color: Colors.blue),
tooltip: "Attach File",
),
// Camera (icon-only)
Stack(
alignment: Alignment.center,
children: [
IconButton(
onPressed:
isSubmitting || controller.isProcessingAttachment.value
? null
: _pickFromCamera,
icon: const Icon(Icons.camera_alt,
size: 24, color: Colors.blue),
tooltip: "Camera",
),
if (controller.isProcessingAttachment.value)
const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const Spacer(),
// Submit button
ElevatedButton(
onPressed: isSubmitting ? null : _submitComment,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 2,
),
child: isSubmitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text("Post Comment"),
),
],
),
MySpacing.height(30),
const Divider(height: 1, thickness: 0.5),
MySpacing.height(20),
Obx(() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
"Comments (${controller.jobComments.length})",
fontWeight: 700),
MySpacing.height(16),
_buildCommentList(),
],
)),
],
);
}
}

View File

@ -6,6 +6,7 @@ import 'package:on_field_work/helpers/services/app_initializer.dart';
import 'package:on_field_work/view/my_app.dart';
import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/view/layouts/offline_screen.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
Future<void> main() async {
@ -54,37 +55,38 @@ Widget _buildErrorApp() => const MaterialApp(
),
);
class MainWrapper extends StatelessWidget {
class MainWrapper extends StatefulWidget {
const MainWrapper({super.key});
@override
State<MainWrapper> createState() => _MainWrapperState();
}
class _MainWrapperState extends State<MainWrapper> {
List<ConnectivityResult> _connectivityStatus = [ConnectivityResult.none];
final Connectivity _connectivity = Connectivity();
@override
void initState() {
super.initState();
_initializeConnectivity();
_connectivity.onConnectivityChanged.listen((results) {
setState(() => _connectivityStatus = results);
});
}
Future<void> _initializeConnectivity() async {
final result = await _connectivity.checkConnectivity();
setState(() => _connectivityStatus = result);
}
@override
Widget build(BuildContext context) {
// 1. Use FutureBuilder to check the current connectivity status ONCE.
return FutureBuilder<List<ConnectivityResult>>(
future: Connectivity().checkConnectivity(),
builder: (context, initialSnapshot) {
// If the initial check is still running, display a standard loading screen.
if (!initialSnapshot.hasData) {
return const MaterialApp(
home: Center(child: CircularProgressIndicator()),
);
}
// 2. Once the initial status is known, use StreamBuilder for real-time updates.
return StreamBuilder<List<ConnectivityResult>>(
stream: Connectivity().onConnectivityChanged,
// 💡 CRITICAL: Use the actual result from the FutureBuilder as the initial data.
initialData: initialSnapshot.data!,
builder: (context, streamSnapshot) {
final List<ConnectivityResult> results =
streamSnapshot.data ?? [ConnectivityResult.none];
final bool isOffline = results.contains(ConnectivityResult.none);
// Pass the accurate connectivity status down to MyApp.
return MyApp(isOffline: isOffline);
},
);
},
);
final bool isOffline =
_connectivityStatus.contains(ConnectivityResult.none);
return isOffline
? const MaterialApp(
debugShowCheckedModeBanner: false, home: OfflineScreen())
: const MyApp();
}
}

View File

@ -23,7 +23,6 @@ class AttendanceActionButton extends StatefulWidget {
}
class _AttendanceActionButtonState extends State<AttendanceActionButton> {
final attendanceController = Get.find<AttendanceController>();
late final String uniqueLogKey;
@override
@ -190,9 +189,12 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
controller.uploadingStates[uniqueLogKey]?.value = false;
if (selectedProjectId.isNotEmpty) {
await attendanceController.fetchProjectData(selectedProjectId);
attendanceController.update(['attendance_dashboard_controller']);
if (success) {
await controller.fetchTodaysAttendance(selectedProjectId);
await controller.fetchAttendanceLogs(selectedProjectId);
await controller.fetchRegularizationLogs(selectedProjectId);
await controller.fetchProjectData(selectedProjectId);
controller.update();
}
}
@ -271,9 +273,13 @@ class AttendanceActionButtonUI extends StatelessWidget {
textStyle: const TextStyle(fontSize: 12),
),
child: isUploading
? const Text(
'Loading...',
style: TextStyle(fontSize: 12, color: Colors.white),
? Container(
width: 60,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
)
: Row(
mainAxisSize: MainAxisSize.min,

View File

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/date_range_picker.dart';
class AttendanceFilterBottomSheet extends StatefulWidget {
@ -25,6 +27,21 @@ class AttendanceFilterBottomSheet extends StatefulWidget {
class _AttendanceFilterBottomSheetState
extends State<AttendanceFilterBottomSheet> {
late String tempSelectedTab;
@override
void initState() {
super.initState();
tempSelectedTab = widget.selectedTab;
}
String getLabelText() {
final start = DateTimeUtils.formatDate(
widget.controller.startDateAttendance.value, 'dd MMM yyyy');
final end = DateTimeUtils.formatDate(
widget.controller.endDateAttendance.value, 'dd MMM yyyy');
return "$start - $end";
}
Widget _popupSelector({
required String currentValue,
@ -34,8 +51,12 @@ class _AttendanceFilterBottomSheetState
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected,
itemBuilder: (context) =>
items.map((e) => PopupMenuItem<String>(value: e, child: MyText(e))).toList(),
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(
@ -86,11 +107,46 @@ class _AttendanceFilterBottomSheetState
);
}
List<Widget> _buildFilters() {
final List<Widget> widgets = [];
List<Widget> buildMainFilters() {
final hasRegularizationPermission = widget.permissionController
.hasPermission(Permissions.regularizeAttendance);
final viewOptions = [
{'label': 'Today\'s Attendance', 'value': 'todaysAttendance'},
{'label': 'Attendance Logs', 'value': 'attendanceLogs'},
{'label': 'Regularization Requests', 'value': 'regularizationRequests'},
];
final filteredOptions = viewOptions.where((item) {
return item['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
final List<Widget> widgets = [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: MyText.titleSmall("View", fontWeight: 600),
),
),
...filteredOptions.map((item) {
return RadioListTile<String>(
dense: true,
contentPadding: EdgeInsets.zero,
title: MyText.bodyMedium(
item['label']!,
fontWeight: 500,
),
value: item['value']!,
groupValue: tempSelectedTab,
onChanged: (value) => setState(() => tempSelectedTab = value!),
);
}),
];
// Organization selector
widgets.addAll([
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Align(
@ -124,8 +180,7 @@ class _AttendanceFilterBottomSheetState
}),
]);
// Date range (only for Attendance Logs)
if (widget.selectedTab == 'attendanceLogs') {
if (tempSelectedTab == 'attendanceLogs') {
widgets.addAll([
const Divider(),
Padding(
@ -153,20 +208,24 @@ class _AttendanceFilterBottomSheetState
@override
Widget build(BuildContext context) {
return SafeArea(
// FIX: avoids hiding under navigation buttons
child: BaseBottomSheet(
title: "Attendance Filter",
submitText: "Apply",
onCancel: () => Navigator.pop(context),
onSubmit: () => Navigator.pop(context, {
'selectedTab': tempSelectedTab,
'selectedOrganization': widget.controller.selectedOrganization?.id,
}),
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
padding:
const EdgeInsets.only(bottom: 24), // FIX: extra safe padding
child: SingleChildScrollView(
// FIX: full scrollable in landscape
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildFilters(),
children: buildMainFilters(),
),
),
),

View File

@ -1,192 +0,0 @@
import 'dart:convert';
/// ===============================
/// MAIN MODEL: CollectionOverview
/// ===============================
class CollectionOverviewResponse {
final bool success;
final String message;
final CollectionOverviewData data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
CollectionOverviewResponse({
required this.success,
required this.message,
required this.data,
required this.errors,
required this.statusCode,
required this.timestamp,
});
factory CollectionOverviewResponse.fromJson(Map<String, dynamic> json) {
return CollectionOverviewResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: CollectionOverviewData.fromJson(json['data'] ?? {}),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
}
/// ===============================
/// DATA BLOCK
/// ===============================
class CollectionOverviewData {
final double totalDueAmount;
final double totalCollectedAmount;
final double totalValue;
final double pendingPercentage;
final double collectedPercentage;
final int bucket0To30Invoices;
final int bucket30To60Invoices;
final int bucket60To90Invoices;
final int bucket90PlusInvoices;
final double bucket0To30Amount;
final double bucket30To60Amount;
final double bucket60To90Amount;
final double bucket90PlusAmount;
final double topClientBalance;
final TopClient? topClient;
CollectionOverviewData({
required this.totalDueAmount,
required this.totalCollectedAmount,
required this.totalValue,
required this.pendingPercentage,
required this.collectedPercentage,
required this.bucket0To30Invoices,
required this.bucket30To60Invoices,
required this.bucket60To90Invoices,
required this.bucket90PlusInvoices,
required this.bucket0To30Amount,
required this.bucket30To60Amount,
required this.bucket60To90Amount,
required this.bucket90PlusAmount,
required this.topClientBalance,
required this.topClient,
});
factory CollectionOverviewData.fromJson(Map<String, dynamic> json) {
return CollectionOverviewData(
totalDueAmount: (json['totalDueAmount'] ?? 0).toDouble(),
totalCollectedAmount: (json['totalCollectedAmount'] ?? 0).toDouble(),
totalValue: (json['totalValue'] ?? 0).toDouble(),
pendingPercentage: (json['pendingPercentage'] ?? 0).toDouble(),
collectedPercentage: (json['collectedPercentage'] ?? 0).toDouble(),
bucket0To30Invoices: json['bucket0To30Invoices'] ?? 0,
bucket30To60Invoices: json['bucket30To60Invoices'] ?? 0,
bucket60To90Invoices: json['bucket60To90Invoices'] ?? 0,
bucket90PlusInvoices: json['bucket90PlusInvoices'] ?? 0,
bucket0To30Amount: (json['bucket0To30Amount'] ?? 0).toDouble(),
bucket30To60Amount: (json['bucket30To60Amount'] ?? 0).toDouble(),
bucket60To90Amount: (json['bucket60To90Amount'] ?? 0).toDouble(),
bucket90PlusAmount: (json['bucket90PlusAmount'] ?? 0).toDouble(),
topClientBalance: (json['topClientBalance'] ?? 0).toDouble(),
topClient: json['topClient'] != null
? TopClient.fromJson(json['topClient'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'totalDueAmount': totalDueAmount,
'totalCollectedAmount': totalCollectedAmount,
'totalValue': totalValue,
'pendingPercentage': pendingPercentage,
'collectedPercentage': collectedPercentage,
'bucket0To30Invoices': bucket0To30Invoices,
'bucket30To60Invoices': bucket30To60Invoices,
'bucket60To90Invoices': bucket60To90Invoices,
'bucket90PlusInvoices': bucket90PlusInvoices,
'bucket0To30Amount': bucket0To30Amount,
'bucket30To60Amount': bucket30To60Amount,
'bucket60To90Amount': bucket60To90Amount,
'bucket90PlusAmount': bucket90PlusAmount,
'topClientBalance': topClientBalance,
'topClient': topClient?.toJson(),
};
}
}
/// ===============================
/// NESTED MODEL: Top Client
/// ===============================
class TopClient {
final String id;
final String name;
final String? email;
final String? contactPerson;
final String? address;
final String? gstNumber;
final String? contactNumber;
final int? sprid;
TopClient({
required this.id,
required this.name,
this.email,
this.contactPerson,
this.address,
this.gstNumber,
this.contactNumber,
this.sprid,
});
factory TopClient.fromJson(Map<String, dynamic> json) {
return TopClient(
id: json['id'] ?? '',
name: json['name'] ?? '',
email: json['email'],
contactPerson: json['contactPerson'],
address: json['address'],
gstNumber: json['gstNumber'],
contactNumber: json['contactNumber'],
sprid: json['sprid'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'gstNumber': gstNumber,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
}
/// ===============================
/// Optional: Quick decode method
/// ===============================
CollectionOverviewResponse parseCollectionOverview(String jsonString) {
return CollectionOverviewResponse.fromJson(jsonDecode(jsonString));
}

View File

@ -1,221 +0,0 @@
// ============================
// PurchaseInvoiceOverviewModel.dart
// ============================
class PurchaseInvoiceOverviewResponse {
final bool? success;
final String? message;
final PurchaseInvoiceOverviewData? data;
final dynamic errors;
final int? statusCode;
final DateTime? timestamp;
PurchaseInvoiceOverviewResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory PurchaseInvoiceOverviewResponse.fromJson(Map<String, dynamic> json) {
return PurchaseInvoiceOverviewResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null
? PurchaseInvoiceOverviewData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp?.toIso8601String(),
};
}
}
class PurchaseInvoiceOverviewData {
final int? totalInvoices;
final double? totalValue;
final double? averageValue;
final List<StatusBreakdown>? statusBreakdown;
final List<ProjectBreakdown>? projectBreakdown;
final TopSupplier? topSupplier;
PurchaseInvoiceOverviewData({
this.totalInvoices,
this.totalValue,
this.averageValue,
this.statusBreakdown,
this.projectBreakdown,
this.topSupplier,
});
factory PurchaseInvoiceOverviewData.fromJson(Map<String, dynamic> json) {
return PurchaseInvoiceOverviewData(
totalInvoices: json['totalInvoices'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
averageValue: (json['averageValue'] != null)
? (json['averageValue'] as num).toDouble()
: null,
statusBreakdown: json['statusBreakdown'] != null
? (json['statusBreakdown'] as List)
.map((e) => StatusBreakdown.fromJson(e))
.toList()
: null,
projectBreakdown: json['projectBreakdown'] != null
? (json['projectBreakdown'] as List)
.map((e) => ProjectBreakdown.fromJson(e))
.toList()
: null,
topSupplier: json['topSupplier'] != null
? TopSupplier.fromJson(json['topSupplier'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'totalInvoices': totalInvoices,
'totalValue': totalValue,
'averageValue': averageValue,
'statusBreakdown': statusBreakdown?.map((e) => e.toJson()).toList(),
'projectBreakdown': projectBreakdown?.map((e) => e.toJson()).toList(),
'topSupplier': topSupplier?.toJson(),
};
}
}
class StatusBreakdown {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
StatusBreakdown({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory StatusBreakdown.fromJson(Map<String, dynamic> json) {
return StatusBreakdown(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}
class ProjectBreakdown {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
ProjectBreakdown({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory ProjectBreakdown.fromJson(Map<String, dynamic> json) {
return ProjectBreakdown(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}
class TopSupplier {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
TopSupplier({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory TopSupplier.fromJson(Map<String, dynamic> json) {
return TopSupplier(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}

View File

@ -148,7 +148,7 @@ class DocumentType {
final String name;
final String? regexExpression;
final String allowedContentType;
final double maxSizeAllowedInMB;
final int maxSizeAllowedInMB;
final bool isValidationRequired;
final bool isMandatory;
final bool isSystem;

View File

@ -1,7 +1,7 @@
class DocumentsResponse {
final bool success;
final String message;
final DocumentDataWrapper? data;
final DocumentDataWrapper data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
@ -9,7 +9,7 @@ class DocumentsResponse {
DocumentsResponse({
required this.success,
required this.message,
this.data,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
@ -19,13 +19,11 @@ class DocumentsResponse {
return DocumentsResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? DocumentDataWrapper.fromJson(json['data'])
: null,
data: DocumentDataWrapper.fromJson(json['data']),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp']) ?? DateTime.now()
? DateTime.parse(json['timestamp'])
: DateTime.now(),
);
}
@ -34,7 +32,7 @@ class DocumentsResponse {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
@ -63,10 +61,9 @@ class DocumentDataWrapper {
currentPage: json['currentPage'] ?? 0,
totalPages: json['totalPages'] ?? 0,
totalEntites: json['totalEntites'] ?? 0,
data: (json['data'] as List<dynamic>?)
?.map((e) => DocumentItem.fromJson(e))
.toList() ??
[],
data: (json['data'] as List<dynamic>? ?? [])
.map((e) => DocumentItem.fromJson(e))
.toList(),
);
}
@ -86,28 +83,28 @@ class DocumentItem {
final String name;
final String documentId;
final String description;
final DateTime? uploadedAt;
final DateTime uploadedAt;
final String? parentAttachmentId;
final bool isCurrentVersion;
final int version;
final bool isActive;
final bool? isVerified;
final UploadedBy? uploadedBy;
final DocumentType? documentType;
final UploadedBy uploadedBy;
final DocumentType documentType;
DocumentItem({
required this.id,
required this.name,
required this.documentId,
required this.description,
this.uploadedAt,
required this.uploadedAt,
this.parentAttachmentId,
required this.isCurrentVersion,
required this.version,
required this.isActive,
this.isVerified,
this.uploadedBy,
this.documentType,
required this.uploadedBy,
required this.documentType,
});
factory DocumentItem.fromJson(Map<String, dynamic> json) {
@ -116,20 +113,14 @@ class DocumentItem {
name: json['name'] ?? '',
documentId: json['documentId'] ?? '',
description: json['description'] ?? '',
uploadedAt: json['uploadedAt'] != null
? DateTime.tryParse(json['uploadedAt'])
: null,
uploadedAt: DateTime.parse(json['uploadedAt']),
parentAttachmentId: json['parentAttachmentId'],
isCurrentVersion: json['isCurrentVersion'] ?? false,
version: json['version'] ?? 0,
isActive: json['isActive'] ?? false,
isVerified: json['isVerified'],
uploadedBy: json['uploadedBy'] != null
? UploadedBy.fromJson(json['uploadedBy'])
: null,
documentType: json['documentType'] != null
? DocumentType.fromJson(json['documentType'])
: null,
uploadedBy: UploadedBy.fromJson(json['uploadedBy']),
documentType: DocumentType.fromJson(json['documentType']),
);
}
@ -139,14 +130,14 @@ class DocumentItem {
'name': name,
'documentId': documentId,
'description': description,
'uploadedAt': uploadedAt?.toIso8601String(),
'uploadedAt': uploadedAt.toIso8601String(),
'parentAttachmentId': parentAttachmentId,
'isCurrentVersion': isCurrentVersion,
'version': version,
'isActive': isActive,
'isVerified': isVerified,
'uploadedBy': uploadedBy?.toJson(),
'documentType': documentType?.toJson(),
'uploadedBy': uploadedBy.toJson(),
'documentType': documentType.toJson(),
};
}
}
@ -217,7 +208,7 @@ class DocumentType {
final String name;
final String? regexExpression;
final String? allowedContentType;
final double? maxSizeAllowedInMB;
final int? maxSizeAllowedInMB;
final bool isValidationRequired;
final bool isMandatory;
final bool isSystem;
@ -241,7 +232,7 @@ class DocumentType {
return DocumentType(
id: json['id'] ?? '',
name: json['name'] ?? '',
regexExpression: json['regexExpression'],
regexExpression: json['regexExpression'], // nullable
allowedContentType: json['allowedContentType'],
maxSizeAllowedInMB: json['maxSizeAllowedInMB'],
isValidationRequired: json['isValidationRequired'] ?? false,

View File

@ -1,222 +0,0 @@
class ProjectDetailsResponse {
final bool? success;
final String? message;
final ProjectData? data;
final dynamic errors;
final int? statusCode;
final DateTime? timestamp;
ProjectDetailsResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory ProjectDetailsResponse.fromJson(Map<String, dynamic> json) {
return ProjectDetailsResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null ? ProjectData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp?.toIso8601String(),
};
}
}
class ProjectData {
final String? id;
final String? name;
final String? shortName;
final String? projectAddress;
final String? contactPerson;
final DateTime? startDate;
final DateTime? endDate;
final ProjectStatus? projectStatus;
final Promoter? promoter;
final Pmc? pmc;
ProjectData({
this.id,
this.name,
this.shortName,
this.projectAddress,
this.contactPerson,
this.startDate,
this.endDate,
this.projectStatus,
this.promoter,
this.pmc,
});
factory ProjectData.fromJson(Map<String, dynamic> json) {
return ProjectData(
id: json['id'] as String?,
name: json['name'] as String?,
shortName: json['shortName'] as String?,
projectAddress: json['projectAddress'] as String?,
contactPerson: json['contactPerson'] as String?,
startDate: json['startDate'] != null
? DateTime.tryParse(json['startDate'])
: null,
endDate: json['endDate'] != null
? DateTime.tryParse(json['endDate'])
: null,
projectStatus: json['projectStatus'] != null
? ProjectStatus.fromJson(json['projectStatus'])
: null,
promoter: json['promoter'] != null
? Promoter.fromJson(json['promoter'])
: null,
pmc: json['pmc'] != null ? Pmc.fromJson(json['pmc']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'shortName': shortName,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'projectStatus': projectStatus?.toJson(),
'promoter': promoter?.toJson(),
'pmc': pmc?.toJson(),
};
}
}
class ProjectStatus {
final String? id;
final String? status;
ProjectStatus({this.id, this.status});
factory ProjectStatus.fromJson(Map<String, dynamic> json) {
return ProjectStatus(
id: json['id'] as String?,
status: json['status'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'status': status,
};
}
}
class Promoter {
final String? id;
final String? name;
final String? email;
final String? contactPerson;
final String? address;
final String? gstNumber;
final String? contactNumber;
final int? sprid;
Promoter({
this.id,
this.name,
this.email,
this.contactPerson,
this.address,
this.gstNumber,
this.contactNumber,
this.sprid,
});
factory Promoter.fromJson(Map<String, dynamic> json) {
return Promoter(
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
contactPerson: json['contactPerson'] as String?,
address: json['address'] as String?,
gstNumber: json['gstNumber'] as String?,
contactNumber: json['contactNumber'] as String?,
sprid: json['sprid'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'gstNumber': gstNumber,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
}
class Pmc {
final String? id;
final String? name;
final String? email;
final String? contactPerson;
final String? address;
final String? gstNumber;
final String? contactNumber;
final int? sprid;
Pmc({
this.id,
this.name,
this.email,
this.contactPerson,
this.address,
this.gstNumber,
this.contactNumber,
this.sprid,
});
factory Pmc.fromJson(Map<String, dynamic> json) {
return Pmc(
id: json['id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
contactPerson: json['contactPerson'] as String?,
address: json['address'] as String?,
gstNumber: json['gstNumber'] as String?,
contactNumber: json['contactNumber'] as String?,
sprid: json['sprid'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'contactPerson': contactPerson,
'address': address,
'gstNumber': gstNumber,
'contactNumber': contactNumber,
'sprid': sprid,
};
}
}

View File

@ -1,138 +0,0 @@
// Root Response Model
class ProjectsResponse {
final bool? success;
final String? message;
final ProjectsPageData? data;
final dynamic errors;
final int? statusCode;
final String? timestamp;
ProjectsResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory ProjectsResponse.fromJson(Map<String, dynamic> json) {
return ProjectsResponse(
success: json['success'],
message: json['message'],
data: json['data'] != null
? ProjectsPageData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: json['timestamp'],
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
// Pagination + Data List
class ProjectsPageData {
final int? currentPage;
final int? totalPages;
final int? totalEntites;
final List<ProjectData>? data;
ProjectsPageData({
this.currentPage,
this.totalPages,
this.totalEntites,
this.data,
});
factory ProjectsPageData.fromJson(Map<String, dynamic> json) {
return ProjectsPageData(
currentPage: json['currentPage'],
totalPages: json['totalPages'],
totalEntites: json['totalEntites'],
data: (json['data'] as List<dynamic>?)
?.map((e) => ProjectData.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntites': totalEntites,
'data': data?.map((e) => e.toJson()).toList(),
};
}
}
// Individual Project Model
class ProjectData {
final String? id;
final String? name;
final String? shortName;
final String? projectAddress;
final String? contactPerson;
final String? startDate;
final String? endDate;
final String? projectStatusId;
final int? teamSize;
final double? completedWork;
final double? plannedWork;
ProjectData({
this.id,
this.name,
this.shortName,
this.projectAddress,
this.contactPerson,
this.startDate,
this.endDate,
this.projectStatusId,
this.teamSize,
this.completedWork,
this.plannedWork,
});
factory ProjectData.fromJson(Map<String, dynamic> json) {
return ProjectData(
id: json['id'],
name: json['name'],
shortName: json['shortName'],
projectAddress: json['projectAddress'],
contactPerson: json['contactPerson'],
startDate: json['startDate'],
endDate: json['endDate'],
projectStatusId: json['projectStatusId'],
teamSize: json['teamSize'],
completedWork: (json['completedWork'] as num?)?.toDouble(),
plannedWork: (json['plannedWork'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'shortName': shortName,
'projectAddress': projectAddress,
'contactPerson': contactPerson,
'startDate': startDate,
'endDate': endDate,
'projectStatusId': projectStatusId,
'teamSize': teamSize,
'completedWork': completedWork,
'plannedWork': plannedWork,
};
}
}

View File

@ -220,9 +220,8 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty);
for (final p in parts) {
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
if (!controller.enteredTags.contains(p)) {
controller.enteredTags.add(p);
}
}
}
@ -240,9 +239,8 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty);
for (final p in parts) {
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
if (!controller.enteredTags.contains(p)) {
controller.enteredTags.add(p);
}
}
controller.tagCtrl.clear();
@ -258,9 +256,8 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty);
for (final p in parts) {
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
if (!controller.enteredTags.contains(p)) {
controller.enteredTags.add(p);
}
}
controller.tagCtrl.clear();

View File

@ -1,253 +0,0 @@
class JobCommentResponse {
final bool? success;
final String? message;
final JobCommentData? data;
final dynamic errors;
final int? statusCode;
final String? timestamp;
JobCommentResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory JobCommentResponse.fromJson(Map<String, dynamic> json) {
return JobCommentResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null ? JobCommentData.fromJson(json['data']) : null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] as String?,
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
class JobCommentData {
final int? currentPage;
final int? totalPages;
final int? totalEntities;
final List<CommentItem>? data;
JobCommentData({
this.currentPage,
this.totalPages,
this.totalEntities,
this.data,
});
factory JobCommentData.fromJson(Map<String, dynamic> json) {
return JobCommentData(
currentPage: json['currentPage'] as int?,
totalPages: json['totalPages'] as int?,
totalEntities: json['totalEntities'] as int?,
data: json['data'] != null
? List<CommentItem>.from(
(json['data'] as List).map((x) => CommentItem.fromJson(x)))
: null,
);
}
Map<String, dynamic> toJson() => {
'currentPage': currentPage,
'totalPages': totalPages,
'totalEntities': totalEntities,
'data': data?.map((x) => x.toJson()).toList(),
};
}
class CommentItem {
final String? id;
final JobTicket? jobTicket;
final String? comment;
final bool? isActive;
final String? createdAt;
final User? createdBy;
final String? updatedAt;
final User? updatedBy;
final List<Attachment>? attachments;
CommentItem({
this.id,
this.jobTicket,
this.comment,
this.isActive,
this.createdAt,
this.createdBy,
this.updatedAt,
this.updatedBy,
this.attachments,
});
factory CommentItem.fromJson(Map<String, dynamic> json) {
return CommentItem(
id: json['id'] as String?,
jobTicket: json['jobTicket'] != null
? JobTicket.fromJson(json['jobTicket'])
: null,
comment: json['comment'] as String?,
isActive: json['isActive'] as bool?,
createdAt: json['createdAt'] as String?,
createdBy:
json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
updatedAt: json['updatedAt'] as String?,
updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
attachments: json['attachments'] != null
? List<Attachment>.from(
(json['attachments'] as List).map((x) => Attachment.fromJson(x)))
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'jobTicket': jobTicket?.toJson(),
'comment': comment,
'isActive': isActive,
'createdAt': createdAt,
'createdBy': createdBy?.toJson(),
'updatedAt': updatedAt,
'updatedBy': updatedBy?.toJson(),
'attachments': attachments?.map((x) => x.toJson()).toList(),
};
}
class JobTicket {
final String? id;
final String? title;
final String? description;
final String? jobTicketUId;
final String? statusName;
final bool? isArchive;
JobTicket({
this.id,
this.title,
this.description,
this.jobTicketUId,
this.statusName,
this.isArchive,
});
factory JobTicket.fromJson(Map<String, dynamic> json) {
return JobTicket(
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
jobTicketUId: json['jobTicketUId'] as String?,
statusName: json['statusName'] as String?,
isArchive: json['isArchive'] as bool?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'jobTicketUId': jobTicketUId,
'statusName': statusName,
'isArchive': isArchive,
};
}
class User {
final String? id;
final String? firstName;
final String? lastName;
final String? email;
final String? photo;
final String? jobRoleId;
final String? jobRoleName;
User({
this.id,
this.firstName,
this.lastName,
this.email,
this.photo,
this.jobRoleId,
this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String?,
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
email: json['email'] as String?,
photo: json['photo'] as String?,
jobRoleId: json['jobRoleId'] as String?,
jobRoleName: json['jobRoleName'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'firstName': firstName,
'lastName': lastName,
'email': email,
'photo': photo,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName,
};
}
class Attachment {
final String? id;
final String? batchId;
final String? fileName;
final String? preSignedUrl;
final String? thumbPreSignedUrl;
final int? fileSize;
final String? contentType;
final String? uploadedAt;
Attachment({
this.id,
this.batchId,
this.fileName,
this.preSignedUrl,
this.thumbPreSignedUrl,
this.fileSize,
this.contentType,
this.uploadedAt,
});
factory Attachment.fromJson(Map<String, dynamic> json) {
return Attachment(
id: json['id'] as String?,
batchId: json['batchId'] as String?,
fileName: json['fileName'] as String?,
preSignedUrl: json['preSignedUrl'] as String?,
thumbPreSignedUrl: json['thumbPreSignedUrl'] as String?,
fileSize: json['fileSize'] as int?,
contentType: json['contentType'] as String?,
uploadedAt: json['uploadedAt'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'batchId': batchId,
'fileName': fileName,
'preSignedUrl': preSignedUrl,
'thumbPreSignedUrl': thumbPreSignedUrl,
'fileSize': fileSize,
'contentType': contentType,
'uploadedAt': uploadedAt,
};
}

View File

@ -1,85 +0,0 @@
class JobStatusResponse {
final bool? success;
final String? message;
final List<JobStatus>? data;
final dynamic errors;
final int? statusCode;
final String? timestamp;
JobStatusResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory JobStatusResponse.fromJson(Map<String, dynamic> json) {
return JobStatusResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: (json['data'] as List<dynamic>?)
?.map((e) => JobStatus.fromJson(e))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
// --------------------------
// Single Job Status Model
// --------------------------
class JobStatus {
final String? id;
final String? name;
final String? displayName;
final int? level;
JobStatus({
this.id,
this.name,
this.displayName,
this.level,
});
factory JobStatus.fromJson(Map<String, dynamic> json) {
return JobStatus(
id: json['id'] as String?,
name: json['name'] as String?,
displayName: json['displayName'] as String?,
level: json['level'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'displayName': displayName,
'level': level,
};
}
// Add equality by id
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is JobStatus && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}

View File

@ -277,7 +277,6 @@ class UpdateLog {
final Status? status;
final Status? nextStatus;
final String? comment;
final String? updatedAt;
final User? updatedBy;
UpdateLog({
@ -285,7 +284,6 @@ class UpdateLog {
this.status,
this.nextStatus,
this.comment,
this.updatedAt,
this.updatedBy,
});
@ -299,7 +297,6 @@ class UpdateLog {
? Status.fromJson(json['nextStatus'])
: null,
comment: json['comment'] as String?,
updatedAt: json['updatedAt'] as String?,
updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
);

View File

@ -11,6 +11,8 @@ import 'package:on_field_work/view/error_pages/error_404_screen.dart';
import 'package:on_field_work/view/error_pages/error_500_screen.dart';
import 'package:on_field_work/view/dashboard/dashboard_screen.dart';
import 'package:on_field_work/view/Attendence/attendance_screen.dart';
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
import 'package:on_field_work/view/employees/employees_screen.dart';
import 'package:on_field_work/view/auth/login_option_screen.dart';
import 'package:on_field_work/view/auth/mpin_screen.dart';
@ -23,8 +25,6 @@ import 'package:on_field_work/view/finance/finance_screen.dart';
import 'package:on_field_work/view/finance/advance_payment_screen.dart';
import 'package:on_field_work/view/finance/payment_request_screen.dart';
import 'package:on_field_work/view/service_project/service_project_screen.dart';
import 'package:on_field_work/view/infraProject/infra_project_screen.dart';
class AuthMiddleware extends GetMiddleware {
@override
RouteSettings? redirect(String? route) {
@ -70,6 +70,15 @@ getPageRoute() {
name: '/dashboard/employees',
page: () => EmployeesScreen(),
middlewares: [AuthMiddleware()]),
// Daily Task Planning
GetPage(
name: '/dashboard/daily-task-Planning',
page: () => DailyTaskPlanningScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/daily-task-progress',
page: () => DailyProgressReportScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/directory-main-page',
page: () => DirectoryMainScreen(),
@ -84,7 +93,7 @@ getPageRoute() {
name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(),
middlewares: [AuthMiddleware()]),
// Finance
// Finance
GetPage(
name: '/dashboard/finance',
page: () => FinanceScreen(),
@ -93,12 +102,6 @@ getPageRoute() {
name: '/dashboard/payment-request',
page: () => PaymentRequestMainScreen(),
middlewares: [AuthMiddleware()]),
// Infrastructure Projects
GetPage(
name: '/dashboard/infra-projects',
page: () => InfraProjectScreen(),
middlewares: [AuthMiddleware()]),
// Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_container.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
@ -11,14 +12,21 @@ import 'package:on_field_work/model/attendance/log_details_view.dart';
import 'package:on_field_work/model/attendance/attendence_action_button.dart';
import 'package:on_field_work/helpers/utils/attendance_actions.dart';
class AttendanceLogsTab extends StatelessWidget {
class AttendanceLogsTab extends StatefulWidget {
final AttendanceController controller;
const AttendanceLogsTab({super.key, required this.controller});
@override
State<AttendanceLogsTab> createState() => _AttendanceLogsTabState();
}
class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
Widget _buildStatusHeader() {
return Obx(() {
if (!controller.showPendingOnly.value) return const SizedBox.shrink();
if (!widget.controller.showPendingOnly.value) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
@ -38,7 +46,7 @@ class AttendanceLogsTab extends StatelessWidget {
),
),
InkWell(
onTap: () => controller.showPendingOnly.value = false,
onTap: () => widget.controller.showPendingOnly.value = false,
child: const Icon(Icons.close, size: 18, color: Colors.orange),
),
],
@ -47,6 +55,7 @@ class AttendanceLogsTab extends StatelessWidget {
});
}
/// Return button text priority for sorting inside same date
int _getActionPriority(employee) {
final text = AttendanceButtonHelper.getButtonText(
activity: employee.activity,
@ -68,20 +77,32 @@ class AttendanceLogsTab extends StatelessWidget {
final isCheckoutAction =
text.contains("checkout") || text.contains("check out");
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) return 0;
if (isCheckoutAction) return 0;
if (text.contains("regular")) return 1;
if (text == "requested") return 2;
if (text == "approved") return 3;
if (text == "rejected") return 4;
return 5;
int priority;
if (isYesterdayCheckIn && isMissingCheckout && isCheckoutAction) {
priority = 0;
} else if (isCheckoutAction) {
priority = 0;
} else if (text.contains("regular")) {
priority = 1;
} else if (text == "requested") {
priority = 2;
} else if (text == "approved") {
priority = 3;
} else if (text == "rejected") {
priority = 4;
} else {
priority = 5;
}
return priority;
}
@override
Widget build(BuildContext context) {
return Obx(() {
final allLogs = List.of(controller.filteredLogs);
final showPendingOnly = controller.showPendingOnly.value;
final allLogs = List.of(widget.controller.filteredLogs);
// Filter logs if "pending only"
final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly
? allLogs.where((emp) => emp.activity == 1).toList()
: allLogs;
@ -95,6 +116,7 @@ class AttendanceLogsTab extends StatelessWidget {
groupedLogs.putIfAbsent(dateKey, () => []).add(log);
}
// Sort dates (latest first)
final sortedDates = groupedLogs.keys.toList()
..sort((a, b) {
final da = DateTimeUtils.parseDate(a, 'dd MMM yyyy') ?? DateTime(0);
@ -103,19 +125,20 @@ class AttendanceLogsTab extends StatelessWidget {
});
final dateRangeText =
'${DateTimeUtils.formatDate(controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(controller.endDateAttendance.value, 'dd MMM yyyy')}';
'${DateTimeUtils.formatDate(widget.controller.startDateAttendance.value, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance.value, 'dd MMM yyyy')}';
// Sticky header + scrollable list
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
// Header row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
controller.isLoadingAttendanceLogs.value
MyText.titleMedium("Attendance Logs", fontWeight: 600),
widget.controller.isLoading.value
? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall(
dateRangeText,
@ -129,160 +152,136 @@ class AttendanceLogsTab extends StatelessWidget {
// Pending-only header
_buildStatusHeader(),
MySpacing.height(8),
// Divider between header and list
const Divider(height: 1),
// Content: loader, empty, or logs
if (widget.controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
else if (filteredLogs.isEmpty)
SizedBox(
height: 120,
child: Center(
child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
),
)
else
MyCard.bordered(
paddingAll: 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final date in sortedDates) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: MyText.bodyMedium(date, fontWeight: 700),
),
// Scrollable attendance logs
Expanded(
child: controller.isLoadingAttendanceLogs.value
? SkeletonLoaders.employeeListSkeletonLoader()
: filteredLogs.isEmpty
? Center(
child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
)
: ListView.builder(
padding: MySpacing.all(8),
itemCount: sortedDates.length,
itemBuilder: (context, dateIndex) {
final date = sortedDates[dateIndex];
final employees = groupedLogs[date]!
..sort((a, b) {
final priorityCompare = _getActionPriority(a)
.compareTo(_getActionPriority(b));
if (priorityCompare != 0) return priorityCompare;
final aTime =
a.checkOut ?? a.checkIn ?? DateTime(0);
final bTime =
b.checkOut ?? b.checkIn ?? DateTime(0);
return bTime.compareTo(aTime);
});
// Sort employees inside this date by action priority first, then latest entry
for (final emp in (groupedLogs[date]!
..sort(
(a, b) {
final priorityCompare = _getActionPriority(a)
.compareTo(_getActionPriority(b));
if (priorityCompare != 0) return priorityCompare;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8),
child: MyText.bodyMedium(date, fontWeight: 700),
),
...employees.map(
(emp) => Column(
children: [
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Avatar(
firstName: emp.firstName,
lastName: emp.lastName,
size: 31,
final aTime = a.checkOut ?? a.checkIn ?? DateTime(0);
final bTime = b.checkOut ?? b.checkIn ?? DateTime(0);
return bTime.compareTo(
aTime);
},
))) ...[
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: emp.firstName,
lastName: emp.lastName,
size: 31,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis,
),
),
],
),
MySpacing.height(8),
if (emp.checkIn != null ||
emp.checkOut != null)
Row(
children: [
if (emp.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkIn!, 'hh:mm a'),
fontWeight: 600,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
emp.name,
fontWeight: 600,
overflow: TextOverflow
.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${emp.designation})',
fontWeight: 600,
color: Colors.grey[700],
overflow: TextOverflow
.ellipsis,
),
),
],
),
MySpacing.height(8),
if (emp.checkIn != null ||
emp.checkOut != null)
Row(
children: [
if (emp.checkIn !=
null) ...[
const Icon(
Icons
.arrow_circle_right,
size: 16,
color:
Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils
.formatDate(
emp.checkIn!,
'hh:mm a'),
fontWeight: 600,
),
MySpacing.width(16),
],
if (emp.checkOut !=
null) ...[
const Icon(
Icons
.arrow_circle_left,
size: 16,
color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils
.formatDate(
emp.checkOut!,
'hh:mm a'),
fontWeight: 600,
),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController:
controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController:
controller,
),
],
),
],
),
],
if (emp.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateTimeUtils.formatDate(
emp.checkOut!, 'hh:mm a'),
fontWeight: 600,
),
],
),
],
),
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: emp,
attendanceController: widget.controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: emp,
attendanceController: widget.controller,
),
],
),
],
),
],
);
},
),
],
),
),
),
Divider(color: Colors.grey.withOpacity(0.3)),
],
],
],
),
),
],
);
});

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/theme/app_theme.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_flex.dart';
import 'package:on_field_work/helpers/widgets/my_flex_item.dart';
@ -7,15 +8,12 @@ import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/model/attendance/attendence_filter_sheet.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/view/Attendence/regularization_requests_tab.dart';
import 'package:on_field_work/view/Attendence/attendance_logs_tab.dart';
import 'package:on_field_work/view/Attendence/todays_attendance_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
import 'package:on_field_work/model/attendance/attendence_filter_sheet.dart';
class AttendanceScreen extends StatefulWidget {
const AttendanceScreen({super.key});
@ -24,84 +22,45 @@ class AttendanceScreen extends StatefulWidget {
State<AttendanceScreen> createState() => _AttendanceScreenState();
}
class _AttendanceScreenState extends State<AttendanceScreen>
with SingleTickerProviderStateMixin, UIMixin {
class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
final attendanceController = Get.put(AttendanceController());
final permissionController = Get.put(PermissionController());
final projectController = Get.put(ProjectController());
final projectController = Get.find<ProjectController>();
late TabController _tabController;
late List<Map<String, String>> _tabs;
bool _tabsInitialized = false;
String selectedTab = 'todaysAttendance';
@override
void initState() {
super.initState();
ever(permissionController.permissionsLoaded, (loaded) {
if (loaded == true && !_tabsInitialized) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeTabs();
setState(() {});
});
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
// 🔁 Listen for project changes
ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty) await _loadData(projectId);
});
// Watch project changes to reload data
ever<String>(projectController.selectedProjectId, (projectId) async {
if (projectId.isNotEmpty && _tabsInitialized) {
await _fetchTabData(attendanceController.selectedTab);
}
// 🚀 Load initial data only once the screen is shown
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) _loadData(projectId);
});
}
// If permissions are already loaded at init
if (permissionController.permissionsLoaded.value) {
_initializeTabs();
Future<void> _loadData(String projectId) async {
try {
attendanceController.selectedTab = 'todaysAttendance';
await attendanceController.loadAttendanceData(projectId);
attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
debugPrint("Error loading data: $e");
}
}
void _initializeTabs() async {
final allTabs = [
{'label': "Today's", 'value': 'todaysAttendance'},
{'label': "Logs", 'value': 'attendanceLogs'},
{'label': "Regularization", 'value': 'regularizationRequests'},
];
final hasRegularizationPermission =
permissionController.hasPermission(Permissions.regularizeAttendance);
_tabs = allTabs.where((tab) {
return tab['value'] != 'regularizationRequests' ||
hasRegularizationPermission;
}).toList();
_tabController = TabController(length: _tabs.length, vsync: this);
// Keep selectedTab in sync and fetch data on tab change
_tabController.addListener(() async {
if (!_tabController.indexIsChanging) {
final selectedTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
}
});
_tabsInitialized = true;
// Load initial data for default tab
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
final initialTab = _tabs[_tabController.index]['value']!;
attendanceController.selectedTab = initialTab;
await _fetchTabData(initialTab);
}
}
Future<void> _fetchTabData(String tab) async {
Future<void> _refreshData() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
switch (tab) {
// Call only the relevant API for current tab
switch (selectedTab) {
case 'todaysAttendance':
await attendanceController.fetchTodaysAttendance(projectId);
break;
@ -118,8 +77,59 @@ class _AttendanceScreenState extends State<AttendanceScreen>
}
}
Future<void> _refreshData() async {
await _fetchTabData(attendanceController.selectedTab);
Widget _buildAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Attendance',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
);
}
Widget _buildFilterSearchRow() {
@ -155,11 +165,11 @@ class _AttendanceScreenState extends State<AttendanceScreen>
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
@ -167,14 +177,17 @@ class _AttendanceScreenState extends State<AttendanceScreen>
}),
),
),
MySpacing.width(8),
// 🛠 Filter Icon (no red dot here anymore)
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
padding: EdgeInsets.zero,
@ -187,18 +200,19 @@ class _AttendanceScreenState extends State<AttendanceScreen>
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(5)),
BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController,
permissionController: permissionController,
selectedTab: _tabs[_tabController.index]['value']!,
selectedTab: selectedTab,
),
);
if (result != null) {
final selectedProjectId =
projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
final selectedOrgId =
result['selectedOrganization'] as String?;
@ -209,12 +223,111 @@ class _AttendanceScreenState extends State<AttendanceScreen>
}
if (selectedProjectId.isNotEmpty) {
await _fetchTabData(attendanceController.selectedTab);
try {
await attendanceController.fetchTodaysAttendance(
selectedProjectId,
);
await attendanceController.fetchAttendanceLogs(
selectedProjectId,
);
await attendanceController.fetchRegularizationLogs(
selectedProjectId,
);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
attendanceController
.update(['attendance_dashboard_controller']);
}
if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView);
attendanceController.selectedTab = selectedView;
if (selectedProjectId.isNotEmpty) {
await attendanceController
.fetchProjectData(selectedProjectId);
}
}
}
},
),
),
MySpacing.width(8),
// Pending Actions Menu (red dot here instead)
if (selectedTab == 'attendanceLogs')
Obx(() {
final showPending = attendanceController.showPendingOnly.value;
return Stack(
children: [
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
itemBuilder: (context) => [
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
PopupMenuItem<int>(
value: 0,
enabled: false,
child: Obx(() => Row(
children: [
const SizedBox(width: 10),
const Expanded(
child: Text('Show Pending Actions')),
Switch.adaptive(
value: attendanceController
.showPendingOnly.value,
activeColor: Colors.indigo,
onChanged: (val) {
attendanceController
.showPendingOnly.value = val;
Navigator.pop(context);
},
),
],
)),
),
],
),
),
if (showPending)
Positioned(
top: 6,
right: 6,
child: Container(
height: 8,
width: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
);
}),
],
),
);
@ -233,121 +346,66 @@ class _AttendanceScreenState extends State<AttendanceScreen>
);
}
Widget _buildTabBarView() {
return TabBarView(
controller: _tabController,
children: _tabs.map((tab) {
switch (tab['value']) {
case 'attendanceLogs':
return AttendanceLogsTab(controller: attendanceController);
case 'regularizationRequests':
return RegularizationRequestsTab(controller: attendanceController);
case 'todaysAttendance':
default:
return TodaysAttendanceTab(controller: attendanceController);
}
}).toList(),
);
Widget _buildSelectedTabContent() {
switch (selectedTab) {
case 'attendanceLogs':
return AttendanceLogsTab(controller: attendanceController);
case 'regularizationRequests':
return RegularizationRequestsTab(controller: attendanceController);
case 'todaysAttendance':
default:
return TodaysAttendanceTab(controller: attendanceController);
}
}
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
if (!_tabsInitialized) {
return Scaffold(
appBar: CustomAppBar(
title: "Attendance",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: CustomAppBar(
title: "Attendance",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: _buildAppBar(),
),
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
SafeArea(
child: GetBuilder<AttendanceController>(
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId =
projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
body: SafeArea(
child: GetBuilder<AttendanceController>(
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId = projectController.selectedProjectId.value;
final noProjectSelected = selectedProjectId.isEmpty;
return MyRefreshIndicator(
onRefresh: _refreshData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.zero,
child: Column(
return MyRefreshIndicator(
onRefresh: _refreshData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
_buildFilterSearchRow(),
MyFlex(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e['label']!).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
onTap: (index) async {
final selectedTab = _tabs[index]['value']!;
attendanceController.selectedTab = selectedTab;
await _fetchTabData(selectedTab);
},
),
),
_buildFilterSearchRow(),
MyFlex(
children: [
MyFlexItem(
sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected
? _buildNoProjectWidget()
: SizedBox(
height:
MediaQuery.of(context).size.height -
200,
child: _buildTabBarView(),
),
),
],
MyFlexItem(
sizes: 'lg-12 md-12 sm-12',
child: noProjectSelected
? _buildNoProjectWidget()
: _buildSelectedTabContent(),
),
],
),
),
);
},
),
),
],
],
),
),
);
},
),
),
);
}
@override
void dispose() {
_tabController.dispose();
// 🧹 Clean up the controller when user leaves this screen
if (Get.isRegistered<AttendanceController>()) {
Get.delete<AttendanceController>();
}

View File

@ -1,3 +1,4 @@
// lib/view/attendance/tabs/regularization_requests_tab.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
@ -18,136 +19,140 @@ class RegularizationRequestsTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() {
final isLoading = controller.isLoadingRegularizationLogs.value;
final employees = controller.filteredRegularizationLogs;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0),
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
),
Obx(() {
final employees = controller.filteredRegularizationLogs;
if (isLoading) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
if (controller.isLoadingRegularizationLogs.value) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
if (employees.isEmpty) {
return const SizedBox(
height: 120,
child: Center(
child: Text("No Regularization Requests Found for this Project"),
),
);
}
if (employees.isEmpty) {
return const SizedBox(
height: 120,
child: Center(
child:
Text("No Regularization Requests Found for this Project"),
),
);
}
return ListView.builder(
itemCount: employees.length,
padding: MySpacing.only(bottom: 80),
itemBuilder: (context, index) {
final employee = employees[index]; // Corrected index
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MyCard.bordered(
paddingAll: 8,
child: Column(
children: [
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 35,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: MyText.bodyMedium(
employee.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${employee.role})',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
return MyCard.bordered(
paddingAll: 8,
child: Column(
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
MyContainer(
paddingAll: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31,
),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (employee.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkIn!),
Flexible(
child: MyText.bodyMedium(
employee.name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
MySpacing.width(16),
],
if (employee.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkOut!),
),
MySpacing.width(6),
Flexible(
child: MyText.bodySmall(
'(${employee.role})',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
],
),
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.approve,
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null) ...[
const Icon(Icons.arrow_circle_right,
size: 16, color: Colors.green),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkIn!),
fontWeight: 600,
),
MySpacing.width(16),
],
if (employee.checkOut != null) ...[
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
MyText.bodySmall(
DateFormat('hh:mm a')
.format(employee.checkOut!),
fontWeight: 600,
),
],
],
),
const SizedBox(width: 8),
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.reject,
),
const SizedBox(width: 8),
if (employee.checkIn != null)
AttendanceLogViewButton(
employee: employee,
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.approve,
),
],
),
],
const SizedBox(width: 8),
RegularizeActionButton(
attendanceController: controller,
log: employee,
uniqueLogKey: employee.employeeId,
action: ButtonActions.reject,
),
const SizedBox(width: 8),
if (employee.checkIn != null)
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
),
],
),
),
),
],
],
),
),
),
],
),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
);
}),
),
);
},
);
});
}),
],
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:on_field_work/controller/attendance/attendance_screen_controller
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_container.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
@ -21,119 +22,124 @@ class TodaysAttendanceTab extends StatelessWidget {
final isLoading = controller.isLoadingEmployees.value;
final employees = controller.filteredEmployees;
if (isLoading) {
return SkeletonLoaders.employeeListSkeletonLoader();
}
if (employees.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text("No Employees Assigned"),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
Expanded(
child:
MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600,
color: Colors.grey[700],
),
],
),
),
);
}
return ListView.builder(
itemCount: employees.length + 1,
padding: MySpacing.only(bottom: 80),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 12, top: 4),
child: Row(
children: [
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
fontWeight: 600,
color: Colors.grey[700],
),
],
),
);
}
final employee = employees[index - 1];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: MyCard.bordered(
paddingAll: 10,
if (isLoading)
SkeletonLoaders.employeeListSkeletonLoader()
else if (employees.isEmpty)
const SizedBox(
height: 120,
child: Center(child: Text("No Employees Assigned")))
else
MyCard.bordered(
paddingAll: 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- 1. Employee Info Row (Avatar, Name, Designation ONLY) ---
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: List.generate(employees.length, (index) {
final employee = employees[index];
return Column(
children: [
// Avatar
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 30,
),
MySpacing.width(10),
// Employee Details (Expanded to use remaining space)
Expanded(
child: Column(
MyContainer(
paddingAll: 5,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyText.titleSmall(employee.name,
fontWeight: 600, overflow: TextOverflow.ellipsis),
MyText.labelSmall(
employee.designation,
fontWeight: 500,
color: Colors.grey[600],
overflow: TextOverflow.ellipsis,
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: [
MyText.bodyMedium(employee.name,
fontWeight: 600),
MyText.bodySmall(
'(${employee.designation})',
fontWeight: 600,
color: Colors.grey[700]),
],
),
MySpacing.height(8),
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null)
Row(
children: [
const Icon(
Icons.arrow_circle_right,
size: 16,
color: Colors.green),
MySpacing.width(4),
Text(DateTimeUtils.formatDate(
employee.checkIn!,
'hh:mm a')),
],
),
if (employee.checkOut != null) ...[
MySpacing.width(16),
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
Text(DateTimeUtils.formatDate(
employee.checkOut!, 'hh:mm a')),
],
],
),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: controller,
),
if (employee.checkIn != null) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
],
),
],
),
),
],
),
),
// Status Text (Added back for context)
if (employee.checkIn == null)
MyText.bodySmall(
'Check In Pending',
fontWeight: 600,
color: Colors.red,
)
else if (employee.checkOut == null)
MyText.bodySmall(
'Checked In',
fontWeight: 600,
color: Colors.green,
),
if (index != employees.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
),
// --- Separator before buttons ---
MySpacing.height(12),
// --- 2. Action Buttons Row (Below main info) ---
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: controller,
),
if (employee.checkIn != null) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
),
],
],
),
],
);
}),
),
),
);
},
],
);
});
}
}
}

View File

@ -8,7 +8,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/view/auth/request_demo_bottom_sheet.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/wave_background.dart';
import 'package:package_info_plus/package_info_plus.dart';
enum LoginOption { email, otp }
@ -32,8 +31,6 @@ class _WelcomeScreenState extends State<WelcomeScreen>
late final Animation<double> _logoAnimation;
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
String _appVersion = '';
@override
void initState() {
super.initState();
@ -46,15 +43,6 @@ class _WelcomeScreenState extends State<WelcomeScreen>
curve: Curves.easeOutBack,
);
_controller.forward();
_fetchAppVersion();
}
Future<void> _fetchAppVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
setState(() {
_appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
});
}
@override
@ -154,14 +142,11 @@ class _WelcomeScreenState extends State<WelcomeScreen>
option: null,
),
const SizedBox(height: 36),
// Dynamic App Version
if (_appVersion.isNotEmpty)
MyText(
'App version $_appVersion',
color: Colors.grey,
fontSize: 12,
),
MyText(
'App version 1.0.0',
color: Colors.grey,
fontSize: 12,
),
],
),
),

View File

@ -1,25 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/attendance/attendance_screen_controller.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/attendance_overview_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/project_progress_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/model/attendance/attendence_action_button.dart';
import 'package:on_field_work/model/attendance/log_details_view.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
import 'package:on_field_work/view/layouts/layout.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@ -31,8 +28,6 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final DashboardController dashboardController =
Get.put(DashboardController(), permanent: true);
final AttendanceController attendanceController =
Get.put(AttendanceController());
final DynamicMenuController menuController = Get.put(DynamicMenuController());
final ProjectController projectController = Get.find<ProjectController>();
@ -46,212 +41,83 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
Future<void> _checkMpinStatus() async {
hasMpin = await LocalStorage.getIsMpin();
if (mounted) {
setState(() {});
}
if (mounted) setState(() {});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Widget _cardWrapper({required Widget child}) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.04)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: child,
);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
@override
Widget build(BuildContext context) {
return Layout(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDashboardCards(),
MySpacing.height(24),
_buildAttendanceChartSection(),
MySpacing.height(24),
_buildProjectProgressChartSection(),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.teamsOverview(),
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: DashboardOverviewWidgets.tasksOverview(),
),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
],
),
),
);
}
// ---------------------------------------------------------------------------
// Quick Actions
// ---------------------------------------------------------------------------
Widget _quickActions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Quick Action'),
Obx(() {
if (dashboardController.isLoadingEmployees.value) {
// Show loading skeleton
return SkeletonLoaders.attendanceQuickCardSkeleton();
}
final employees = dashboardController.employees;
final employee = employees.isNotEmpty ? employees.first : null;
if (employee == null) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
gradient: LinearGradient(
colors: [
contentTheme.primary.withOpacity(0.3),
contentTheme.primary.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Text(
'No attendance data available',
style: TextStyle(color: Colors.white),
),
);
}
// Actual employee quick action card
final bool isCheckedIn = employee.checkIn != null;
final bool isCheckedOut = employee.checkOut != null;
final String statusText = !isCheckedIn
? 'Check In Pending'
: isCheckedIn && !isCheckedOut
? 'Checked In'
: 'Checked Out';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
gradient: LinearGradient(
colors: [
contentTheme.primary.withOpacity(0.3),
contentTheme.primary.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 30,
),
MySpacing.width(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(
employee.name,
fontWeight: 600,
color: Colors.white,
),
MyText.labelSmall(
employee.designation,
fontWeight: 500,
color: Colors.white70,
),
],
),
),
MyText.bodySmall(
statusText,
fontWeight: 600,
color: Colors.white,
),
],
),
const SizedBox(height: 12),
Text(
!isCheckedIn
? 'You are not checked-in yet. Please check-in to start your work.'
: !isCheckedOut
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
: 'You have checked-out for today.',
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AttendanceActionButton(
employee: employee,
attendanceController: attendanceController,
),
if (isCheckedIn) ...[
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: attendanceController,
),
],
],
),
],
),
);
}),
],
);
}
// ---------------------------------------------------------------------------
// Dashboard Modules
// ---------------------------------------------------------------------------
Widget _dashboardModules() {
/// ---------------- Dynamic Dashboard Cards ----------------
Widget _buildDashboardCards() {
return Obx(() {
if (menuController.isLoading.value) {
return SkeletonLoaders.dashboardCardsSkeleton(
maxWidth: MediaQuery.of(context).size.width,
return SkeletonLoaders.dashboardCardsSkeleton();
}
if (menuController.hasError.value || menuController.menuItems.isEmpty) {
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
style: TextStyle(color: Colors.red),
),
);
}
final bool projectSelected = projectController.selectedProject != null;
final projectSelected = projectController.selectedProject != null;
// these are String constants from permission_constants.dart
// Define dashboard card meta with order
final List<String> cardOrder = [
MenuItems.attendance,
MenuItems.employees,
MenuItems.dailyTaskPlanning,
MenuItems.dailyProgressReport,
MenuItems.directory,
MenuItems.finance,
MenuItems.documents,
MenuItems.serviceProjects,
MenuItems.infraProjects,
MenuItems.serviceProjects
];
final Map<String, _DashboardCardMeta> meta = {
final Map<String, _DashboardCardMeta> cardMeta = {
MenuItems.attendance:
_DashboardCardMeta(LucideIcons.scan_face, contentTheme.success),
MenuItems.employees:
_DashboardCardMeta(LucideIcons.users, contentTheme.warning),
MenuItems.dailyTaskPlanning:
_DashboardCardMeta(LucideIcons.logs, contentTheme.info),
MenuItems.dailyProgressReport:
_DashboardCardMeta(LucideIcons.list_todo, contentTheme.info),
MenuItems.directory:
_DashboardCardMeta(LucideIcons.folder, contentTheme.info),
MenuItems.finance:
@ -260,328 +126,180 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
_DashboardCardMeta(LucideIcons.file_text, contentTheme.info),
MenuItems.serviceProjects:
_DashboardCardMeta(LucideIcons.package, contentTheme.info),
MenuItems.infraProjects:
_DashboardCardMeta(LucideIcons.building_2, contentTheme.primary),
};
final Map<String, dynamic> allowed = {
for (final m in menuController.menuItems)
if (m.available && meta.containsKey(m.id)) m.id: m,
// Filter only available menus that exist in cardMeta
final allowedMenusMap = {
for (var menu in menuController.menuItems)
if (menu.available && cardMeta.containsKey(menu.id)) menu.id: menu
};
final List<String> filtered =
cardOrder.where((id) => allowed.containsKey(id)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Modules',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
if (!projectSelected)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Select Project',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
),
],
),
if (allowedMenusMap.isEmpty) {
return const Center(
child: Text(
"No accessible modules found.",
style: TextStyle(color: Colors.grey),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 15,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
),
itemCount: filtered.length,
itemBuilder: (context, index) {
final String id = filtered[index];
final item = allowed[id]!;
final _DashboardCardMeta cardMeta = meta[id]!;
final bool isEnabled =
item.name == 'Attendance' ? true : projectSelected;
return GestureDetector(
onTap: () {
if (!isEnabled) {
Get.snackbar(
'Required',
'Please select a project first',
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16),
backgroundColor: Colors.black87,
colorText: Colors.white,
duration: const Duration(seconds: 2),
);
} else {
Get.toNamed(item.mobileLink);
}
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isEnabled
? Colors.black12.withOpacity(0.06)
: Colors.transparent,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
cardMeta.icon,
size: 20,
color:
isEnabled ? cardMeta.color : Colors.grey.shade300,
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
item.name,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
fontWeight:
isEnabled ? FontWeight.w600 : FontWeight.w400,
color: isEnabled
? Colors.black87
: Colors.grey.shade400,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
),
],
);
});
}
// ---------------------------------------------------------------------------
// Project Selector
// ---------------------------------------------------------------------------
Widget _projectSelector() {
return Obx(() {
final bool isLoading = projectController.isLoading.value;
final bool expanded = projectController.isProjectSelectionExpanded.value;
final projects = projectController.projects;
final String? selectedId = projectController.selectedProjectId.value;
if (isLoading) {
return SkeletonLoaders.dashboardCardsSkeleton(
maxWidth: MediaQuery.of(context).size.width,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Project'),
GestureDetector(
onTap: () => projectController.isProjectSelectionExpanded.toggle(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.15)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
const Icon(
Icons.work_outline,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
projects
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
Icon(
expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 26,
color: Colors.black54,
),
],
),
),
),
if (expanded) _projectDropdownList(projects, selectedId),
],
);
// Create list of cards in fixed order
final stats =
cardOrder.where((id) => allowedMenusMap.containsKey(id)).map((id) {
final menu = allowedMenusMap[id]!;
final meta = cardMeta[id]!;
return _DashboardStatItem(
meta.icon, menu.name, meta.color, menu.mobileLink);
}).toList();
return LayoutBuilder(builder: (context, constraints) {
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
double cardWidth =
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
return Wrap(
spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.start,
children: stats
.map((stat) =>
_buildDashboardCard(stat, projectSelected, cardWidth))
.toList(),
);
});
});
}
Widget _projectDropdownList(List projects, String? selectedId) {
return Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.2)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.07),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.33,
),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: 'Search project...',
isDense: true,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
),
),
const SizedBox(height: 10),
Expanded(
child: ListView.builder(
itemCount: projects.length,
itemBuilder: (_, index) {
final project = projects[index];
return RadioListTile<String>(
dense: true,
value: project.id,
groupValue: selectedId,
onChanged: (value) {
if (value != null) {
projectController.updateSelectedProject(value);
projectController.isProjectSelectionExpanded.value =
false;
}
},
title: Text(project.name),
);
},
),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
Widget _buildDashboardCard(
_DashboardStatItem stat, bool isProjectSelected, double width) {
final isEnabled = stat.title == "Attendance" ? true : isProjectSelected;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff5f6fa),
body: Layout(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_projectSelector(),
MySpacing.height(20),
_quickActions(),
MySpacing.height(20),
_dashboardModules(),
MySpacing.height(20),
_sectionTitle('Reports & Analytics'),
CompactPurchaseInvoiceDashboard(),
MySpacing.height(20),
CollectionsHealthWidget(),
MySpacing.height(20),
_cardWrapper(
child: ExpenseTypeReportChart(),
),
_cardWrapper(
child: ExpenseByStatusWidget(
controller: dashboardController,
return Opacity(
opacity: isEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isEnabled,
child: InkWell(
onTap: () => _onDashboardCardTap(stat, isEnabled),
borderRadius: BorderRadius.circular(5),
child: MyCard.bordered(
width: width,
height: 60,
paddingAll: 4,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: stat.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
stat.icon,
size: 16,
color: stat.color,
),
),
),
_cardWrapper(
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(20),
],
MySpacing.height(4),
Flexible(
child: Text(
stat.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
overflow: TextOverflow.ellipsis,
),
maxLines: 2,
),
),
],
),
),
),
),
);
}
void _onDashboardCardTap(_DashboardStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project before accessing this module.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
Get.toNamed(statItem.route);
}
}
/// ---------------- Project Progress Chart ----------------
Widget _buildProjectProgressChartSection() {
return Obx(() {
if (dashboardController.projectChartData.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text("No project progress data available."),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: ProjectProgressChart(
data: dashboardController.projectChartData,
),
),
);
});
}
/// ---------------- Attendance Chart ----------------
Widget _buildAttendanceChartSection() {
return Obx(() {
final attendanceMenu = menuController.menuItems
.firstWhereOrNull((m) => m.id == MenuItems.attendance);
if (attendanceMenu == null || !attendanceMenu.available)
return const SizedBox.shrink();
final isProjectSelected = projectController.selectedProject != null;
return Opacity(
opacity: isProjectSelected ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isProjectSelected,
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
height: 400,
child: AttendanceDashboardChart(),
),
),
),
);
});
}
}
/// ---------------- Dashboard Card Models ----------------
class _DashboardStatItem {
final IconData icon;
final String title;
final Color color;
final String route;
_DashboardStatItem(this.icon, this.title, this.color, this.route);
}
class _DashboardCardMeta {
final IconData icon;
final Color color;
const _DashboardCardMeta(this.icon, this.color);
_DashboardCardMeta(this.icon, this.color);
}

View File

@ -13,7 +13,6 @@ import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
@ -24,21 +23,18 @@ class ContactDetailScreen extends StatefulWidget {
}
class _ContactDetailScreenState extends State<ContactDetailScreen>
with SingleTickerProviderStateMixin, UIMixin {
with UIMixin {
late final DirectoryController directoryController;
late final ProjectController projectController;
late Rx<ContactModel> contactRx;
late TabController _tabController;
@override
void initState() {
super.initState();
directoryController = Get.find<DirectoryController>();
projectController = Get.put(ProjectController());
projectController = Get.find<ProjectController>();
contactRx = widget.contact.obs;
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await directoryController.fetchCommentsForContact(contactRx.value.id,
active: true);
@ -53,54 +49,61 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
// AppBar is outside SafeArea (correct)
appBar: CustomAppBar(
title: 'Contact Profile',
backgroundColor: appBarColor,
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
),
// Only the content is wrapped inside SafeArea
body: SafeArea(
child: Column(
children: [
// ************ GRADIENT + SUBHEADER + TABBAR ************
Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
contentTheme.primary,
contentTheme.primary.withOpacity(0),
],
),
),
child: Obx(() => _buildSubHeader(contactRx.value)),
),
// ************ TAB CONTENT ************
Expanded(
child: TabBarView(
controller: _tabController,
children: [
return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: _buildMainAppBar(),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() => _buildSubHeader(contactRx.value)),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded(
child: TabBarView(children: [
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
]),
),
],
),
),
),
);
}
PreferredSizeWidget _buildMainAppBar() {
return AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.2,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () =>
Get.offAllNamed('/dashboard/directory-main-page'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (p) {
return ProjectLabel(p.selectedProject?.name);
}),
],
),
),
@ -115,10 +118,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
final lastName =
contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
final Color primaryColor = contentTheme.primary;
return Container(
color: Colors.transparent,
return Padding(
padding: MySpacing.xy(16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -137,53 +137,20 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
],
),
]),
MySpacing.height(12),
// === MODERN PILL-SHAPED TABBAR ===
Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(24),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding:
const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
labelColor: primaryColor,
unselectedLabelColor: Colors.grey.shade600,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
tabs: const [
Tab(text: "Details"),
Tab(text: "Notes"),
],
dividerColor: Colors.transparent,
),
TabBar(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: contentTheme.primary,
tabs: const [
Tab(text: "Details"),
Tab(text: "Notes"),
],
),
],
),
);
}
// --- DETAILS TAB ---
Widget _buildDetailsTab(ContactModel contact) {
final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds
@ -261,8 +228,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
_iconInfoRow(Icons.location_on, "Address", contact.address),
]),
_infoCard("Organization", [
_iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category),
]),
_infoCard("Meta Info", [
@ -315,7 +281,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
);
}
// --- COMMENTS TAB ---
Widget _buildCommentsTab() {
return Obx(() {
final contactId = contactRx.value.id;
@ -657,3 +622,25 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
);
}
}
class ProjectLabel extends StatelessWidget {
final String? projectName;
const ProjectLabel(this.projectName, {super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName ?? 'Select Project',
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}
}

View File

@ -3,13 +3,12 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/directory/directory_controller.dart';
import 'package:on_field_work/controller/directory/notes_controller.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/view/directory/directory_view.dart';
import 'package:on_field_work/view/directory/notes_view.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class DirectoryMainScreen extends StatefulWidget {
const DirectoryMainScreen({super.key});
@ -19,7 +18,7 @@ class DirectoryMainScreen extends StatefulWidget {
}
class _DirectoryMainScreenState extends State<DirectoryMainScreen>
with SingleTickerProviderStateMixin, UIMixin {
with SingleTickerProviderStateMixin {
late TabController _tabController;
final DirectoryController controller = Get.put(DirectoryController());
@ -39,46 +38,97 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return OrientationBuilder(
builder: (context, orientation) {
final bool isLandscape = orientation == Orientation.landscape;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: "Directory",
onBackPressed: () => Get.offNamed('/dashboard'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// === TOP GRADIENT ===
Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: PreferredSize(
preferredSize: Size.fromHeight(
isLandscape ? 55 : 72, // Responsive height
),
child: SafeArea(
bottom: false,
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
/// FIX: Flexible to prevent overflow in landscape
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Directory',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
),
SafeArea(
top: false,
/// MAIN CONTENT
body: SafeArea(
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Directory", "Notes"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Directory"),
Tab(text: "Notes"),
],
),
),
// === TABBAR VIEW ===
Expanded(
child: TabBarView(
controller: _tabController,
@ -91,8 +141,8 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
],
),
),
],
),
);
},
);
}
}

View File

@ -13,7 +13,6 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/document/document_edit_bottom_sheet.dart';
import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class DocumentDetailsPage extends StatefulWidget {
final String documentId;
@ -24,7 +23,7 @@ class DocumentDetailsPage extends StatefulWidget {
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller =
Get.find<DocumentDetailsController>();
@ -50,78 +49,50 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> with UIMixin
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: 'Document Details',
backgroundColor: appBarColor,
onBackPressed: () {
Get.back();
},
),
body: Stack(
children: [
// Gradient behind content
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
body: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
final docResponse = controller.documentDetails.value;
if (docResponse == null || docResponse.data == null) {
return Center(
child: MyText.bodyMedium(
"Failed to load document details.",
color: Colors.grey,
),
);
}
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium("Versions",
fontWeight: 700, color: Colors.black),
const SizedBox(height: 10),
_buildVersionsSection(),
],
),
),
// Main content
SafeArea(
child: Obx(() {
if (controller.isLoading.value) {
return SkeletonLoaders.documentDetailsSkeletonLoader();
}
final docResponse = controller.documentDetails.value;
if (docResponse == null || docResponse.data == null) {
return Center(
child: MyText.bodyMedium(
"Failed to load document details.",
color: Colors.grey,
),
);
}
final doc = docResponse.data!;
return MyRefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailsCard(doc),
const SizedBox(height: 20),
MyText.titleMedium(
"Versions",
fontWeight: 700,
color: Colors.black,
),
const SizedBox(height: 10),
_buildVersionsSection(),
],
),
),
);
}),
),
],
),
);
}),
);
}

View File

@ -115,6 +115,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
void dispose() {
_scrollController.dispose();
_fabAnimationController.dispose();
docController.searchController.dispose();
docController.documents.clear();
super.dispose();
}
@ -136,7 +137,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
],
),
child: TextField(
controller: docController.searchController, // keep GetX controller
controller: docController.searchController,
onChanged: (value) {
docController.searchQuery.value = value;
docController.fetchDocuments(
@ -427,21 +428,14 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
}
Widget _buildDocumentCard(DocumentItem doc, bool showDateHeader) {
final uploadDate = doc.uploadedAt != null
? DateFormat("dd MMM yyyy").format(doc.uploadedAt!.toLocal())
: '-';
final uploadTime = doc.uploadedAt != null
? DateFormat("hh:mm a").format(doc.uploadedAt!.toLocal())
: '';
final uploadDate =
DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal());
final uploadTime = DateFormat("hh:mm a").format(doc.uploadedAt.toLocal());
final uploader = doc.uploadedBy.firstName.isNotEmpty
? "${doc.uploadedBy.firstName} ${doc.uploadedBy.lastName}".trim()
: "You";
final uploader =
(doc.uploadedBy != null && doc.uploadedBy!.firstName.isNotEmpty)
? "${doc.uploadedBy!.firstName} ${doc.uploadedBy!.lastName ?? ''}"
.trim()
: "You";
final iconColor =
_getDocumentTypeColor(doc.documentType?.name ?? 'unknown');
final iconColor = _getDocumentTypeColor(doc.documentType.name);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -479,16 +473,17 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_getDocumentIcon(doc.documentType?.name ?? 'unknown'),
color: iconColor,
size: 24,
)),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_getDocumentIcon(doc.documentType.name),
color: iconColor,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
@ -502,7 +497,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
borderRadius: BorderRadius.circular(6),
),
child: MyText.labelSmall(
doc.documentType?.name ?? 'Unknown',
doc.documentType.name,
fontWeight: 600,
color: iconColor,
letterSpacing: 0.3,
@ -803,93 +798,97 @@ class _UserDocumentsPageState extends State<UserDocumentsPage>
);
}
Widget _buildBody() {
// Non-reactive widgets
final searchBar = _buildSearchBar();
final filterChips = _buildFilterChips();
final statusBanner = _buildStatusBanner();
Widget _buildBody() {
return Obx(() {
// Check permissions
if (permissionController.permissions.isEmpty) {
return _buildLoadingIndicator();
}
return Column(
children: [
searchBar,
filterChips,
statusBanner,
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return _buildPermissionDenied();
}
// Only the list is reactive
Expanded(
child: Obx(() {
if (!permissionController.hasPermission(Permissions.viewDocument)) {
return _buildPermissionDenied();
}
// Show skeleton loader
if (docController.isLoading.value && docController.documents.isEmpty) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: SkeletonLoaders.documentSkeletonLoader(),
);
}
final docs = docController.documents;
final docs = docController.documents;
// Skeleton loader
if (docController.isLoading.value && docs.isEmpty) {
return SkeletonLoaders.documentSkeletonLoader();
}
return Column(
children: [
_buildSearchBar(),
_buildFilterChips(),
_buildStatusBanner(),
Expanded(
child: MyRefreshIndicator(
onRefresh: () async {
final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds':
docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(),
};
// Empty state
if (!docController.isLoading.value && docs.isEmpty) {
return _buildEmptyState();
}
// List of documents
return MyRefreshIndicator(
onRefresh: () async {
final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(),
};
await docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
filter: jsonEncode(combinedFilter),
reset: true,
);
},
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100, top: 8),
itemCount: docs.length + 1,
itemBuilder: (context, index) {
if (index == docs.length) {
return Obx(() {
if (docController.isLoading.value) {
return _buildLoadingIndicator();
}
if (!docController.hasMore.value && docs.isNotEmpty) {
return _buildNoMoreIndicator();
}
return const SizedBox.shrink();
});
}
final doc = docs[index];
final currentDate = doc.uploadedAt != null
? DateFormat("dd MMM yyyy").format(doc.uploadedAt!.toLocal())
: '';
final prevDate = index > 0
? (docs[index - 1].uploadedAt != null
? DateFormat("dd MMM yyyy")
.format(docs[index - 1].uploadedAt!.toLocal())
: '')
: null;
final showDateHeader = currentDate != prevDate;
return _buildDocumentCard(doc, showDateHeader);
await docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
filter: jsonEncode(combinedFilter),
reset: true,
);
},
child: docs.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _buildEmptyState(),
),
],
)
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100, top: 8),
itemCount: docs.length + 1,
itemBuilder: (context, index) {
if (index == docs.length) {
return Obx(() {
if (docController.isLoading.value) {
return _buildLoadingIndicator();
}
if (!docController.hasMore.value &&
docs.isNotEmpty) {
return _buildNoMoreIndicator();
}
return const SizedBox.shrink();
});
}
final doc = docs[index];
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 _buildDocumentCard(doc, showDateHeader);
},
),
),
);
}),
),
],
);
}
),
],
);
});
}
Widget _buildFAB() {
return Obx(() {

View File

@ -3,7 +3,6 @@ import 'package:get/get.dart';
import 'package:on_field_work/view/employees/employee_detail_screen.dart';
import 'package:on_field_work/view/document/user_document_screen.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class EmployeeProfilePage extends StatefulWidget {
final String employeeId;
@ -15,15 +14,12 @@ class EmployeeProfilePage extends StatefulWidget {
}
class _EmployeeProfilePageState extends State<EmployeeProfilePage>
with SingleTickerProviderStateMixin, UIMixin {
// We no longer need to listen to the TabController for setState,
// as the TabBar handles its own state updates via the controller.
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
// Initialize TabController with 2 tabs
_tabController = TabController(length: 2, vsync: this);
}
@ -33,109 +29,46 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
super.dispose();
}
// --- No need for _buildSegmentedButton function anymore ---
@override
Widget build(BuildContext context) {
// Accessing theme colors for consistency
final Color appBarColor = contentTheme.primary;
final Color primaryColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: "Employee Profile",
onBackPressed: () => Get.back(),
backgroundColor: appBarColor,
),
body: Stack(
body: Column(
children: [
// === Gradient at the top behind AppBar + Toggle ===
// This container ensures the background color transitions nicely
// ---------------- TabBar outside AppBar ----------------
Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Details"),
Tab(text: "Documents"),
],
),
),
// === Main Content Area ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
// 🛑 NEW: The Modern TabBar Implementation 🛑
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Container(
height: 48, // Define a specific height for the TabBar container
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24.0), // Rounded corners for a chip-like look
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: _tabController,
// Style the indicator as a subtle pill/chip
indicator: BoxDecoration(
color: primaryColor.withOpacity(0.1), // Light background color for the selection
borderRadius: BorderRadius.circular(24.0),
),
indicatorSize: TabBarIndicatorSize.tab,
// The padding is used to slightly shrink the indicator area
indicatorPadding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
// Text styling
labelColor: primaryColor, // Selected text color is primary
unselectedLabelColor: Colors.grey.shade600, // Unselected text color is darker grey
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
// Tabs (No custom widget needed, just use the built-in Tab)
tabs: const [
Tab(text: "Details"),
Tab(text: "Documents"),
],
// Setting this to zero removes the default underline
dividerColor: Colors.transparent,
),
),
// ---------------- TabBarView ----------------
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Details Tab
EmployeeDetailPage(
employeeId: widget.employeeId,
fromProfile: true,
),
// 🛑 TabBarView (The Content) 🛑
Expanded(
child: TabBarView(
controller: _tabController,
children: [
EmployeeDetailPage(
employeeId: widget.employeeId,
fromProfile: true,
),
UserDocumentsPage(
entityId: widget.employeeId,
isEmployee: true,
),
],
),
// Documents Tab
UserDocumentsPage(
entityId: widget.employeeId,
isEmployee: true,
),
],
),
@ -144,4 +77,4 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
),
);
}
}
}

View File

@ -17,7 +17,6 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/view/employees/manage_reporting_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key});
@ -105,7 +104,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
backgroundColor: Colors.transparent,
builder: (_) => AssignProjectBottomSheet(
employeeId: employeeId,
jobRoleId: employeeData['jobRoleId'] as String,
jobRoleId: employeeData['jobRoleId'] as String,
),
);
@ -114,69 +113,98 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Employees",
backgroundColor: appBarColor,
projectName: Get.find<ProjectController>().selectedProject?.name ??
'Select Project',
onBackPressed: () => Get.offNamed('/dashboard'),
),
body: Stack(
children: [
// Gradient behind content
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator(
onRefresh: _refreshEmployees,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
),
);
},
),
),
],
),
backgroundColor: Colors.white,
appBar: _buildAppBar(),
floatingActionButton: _buildFloatingActionButton(),
body: SafeArea(
child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator(
onRefresh: _refreshEmployees,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
),
);
},
),
),
);
}
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Employees',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}

View File

@ -14,7 +14,7 @@ import 'package:on_field_work/controller/expense/add_expense_controller.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
@ -81,62 +81,34 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
canSubmit.value = result;
}
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: CustomAppBar(
title: "Expense Details",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
body: Stack(
children: [
// Gradient behind content
Container(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
// Main content
SafeArea(
child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final statusColor = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color);
final formattedAmount = formatExpenseAmount(expense.amount);
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator(
return MyRefreshIndicator(
onRefresh: () async {
await controller.fetchExpenseDetails();
},
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom
),
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
@ -150,21 +122,21 @@ Widget build(BuildContext context) {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header & Status
// ---------------- Header & Status ----------------
_InvoiceHeader(expense: expense),
const Divider(height: 30, thickness: 1.2),
// Activity Logs
// ---------------- Activity Logs ----------------
InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2),
// Amount & Summary
// ---------------- Amount & Summary ----------------
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('Amount', fontWeight: 600),
MyText.bodyMedium('Amount',
fontWeight: 600),
const SizedBox(height: 4),
MyText.bodyLarge(
formattedAmount,
@ -174,6 +146,7 @@ Widget build(BuildContext context) {
],
),
const Spacer(),
// Optional: Pre-approved badge
if (expense.preApproved)
Container(
padding: const EdgeInsets.symmetric(
@ -192,19 +165,19 @@ Widget build(BuildContext context) {
),
const Divider(height: 30, thickness: 1.2),
// Parties
// ---------------- Parties ----------------
_InvoicePartiesTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
// Expense Details
// ---------------- Expense Details ----------------
_InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2),
// Documents
// ---------------- Documents ----------------
_InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2),
// Totals
// ---------------- Totals ----------------
_InvoiceTotals(
expense: expense,
formattedAmount: formattedAmount,
@ -216,109 +189,122 @@ Widget build(BuildContext context) {
),
),
),
),
);
}),
),
],
),
floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
));
}),
),
floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return const SizedBox.shrink();
}
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
return const SizedBox.shrink();
}
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
return const SizedBox.shrink();
}
return FloatingActionButton.extended(
onPressed: () async {
final editData = {
'id': expense.id,
'projectName': expense.project.name,
'amount': expense.amount,
'supplerName': expense.supplerName,
'description': expense.description,
'transactionId': expense.transactionId,
'location': expense.location,
'transactionDate': expense.transactionDate,
'noOfPersons': expense.noOfPersons,
'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id,
'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName,
'attachments': expense.documents
.map((doc) => {
'url': doc.preSignedUrl,
'fileName': doc.fileName,
'documentId': doc.documentId,
'contentType': doc.contentType,
})
.toList(),
};
return FloatingActionButton.extended(
onPressed: () async {
final editData = {
'id': expense.id,
'projectName': expense.project.name,
'amount': expense.amount,
'supplerName': expense.supplerName,
'description': expense.description,
'transactionId': expense.transactionId,
'location': expense.location,
'transactionDate': expense.transactionDate,
'noOfPersons': expense.noOfPersons,
'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id,
'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName,
'attachments': expense.documents
.map((doc) => {
'url': doc.preSignedUrl,
'fileName': doc.fileName,
'documentId': doc.documentId,
'contentType': doc.contentType,
})
.toList(),
};
logSafe('editData: $editData', level: LogLevel.info);
final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
final addCtrl = Get.put(AddExpenseController());
await showAddExpenseBottomSheet(isEdit: true);
await controller.fetchExpenseDetails();
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.edit),
label: MyText.bodyMedium("Edit Expense",
fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0x11000000))),
await showAddExpenseBottomSheet(isEdit: true);
await controller.fetchExpenseDetails();
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.edit),
label: MyText.bodyMedium("Edit Expense",
fontWeight: 600, color: Colors.white),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null) return const SizedBox();
return SafeArea(
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0x11000000))),
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
logSafe(
'🔐 Permission Logic:\n'
'🔸 Status: ${next.name}\n'
'🔸 Status ID: ${next.id}\n'
'🔸 Parsed Permissions: $parsedPermissions\n'
'🔸 Is Submit: $isSubmitStatus\n'
'🔸 Created By Current User: $isCreatedByCurrentUser',
level: LogLevel.debug,
);
if (isSubmitStatus) {
// Submit can be done ONLY by the creator
return isCreatedByCurrentUser;
}
// All other statuses - check permission normally
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
if (isSubmitStatus) return isCreatedByCurrentUser;
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
),
);
}),
);
}
);
}),
);
}
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) {
@ -463,6 +449,64 @@ Widget build(BuildContext context) {
}
}
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController;
const _AppBar({required this.projectController});
@override
Widget build(BuildContext context) {
return AppBar(
automaticallyImplyLeading: false,
elevation: 1,
backgroundColor: Colors.white,
title: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.toNamed('/dashboard/expense-main-page'),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge('Expense Details',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _InvoiceHeader extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceHeader({required this.expense});

View File

@ -12,8 +12,6 @@ import 'package:on_field_work/helpers/widgets/expense/expense_main_components.da
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key});
@ -89,94 +87,65 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: CustomAppBar(
title: "Expense & Reimbursement",
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/finance'),
),
body: Stack(
appBar: ExpenseAppBar(projectController: projectController),
body: Column(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
// ---------------- TabBar ----------------
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
),
// === MAIN CONTENT ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Current Month", "History"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
// ---------------- Gray background for rest ----------------
Expanded(
child: Container(
color: Colors.grey[100],
child: Column(
children: [
// ---------------- Search ----------------
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// CONTENT AREA
Expanded(
child: Container(
color: Colors.transparent,
child: Column(
// ---------------- TabBarView ----------------
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// SEARCH & FILTER
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: SearchAndFilter(
controller: searchController,
onChanged: (_) => setState(() {}),
onFilterTap: _openFilterBottomSheet,
expenseController: expenseController,
),
),
// TABBAR VIEW
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
_buildExpenseList(isHistory: false),
_buildExpenseList(isHistory: true),
],
),
),
),
],
],
),
),
),
],
),
floatingActionButton: Obx(() {
if (permissionController.permissions.isEmpty)
// Show loader or hide FAB while permissions are loading
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink();
}
final canUpload =
permissionController.hasPermission(Permissions.expenseUpload);

View File

@ -3,9 +3,10 @@ import 'package:get/get.dart';
import 'package:on_field_work/controller/finance/advance_payment_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class AdvancePaymentScreen extends StatefulWidget {
const AdvancePaymentScreen({super.key});
@ -48,106 +49,148 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Advance Payments",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// ===== TOP GRADIENT =====
Container(
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
appBar: _buildAppBar(),
// SafeArea added so nothing hides under system navigation buttons
body: SafeArea(
bottom: true,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: RefreshIndicator(
onRefresh: () async {
final emp = controller.selectedEmployee.value;
if (emp != null) {
await controller.fetchAdvancePayments(emp.id.toString());
}
},
color: Colors.white,
backgroundColor: contentTheme.primary,
strokeWidth: 2.5,
displacement: 60,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
// Extra bottom padding so content does NOT go under 3-button navbar
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 20,
),
child: Column(
children: [
_buildSearchBar(),
_buildEmployeeDropdown(context),
_buildTopBalance(),
_buildPaymentList(),
],
),
),
),
),
),
),
);
}
// ===== MAIN CONTENT =====
SafeArea(
top: false,
bottom: true,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: RefreshIndicator(
onRefresh: () async {
final emp = controller.selectedEmployee.value;
if (emp != null) {
await controller.fetchAdvancePayments(emp.id.toString());
}
},
color: Colors.white,
backgroundColor: appBarColor,
strokeWidth: 2.5,
displacement: 60,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 20,
// ---------------- AppBar ----------------
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Advance Payments',
fontWeight: 700,
color: Colors.black,
),
child: Column(
children: [
// ===== SEARCH BAR FLOATING OVER GRADIENT =====
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: SizedBox(
height: 38,
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: (v) =>
controller.searchQuery.value = v.trim(),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 0),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: appBarColor, width: 1.5),
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
),
),
// ===== EMPLOYEE DROPDOWN =====
_buildEmployeeDropdown(context),
// ===== TOP BALANCE =====
_buildTopBalance(),
// ===== PAYMENTS LIST =====
_buildPaymentList(),
],
],
);
},
),
],
),
),
],
),
),
),
);
}
// ---------------- Search ----------------
Widget _buildSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 38,
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: (v) => controller.searchQuery.value = v.trim(),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
),
),
@ -324,6 +367,9 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
? DateFormat('dd MMM yyyy').format(parsedDate)
: (dateStr.isNotEmpty ? dateStr : '');
final formattedTime =
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
final project = item.name ?? '';
final desc = item.title ?? '';
final amount = (item.amount ?? 0).toDouble();
@ -353,6 +399,16 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
style:
TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
if (formattedTime.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
formattedTime,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontStyle: FontStyle.italic),
),
]
],
),
const SizedBox(height: 4),

View File

@ -6,14 +6,13 @@ import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dar
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_card.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class FinanceScreen extends StatefulWidget {
const FinanceScreen({super.key});
@ -53,116 +52,132 @@ class _FinanceScreenState extends State<FinanceScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
appBar: CustomAppBar(
title: "Finance",
onBackPressed: () => Get.offAllNamed( '/dashboard' ),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// Top fade under AppBar
Container(
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Bottom fade (above system buttons or FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
),
),
),
// Main scrollable content
SafeArea(
top: false,
bottom: true,
child: FadeTransition(
opacity: _fadeAnimation,
child: Obx(() {
if (menuController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (menuController.hasError.value ||
menuController.menuItems.isEmpty) {
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
style: TextStyle(color: Colors.red),
),
);
}
final financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
MenuItems.advancePaymentStatements,
];
final financeMenus = menuController.menuItems
.where((m) => financeMenuIds.contains(m.id) && m.available)
.toList();
if (financeMenus.isEmpty) {
return const Center(
child: Text(
"You dont have access to the Finance section.",
style: TextStyle(color: Colors.grey),
),
);
}
final double bottomInset =
MediaQuery.of(context).viewPadding.bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
bottomInset + 24,
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildFinanceModulesCompact(financeMenus),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
MyText.titleLarge(
'Finance',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
);
}),
),
],
),
),
],
),
),
body: SafeArea(
top: false, // keep appbar area same
bottom: true, // avoid system bottom buttons
child: FadeTransition(
opacity: _fadeAnimation,
child: Obx(() {
if (menuController.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (menuController.hasError.value ||
menuController.menuItems.isEmpty) {
return const Center(
child: Text(
"Failed to load menus. Please try again later.",
style: TextStyle(color: Colors.red),
),
);
}
final financeMenuIds = [
MenuItems.expenseReimbursement,
MenuItems.paymentRequests,
MenuItems.advancePaymentStatements,
];
final financeMenus = menuController.menuItems
.where((m) => financeMenuIds.contains(m.id) && m.available)
.toList();
if (financeMenus.isEmpty) {
return const Center(
child: Text(
"You dont have access to the Finance section.",
style: TextStyle(color: Colors.grey),
),
);
}
// ---- IMPORTANT FIX: Add bottom safe padding ----
final double bottomInset =
MediaQuery.of(context).viewPadding.bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
bottomInset +
24, // ensures charts never go under system buttons
),
child: Column(
children: [
_buildFinanceModulesCompact(financeMenus),
MySpacing.height(24),
ExpenseByStatusWidget(controller: dashboardController),
MySpacing.height(24),
ExpenseTypeReportChart(),
MySpacing.height(24),
MonthlyExpenseDashboardChart(),
],
),
);
}),
),
),
);
}

View File

@ -21,7 +21,6 @@ import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/finance/payment_request_rembursement_bottom_sheet.dart';
import 'package:on_field_work/model/finance/make_expense_bottom_sheet.dart';
import 'package:on_field_work/model/finance/add_payment_request_bottom_sheet.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class PaymentRequestDetailScreen extends StatefulWidget {
final String paymentRequestId;
@ -108,101 +107,76 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: CustomAppBar(
title: "Payment Request Details",
backgroundColor: appBarColor,
),
body: Stack(
children: [
// ===== TOP GRADIENT =====
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
appBar: _buildAppBar(),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value &&
controller.paymentRequest.value == null) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
}
final request = controller.paymentRequest.value;
if ((controller.errorMessage.value).isNotEmpty) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
if (request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
),
),
// ===== MAIN CONTENT =====
SafeArea(
child: Obx(() {
if (controller.isLoading.value &&
controller.paymentRequest.value == null) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
}
final request = controller.paymentRequest.value;
if ((controller.errorMessage.value).isNotEmpty) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
if (request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
request: request,
colorParser: _parseColor,
employeeInfo: employeeInfo,
onEdit: () =>
_openEditPaymentRequestBottomSheet(request),
),
const Divider(height: 30, thickness: 1.2),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
request: request,
colorParser: _parseColor,
employeeInfo: employeeInfo,
onEdit: () =>
_openEditPaymentRequestBottomSheet(request),
),
),
const Divider(height: 30, thickness: 1.2),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
),
),
),
),
);
}),
),
],
),
),
);
}),
),
bottomNavigationBar: _buildBottomActionBar(),
);
@ -217,9 +191,11 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return const SizedBox.shrink();
}
if (!_checkedPermission && request != null && employeeInfo != null) {
if (!_checkedPermission) {
_checkedPermission = true;
_checkPermissionToSubmit(request);
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(request);
});
}
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
@ -293,20 +269,15 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
comment: comment.trim(),
);
if (!success) {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status',
type: SnackbarType.error,
);
return;
}
showAppSnackbar(
title: 'Success',
message: 'Status updated successfully',
type: SnackbarType.success,
title: success ? 'Success' : 'Error',
message: success
? 'Status updated successfully'
: 'Failed to update status',
type: success ? SnackbarType.success : SnackbarType.error,
);
if (success) await controller.fetchPaymentRequestDetail();
}
},
child: MyText.bodySmall(
@ -321,6 +292,65 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
);
});
}
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Request Details',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
);
}
}
class PaymentRequestPermissionHelper {

View File

@ -13,8 +13,6 @@ import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class PaymentRequestMainScreen extends StatefulWidget {
const PaymentRequestMainScreen({super.key});
@ -98,80 +96,53 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: Colors.white,
appBar: CustomAppBar(
title: "Payment Requests",
onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor,
),
body: Stack(
children: [
// === FULL GRADIENT BEHIND APPBAR & TABBAR ===
Positioned.fill(
child: Column(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
Expanded(
child: Container(color: Colors.grey[100]),
),
],
),
),
appBar: _buildAppBar(),
// === MAIN CONTENT ===
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: const ["Current Month", "History"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
// CONTENT AREA
Expanded(
child: Container(
color: Colors.transparent,
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaymentRequestList(isHistory: false),
_buildPaymentRequestList(isHistory: true),
],
),
),
],
),
),
),
],
// ------------------------
// FIX: SafeArea prevents content from going under 3-button navbar
// ------------------------
body: SafeArea(
bottom: true,
child: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
tabs: const [
Tab(text: "Current Month"),
Tab(text: "History"),
],
),
),
),
],
Expanded(
child: Container(
color: Colors.grey[100],
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaymentRequestList(isHistory: false),
_buildPaymentRequestList(isHistory: true),
],
),
),
],
),
),
),
],
),
),
floatingActionButton: Obx(() {
if (permissionController.permissions.isEmpty) {
return const SizedBox.shrink();
@ -195,6 +166,67 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
);
}
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Requests',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0),

View File

@ -1,377 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/utils/launcher_utils.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
import 'package:on_field_work/controller/infra_project/infra_project_screen_details_controller.dart';
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
class InfraProjectDetailsScreen extends StatefulWidget {
final String projectId;
final String? projectName;
const InfraProjectDetailsScreen({
super.key,
required this.projectId,
this.projectName,
});
@override
State<InfraProjectDetailsScreen> createState() =>
_InfraProjectDetailsScreenState();
}
class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
with SingleTickerProviderStateMixin, UIMixin {
late final TabController _tabController;
final DynamicMenuController menuController =
Get.find<DynamicMenuController>();
final List<_InfraTab> _tabs = [];
@override
void initState() {
super.initState();
_prepareTabs();
}
void _prepareTabs() {
// Profile tab is always added
_tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab()));
final allowedMenu = menuController.menuItems.where((m) => m.available);
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
_tabs.add(
_InfraTab(
name: "Task Planning",
view: DailyTaskPlanningScreen(projectId: widget.projectId),
),
);
}
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
_tabs.add(
_InfraTab(
name: "Task Progress",
view: DailyProgressReportScreen(projectId: widget.projectId),
),
);
}
_tabController = TabController(length: _tabs.length, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget _buildProfileTab() {
final controller =
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.isNotEmpty) {
return Center(child: Text(controller.errorMessage.value));
}
final data = controller.projectDetails.value;
if (data == null) {
return const Center(child: Text("No project data available"));
}
return MyRefreshIndicator(
onRefresh: controller.fetchProjectDetails,
backgroundColor: Colors.indigo,
color: Colors.white,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildHeaderCard(data),
MySpacing.height(16),
_buildProjectInfoSection(data),
if (data.promoter != null) MySpacing.height(12),
if (data.promoter != null) _buildPromoterInfo(data.promoter!),
if (data.pmc != null) MySpacing.height(12),
if (data.pmc != null) _buildPMCInfo(data.pmc!),
MySpacing.height(40),
],
),
),
);
});
}
Widget _buildHeaderCard(dynamic data) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.work_outline, size: 35),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(data.name ?? "-", fontWeight: 700),
MySpacing.height(6),
MyText.bodySmall(data.shortName ?? "-", fontWeight: 500),
],
),
),
],
),
),
);
}
Widget _buildProjectInfoSection(dynamic data) {
return _buildSectionCard(
title: 'Project Information',
titleIcon: Icons.info_outline,
children: [
_buildDetailRow(
icon: Icons.location_on_outlined,
label: 'Address',
value: data.projectAddress ?? "-"),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'Start Date',
value: data.startDate != null
? DateFormat('d/M/yyyy').format(data.startDate!)
: "-"),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'End Date',
value: data.endDate != null
? DateFormat('d/M/yyyy').format(data.endDate!)
: "-"),
_buildDetailRow(
icon: Icons.flag_outlined,
label: 'Status',
value: data.projectStatus?.status ?? "-"),
_buildDetailRow(
icon: Icons.person_outline,
label: 'Contact Person',
value: data.contactPerson ?? "-",
isActionable: true,
onTap: () {
if (data.contactPerson != null) {
LauncherUtils.launchPhone(data.contactPerson!);
}
}),
],
);
}
Widget _buildPromoterInfo(dynamic promoter) {
return _buildSectionCard(
title: 'Promoter Information',
titleIcon: Icons.business_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline,
label: 'Name',
value: promoter.name ?? "-"),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact',
value: promoter.contactNumber ?? "-",
isActionable: true,
onTap: () =>
LauncherUtils.launchPhone(promoter.contactNumber ?? "")),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Email',
value: promoter.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(promoter.email ?? "")),
],
);
}
Widget _buildPMCInfo(dynamic pmc) {
return _buildSectionCard(
title: 'PMC Information',
titleIcon: Icons.engineering_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact',
value: pmc.contactNumber ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? "")),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Email',
value: pmc.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(pmc.email ?? "")),
],
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
VoidCallback? onTap,
bool isActionable = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: InkWell(
onTap: isActionable ? onTap : null,
borderRadius: BorderRadius.circular(5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)),
MySpacing.width(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
label,
fontSize: 12,
color: Colors.grey[600],
fontWeight: 500,
),
MySpacing.height(4),
MyText.bodyMedium(
value,
fontSize: 15,
fontWeight: 500,
color: isActionable ? Colors.blueAccent : Colors.black87,
decoration: isActionable
? TextDecoration.underline
: TextDecoration.none,
),
],
),
),
],
),
),
);
}
Widget _buildSectionCard({
required String title,
required IconData titleIcon,
required List<Widget> children,
}) {
return Card(
elevation: 2,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(titleIcon, size: 20),
MySpacing.width(8),
MyText.bodyLarge(
title,
fontSize: 16,
fontWeight: 700,
color: Colors.black87,
),
],
),
MySpacing.height(8),
const Divider(),
...children,
],
),
),
);
}
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar(
title: "Infra Projects",
onBackPressed: () => Get.back(),
projectName: widget.projectName,
backgroundColor: appBarColor,
),
body: Stack(
children: [
Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [appBarColor, appBarColor.withOpacity(0)],
),
),
),
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e.name).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
),
Expanded(
child: TabBarView(
controller: _tabController,
children: _tabs.map((e) => e.view).toList(),
),
),
],
),
),
],
),
);
}
}
/// INTERNAL MODEL
class _InfraTab {
final String name;
final Widget view;
_InfraTab({required this.name, required this.view});
}

View File

@ -1,278 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/controller/infra_project/infra_project_screen_controller.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
class InfraProjectScreen extends StatefulWidget {
const InfraProjectScreen({super.key});
@override
State<InfraProjectScreen> createState() => _InfraProjectScreenState();
}
class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
final TextEditingController searchController = TextEditingController();
final InfraProjectController controller = Get.put(InfraProjectController());
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchProjects();
});
searchController.addListener(() {
controller.updateSearch(searchController.text);
});
}
Future<void> _refreshProjects() async {
await controller.fetchProjects();
}
// ---------------------------------------------------------------------------
// PROJECT CARD
// ---------------------------------------------------------------------------
Widget _buildProjectCard(ProjectData project) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
shadowColor: Colors.indigo.withOpacity(0.10),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () {
Get.to(() => InfraProjectDetailsScreen(projectId: project.id!, projectName: project.name));
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TOP: Name + Status
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: MyText.titleMedium(
project.name ?? "-",
fontWeight: 700,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(8),
],
),
MySpacing.height(10),
if (project.shortName != null)
_buildDetailRow(
Icons.badge_outlined,
Colors.teal,
"Short Name: ${project.shortName}",
),
MySpacing.height(8),
if (project.projectAddress != null)
_buildDetailRow(
Icons.location_on_outlined,
Colors.orange,
"Address: ${project.projectAddress}",
),
MySpacing.height(8),
if (project.contactPerson != null)
_buildDetailRow(
Icons.phone,
Colors.green,
"Contact: ${project.contactPerson}",
),
MySpacing.height(12),
if (project.teamSize != null)
_buildDetailRow(
Icons.group,
Colors.indigo,
"Team Size: ${project.teamSize}",
),
],
),
),
),
);
}
Widget _buildDetailRow(IconData icon, Color color, String value) {
return Row(
children: [
Icon(icon, size: 18, color: color),
MySpacing.width(8),
Expanded(
child: MyText.bodySmall(
value,
color: Colors.grey[900],
fontWeight: 500,
fontSize: 13,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
],
);
}
// ---------------------------------------------------------------------------
// EMPTY STATE
// ---------------------------------------------------------------------------
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.work_outline, size: 60, color: Colors.grey),
MySpacing.height(18),
MyText.titleMedium(
'No matching projects found.',
fontWeight: 600,
color: Colors.grey,
),
MySpacing.height(10),
MyText.bodySmall(
'Try adjusting your filters or refresh.',
color: Colors.grey,
),
],
),
);
}
// ---------------------------------------------------------------------------
// MAIN BUILD
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Infra Projects",
projectName: 'All Infra Projects',
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: Stack(
children: [
// GRADIENT BACKDROP
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0),
],
),
),
),
SafeArea(
bottom: true,
child: Column(
children: [
// SEARCH BAR
Padding(
padding: MySpacing.xy(8, 8),
child: SizedBox(
height: 35,
child: TextField(
controller: searchController,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
controller.updateSearch("");
},
);
},
),
hintText: "Search projects...",
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
),
// LIST
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects;
return MyRefreshIndicator(
onRefresh: _refreshProjects,
backgroundColor: Colors.indigo,
color: Colors.white,
child: projects.isEmpty
? _buildEmptyState()
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 100),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
),
),
],
),
);
}
}

View File

@ -7,9 +7,8 @@ import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/images.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget {
final Widget? child;
@ -21,10 +20,11 @@ class Layout extends StatefulWidget {
State<Layout> createState() => _LayoutState();
}
class _LayoutState extends State<Layout> with UIMixin {
class _LayoutState extends State<Layout> {
final LayoutController controller = LayoutController();
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
final projectController = Get.find<ProjectController>();
bool hasMpin = true;
@ -58,166 +58,374 @@ class _LayoutState extends State<Layout> with UIMixin {
}
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
final primaryColor = contentTheme.primary;
return Scaffold(
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: Column(
children: [
// Solid primary background area
Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(isMobile),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {},
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (projectController.isProjectSelectionExpanded.value) {
projectController.isProjectSelectionExpanded.value = false;
}
},
child: Stack(
children: [
Column(
children: [
_buildHeader(context, isMobile),
Expanded(
child: SingleChildScrollView(
key: controller.scrollKey,
padding: EdgeInsets.zero,
padding: EdgeInsets.symmetric(
horizontal: 0, vertical: isMobile ? 16 : 32),
child: widget.child,
),
),
),
],
),
),
],
));
_buildProjectDropdown(context, isMobile),
],
),
),
),
);
}
Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant;
/// Header Section
Widget _buildHeader(BuildContext context, bool isMobile) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
child: Container(
margin: const EdgeInsets.only(bottom: 18),
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
// Logo section
Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
Images.logoDark,
height: 50,
width: 50,
fit: BoxFit.contain,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Obx(() {
final isLoading = projectController.isLoading.value;
// Beta badge
if (ApiEndpoints.baseUrl.contains("stage"))
Positioned(
bottom: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white, width: 1.2),
),
child: const Text(
'B',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
if (isLoading) {
return _buildLoadingSkeleton();
}
final isExpanded = projectController.isProjectSelectionExpanded.value;
final selectedProjectId = projectController.selectedProjectId.value;
final selectedProject = projectController.projects.firstWhereOrNull(
(p) => p.id == selectedProjectId,
);
final hasProjects = projectController.projects.isNotEmpty;
if (!hasProjects) {
projectController.selectedProjectId.value = '';
} else if (selectedProject == null) {
projectController
.updateSelectedProject(projectController.projects.first.id);
}
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Stack(
clipBehavior: Clip.none,
children: [
Image.asset(
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),
Expanded(
child: hasProjects
? (projectController.projects.length > 1
? GestureDetector(
onTap: () => projectController
.isProjectSelectionExpanded
.toggle(),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: MyText.bodyLarge(
selectedProject?.name ??
"Select Project",
fontWeight: 700,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
),
),
Icon(
isExpanded
? Icons
.arrow_drop_up_outlined
: Icons
.arrow_drop_down_outlined,
color: Colors.black,
),
],
),
),
],
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
selectedProject?.name ?? "No Project",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
))
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"No Project Assigned",
fontWeight: 700,
color: Colors.redAccent,
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
),
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu),
onPressed: () => controller.scaffoldKey.currentState
?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
right: 10,
top: 10,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border:
Border.all(color: Colors.white, width: 2),
),
),
),
],
)
],
),
),
if (isExpanded && hasProjects)
Positioned(
top: 70,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(5),
color: Colors.white,
child: _buildProjectList(context, isMobile),
),
],
),
],
),
);
}),
);
}
/// Loading Skeleton for Header
Widget _buildLoadingSkeleton() {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Container(
height: 50,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
const SizedBox(width: 12),
// Titles
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"Dashboard",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: Colors.black87,
Container(
height: 18,
width: 140,
color: Colors.grey.shade300,
),
const SizedBox(height: 6),
Container(
height: 14,
width: 100,
color: Colors.grey.shade200,
),
if (selectedTenant != null)
MyText.bodySmall(
"Organization: ${selectedTenant.name}",
color: Colors.black54,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Menu button with red dot if MPIN missing
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.menu, color: Colors.black87),
onPressed: () =>
controller.scaffoldKey.currentState?.openEndDrawer(),
),
if (!hasMpin)
Positioned(
right: 10,
top: 10,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
const SizedBox(width: 10),
Container(
height: 30,
width: 30,
color: Colors.grey.shade300,
),
],
),
),
);
}
/// Project List Popup
Widget _buildProjectDropdown(BuildContext context, bool isMobile) {
return Obx(() {
if (!projectController.isProjectSelectionExpanded.value) {
return const SizedBox.shrink();
}
return Positioned(
top: 95,
left: 16,
right: 16,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(5),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.all(10),
child: _buildProjectList(context, isMobile),
),
),
);
});
}
Widget _buildProjectList(BuildContext context, bool isMobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Switch Project", fontWeight: 600),
const SizedBox(height: 4),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
isMobile ? MediaQuery.of(context).size.height * 0.4 : 400,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: projectController.projects.length,
itemBuilder: (context, index) {
final project = projectController.projects[index];
final selectedId = projectController.selectedProjectId.value;
final isSelected = project.id == selectedId;
return RadioListTile<String>(
value: project.id,
groupValue: selectedId,
onChanged: (value) {
projectController.updateSelectedProject(value!);
projectController.isProjectSelectionExpanded.value = false;
},
title: Text(
project.name,
style: TextStyle(
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.blueAccent : Colors.black87,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 0),
activeColor: Colors.blueAccent,
tileColor: isSelected
? Colors.blueAccent.withOpacity(0.1)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
visualDensity: const VisualDensity(vertical: -4),
);
},
),
),
],
);
}
}

View File

@ -247,43 +247,6 @@ class _UserProfileBarState extends State<UserProfileBar>
final tenants = tenantSwitchController.tenants;
if (tenants.isEmpty) return _noTenantContainer();
// If only one organization, don't show switch option
if (tenants.length == 1) {
final selectedTenant = tenants.first;
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: selectedTenant.logoImage),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
selectedTenant.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: contentTheme.primary),
),
),
const Icon(Icons.check_circle, color: Colors.green, size: 18),
],
),
);
}
final selectedTenant = TenantService.currentTenant;

View File

@ -14,9 +14,7 @@ import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/routes.dart';
class MyApp extends StatelessWidget {
final bool isOffline;
const MyApp({super.key, required this.isOffline});
const MyApp({super.key});
Future<String> _getInitialRoute() async {
try {
@ -42,62 +40,6 @@ class MyApp extends StatelessWidget {
}
}
// REVISED: Helper Widget to show a full-screen, well-designed offline status
Widget _buildConnectivityOverlay(BuildContext context) {
// If not offline, return an empty widget.
if (!isOffline) return const SizedBox.shrink();
// Otherwise, return a full-screen overlay.
return Directionality(
textDirection: AppTheme.textDirection,
child: Scaffold(
backgroundColor:
Colors.grey.shade100, // Light background for the offline state
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
color: Colors.red.shade700, // Prominent color
size: 100,
),
const SizedBox(height: 24),
const Text(
"You Are Offline",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 40.0),
child: Text(
"Please check your internet connection and try again.",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.black54,
),
),
),
const SizedBox(height: 32),
// Optional: Add a button for the user to potentially refresh/retry
// ElevatedButton(
// onPressed: () {
// // Add logic to re-check connectivity or navigate (if possible)
// },
// child: const Text("RETRY"),
// ),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<AppNotifier>(
@ -129,18 +71,9 @@ class MyApp extends StatelessWidget {
getPages: getPageRoute(),
builder: (context, child) {
NavigationService.registerContext(context);
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
// This allows the full-screen view to cover everything, including the main app content.
return Stack(
children: [
Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
),
// 2. The full-screen connectivity overlay, only visible when offline
_buildConnectivityOverlay(context),
],
return Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
);
},
localizationsDelegates: [

View File

@ -13,7 +13,6 @@ import 'package:on_field_work/model/service_project/service_project_allocation_b
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/view/service_project/jobs_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId;
@ -430,8 +429,6 @@ class _ServiceProjectDetailsScreenState
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
@ -439,68 +436,55 @@ class _ServiceProjectDetailsScreenState
projectName: widget.projectName,
onBackPressed: () => Get.toNamed('/dashboard/service-projects'),
),
body: Stack(
children: [
// === TOP FADE BELOW APPBAR ===
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
body: SafeArea(
child: Column(
children: [
// TabBar
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.red,
indicatorWeight: 3,
isScrollable: false,
tabs: [
Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")),
Tab(child: MyText.bodyMedium("Teams")),
],
),
),
),
SafeArea(
top: false,
bottom: true,
child: Column(
children: [
PillTabBar(
// TabBarView
Expanded(
child: Obx(() {
if (controller.isLoading.value &&
controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController,
tabs: const ["Profile", "Jobs", "Teams"],
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary.withOpacity(0.1),
height: 48,
),
// === TABBAR VIEW ===
Expanded(
child: Obx(() {
if (controller.isLoading.value &&
controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) {
return Center(
child:
MyText.bodyMedium(controller.errorMessage.value));
}
return TabBarView(
controller: _tabController,
children: [
_buildProfileTab(),
JobsTab(
scrollController: _jobScrollController,
projectName: widget.projectName ?? '',
),
_buildTeamsTab(),
],
);
}),
),
],
children: [
_buildProfileTab(),
JobsTab(
scrollController: _jobScrollController,
projectName: widget.projectName ?? '',
),
_buildTeamsTab(),
],
);
}),
),
),
],
],
),
),
floatingActionButton: _tabController.index == 1
? FloatingActionButton.extended(

View File

@ -18,8 +18,6 @@ import 'dart:io';
import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/model/service_project/job_status_response.dart';
import 'package:on_field_work/helpers/widgets/serviceProject/add_comment_widget.dart';
class JobDetailsScreen extends StatefulWidget {
final String jobId;
@ -49,15 +47,11 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
@override
void initState() {
super.initState();
controller = Get.find<ServiceProjectDetailsController>();
// Fetch job detail first
controller.fetchJobDetail(widget.jobId).then((_) async {
controller = Get.put(ServiceProjectDetailsController());
controller.fetchJobDetail(widget.jobId).then((_) {
final job = controller.jobDetail.value?.data;
if (job != null) {
// Populate form fields
_selectedTags.value =
(job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList();
_selectedTags.value = job.tags ?? [];
_titleController.text = job.title ?? '';
_descriptionController.text = job.description ?? '';
_startDateController.text = DateTimeUtils.convertUtcToLocal(
@ -67,21 +61,7 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
job.dueDate ?? '',
format: "yyyy-MM-dd");
_selectedAssignees.value = job.assignees ?? [];
// 🔹 Fetch job status only if existing status ID present
final existingStatusId = job.status?.id;
if (existingStatusId != null) {
await controller.fetchJobStatus(statusId: existingStatusId);
// Set selectedJobStatus to match existing status ID
if (controller.jobStatusList.isNotEmpty) {
controller.selectedJobStatus.value =
controller.jobStatusList.firstWhere(
(s) => s.id == existingStatusId,
orElse: () => controller.jobStatusList.first,
);
}
}
_selectedTags.value = job.tags ?? [];
}
});
}
@ -96,31 +76,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
super.dispose();
}
bool _tagsAreDifferent(List<Tag> original, List<Tag> current) {
// Compare by id / name sets (simple equality)
final origIds = original.map((t) => t.id ?? '').toSet();
final currIds = current.map((t) => t.id ?? '').toSet();
final origNames = original.map((t) => t.name?.trim() ?? '').toSet();
final currNames = current.map((t) => t.name?.trim() ?? '').toSet();
return !(origIds == currIds && origNames == currNames);
}
Future<void> _editJob() async {
_processTagsInput(); // process any new tag input
final job = controller.jobDetail.value?.data;
if (job == null) return;
final List<Map<String, dynamic>> operations = [];
// 1 Title
final trimmedTitle = _titleController.text.trim();
if (trimmedTitle != job.title) {
operations
.add({"op": "replace", "path": "/title", "value": trimmedTitle});
}
// 2 Description
final trimmedDescription = _descriptionController.text.trim();
if (trimmedDescription != job.description) {
operations.add({
@ -130,7 +97,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
});
}
// 3 Start & Due Date
final startDate = DateTime.tryParse(_startDateController.text);
final dueDate = DateTime.tryParse(_dueDateController.text);
@ -150,57 +116,41 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
});
}
// 4 Assignees
final originalAssignees = job.assignees ?? [];
final assigneesPayload = originalAssignees.map((a) {
final originalAssignees = job.assignees;
final assigneesPayload = originalAssignees?.map((a) {
final isSelected = _selectedAssignees.any((s) => s.id == a.id);
return {"employeeId": a.id, "isActive": isSelected};
}).toList();
for (var s in _selectedAssignees) {
if (!originalAssignees.any((a) => a.id == s.id)) {
assigneesPayload.add({"employeeId": s.id, "isActive": true});
if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) {
assigneesPayload?.add({"employeeId": s.id, "isActive": true});
}
}
operations.add(
{"op": "replace", "path": "/assignees", "value": assigneesPayload});
// 5 Tags
final originalTags = job.tags ?? [];
final currentTags = _selectedTags.toList();
if (_tagsAreDifferent(originalTags, currentTags)) {
final List<Map<String, dynamic>> finalTagsPayload = [];
final originalTags = job.tags;
final replaceTagsPayload = originalTags?.map((t) {
final isSelected = _selectedTags.any((s) => s.id == t.id);
return {"id": t.id, "name": t.name, "isActive": isSelected};
}).toList();
for (var ot in originalTags) {
final isSelected = currentTags.any((ct) =>
(ct.id != null && ct.id == ot.id) ||
(ct.name?.trim() == ot.name?.trim()));
finalTagsPayload.add({
"id": ot.id,
"name": ot.name,
"isActive": isSelected,
});
}
for (var ct in currentTags.where((c) => c.id == null || c.id == "0")) {
finalTagsPayload.add({"name": ct.name, "isActive": true});
}
final addTagsPayload = _selectedTags
.where((t) => t.id == "0")
.map((t) => {"name": t.name, "isActive": true})
.toList();
if ((replaceTagsPayload?.isNotEmpty ?? false)) {
operations
.add({"op": "replace", "path": "/tags", "value": finalTagsPayload});
.add({"op": "replace", "path": "/tags", "value": replaceTagsPayload});
}
// 6 Job Status
final selectedStatus = controller.selectedJobStatus.value;
if (selectedStatus != null && selectedStatus.id != job.status?.id) {
operations.add({
"op": "replace",
"path": "/statusId", // make sure API expects this field
"value": selectedStatus.id
});
if (addTagsPayload.isNotEmpty) {
operations.add({"op": "add", "path": "/tags", "value": addTagsPayload});
}
// 7 Check if anything changed
if (operations.isEmpty) {
showAppSnackbar(
title: "Info",
@ -209,7 +159,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
return;
}
// 8 Call API
final success = await ApiService.editServiceProjectJobApi(
jobId: job.id ?? "",
operations: operations,
@ -220,15 +169,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
title: "Success",
message: "Job updated successfully",
type: SnackbarType.success);
// Re-fetch job detail & update tags locally
await controller.fetchJobDetail(widget.jobId);
final updatedJob = controller.jobDetail.value?.data;
if (updatedJob != null) {
_selectedTags.value = (updatedJob.tags ?? [])
.map((t) => Tag(id: t.id, name: t.name))
.toList();
setState(() {});
_selectedTags.value = updatedJob.tags ?? [];
}
isEditing.value = false;
@ -240,29 +184,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}
}
void _processTagsInput() {
final input = _tagTextController.text;
// Remove comma behaviour treat whole input as one tag
String tag = input.trim();
if (tag.isEmpty) {
_tagTextController.clear();
return;
}
// Convert underscore to space
tag = tag.replaceAll("_", " ");
// Avoid duplicate tags (case-insensitive)
if (!_selectedTags
.any((t) => (t.name ?? "").toLowerCase() == tag.toLowerCase())) {
_selectedTags.add(Tag(id: "0", name: tag));
}
// Clear text field
_tagTextController.clear();
}
Future<void> _handleTagAction() async {
final job = controller.jobDetail.value?.data;
if (job == null) return;
@ -487,8 +408,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
border: Border.all(color: Colors.grey.shade400),
),
alignment: Alignment.centerLeft,
child: Text("Tap to select assignees",
style: TextStyle(fontSize: 14, color: Colors.grey[700])),
child: Text(
"Tap to select assignees",
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
),
),
],
@ -499,24 +422,19 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Widget _tagEditor() {
return Obx(() {
final editing = isEditing.value;
final job = controller.jobDetail.value?.data;
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
final tags = _selectedTags;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: displayTags
children: tags
.map(
(t) => Chip(
label: Text(t.name ?? ''),
onDeleted: editing
? () {
_selectedTags.removeWhere((x) =>
(x.id != null && x.id == t.id) ||
(x.name == t.name));
_selectedTags.remove(t);
}
: null,
),
@ -527,21 +445,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (editing)
TextField(
controller: _tagTextController,
onChanged: (value) {
// If space or comma typed process tags immediately
if (value.endsWith(" ") || value.contains(",")) {
_processTagsInput();
onSubmitted: (v) {
final value = v.trim();
if (value.isNotEmpty && !tags.any((t) => t.name == value)) {
_selectedTags.add(Tag(id: "0", name: value));
}
},
onSubmitted: (_) {
// Still supports ENTER
_processTagsInput();
_tagTextController.clear();
},
decoration: InputDecoration(
hintText: "Type tags (space or comma to add multiple tags)",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
hintText: "Type and press enter to add tags",
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(5)),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
@ -581,10 +495,11 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
const Spacer(),
Obx(() => IconButton(
icon: Icon(
isAttendanceExpanded.value
? Icons.expand_less
: Icons.expand_more,
color: Colors.grey[600]),
isAttendanceExpanded.value
? Icons.expand_less
: Icons.expand_more,
color: Colors.grey[600],
),
onPressed: () async {
isAttendanceExpanded.value =
!isAttendanceExpanded.value;
@ -611,17 +526,22 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
height: 16,
width: 16,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2))
color: Colors.white,
strokeWidth: 2,
),
)
: Icon(action == 0 ? Icons.login : Icons.logout),
label: MyText.bodyMedium(
action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600,
color: Colors.white),
action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600,
color: Colors.white,
),
onPressed: isLoading ? null : _handleTagAction,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
borderRadius: BorderRadius.circular(5),
),
backgroundColor:
action == 0 ? Colors.green : Colors.red,
),
@ -643,8 +563,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (logs.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: MyText.bodyMedium("No attendance logs available",
color: Colors.grey[600]),
child: MyText.bodyMedium(
"No attendance logs available",
color: Colors.grey[600],
),
);
}
@ -679,21 +601,25 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Row(
children: [
Icon(
log.action == 0
? Icons.login
: Icons.logout,
color: log.action == 0
? Colors.green
: Colors.red,
size: 18),
log.action == 0 ? Icons.login : Icons.logout,
color: log.action == 0
? Colors.green
: Colors.red,
size: 18,
),
const SizedBox(width: 6),
Expanded(
child: Text(employeeName,
style: const TextStyle(
fontWeight: FontWeight.w600))),
Text("$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700])),
child: Text(
employeeName,
style: const TextStyle(
fontWeight: FontWeight.w600),
),
),
Text(
"$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
],
),
const SizedBox(height: 4),
@ -701,9 +627,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
// Comment
if (log.comment?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(log.comment!,
style: const TextStyle(fontSize: 13))),
padding: const EdgeInsets.only(top: 4),
child: Text(
log.comment!,
style: const TextStyle(fontSize: 13),
),
),
// Location
if (log.latitude != null && log.longitude != null)
@ -728,12 +657,14 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Icon(Icons.location_on,
size: 14, color: Colors.blue),
SizedBox(width: 4),
Text("View Location",
style: TextStyle(
fontSize: 12,
color: Colors.blue,
decoration:
TextDecoration.underline)),
Text(
"View Location",
style: TextStyle(
fontSize: 12,
color: Colors.blue,
decoration:
TextDecoration.underline),
),
],
),
),
@ -748,13 +679,16 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
context: context,
builder: (_) => Dialog(
child: Image.network(
log.document!.preSignedUrl,
fit: BoxFit.cover,
height: 250,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 50,
color: Colors.grey)),
log.document!.preSignedUrl,
fit: BoxFit.cover,
height: 250,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.broken_image,
size: 50,
color: Colors.grey,
),
),
),
),
child: ClipRRect(
@ -767,9 +701,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
width: 50,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 40,
color: Colors.grey),
Icons.broken_image,
size: 40,
color: Colors.grey,
),
),
),
),
@ -793,10 +728,14 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700)),
Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)),
flex: 3,
child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700),
),
Expanded(
flex: 5,
child: MyText.bodyMedium(value, fontWeight: 500),
),
],
);
}
@ -818,144 +757,20 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
);
}
Widget _buildJobStatusCard() {
final job = controller.jobDetail.value?.data;
if (job == null) return const SizedBox();
// Existing status info
final statusName = job.status?.displayName ?? "N/A";
Color statusColor;
switch (job.status?.level) {
case 1:
statusColor = Colors.green;
break;
case 2:
statusColor = Colors.orange;
break;
case 3:
statusColor = Colors.blue;
break;
case 4:
statusColor = Colors.red;
break;
default:
statusColor = Colors.grey;
}
final editing = isEditing.value;
// Ensure selectedJobStatus initialized
if (editing && controller.selectedJobStatus.value == null) {
final existingStatusId = job.status?.id;
if (existingStatusId != null && controller.jobStatusList.isNotEmpty) {
controller.selectedJobStatus.value =
controller.jobStatusList.firstWhere(
(s) => s.id == existingStatusId,
orElse: () => controller.jobStatusList.first,
);
}
}
return _buildSectionCard(
title: "Job Status",
titleIcon: Icons.flag_outlined,
children: [
// 1 Display existing status
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(Icons.flag, color: statusColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: statusColor),
),
const SizedBox(height: 2),
Text(
"Level: ${job.status?.level ?? '-'}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
),
],
),
const SizedBox(height: 16),
// 2 PopupMenuButton for new selection
if (editing)
Obx(() {
final selectedStatus = controller.selectedJobStatus.value;
final statuses = controller.jobStatusList;
return PopupMenuButton<JobStatus>(
onSelected: (val) => controller.selectedJobStatus.value = val,
itemBuilder: (_) => statuses
.map(
(s) => PopupMenuItem(
value: s,
child: Text(s.displayName ?? "N/A"),
),
)
.toList(),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
selectedStatus?.displayName ?? "Select Job Status",
style:
TextStyle(color: Colors.grey.shade700, fontSize: 14),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}),
],
);
}
@override
Widget build(BuildContext context) {
final projectName = widget.projectName;
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Job Details Screen",
onBackPressed: () => Get.back(),
projectName: projectName,
backgroundColor: appBarColor,
),
floatingActionButton: Obx(() => FloatingActionButton.extended(
onPressed:
isEditing.value ? _editJob : () => isEditing.value = true,
backgroundColor: appBarColor,
backgroundColor: contentTheme.primary,
label: MyText.bodyMedium(
isEditing.value ? "Save" : "Edit",
color: Colors.white,
@ -963,115 +778,63 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
),
icon: Icon(isEditing.value ? Icons.save : Icons.edit),
)),
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
body: Obx(() {
if (controller.isJobDetailLoading.value) {
return const Center(child: CircularProgressIndicator());
}
// Bottom fade (for smooth transition above FAB)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 60, // adjust based on FAB height
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
appBarColor.withOpacity(0.05),
Colors.transparent,
],
),
),
),
),
if (controller.jobDetailErrorMessage.value.isNotEmpty) {
return Center(
child: MyText.bodyMedium(controller.jobDetailErrorMessage.value));
}
// Main scrollable content
Obx(() {
if (controller.isJobDetailLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final job = controller.jobDetail.value?.data;
if (job == null) {
return Center(child: MyText.bodyMedium("No details available"));
}
if (controller.jobDetailErrorMessage.value.isNotEmpty) {
return Center(
child: MyText.bodyMedium(
controller.jobDetailErrorMessage.value));
}
final job = controller.jobDetail.value?.data;
if (job == null) {
return Center(child: MyText.bodyMedium("No details available"));
}
return SingleChildScrollView(
padding: MySpacing.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return SingleChildScrollView(
padding: MySpacing.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_buildJobStatusCard(),
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",
titleIcon: Icons.task_outlined,
children: [
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
],
),
MySpacing.height(12),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_buildSectionCard(
title: "Assignees",
titleIcon: Icons.person_outline,
children: [_assigneeInputWithChips()]),
MySpacing.height(16),
_buildSectionCard(
title: "Tags",
titleIcon: Icons.label_outline,
children: [_tagEditor()]),
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Update Logs",
titleIcon: Icons.history,
children: [JobTimeline(logs: job.updateLogs ?? [])]),
// NEW CARD ADDED HERE
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Comment Section",
titleIcon: Icons.comment_outlined,
children: [
AddCommentWidget(
jobId: job.id ?? "",
jobTicketId: job.jobTicketUId ?? ""),
]),
// END NEW CARD
MySpacing.height(80),
_editableRow("Title", _titleController),
_editableRow("Description", _descriptionController),
_dateRangePicker(),
],
),
);
}),
],
),
MySpacing.height(12),
_buildSectionCard(
title: "Project Branch",
titleIcon: Icons.account_tree_outlined,
children: [_branchDisplay()],
),
MySpacing.height(16),
_buildSectionCard(
title: "Assignees",
titleIcon: Icons.person_outline,
children: [_assigneeInputWithChips()]),
MySpacing.height(16),
_buildSectionCard(
title: "Tags",
titleIcon: Icons.label_outline,
children: [_tagEditor()]),
MySpacing.height(16),
if ((job.updateLogs?.isNotEmpty ?? false))
_buildSectionCard(
title: "Update Logs",
titleIcon: Icons.history,
children: [JobTimeline(logs: job.updateLogs ?? [])]),
MySpacing.height(80),
],
),
);
}),
);
}
}
@ -1108,11 +871,12 @@ class JobTimeline extends StatelessWidget {
isFirst: index == 0,
isLast: index == reversedLogs.length - 1,
indicatorStyle: const IndicatorStyle(
width: 16,
height: 16,
indicator: DecoratedBox(
decoration: BoxDecoration(
color: Colors.blue, shape: BoxShape.circle))),
width: 16,
height: 16,
indicator: DecoratedBox(
decoration:
BoxDecoration(color: Colors.blue, shape: BoxShape.circle)),
),
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding(
padding: const EdgeInsets.all(12),
@ -1127,12 +891,13 @@ class JobTimeline extends StatelessWidget {
const SizedBox(height: 10),
Row(children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600)),
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600),
),
const SizedBox(width: 6),
Expanded(child: MyText.bodySmall(updatedBy)),
]),

View File

@ -181,115 +181,99 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
@override
Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar(
title: "Service Projects",
projectName: 'All Service Projects',
backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard'),
),
body: Stack(
children: [
Container(
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
appBarColor,
appBarColor.withOpacity(0.0),
],
),
),
),
// Main content
SafeArea(
bottom: true,
child: Column(
children: [
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 35,
child: TextField(
controller: searchController,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
suffixIcon:
ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty)
return const SizedBox.shrink();
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
controller.updateSearch('');
},
);
// FIX 1: Entire body wrapped in SafeArea
body: SafeArea(
bottom: true,
child: Column(
children: [
Padding(
padding: MySpacing.xy(8, 8),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 35,
child: TextField(
controller: searchController,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
prefixIcon: const Icon(Icons.search,
size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
if (value.text.isEmpty) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
searchController.clear();
controller.updateSearch('');
},
),
hintText: 'Search projects...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide:
BorderSide(color: Colors.grey.shade300),
),
),
);
},
),
hintText: 'Search projects...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
],
),
),
),
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects;
return MyRefreshIndicator(
onRefresh: _refreshProjects,
backgroundColor: Colors.indigo,
color: Colors.white,
child: projects.isEmpty
? _buildEmptyState()
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 120),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
],
),
),
),
],
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final projects = controller.filteredProjects;
return MyRefreshIndicator(
onRefresh: _refreshProjects,
backgroundColor: Colors.indigo,
color: Colors.white,
child: projects.isEmpty
? _buildEmptyState()
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
// FIX 2: Increased bottom padding for landscape
padding: MySpacing.only(
left: 8, right: 8, top: 4, bottom: 120),
itemCount: projects.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) =>
_buildProjectCard(projects[index]),
),
);
}),
),
],
),
),
);
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
// Assuming 'package:on_field_work/images.dart' correctly provides 'Images.logoDark'
import 'package:on_field_work/images.dart';
class SplashScreen extends StatefulWidget {
@ -9,9 +8,8 @@ class SplashScreen extends StatefulWidget {
const SplashScreen({
super.key,
this.message =
'GET WORK DONE, ANYWHERE.', // Default message for a modern look
this.logoSize = 150, // Slightly larger logo
this.message,
this.logoSize = 120,
this.backgroundColor = Colors.white,
});
@ -22,59 +20,20 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
// Animation for the logo's vertical float effect
late Animation<double> _floatAnimation;
// Animation for logo's initial scale-in
late Animation<double> _scaleAnimation;
// Animation for logo and text fade-in
late Animation<double> _opacityAnimation;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration:
const Duration(seconds: 3), // Longer duration for complex sequence
duration: const Duration(seconds: 1),
vsync: this,
);
)..repeat(reverse: true);
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration)
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve:
const Interval(0.0, 0.4, curve: Curves.easeOutBack), // Bouncy start
),
_animation = Tween<double>(begin: 0.0, end: 8.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
// Overall fade-in: from 0.0 to 1.0 (happens in the first 50% of the duration)
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeIn),
),
);
// Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations)
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
// Start the complex animation sequence
_controller.forward().then((_) {
// After the initial scale/fade, switch to repeating the float animation
if (mounted) {
_controller.repeat(
min: 0.4, // Start repeat from the float interval
max: 1.0,
reverse: true,
);
}
});
}
@override
@ -83,14 +42,33 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose();
}
// A simple, modern custom progress indicator
Widget _buildProgressIndicator() {
return SizedBox(
width: 60,
child: LinearProgressIndicator(
backgroundColor: Colors.blueAccent.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blueAccent),
),
Widget _buildAnimatedDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
double opacity;
if (index == 0) {
opacity = (0.3 + _animation.value / 8).clamp(0.0, 1.0);
} else if (index == 1) {
opacity = (0.3 + (_animation.value / 8)).clamp(0.0, 1.0);
} else {
opacity = (0.3 + (1 - _animation.value / 8)).clamp(0.0, 1.0);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(opacity),
shape: BoxShape.circle,
),
);
},
);
}),
);
}
@ -98,56 +76,43 @@ class _SplashScreenState extends State<SplashScreen>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: widget.backgroundColor,
// Full screen display, no SafeArea needed for a full bleed splash
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animated Logo (Scale, Opacity, and Float)
FadeTransition(
opacity: _opacityAnimation,
child: AnimatedBuilder(
animation: _floatAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _floatAnimation.value),
child: ScaleTransition(
scale: _scaleAnimation,
child: SizedBox(
width: widget.logoSize,
height: widget.logoSize,
// Replace with your actual logo image widget
child: Image.asset(Images.logoDark),
),
),
);
},
),
),
const SizedBox(height: 30),
// Text Message (Fades in slightly after logo)
if (widget.message != null)
FadeTransition(
opacity: _opacityAnimation,
child: Text(
widget.message!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
letterSpacing: 1.2,
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo with slight bounce animation
ScaleTransition(
scale: Tween(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
),
child: SizedBox(
width: widget.logoSize,
height: widget.logoSize,
child: Image.asset(Images.logoDark),
),
),
const SizedBox(height: 40),
// Modern Loading Indicator
_buildProgressIndicator(),
],
const SizedBox(height: 20),
// Text message
if (widget.message != null)
Text(
widget.message!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 30),
// Animated loading dots
_buildAnimatedDots(),
],
),
),
),
);

View File

@ -19,8 +19,7 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
class DailyProgressReportScreen extends StatefulWidget {
final String projectId;
const DailyProgressReportScreen({super.key, required this.projectId});
const DailyProgressReportScreen({super.key});
@override
State<DailyProgressReportScreen> createState() =>
@ -63,15 +62,21 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
}
}
});
// Use projectId passed from parent instead of global selectedProjectId
final initialProjectId = widget.projectId;
final initialProjectId = projectController.selectedProjectId.value;
if (initialProjectId.isNotEmpty) {
dailyTaskController.selectedProjectId = initialProjectId;
dailyTaskController.fetchTaskData(initialProjectId);
}
// Removed the ever<ProjectController> block to keep it independent
// Update when project changes
ever<String>(projectController.selectedProjectId, (newProjectId) async {
if (newProjectId.isNotEmpty &&
newProjectId != dailyTaskController.selectedProjectId) {
dailyTaskController.selectedProjectId = newProjectId;
await dailyTaskController.fetchTaskData(newProjectId);
dailyTaskController.update(['daily_progress_report_controller']);
}
});
}
@override
@ -83,72 +88,129 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: _openFilterSheet,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
children: [
MyText.bodySmall(
"Filter",
fontWeight: 600,
color: Colors.black,
),
const SizedBox(width: 4),
const Icon(Icons.tune,
size: 20, color: Colors.black),
],
),
),
),
],
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Progress Report',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
),
MySpacing.height(8),
Padding(
padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
],
);
},
),
],
),
],
),
),
],
),
),
],
),
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: _refreshData,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: GetBuilder<DailyTaskController>(
init: dailyTaskController,
tag: 'daily_progress_report_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(15),
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(22),
onTap: _openFilterSheet,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Row(
children: [
MyText.bodySmall(
"Filter",
fontWeight: 600,
color: Colors.black,
),
const SizedBox(width: 4),
Icon(Icons.tune,
size: 20, color: Colors.black),
],
),
),
),
],
),
),
MySpacing.height(8),
Padding(
padding: MySpacing.x(8),
child: _buildDailyProgressReportTab(),
),
],
);
},
),
),
],
),
),
),
);
}
Future<void> _openFilterSheet() async {
// Fetch filter data first
if (dailyTaskController.taskFilterData == null) {
await dailyTaskController
.fetchTaskFilter(dailyTaskController.selectedProjectId ?? '');
@ -245,27 +307,32 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
final isLoading = dailyTaskController.isLoading.value;
final groupedTasks = dailyTaskController.groupedDailyTasks;
// 🟡 Show loading skeleton on first load
if (isLoading && dailyTaskController.currentPage == 1) {
return SkeletonLoaders.dailyProgressReportSkeletonLoader();
}
// No data available
if (groupedTasks.isEmpty) {
return Center(
child: MyText.bodySmall(
"No Progress Report Found for selected filters.",
"No Progress Report Found",
fontWeight: 600,
),
);
}
// 🔽 Sort all date keys by descending (latest first)
final sortedDates = groupedTasks.keys.toList()
..sort((a, b) => b.compareTo(a));
// 🔹 Auto expand if only one date present
if (sortedDates.length == 1 &&
!dailyTaskController.expandedDates.contains(sortedDates[0])) {
dailyTaskController.expandedDates.add(sortedDates[0]);
}
// 🧱 Return a scrollable column of cards
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -284,6 +351,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🗓 Date Header
GestureDetector(
onTap: () => dailyTaskController.toggleDate(dateKey),
child: Padding(
@ -308,6 +376,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
),
),
// 🔽 Task List (expandable)
Obx(() {
if (!dailyTaskController.expandedDates
.contains(dateKey)) {
@ -345,12 +415,15 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🏗 Activity name & location
MyText.bodyMedium(activityName,
fontWeight: 600),
const SizedBox(height: 2),
MyText.bodySmall(location,
color: Colors.grey),
const SizedBox(height: 8),
// 👥 Team Members
GestureDetector(
onTap: () => _showTeamMembersBottomSheet(
task.teamMembers),
@ -368,6 +441,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
),
const SizedBox(height: 8),
// 📊 Progress info
MyText.bodySmall(
"Completed: $completed / $planned",
fontWeight: 600,
@ -412,6 +487,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
: Colors.red[700],
),
const SizedBox(height: 12),
// 🎯 Action Buttons
SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
@ -470,6 +547,8 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
),
);
}),
// 🔻 Loading More Indicator
Obx(() => dailyTaskController.isLoadingMore.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),

View File

@ -7,6 +7,7 @@ import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/permission_controller.dart';
import 'package:on_field_work/controller/task_Planning/daily_task_Planning_controller.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:percent_indicator/percent_indicator.dart';
import 'package:on_field_work/model/dailyTaskPlanning/assign_task_bottom_sheet .dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
@ -16,9 +17,7 @@ import 'package:on_field_work/controller/tenant/service_controller.dart';
import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart';
class DailyTaskPlanningScreen extends StatefulWidget {
final String projectId; // Optional projectId from parent
DailyTaskPlanningScreen({super.key, required this.projectId});
DailyTaskPlanningScreen({super.key});
@override
State<DailyTaskPlanningScreen> createState() =>
@ -31,88 +30,156 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Get.put(DailyTaskPlanningController());
final PermissionController permissionController =
Get.put(PermissionController());
final ProjectController projectController = Get.find<ProjectController>();
final ServiceController serviceController = Get.put(ServiceController());
@override
void initState() {
super.initState();
// Use widget.projectId if passed; otherwise fallback to selectedProjectId
final projectId = widget.projectId;
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
// Now this will fetch only services + building list (no deep infra)
dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId);
}
// Whenever project changes, fetch buildings & services (still lazy load infra per building)
ever<String>(
projectController.selectedProjectId,
(newProjectId) {
if (newProjectId.isNotEmpty) {
dailyTaskPlanningController.fetchTaskData(newProjectId);
serviceController.fetchServices(newProjectId);
}
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SafeArea(
child: MyRefreshIndicator(
onRefresh: () async {
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
try {
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}');
}
}
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.x(0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
kToolbarHeight -
MediaQuery.of(context).padding.top,
),
child: GetBuilder<DailyTaskPlanningController>(
init: dailyTaskPlanningController,
tag: 'daily_task_Planning_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController
.fetchTaskData(
projectId,
serviceId: service?.id,
);
}
},
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Daily Task Planning',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(8),
child: dailyProgressReportTab(),
),
],
);
},
],
);
}),
],
),
),
],
),
),
),
),
body: SafeArea(
child: MyRefreshIndicator(
onRefresh: () async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
try {
// keep previous behavior but now fetchTaskData is lighter (buildings only)
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId: serviceController.selectedService?.id,
);
} catch (e) {
debugPrint('Error refreshing task data: ${e.toString()}');
}
}
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.x(0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
kToolbarHeight -
MediaQuery.of(context).padding.top,
),
child: GetBuilder<DailyTaskPlanningController>(
init: dailyTaskPlanningController,
tag: 'daily_task_Planning_controller',
builder: (controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(10),
child: ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchTaskData(
projectId,
serviceId:
service?.id, // <-- pass selected service
);
}
},
),
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(8),
child: dailyProgressReportTab(),
),
],
);
},
),
),
),
],
),
),
);
}
@ -160,7 +227,8 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final buildings = dailyTasks
.expand((task) => task.buildings)
.where((building) =>
(building.plannedWork) > 0 || (building.completedWork) > 0)
(building.plannedWork ) > 0 ||
(building.completedWork ) > 0)
.toList();
if (buildings.isEmpty) {
@ -199,14 +267,16 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
});
if (expanded && !buildingLoaded && !buildingLoading) {
final projectId = widget.projectId;
// fetch infra details for this building lazily
final projectId =
projectController.selectedProjectId.value;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController.fetchBuildingInfra(
building.id.toString(),
projectId,
serviceController.selectedService?.id,
);
setMainState(() {});
setMainState(() {}); // rebuild to reflect loaded data
}
}
},
@ -250,7 +320,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
Padding(
padding: const EdgeInsets.all(16.0),
child: MyText.bodySmall(
"No Progress Report Found for this Project",
"No Progress Report Found",
fontWeight: 600,
),
)

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+18
version: 1.0.0+16
environment:
sdk: ^3.5.3
@ -83,6 +83,8 @@ dependencies:
timeago: ^3.7.1
cached_network_image: ^3.4.1
cryptography: ^2.7.0
gallery_saver_plus: ^3.2.9
share_plus: ^12.0.1
timeline_tile: ^2.0.0